From 052f2791ac1b2ee180893d7589432647cc3ff4a0 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 16 Jul 2023 18:14:25 -0700 Subject: [PATCH] Bug shared moments 1116 (#1119) * Partial for #1116, shared moment photos * Added --shared-moment, --not-shared-moment query args --- API_README.md | 4 +++ osxphotos/cli/cli_params.py | 10 ++++++ osxphotos/cli/export.py | 5 +++ osxphotos/photoinfo.py | 60 ++++++++++++++++++++++++++++++++-- osxphotos/photosdb/photosdb.py | 14 +++++++- osxphotos/queryoptions.py | 5 +++ 6 files changed, 94 insertions(+), 4 deletions(-) diff --git a/API_README.md b/API_README.md index 7c774cc3..3a4ff9fb 100644 --- a/API_README.md +++ b/API_README.md @@ -1188,6 +1188,10 @@ Return true if photo was shared via syndication (e.g. via Messages, etc.); these Return True if syndicated photo has been saved to library; returns False if photo is not syndicated or has not been saved to the library. Syndicated photos are photos that appear in "Shared with you" album. Photos 8+ only; returns None if not Photos 8+. +### `shared_moment` + +Return True if photo is part of a shared moment, otherwise False. Shared moments are created when multiple photos are shared via iCloud. (e.g. in Messages) + #### `uti` Returns Uniform Type Identifier (UTI) for the current version of the image, for example: 'public.jpeg' or 'com.apple. quicktime-movie'. If the image has been edited, `uti` will return the UTI for the edited image, otherwise it will return the UTI for the original image. diff --git a/osxphotos/cli/cli_params.py b/osxphotos/cli/cli_params.py index 05b2e0fd..515a94f0 100644 --- a/osxphotos/cli/cli_params.py +++ b/osxphotos/cli/cli_params.py @@ -611,6 +611,16 @@ _QUERY_PARAMETERS_DICT = { is_flag=True, help="Search for syndicated photos that have not saved to the library", ), + "--shared-moment": click.Option( + ["--shared-moment"], + is_flag=True, + help="Search for photos that are part of a shared moment", + ), + "--not-shared-moment": click.Option( + ["--not-shared-moment"], + is_flag=True, + help="Search for photos that are not part of a shared moment", + ), "--regex": click.Option( ["--regex"], metavar="REGEX TEMPLATE", diff --git a/osxphotos/cli/export.py b/osxphotos/cli/export.py index 796806c2..0090d1e3 100644 --- a/osxphotos/cli/export.py +++ b/osxphotos/cli/export.py @@ -894,6 +894,8 @@ def export( not_syndicated, saved_to_library, not_saved_to_library, + shared_moment, + not_shared_moment, selected=False, # Isn't provided on unsupported platforms # debug, # debug, watch, breakpoint handled in cli/__init__.py # watch, @@ -1120,6 +1122,8 @@ def export( not_syndicated = cfg.not_syndicated saved_to_library = cfg.saved_to_library not_saved_to_library = cfg.not_saved_to_library + shared_moment = cfg.shared_moment + not_shared_moment = cfg.not_shared_moment # config file might have changed verbose verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme) @@ -1171,6 +1175,7 @@ def export( ("title", "no_title"), ("syndicated", "not_syndicated"), ("saved_to_library", "not_saved_to_library"), + ("shared_moment", "not_shared_moment"), ] dependent_options = [ ("append", ("report")), diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 02a5477c..5edfbb6f 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -173,6 +173,12 @@ class PhotoInfo: """Returns candidate path for original photo on Photos >= version 5""" if self._info["shared"]: return self._path_5_shared() + if self.shared_moment and self._path_shared_moment(): + # path for photos in shared moments if it's in the shared moment folder + # the file may also be in the originals folder which the next check will catch + # check shared_moment first as a photo can be both a shared moment and syndicated + # and if so, will be in the shared moment folder + return self._path_shared_moment() if self.syndicated and not self.saved_to_library: # path for "shared with you" syndicated photos that have not yet been saved to the library return self._path_syndication() @@ -230,6 +236,21 @@ class PhotoInfo: ) return path if os.path.isfile(path) else None + def _path_shared_moment(self): + """Return path for shared moment photo on Photos >= version 8""" + # Photos 8+ stores shared moment photos in a separate directory + # in ~/Photos Library.photoslibrary/scopes/momentshared/originals/X/UUID.ext + # where X is first digit of UUID + momentshared_path = "scopes/momentshared/originals" + uuid_dir = self.uuid[0] + path = os.path.join( + self._db._library_path, + momentshared_path, + uuid_dir, + self.filename, + ) + return path if os.path.isfile(path) else None + def _path_4(self): """Returns candidate path for original photo on Photos <= version 4""" if self._info["has_raw"]: @@ -910,6 +931,8 @@ class PhotoInfo: elif self.live_photo and self.path and not self.ismissing: if self.shared: return self._path_live_photo_shared_5() + if self.shared_moment: + return self._path_live_shared_moment() if self.syndicated and not self.saved_to_library: # syndicated ("Shared with you") photos not yet saved to library return self._path_live_syndicated() @@ -987,6 +1010,22 @@ class PhotoInfo: ) return live_photo if os.path.isfile(live_photo) else None + def _path_live_shared_moment(self): + """Return path for live shared moment photo on Photos >= version 8""" + # Photos 8+ stores live shared moment photos in a separate directory + # in ~/Photos Library.photoslibrary/scopes/momentshared/originals/X/UUID_3.mov + # where X is first digit of UUID + shared_moment_path = "scopes/momentshared/originals" + uuid_dir = self.uuid[0] + filename = f"{pathlib.Path(self.filename).stem}_3.mov" + live_photo = os.path.join( + self._db._library_path, + shared_moment_path, + uuid_dir, + filename, + ) + return live_photo if os.path.isfile(live_photo) else None + @cached_property def path_derivatives(self) -> list[str]: """Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)""" @@ -997,19 +1036,29 @@ class PhotoInfo: return self._path_derivatives_5_shared() directory = self._uuid[0] # first char of uuid - if self.syndicated and not self.saved_to_library: + if self.shared_moment: + # shared moments + derivative_path = "scopes/momentshared/resources/derivatives" + thumb_path = ( + f"{derivative_path}/masters/{directory}/{self.uuid}_4_5005_c.jpeg" + ) + elif self.syndicated and not self.saved_to_library: # syndicated ("Shared with you") photos not yet saved to library derivative_path = "scopes/syndication/resources/derivatives" thumb_path = ( f"{derivative_path}/masters/{directory}/{self.uuid}_4_5005_c.jpeg" ) else: - derivative_path = f"resources/derivatives/{directory}" + derivative_path = "resources/derivatives" thumb_path = ( f"resources/derivatives/masters/{directory}/{self.uuid}_4_5005_c.jpeg" ) - derivative_path = pathlib.Path(self._db._library_path).joinpath(derivative_path) + derivative_path = ( + pathlib.Path(self._db._library_path) + .joinpath(derivative_path) + .joinpath(directory) + ) thumb_path = pathlib.Path(self._db._library_path).joinpath(thumb_path) # find all files that start with uuid in derivative path @@ -1377,6 +1426,11 @@ class PhotoInfo: except KeyError: return False + @cached_property + def shared_moment(self) -> bool: + """Returns True if photo is part of a shared moment otherwise False""" + return bool(self._info["moment_share"]) + @property def labels(self): """returns list of labels applied to photo by Photos image categorization diff --git a/osxphotos/photosdb/photosdb.py b/osxphotos/photosdb/photosdb.py index efa65b20..863d1d75 100644 --- a/osxphotos/photosdb/photosdb.py +++ b/osxphotos/photosdb/photosdb.py @@ -1235,6 +1235,9 @@ class PhotosDB: # photos 5+ only, for shared photos self._dbphotos[uuid]["cloudownerhashedpersonid"] = None + # photos 8+ only, shared moments + self._dbphotos[uuid]["moment_share"] = None + # compute signatures for finding possible duplicates signature = self._duplicate_signature(uuid) try: @@ -1949,7 +1952,8 @@ class PhotosDB: {asset_table}.ZSAVEDASSETTYPE, {asset_table}.ZADDEDDATE, {asset_table}.Z_PK, - {asset_table}.ZCLOUDOWNERHASHEDPERSONID + {asset_table}.ZCLOUDOWNERHASHEDPERSONID, + {asset_table}.ZMOMENTSHARE FROM {asset_table} JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK ORDER BY {asset_table}.ZUUID """ @@ -2000,6 +2004,7 @@ class PhotosDB: # 41 ZGENERICASSET.ZADDEDDATE -- date item added to the library # 42 ZGENERICASSET.Z_PK -- primary key # 43 ZGENERICASSET.ZCLOUDOWNERHASHEDPERSONID -- used to look up owner name (for shared photos) + # 44 ZASSET.ZMOMENTSHARE -- FK for ZSHARE (shared moments, Photos 8+) for row in c: uuid = row[0] @@ -2187,6 +2192,8 @@ class PhotosDB: info["pk"] = row[42] info["cloudownerhashedpersonid"] = row[43] + info["moment_share"] = row[44] + # initialize import session info which will be filled in later # not every photo has an import session so initialize all records now info["import_session"] = None @@ -3532,6 +3539,11 @@ class PhotosDB: elif options.not_saved_to_library: photos = [p for p in photos if p.syndicated and not p.saved_to_library] + if options.shared_moment: + photos = [p for p in photos if p.shared_moment] + elif options.not_shared_moment: + photos = [p for p in photos if not p.shared_moment] + if options.function: for function in options.function: photos = function[0](photos) diff --git a/osxphotos/queryoptions.py b/osxphotos/queryoptions.py index 71b459b8..0df18d88 100644 --- a/osxphotos/queryoptions.py +++ b/osxphotos/queryoptions.py @@ -112,6 +112,8 @@ class QueryOptions: not_syndicated: search for photos that have not been shared via syndication ("Shared with You" album via Messages, etc.) saved_to_library: search for syndicated photos that have been saved to the Photos library not_saved_to_library: search for syndicated photos that have not been saved to the Photos library + shared_moment: search for photos that have been shared via a shared moment + not_shared_moment: search for photos that have not been shared via a shared moment """ added_after: Optional[datetime.datetime] = None @@ -200,6 +202,8 @@ class QueryOptions: not_syndicated: Optional[bool] = None saved_to_library: Optional[bool] = None not_saved_to_library: Optional[bool] = None + shared_moment: Optional[bool] = None + not_shared_moment: Optional[bool] = None def asdict(self): return asdict(self) @@ -271,6 +275,7 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions: ("deleted_only", "not_deleted"), ("syndicated", "not_syndicated"), ("saved_to_library", "not_saved_to_library"), + ("shared_moment", "not_shared_moment"), ] # TODO: add option to validate requiring at least one query arg for arg, not_arg in exclusive: