diff --git a/README.md b/README.md index 01cda074..3d649aa2 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ - [`uti`](#uti) - [`burst`](#burst) - [`burst_photos`](#burst_photos) + - [`live_photo`](#live_photo) + - [`path_live_photo`](#path_live_photo) - [`json()`](#json) - [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse) + [Utility Functions](#utility-functions) @@ -522,13 +524,15 @@ Returns a list of albums the photo is contained in 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](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#ismissing)) +Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see [hasadjustments](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#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](#path) and [hasadjustments](#hasadjustments). +**Note**: will also return None if the edited photo is missing on disk. + #### `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. +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 and user hasn't enabled "Copy items to the Photos library" in Photos preferences. **Note**: this status is computed based on data in the Photos library and `ismissing` does not verify if the photo is actually missing. See also [path](#path). #### `hasadjustments` Returns `True` if the picture has been edited, otherwise `False` @@ -586,6 +590,14 @@ IMG_9854.JPG IMG_9855.JPG ``` +#### `live_photo` +Returns True if photo is an Apple live photo (ie. it has an associated "live" video component), otherwise returns False. See [path_live_photo](#path_live_photo). + +#### `path_live_photo` +Returns the path to the live video component of a [live photo](#live_photo). If photo is not a live photo, returns None. + +**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud. + #### `json()` Returns a JSON representation of all photo info diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index bf63adea..27a8b4e7 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -115,6 +115,9 @@ class PhotoInfo: """ absolute path on disk of the edited picture """ """ None if photo has not been edited """ + # TODO: break this code into a _path_edited_4 and _path_edited_5 + # version to simplify the big if/then; same for path_live_photo + photopath = None if self._db._db_version < _PHOTOS_5_VERSION: @@ -148,13 +151,14 @@ class PhotoInfo: ) 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" + f"MISSING PATH: 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_resource_id is None" ) + photopath = None else: photopath = None @@ -231,7 +235,6 @@ class PhotoInfo: @property def title(self): """ name / title of picture """ - # TODO: Update documentation and tests to use title return self._info["name"] @property @@ -333,11 +336,7 @@ class PhotoInfo: @property def live_photo(self): """ Returns True if photo is a live photo, otherwise False """ - # TODO: fixme for Photos 4 - if self._db._db_version >= _PHOTOS_5_VERSION: - return self._info["live_photo"] - else: - return None + return self._info["live_photo"] @property def path_live_photo(self): @@ -346,20 +345,49 @@ class PhotoInfo: If photo is missing, returns None """ photopath = None - # TODO: fixme for Photos 4 if self._db._db_version < _PHOTOS_5_VERSION: - photopath = None + if self.live_photo and not self.ismissing: + live_model_id = self._info["live_model_id"] + if live_model_id == None: + logging.debug(f"missing live_model_id: {self._uuid}") + photopath = None + else: + folder_id, file_id = _get_resource_loc(live_model_id) + library_path = self._db.library_path + photopath = os.path.join( + library_path, + "resources", + "media", + "master", + folder_id, + "00", + f"jpegvideocomplement_{file_id}.mov", + ) + if not os.path.isfile(photopath): + # In testing, I've seen occasional missing movie for live photo + # These appear to be valid -- e.g. live component hasn't been downloaded from iCloud + # photos 4 has "isOnDisk" column we could check + # or could do the actual check with "isfile" + # TODO: should this be a warning or debug? + logging.debug( + f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist" + ) + photopath = None + else: + photopath = None else: + # Photos 5 if self.live_photo and not self.ismissing: filename = pathlib.Path(self.path) photopath = filename.parent.joinpath(f"{filename.stem}_3.mov") if not os.path.isfile(photopath): - photopath = None # In testing, I've seen occasional missing movie for live photo + # these appear to be valid -- e.g. video component not yet downloaded from iCloud # TODO: should this be a warning or debug? logging.debug( - f"live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist" + f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist" ) + photopath = None else: photopath = None diff --git a/osxphotos/photosdb.py b/osxphotos/photosdb.py index fa423455..1af6202d 100644 --- a/osxphotos/photosdb.py +++ b/osxphotos/photosdb.py @@ -484,9 +484,10 @@ class PhotosDB: RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden, RKVersion.latitude, RKVersion.longitude, RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI, - RKVersion.burstUuid, RKVersion.burstPickType - from RKVersion, RKMaster where RKVersion.isInTrash = 0 and - RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf' """ + RKVersion.burstUuid, RKVersion.burstPickType, + RKVersion.specialType, RKMaster.modelID + FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND + RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """ ) # order of results @@ -515,6 +516,8 @@ class PhotosDB: # 22 RKMaster.UTI # 23 RKVersion.burstUuid # 24 RKVersion.burstPickType + # 25 RKVersion.specialType + # 26 RKMaster.modelID for row in c: uuid = row[0] @@ -579,26 +582,56 @@ class PhotosDB: self._dbphotos[uuid]["burst"] = True burst_uuid = row[23] if burst_uuid not in self._dbphotos_burst: - self._dbphotos_burst[burst_uuid] = set() + self._dbphotos_burst[burst_uuid] = set() self._dbphotos_burst[burst_uuid].add(uuid) if row[24] != 2 and row[24] != 4: - self._dbphotos[uuid]["burst_key"] = True # it's a key photo (selected from the burst) + self._dbphotos[uuid][ + "burst_key" + ] = True # it's a key photo (selected from the burst) else: - self._dbphotos[uuid]["burst_key"] = False # it's a burst photo but not one that's selected + self._dbphotos[uuid][ + "burst_key" + ] = False # it's a burst photo but not one that's selected else: # not a burst photo self._dbphotos[uuid]["burst"] = False self._dbphotos[uuid]["burst_key"] = None - # get details needed to find path of the edited photos and live photos + # RKVersion.specialType + # 1 == panorama + # 2 == slow-mo movie + # 3 == time-lapse movie + # 4 == HDR + # 5 == live photo + # 6 == screenshot + # 8 == HDR live photo + # 9 = portrait + + # get info on special types + self._dbphotos[uuid]["specialType"] = row[25] + self._dbphotos[uuid]["masterModelID"] = row[26] + self._dbphotos[uuid]["panorama"] = True if row[25] == 1 else False + self._dbphotos[uuid]["slow_mo"] = True if row[25] == 2 else False + self._dbphotos[uuid]["time_lapse"] = True if row[25] == 3 else False + self._dbphotos[uuid]["hdr"] = ( + True if (row[25] == 4 or row[25] == 8) else False + ) + self._dbphotos[uuid]["live_photo"] = ( + True if (row[25] == 5 or row[25] == 8) else False + ) + self._dbphotos[uuid]["screenshot"] = True if row[25] == 6 else False + self._dbphotos[uuid]["portrait"] = True if row[25] == 9 else False + + # get details needed to find path of the edited 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 " + """ 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 """ ) + # get info on path of live photo movie # Order of results: # 0 RKVersion.uuid @@ -610,15 +643,10 @@ class PhotosDB: # 6 RKModelResource.attachedModelType # 7 RKModelResource.resourceType - # TODO: add live photos - # attachedmodeltype is 2, it's a photo, could be more than one - # attachedmodeltype == 2 could also be movie? - # 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: + # get info on adjustments (edits) if self._dbphotos[uuid]["adjustmentUuid"] == row[3]: if ( row[1] != "UNADJUSTEDNONRAW" @@ -654,10 +682,50 @@ class PhotosDB: if uuid in self._dbphotos: self._dbphotos[uuid]["adjustmentFormatID"] = row[3] - # init any uuids that had no edits + + + # get details to find path of live photos + c.execute( + """ SELECT + RKVersion.uuid, + RKModelResource.modelId, + RKModelResource.UTI, + RKVersion.specialType, + RKModelResource.attachedModelType, + RKModelResource.resourceType, + RKModelResource.isOnDisk + FROM RKVersion + INNER JOIN RKMaster on RKVersion.masterUuid = RKMaster.uuid + INNER JOIN RKModelResource on RKMaster.modelId = RKModelResource.attachedModelId + WHERE RKModelResource.UTI = 'com.apple.quicktime-movie' + AND RKMaster.isInTrash = 0 + AND RKVersion.isInTrash = 0 + """ + ) + + # Order of results + # 0 RKVersion.uuid, + # 1 RKModelResource.modelId, + # 2 RKModelResource.UTI, + # 3 RKVersion.specialType, + # 4 RKModelResource.attachedModelType, + # 5 RKModelResource.resourceType + # 6 RKModelResource.isOnDisk + + # TODO: don't think we need most of these fields, remove from SQL query? + for row in c: + uuid = row[0] + if uuid in self._dbphotos: + self._dbphotos[uuid]["live_model_id"] = row[1] + self._dbphotos[uuid]["modeResourceIsOnDisk"] = True if row[6] == 1 else False + + # init any uuids that had no edits or live photos for uuid in self._dbphotos: if "edit_resource_id" not in self._dbphotos[uuid]: self._dbphotos[uuid]["edit_resource_id"] = None + if "live_model_id" not in self._dbphotos[uuid]: + self._dbphotos[uuid]["live_model_id"] = None + self._dbphotos[uuid]["modeResourceIsOnDisk"] = None conn.close() @@ -852,7 +920,8 @@ class PhotosDB: ZGENERICASSET.ZUNIFORMTYPEIDENTIFIER, ZGENERICASSET.ZAVALANCHEUUID, ZGENERICASSET.ZAVALANCHEPICKTYPE, - ZGENERICASSET.ZKINDSUBTYPE + ZGENERICASSET.ZKINDSUBTYPE, + ZGENERICASSET.ZCUSTOMRENDEREDVALUE FROM ZGENERICASSET JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK WHERE ZGENERICASSET.ZTRASHEDSTATE = 0 @@ -880,8 +949,8 @@ class PhotosDB: # 18 ZUNIFORMTYPEIDENTIFIER -- UTI # 19 ZGENERICASSET.ZAVALANCHEUUID, -- if not NULL, is burst photo # 20 ZGENERICASSET.ZAVALANCHEPICKTYPE -- if not 2, is a selected burst photo - # 21 ZGENERICASSET.ZKINDSUBTYPE - + # 21 ZGENERICASSET.ZKINDSUBTYPE -- determine if live photos, etc + # 22 ZGENERICASSET.ZCUSTOMRENDEREDVALUE -- determine if HDR photo for row in c: uuid = row[0] @@ -942,27 +1011,51 @@ class PhotosDB: # handle burst photos # if burst photo, determine whether or not it's a selected burst photo # in Photos 5, burstUUID is called avalancheUUID - info["burstUUID"] = row[19] # avalancheUUID - info["burstPickType"] = row[20] #avalanchePickType + info["burstUUID"] = row[19] # avalancheUUID + info["burstPickType"] = row[20] # avalanchePickType if row[19] is not None: # it's a burst photo info["burst"] = True burst_uuid = row[19] if burst_uuid not in self._dbphotos_burst: - self._dbphotos_burst[burst_uuid] = set() + self._dbphotos_burst[burst_uuid] = set() self._dbphotos_burst[burst_uuid].add(uuid) if row[20] != 2 and row[20] != 4: - info["burst_key"] = True # it's a key photo (selected from the burst) + 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 + info[ + "burst_key" + ] = False # it's a burst photo but not one that's selected else: # not a burst photo info["burst"] = False info["burst_key"] = None # Info on sub-type (live photo, panorama, etc) + # ZGENERICASSET.ZKINDSUBTYPE + # 1 == panorama + # 2 == live photo + # 10 = screenshot + # 100 = shared movie (MP4) ?? + # 101 = slow-motion video + # 102 = Time lapse video info["subtype"] = row[21] + info["panorama"] = True if row[21] == 1 else False info["live_photo"] = True if row[21] == 2 else False + info["screenshot"] = True if row[21] == 10 else False + info["slow_mo"] = True if row[21] == 101 else False + info["time_lapse"] = True if row[21] == 102 else False + + # Handle HDR photos and portraits + # ZGENERICASSET.ZCUSTOMRENDEREDVALUE + # 3 = HDR photo + # 4 = non-HDR version of the photo + # 8 = portrait + info["customRenderedValue"] = row[22] + info["hdr"] = True if row[22] == 3 else False + info["portrait"] = True if row[22] == 8 else False self._dbphotos[uuid] = info