From 68eef42599c737e180d2d0ead936630abd5a8a65 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Fri, 29 Nov 2019 08:47:11 -0800 Subject: [PATCH] Added path_edited() for Photos 5, still needs to be added for Photos <= 4.0 --- README.md | 3 ++ osxphotos/__init__.py | 90 +++++++++++++++++++++++++++++----- osxphotos/cmd_line.py | 2 + setup.py | 12 ++--- tests/test_catalina_10_15_1.py | 28 ++++++++++- tests/test_mojave_10_14_6.py | 56 ++++++++++++++++++++- 6 files changed, 169 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 49a8b6cf..2a499972 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,9 @@ Returns a list of the names of the persons in the photo #### `path()` Returns the absolute path to the photo on disk as a string. Note: this returns the path to the *original* unedited file (see `hasadjustments()`). If the file is missing on disk, path=`None` (see `ismissing()`) +#### `path_edited()` +Returns the absolute path to the edited photo on disk as a string. If the photo has not beed edited, returns `None`. See also `path()` and `hasadjustments()`. Note: Currently only implemented for Photos 5.0+ (MacOS 10.15); returns `None` on previous versions. + #### `ismissing()` Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted. Note: this status is set by Photos and osxphotos does not verify that the file path returned by `path()` actually exists. It merely reports what Photos has stored in the library database. diff --git a/osxphotos/__init__.py b/osxphotos/__init__.py index a87ae1c9..5cf2ccf4 100644 --- a/osxphotos/__init__.py +++ b/osxphotos/__init__.py @@ -20,7 +20,6 @@ from Foundation import * from . import _applescript -# TODO: add hasAdjustments to process_database5 (see ZGENERICASSET.ZHASADJUSTMENTS = 1 ) # TODO: find edited photos: see https://github.com/orangeturtle739/photos-export/blob/master/extract_photos.py # TODO: Add test for imageTimeZoneOffsetSeconds = None # TODO: Fix command line so multiple --keyword, etc. are AND (instead of OR as they are in .photos()) @@ -149,12 +148,11 @@ class PhotosDB: self._dbfile = dbfile self._tmp_db = self._copy_db_file(self._dbfile) - # zzz - - # TODO: replace os.path with pathlib - # TODO: clean this up -- we'll already know library_path + # TODO: replace os.path with pathlib? + # TODO: clean this up -- library path computed twice library_path = os.path.dirname(os.path.abspath(dbfile)) (library_path, _) = os.path.split(library_path) + self._library_path = library_path if int(self._db_version) < int(_PHOTOS_5_VERSION): masters_path = os.path.join(library_path, "Masters") self._masters_path = masters_path @@ -273,8 +271,10 @@ class PhotosDB: return os.path.abspath(self._dbfile) def get_photos_library_path(self): - """ return the path to the Photos library """ - # TODO: move this to a module-level function + """ return the path to the last opened Photos library """ + # TODO: this is only for last opened library + # TODO: Need a module level method for this and another PhotosDB method to get current library path + # TODO: Also need a way to get path of system library plist_file = Path( str(Path.home()) + "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist" @@ -776,9 +776,7 @@ class PhotosDB: row[5] + td ) - self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp( - row[5] + td - ) + self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(row[5] + td) self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[6] self._dbphotos[uuid]["hidden"] = row[9] self._dbphotos[uuid]["favorite"] = row[10] @@ -824,6 +822,23 @@ class PhotosDB: f"WARNING: found description {row[1]} but no photo for {uuid}" ) + # get information about adjusted/edited photos + c.execute( + "SELECT ZGENERICASSET.ZUUID, ZGENERICASSET.ZHASADJUSTMENTS, ZUNMANAGEDADJUSTMENT.ZADJUSTMENTFORMATIDENTIFIER " + "FROM ZGENERICASSET, ZUNMANAGEDADJUSTMENT " + "JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK " + "WHERE ZADDITIONALASSETATTRIBUTES.ZUNMANAGEDADJUSTMENT = ZUNMANAGEDADJUSTMENT.Z_PK " + "AND ZGENERICASSET.ZTRASHEDSTATE = 0 AND ZGENERICASSET.ZKIND = 0 " + ) + for row in c: + uuid = row[0] + if uuid in self._dbphotos: + self._dbphotos[uuid]["adjustmentID"] = row[2] + else: + logging.debug( + f"WARNING: found adjustmentformatidentifier {row[2]} but no photo for uuid {row[0]}" + ) + # get information on local/remote availability c.execute( "SELECT ZGENERICASSET.ZUUID, " @@ -986,7 +1001,7 @@ class PhotoInfo: return self.__info["imageTimeZoneOffsetSeconds"] def path(self): - """ absolute path on disk of the picture """ + """ absolute path on disk of the original picture """ photopath = "" if self.__db._db_version < _PHOTOS_5_VERSION: @@ -1026,6 +1041,56 @@ class PhotoInfo: return photopath + def path_edited(self): + """ absolute path on disk of the edited picture """ + """ None if photo has not been edited """ + photopath = "" + + if self.__db._db_version < _PHOTOS_5_VERSION: + # TODO: implement this + photopath = None + logging.debug( + "WARNING: path_edited not implemented yet for this database version" + ) + # if self.__info["isMissing"] == 1: + # photopath = None # path would be meaningless until downloaded + else: + # in Photos 5.0 / Catalina / MacOS 10.15: + # edited photos appear to always be converted to .jpeg and stored in + # library_name/resources/renders/X/UUID_1_201_a.jpeg + # where X = first letter of UUID + # and UUID = UUID of image + # this seems to be true even for photos not copied to Photos library and + # where original format was not jpg/jpeg + # if more than one edit, previous edit is stored as UUID_p.jpeg + + if self.__info["hasAdjustments"]: + library = self.__db._library_path + directory = self.__uuid[0] # first char of uuid + photopath = os.path.join( + library, + "resources", + "renders", + directory, + f"{self.__uuid}_1_201_a.jpeg", + ) + + if not os.path.isfile(photopath): + logging.warning( + f"WARNING: edited file should be at {photopath} but does not appear to exist" + ) + photopath = None + else: + photopath = None + + # TODO: might be possible for original/master to be missing but edit to still be there + # if self.__info["isMissing"] == 1: + # photopath = None # path would be meaningless until downloaded + + logging.debug(photopath) + + return photopath + def description(self): """ long / extended description of picture """ return self.__info["extendedDescription"] @@ -1064,7 +1129,6 @@ class PhotoInfo: def hasadjustments(self): """ True if picture has adjustments """ - """ TODO: not accurate for Photos version >= 5 """ return True if self.__info["hasAdjustments"] == 1 else False def favorite(self): @@ -1108,6 +1172,7 @@ class PhotoInfo: "hidden": self.hidden(), "latitude": self._latitude(), "longitude": self._longitude(), + "path_edited": self.path_edited(), } return yaml.dump(info, sort_keys=False) @@ -1131,6 +1196,7 @@ class PhotoInfo: "hidden": self.hidden(), "latitude": self._latitude(), "longitude": self._longitude(), + "path_edited": self.path_edited(), } return json.dumps(pic) diff --git a/osxphotos/cmd_line.py b/osxphotos/cmd_line.py index 79271af5..422cfa30 100644 --- a/osxphotos/cmd_line.py +++ b/osxphotos/cmd_line.py @@ -327,6 +327,7 @@ def print_photo_info(photos, json=False): "hidden", "latitude", "longitude", + "path_edited", ] ) for p in photos: @@ -348,6 +349,7 @@ def print_photo_info(photos, json=False): p.hidden(), p._latitude(), p._longitude(), + p.path_edited(), ] ) for row in dump: diff --git a/setup.py b/setup.py index 090b9dec..fab1b99d 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# setup.py script for osxphotos +# setup.py script for osxphotos # # Copyright (c) 2019 Rhet Turnbull, rturnbull+git@gmail.com # All rights reserved. @@ -38,7 +38,7 @@ with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: setup( name="osxphotos", - version="0.14.6", + version="0.14.7", description="Manipulate (read-only) Apple's Photos app library on Mac OS X", long_description=long_description, long_description_content_type="text/markdown", @@ -47,7 +47,7 @@ setup( url="https://github.com/RhetTbull/", project_urls={"GitHub": "https://github.com/RhetTbull/osxphotos"}, download_url="https://github.com/RhetTbull/osxphotos", - packages=find_packages(exclude=["tests","examples"]), + packages=find_packages(exclude=["tests", "examples"]), license="License :: OSI Approved :: MIT License", classifiers=[ "Development Status :: 4 - Beta", @@ -58,8 +58,6 @@ setup( "Programming Language :: Python :: 3.6", "Topic :: Software Development :: Libraries :: Python Modules", ], - install_requires=["pyobjc","Click","pyyaml",], - entry_points = { - 'console_scripts' : ['osxphotos=osxphotos.cmd_line:cli'], - } + install_requires=["pyobjc", "Click", "pyyaml"], + entry_points={"console_scripts": ["osxphotos=osxphotos.cmd_line:cli"]}, ) diff --git a/tests/test_catalina_10_15_1.py b/tests/test_catalina_10_15_1.py index ecee2198..a6172e9d 100644 --- a/tests/test_catalina_10_15_1.py +++ b/tests/test_catalina_10_15_1.py @@ -216,7 +216,7 @@ def test_hasadjustments1(): photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"]) assert len(photos) == 1 p = photos[0] - assert p.hasadjustments() == True + assert p.hasadjustments() == True def test_hasadjustments2(): @@ -230,6 +230,32 @@ def test_hasadjustments2(): assert p.hasadjustments() == False +def test_path_edited1(): + # test a valid edited path + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"]) + assert len(photos) == 1 + p = photos[0] + path = p.path_edited() + assert path.endswith( + "resources/renders/E/E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_1_201_a.jpeg" + ) + + +def test_path_edited2(): + # test an invalid edited path + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"]) + assert len(photos) == 1 + p = photos[0] + path = p.path_edited() + assert path is None + + def test_count(): import osxphotos diff --git a/tests/test_mojave_10_14_6.py b/tests/test_mojave_10_14_6.py index 42bf81d5..7c266638 100644 --- a/tests/test_mojave_10_14_6.py +++ b/tests/test_mojave_10_14_6.py @@ -182,7 +182,7 @@ def test_not_hidden(): def test_location_1(): - #test photo with lat/lon info + # test photo with lat/lon info import osxphotos photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) @@ -193,6 +193,7 @@ def test_location_1(): assert lat == pytest.approx(51.50357167) assert lon == pytest.approx(-0.1318055) + def test_location_2(): # test photo with no location info import osxphotos @@ -202,9 +203,60 @@ def test_location_2(): assert len(photos) == 1 p = photos[0] lat, lon = p.location() - assert lat is None + assert lat is None assert lon is None + +def test_hasadjustments1(): + # test hasadjustments == True + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=["6bxcNnzRQKGnK4uPrCJ9UQ"]) + assert len(photos) == 1 + p = photos[0] + assert p.hasadjustments() == True + + +def test_hasadjustments2(): + # test hasadjustments == False + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=["15uNd7%8RguTEgNPKHfTWw"]) + assert len(photos) == 1 + p = photos[0] + assert p.hasadjustments() == False + + +def test_path_edited1(): + # test a valid edited path + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=["6bxcNnzRQKGnK4uPrCJ9UQ"]) + assert len(photos) == 1 + p = photos[0] + path = p.path_edited() + assert path is None + # TODO: update when implemented + # assert path.endswith( + # "resources/renders/E/E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_1_201_a.jpeg" + # ) + + +def test_path_edited2(): + # test an invalid edited path + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=["15uNd7%8RguTEgNPKHfTWw"]) + assert len(photos) == 1 + p = photos[0] + path = p.path_edited() + assert path is None + + def test_count(): import osxphotos