diff --git a/README.md b/README.md index ff9210aa..a8c74876 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Returns a list of the names of the persons in the photo 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 been edited, returns `None`. See also `path()` and `hasadjustments()`. Note: Currently only implemented for Photos 5.0+ (MacOS 10.15); returns `None` on previous versions. +Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also `path()` and `hasadjustments()`. #### `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 5cf2ccf4..a239b41a 100644 --- a/osxphotos/__init__.py +++ b/osxphotos/__init__.py @@ -81,6 +81,24 @@ def _check_file_exists(filename): return os.path.exists(filename) and not os.path.isdir(filename) +def _get_resource_loc(model_id): + """ returns folder_id and file_id needed to find location of edited photo """ + """ and live photos for version <= Photos 4.0 """ + # determine folder where Photos stores edited version + # edited images are stored in: + # Photos Library.photoslibrary/resources/media/version/XX/00/fullsizeoutput_Y.jpeg + # where XX and Y are computed based on RKModelResources.modelId + + # file_id (Y in above example) is hex representation of model_id without leading 0x + file_id = hex_id = hex(model_id)[2:] + + # folder_id (XX) in above example if first two chars of model_id converted to hex + # and left padded with zeros if < 4 digits + folder_id = hex_id.zfill(4)[0:2] + + return folder_id, file_id + + class PhotosDB: def __init__(self, dbfile=None): """ create a new PhotosDB object """ @@ -397,18 +415,6 @@ class PhotosDB: (conn, c) = self._open_sql_file(self._tmp_db) - # if int(self._db_version) > int(_PHOTOS_5_VERSION): - # # need to close the photos.db database and re-open Photos.sqlite - # c.close() - # try: - # os.remove(tmp_db) - # except: - # print("Could not remove temporary database: " + tmp_db, file=sys.stderr) - - # self._dbfile2 = Path(self._dbfile) "Photos.sqlite" - # tmp_db = self._copy_db_file(fname) - # (conn, c) = self._open_sql_file(tmp_db) - # Look for all combinations of persons and pictures i = 0 @@ -500,10 +506,35 @@ class PhotosDB: + "RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds, " + "RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name, " + "RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden, " - + "RKVersion.latitude, RKVersion.longitude " + + "RKVersion.latitude, RKVersion.longitude, " + + "RKVersion.adjustmentUuid " + "from RKVersion, RKMaster where RKVersion.isInTrash = 0 and RKVersion.type = 2 and " + "RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf'" ) + + # order of results + # 0 RKVersion.uuid + # 1 RKVersion.modelId + # 2 RKVersion.masterUuid + # 3 RKVersion.filename + # 4 RKVersion.lastmodifieddate + # 5 RKVersion.imageDate + # 6 RKVersion.mainRating + # 7 RKVersion.hasAdjustments + # 8 RKVersion.hasKeywords + # 9 RKVersion.imageTimeZoneOffsetSeconds + # 10 RKMaster.volumeId + # 11 RKMaster.imagePath + # 12 RKVersion.extendedDescription + # 13 RKVersion.name + # 14 RKMaster.isMissing + # 15 RKMaster.originalFileName + # 16 RKVersion.isFavorite + # 17 RKVersion.isHidden + # 18 RKVersion.latitude + # 19 RKVersion.longitude + # 20 RKVersion.adjustmentUuid + i = 0 for row in c: i = i + 1 @@ -540,6 +571,57 @@ class PhotosDB: self._dbphotos[uuid]["hidden"] = row[17] self._dbphotos[uuid]["latitude"] = row[18] self._dbphotos[uuid]["longitude"] = row[19] + self._dbphotos[uuid]["adjustmentUuid"] = row[20] + + # get details needed to find path of the edited photos and live photos + c.execute( + "SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId, " + "RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType, " + "RKModelResource.attachedModelType, RKModelResource.resourceType " + "FROM RKVersion " + "JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId " + "WHERE RKVersion.isInTrash = 0 " + ) + + # Order of results: + # 0 RKVersion.uuid + # 1 RKVersion.adjustmentUuid + # 2 RKModelResource.modelId + # 3 RKModelResource.resourceTag + # 4 RKModelResource.UTI + # 5 RKVersion.specialType + # 7 RKModelResource.attachedModelType + # 8 RKModelResource.resourceType + + # TODO: add live photos + # attachedmodeltype is 2, it's a photo, could be more than one + # if 5, it's a facetile + # specialtype = 0 == image, 5 or 8 == live photo movie + + for row in c: + uuid = row[0] + if uuid in self._dbphotos: + if self._dbphotos[uuid]["adjustmentUuid"] == row[3]: + if ( + row[1] != "UNADJUSTEDNONRAW" + and row[1] != "UNADJUSTED" + and row[4] == "public.jpeg" + and row[6] == 2 + ): + if "edit_resource_id" in self._dbphotos[uuid]: + logging.warning( + f"WARNING: found more than one edit_resource_id for " + f"UUID {row[0]},adjustmentID {row[1]}, modelID {row[2]}" + ) + # TODO: I think there should never be more than one edit but + # I've seen this once in my library + # should we return all edits or just most recent one? + self._dbphotos[uuid]["edit_resource_id"] = row[2] + + # init any uuids that had no edits + for uuid in self._dbphotos: + if "edit_resource_id" not in self._dbphotos[uuid]: + self._dbphotos[uuid]["edit_resource_id"] = None conn.close() @@ -1047,11 +1129,33 @@ class PhotoInfo: 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["hasAdjustments"]: + edit_id = self.__info["edit_resource_id"] + if edit_id is not None: + library = self.__db._library_path + folder_id, file_id = _get_resource_loc(edit_id) + # todo: is this always true or do we need to search file file_id under folder_id + photopath = os.path.join( + library, + "resources", + "media", + "version", + folder_id, + "00", + f"fullsizeoutput_{file_id}.jpeg", + ) + if not os.path.isfile(photopath): + logging.warning( + f"edited file for UUID {self.__uuid} should be at {photopath} but does not appear to exist" + ) + photopath = None + else: + logging.warning( + f"{self.uuid} hasAdjustments but edit_model_id is None" + ) + else: + photopath = None + # if self.__info["isMissing"] == 1: # photopath = None # path would be meaningless until downloaded else: @@ -1077,7 +1181,7 @@ class PhotoInfo: if not os.path.isfile(photopath): logging.warning( - f"WARNING: edited file should be at {photopath} but does not appear to exist" + f"edited file for UUID {self.__uuid} should be at {photopath} but does not appear to exist" ) photopath = None else: diff --git a/tests/test_mojave_10_14_6.py b/tests/test_mojave_10_14_6.py index 7c266638..ad96fa81 100644 --- a/tests/test_mojave_10_14_6.py +++ b/tests/test_mojave_10_14_6.py @@ -238,11 +238,7 @@ def test_path_edited1(): 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" - # ) + assert path.endswith("resources/media/version/00/00/fullsizeoutput_9.jpeg") def test_path_edited2():