Added PhotoInfo.place for reverse geolocation data

This commit is contained in:
Rhet Turnbull
2020-03-21 10:39:42 -07:00
parent 816b98e617
commit b338b34d50
149 changed files with 1502 additions and 31 deletions

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.22.23"
__version__ = "0.23.0"

View File

@@ -26,13 +26,14 @@ from ._constants import (
_TEMPLATE_DIR,
_XMP_TEMPLATE_NAME,
)
from .exiftool import ExifTool
from .placeinfo import PlaceInfo4, PlaceInfo5
from .utils import (
_copy_file,
_export_photo_uuid_applescript,
_get_resource_loc,
dd_to_dms_str,
)
from .exiftool import ExifTool
class PhotoInfo:
@@ -489,6 +490,34 @@ class PhotoInfo:
""" Returns True if photo is a selfie (front facing camera), otherwise False """
return self._info["selfie"]
@property
def place(self):
""" If Photos version >= 5, returns PlaceInfo object containing reverse geolocation info """
# implementation note: doesn't create the PlaceInfo object until requested
# then memoizes the object in self._place to avoid recreating the object
if self._db._db_version < _PHOTOS_5_VERSION:
try:
return self._place # pylint: disable=access-member-before-definition
except:
if self._info["placeNames"]:
self._place = PlaceInfo4(
self._info["placeNames"], self._info["countryCode"]
)
else:
self._place = None
return self._place
else:
try:
return self._place # pylint: disable=access-member-before-definition
except AttributeError:
if self._info["reverse_geolocation"]:
self._place = PlaceInfo5(self._info["reverse_geolocation"])
else:
self._place = None
return self._place
def export(
self,
dest,

View File

@@ -28,11 +28,11 @@ from ._version import __version__
from .photoinfo import PhotoInfo
from .utils import (
_check_file_exists,
_get_os_version,
get_last_library_path,
_debug,
_open_sql_file,
_db_is_locked,
_debug,
_get_os_version,
_open_sql_file,
get_last_library_path,
)
# TODO: Add test for imageTimeZoneOffsetSeconds = None
@@ -830,6 +830,52 @@ class PhotosDB:
self._dbphotos[uuid]["cloudStatus"] = row[3]
self._dbphotos[uuid]["incloud"] = True if row[2] == 1 else False
# get location data
# get the country codes
country_codes = c.execute(
"SELECT modelID, countryCode "
"FROM RKPlace "
"WHERE countryCode IS NOT NULL "
).fetchall()
countries = {code[0]: code[1] for code in country_codes}
self._db_countries = countries
# save existing row_factory
old_row_factory = c.row_factory
# want only the list of values, not a list of tuples
c.row_factory = lambda cursor, row: row[0]
for uuid in self._dbphotos:
# get placeId which is then used to lookup defaultName
place_ids = c.execute(
"SELECT placeId "
"FROM RKPlaceForVersion "
f"WHERE versionId = '{self._dbphotos[uuid]['modelID']}'"
).fetchall()
self._dbphotos[uuid]["placeIDs"] = place_ids
country_code = [countries[x] for x in place_ids if x in countries]
if len(country_code) > 1:
logging.warning(f"Found more than one country code for uuid: {uuid}")
if country_code:
self._dbphotos[uuid]["countryCode"] = country_code[0]
else:
self._dbphotos[uuid]["countryCode"] = None
place_names = c.execute(
"SELECT DISTINCT defaultName AS name "
"FROM RKPlace "
f"WHERE modelId IN({','.join(map(str,place_ids))}) "
"ORDER BY area ASC "
).fetchall()
self._dbphotos[uuid]["placeNames"] = place_names
self._dbphotos[uuid]["reverse_geolocation"] = None # Photos 5
# restore row_factory
c.row_factory = old_row_factory
# build album_titles dictionary
for album_id in self._dbalbum_details:
title = self._dbalbum_details[album_id]["title"]
@@ -1045,7 +1091,8 @@ class PhotosDB:
ZGENERICASSET.ZKINDSUBTYPE,
ZGENERICASSET.ZCUSTOMRENDEREDVALUE,
ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
ZGENERICASSET.ZCLOUDASSETGUID
ZGENERICASSET.ZCLOUDASSETGUID,
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
FROM ZGENERICASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
WHERE ZGENERICASSET.ZTRASHEDSTATE = 0
@@ -1076,8 +1123,9 @@ class PhotosDB:
# 21 ZGENERICASSET.ZKINDSUBTYPE -- determine if live photos, etc
# 22 ZGENERICASSET.ZCUSTOMRENDEREDVALUE -- determine if HDR photo
# 23 ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE -- 1 if selfie (front facing camera)
# 25 ZGENERICASSET.ZCLOUDASSETGUID -- not null if asset is cloud asset
# 24 ZGENERICASSET.ZCLOUDASSETGUID -- not null if asset is cloud asset
# (e.g. user has "iCloud Photos" checked in Photos preferences)
# 25 ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA -- reverse geolocation data
for row in c:
uuid = row[0]
@@ -1203,6 +1251,12 @@ class PhotosDB:
info["cloudStatus"] = None # Photos 4
info["cloudAvailable"] = None # Photos 4
# reverse geolocation info
info["reverse_geolocation"] = row[25]
info["placeIDs"] = None # Photos 4
info["placeNames"] = None # Photos 4
info["countryCode"] = None # Photos 4
self._dbphotos[uuid] = info
# # if row[19] is not None and ((row[20] == 2) or (row[20] == 4)):
@@ -1371,6 +1425,9 @@ class PhotosDB:
else:
self._dbalbum_titles[title] = [album_id]
# country codes (only used in Photos <=4)
self._db_countries = None
# close connection and remove temporary files
conn.close()
@@ -1715,8 +1772,8 @@ class PhotosDB:
info.burst_key = True # it's a key photo (selected from the burst)
else:
info.burst_key = (
False
) # it's a burst photo but not one that's selected
False # it's a burst photo but not one that's selected
)
else:
# not a burst photo
info.burst = False

433
osxphotos/placeinfo.py Normal file
View File

@@ -0,0 +1,433 @@
"""
PlaceInfo class
Provides reverse geolocation info for photos
"""
from abc import ABC, abstractmethod
from collections import namedtuple
from bpylist import archiver
# postal address information, returned by PlaceInfo.address
PostalAddress = namedtuple(
"PostalAddress",
[
"street",
"sub_locality",
"city",
"sub_administrative_area",
"state",
"postal_code",
"country",
"iso_country_code",
],
)
# The following classes represent Photo Library Reverse Geolocation Info as stored
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
# These classes are used by bpylist.archiver to unarchive the serialized objects
class PLRevGeoLocationInfo:
""" The top level reverse geolocation object """
def __init__(
self,
addressString,
countryCode,
mapItem,
isHome,
compoundNames,
compoundSecondaryNames,
version,
geoServiceProvider,
postalAddress,
):
self.addressString = addressString
self.countryCode = countryCode
self.mapItem = mapItem
self.isHome = isHome
self.compoundNames = compoundNames
self.compoundSecondaryNames = compoundSecondaryNames
self.version = version
self.geoServiceProvider = geoServiceProvider
self.postalAddress = postalAddress
def __eq__(self, other):
for field in [
"addressString",
"countryCode",
"isHome",
"compoundNames",
"compoundSecondaryNames",
"version",
"geoServiceProvider",
"postalAddress",
]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return f"addressString: {self.addressString}, countryCode: {self.countryCode}, isHome: {self.isHome}, mapItem: {self.mapItem}, postalAddress: {self.postalAddress}"
@staticmethod
def encode_archive(obj, archive):
archive.encode("addressString", obj.addressString)
archive.encode("countryCode", obj.countryCode)
archive.encode("mapItem", obj.mapItem)
archive.encode("isHome", obj.isHome)
archive.encode("compoundNames", obj.compoundNames)
archive.encode("compoundSecondaryNames", obj.compoundSecondaryNames)
archive.encode("version", obj.version)
archive.encode("geoServiceProvider", obj.geoServiceProvider)
archive.encode("postalAddress", obj.postalAddress)
@staticmethod
def decode_archive(archive):
addressString = archive.decode("addressString")
countryCode = archive.decode("countryCode")
mapItem = archive.decode("mapItem")
isHome = archive.decode("isHome")
compoundNames = archive.decode("compoundNames")
compoundSecondaryNames = archive.decode("compoundSecondaryNames")
version = archive.decode("version")
geoServiceProvider = archive.decode("geoServiceProvider")
postalAddress = archive.decode("postalAddress")
return PLRevGeoLocationInfo(
addressString,
countryCode,
mapItem,
isHome,
compoundNames,
compoundSecondaryNames,
version,
geoServiceProvider,
postalAddress,
)
class PLRevGeoMapItem:
""" Stores the list of place names, organized by area """
def __init__(self, sortedPlaceInfos, finalPlaceInfos):
self.sortedPlaceInfos = sortedPlaceInfos
self.finalPlaceInfos = finalPlaceInfos
def __eq__(self, other):
for field in ["sortedPlaceInfos", "finalPlaceInfos"]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
sortedPlaceInfos = []
finalPlaceInfos = []
for place in self.sortedPlaceInfos:
sortedPlaceInfos.append(str(place))
for place in self.finalPlaceInfos:
finalPlaceInfos.append(str(place))
return (
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
)
@staticmethod
def encode_archive(obj, archive):
archive.encode("sortedPlaceInfos", obj.sortedPlaceInfos)
archive.encode("finalPlaceInfos", obj.finalPlaceInfos)
@staticmethod
def decode_archive(archive):
sortedPlaceInfos = archive.decode("sortedPlaceInfos")
finalPlaceInfos = archive.decode("finalPlaceInfos")
return PLRevGeoMapItem(sortedPlaceInfos, finalPlaceInfos)
class PLRevGeoMapItemAdditionalPlaceInfo:
""" Additional info about individual places """
def __init__(self, area, name, placeType, dominantOrderType):
self.area = area
self.name = name
self.placeType = placeType
self.dominantOrderType = dominantOrderType
def __eq__(self, other):
for field in ["area", "name", "placeType", "dominantOrderType"]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return f"area: {self.area}, name: {self.name}, placeType: {self.placeType}"
@staticmethod
def encode_archive(obj, archive):
archive.encode("area", obj.area)
archive.encode("name", obj.name)
archive.encode("placeType", obj.placeType)
archive.encode("dominantOrderType", obj.dominantOrderType)
@staticmethod
def decode_archive(archive):
area = archive.decode("area")
name = archive.decode("name")
placeType = archive.decode("placeType")
dominantOrderType = archive.decode("dominantOrderType")
return PLRevGeoMapItemAdditionalPlaceInfo(
area, name, placeType, dominantOrderType
)
class CNPostalAddress:
""" postal address for the reverse geolocation info """
def __init__(
self,
_ISOCountryCode,
_city,
_country,
_postalCode,
_state,
_street,
_subAdministrativeArea,
_subLocality,
):
self._ISOCountryCode = _ISOCountryCode
self._city = _city
self._country = _country
self._postalCode = _postalCode
self._state = _state
self._street = _street
self._subAdministrativeArea = _subAdministrativeArea
self._subLocality = _subLocality
def __eq__(self, other):
for field in [
"_ISOCountryCode",
"_city",
"_country",
"_postalCode",
"_state",
"_street",
"_subAdministrativeArea",
"_subLocality",
]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return ", ".join(
map(
str,
[
self._street,
self._city,
self._subLocality,
self._subAdministrativeArea,
self._state,
self._postalCode,
self._country,
self._ISOCountryCode,
],
)
)
@staticmethod
def encode_archive(obj, archive):
archive.encode("_ISOCountryCode", obj._ISOCountryCode)
archive.encode("_country", obj._country)
archive.encode("_city", obj._city)
archive.encode("_postalCode", obj._postalCode)
archive.encode("_state", obj._state)
archive.encode("_street", obj._street)
archive.encode("_subAdministrativeArea", obj._subAdministrativeArea)
archive.encode("_subLocality", obj._subLocality)
@staticmethod
def decode_archive(archive):
_ISOCountryCode = archive.decode("_ISOCountryCode")
_country = archive.decode("_country")
_city = archive.decode("_city")
_postalCode = archive.decode("_postalCode")
_state = archive.decode("_state")
_street = archive.decode("_street")
_subAdministrativeArea = archive.decode("_subAdministrativeArea")
_subLocality = archive.decode("_subLocality")
return CNPostalAddress(
_ISOCountryCode,
_city,
_country,
_postalCode,
_state,
_street,
_subAdministrativeArea,
_subLocality,
)
# register the classes with bpylist.archiver
archiver.update_class_map({"CNPostalAddress": CNPostalAddress})
archiver.update_class_map(
{"PLRevGeoMapItemAdditionalPlaceInfo": PLRevGeoMapItemAdditionalPlaceInfo}
)
archiver.update_class_map({"PLRevGeoMapItem": PLRevGeoMapItem})
archiver.update_class_map({"PLRevGeoLocationInfo": PLRevGeoLocationInfo})
class PlaceInfo(ABC):
@property
@abstractmethod
def address_str(self):
pass
@property
@abstractmethod
def country_code(self):
pass
@property
@abstractmethod
def ishome(self):
pass
@property
@abstractmethod
def name(self):
pass
@property
@abstractmethod
def names(self):
pass
@property
@abstractmethod
def address(self):
pass
class PlaceInfo4(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos <= 4) """
def __init__(self, place_names, country_code):
""" place_names: list of place names in ascending order by area """
self._place_names = place_names
self._country_code = country_code
@property
def address_str(self):
return None
@property
def country_code(self):
return self._country_code
@property
def ishome(self):
return None
@property
def name(self):
return self._place_names[0]
@property
def names(self):
return self._place_names
@property
def address(self):
return PostalAddress(None, None, None, None, None, None, None, None)
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
else:
return (
self._place_names == other._place_names
and self._country_code == other._country_code
)
def __ne__(self, other):
return not self.__eq__(other)
class PlaceInfo5(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos >= 5) """
def __init__(self, revgeoloc_bplist):
""" revgeoloc_bplist: a binary plist blob containing
a serialized PLRevGeoLocationInfo object """
self._bplist = revgeoloc_bplist
# todo: check for None?
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
@property
def address_str(self):
""" returns the postal address as a string """
return self._plrevgeoloc.addressString
@property
def country_code(self):
""" returns the country code """
return self._plrevgeoloc.countryCode
@property
def ishome(self):
""" returns True if place is user's home address """
return self._plrevgeoloc.isHome
@property
def name(self):
""" returns local place name """
name = (
self._plrevgeoloc.mapItem.sortedPlaceInfos[0].name
if self._plrevgeoloc.mapItem.sortedPlaceInfos
else None
)
return name
@property
def names(self):
""" returns list of all place names in reverse order by area
e.g. most local is at index 0, least local (usually country) is at index -1 """
names = []
# todo: strip duplicates
for name in self._plrevgeoloc.mapItem.sortedPlaceInfos:
names.append(name.name)
return names
@property
def address(self):
addr = self._plrevgeoloc.postalAddress
address = PostalAddress(
street=addr._street,
sub_locality=addr._subLocality,
city=addr._city,
sub_administrative_area=addr._subAdministrativeArea,
state=addr._state,
postal_code=addr._postalCode,
country=addr._country,
iso_country_code=addr._ISOCountryCode,
)
return address
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
else:
return self._plrevgeoloc == other._plrevgeoloc
def __ne__(self, other):
return not self.__eq__(other)