Bug shared moments 1116 (#1119)

* Partial for #1116, shared moment photos

* Added --shared-moment, --not-shared-moment query args
This commit is contained in:
Rhet Turnbull 2023-07-16 18:14:25 -07:00 committed by GitHub
parent 4f0e2a101d
commit 052f2791ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 94 additions and 4 deletions

View File

@ -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. 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+. 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` #### `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. 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.

View File

@ -611,6 +611,16 @@ _QUERY_PARAMETERS_DICT = {
is_flag=True, is_flag=True,
help="Search for syndicated photos that have not saved to the library", 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": click.Option(
["--regex"], ["--regex"],
metavar="REGEX TEMPLATE", metavar="REGEX TEMPLATE",

View File

@ -894,6 +894,8 @@ def export(
not_syndicated, not_syndicated,
saved_to_library, saved_to_library,
not_saved_to_library, not_saved_to_library,
shared_moment,
not_shared_moment,
selected=False, # Isn't provided on unsupported platforms selected=False, # Isn't provided on unsupported platforms
# debug, # debug, watch, breakpoint handled in cli/__init__.py # debug, # debug, watch, breakpoint handled in cli/__init__.py
# watch, # watch,
@ -1120,6 +1122,8 @@ def export(
not_syndicated = cfg.not_syndicated not_syndicated = cfg.not_syndicated
saved_to_library = cfg.saved_to_library saved_to_library = cfg.saved_to_library
not_saved_to_library = cfg.not_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 # config file might have changed verbose
verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme) verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
@ -1171,6 +1175,7 @@ def export(
("title", "no_title"), ("title", "no_title"),
("syndicated", "not_syndicated"), ("syndicated", "not_syndicated"),
("saved_to_library", "not_saved_to_library"), ("saved_to_library", "not_saved_to_library"),
("shared_moment", "not_shared_moment"),
] ]
dependent_options = [ dependent_options = [
("append", ("report")), ("append", ("report")),

View File

@ -173,6 +173,12 @@ class PhotoInfo:
"""Returns candidate path for original photo on Photos >= version 5""" """Returns candidate path for original photo on Photos >= version 5"""
if self._info["shared"]: if self._info["shared"]:
return self._path_5_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: 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 # path for "shared with you" syndicated photos that have not yet been saved to the library
return self._path_syndication() return self._path_syndication()
@ -230,6 +236,21 @@ class PhotoInfo:
) )
return path if os.path.isfile(path) else None 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): def _path_4(self):
"""Returns candidate path for original photo on Photos <= version 4""" """Returns candidate path for original photo on Photos <= version 4"""
if self._info["has_raw"]: if self._info["has_raw"]:
@ -910,6 +931,8 @@ class PhotoInfo:
elif self.live_photo and self.path and not self.ismissing: elif self.live_photo and self.path and not self.ismissing:
if self.shared: if self.shared:
return self._path_live_photo_shared_5() 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: if self.syndicated and not self.saved_to_library:
# syndicated ("Shared with you") photos not yet saved to library # syndicated ("Shared with you") photos not yet saved to library
return self._path_live_syndicated() return self._path_live_syndicated()
@ -987,6 +1010,22 @@ class PhotoInfo:
) )
return live_photo if os.path.isfile(live_photo) else None 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 @cached_property
def path_derivatives(self) -> list[str]: 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)""" """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() return self._path_derivatives_5_shared()
directory = self._uuid[0] # first char of uuid 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 # syndicated ("Shared with you") photos not yet saved to library
derivative_path = "scopes/syndication/resources/derivatives" derivative_path = "scopes/syndication/resources/derivatives"
thumb_path = ( thumb_path = (
f"{derivative_path}/masters/{directory}/{self.uuid}_4_5005_c.jpeg" f"{derivative_path}/masters/{directory}/{self.uuid}_4_5005_c.jpeg"
) )
else: else:
derivative_path = f"resources/derivatives/{directory}" derivative_path = "resources/derivatives"
thumb_path = ( thumb_path = (
f"resources/derivatives/masters/{directory}/{self.uuid}_4_5005_c.jpeg" 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) thumb_path = pathlib.Path(self._db._library_path).joinpath(thumb_path)
# find all files that start with uuid in derivative path # find all files that start with uuid in derivative path
@ -1377,6 +1426,11 @@ class PhotoInfo:
except KeyError: except KeyError:
return False 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 @property
def labels(self): def labels(self):
"""returns list of labels applied to photo by Photos image categorization """returns list of labels applied to photo by Photos image categorization

View File

@ -1235,6 +1235,9 @@ class PhotosDB:
# photos 5+ only, for shared photos # photos 5+ only, for shared photos
self._dbphotos[uuid]["cloudownerhashedpersonid"] = None self._dbphotos[uuid]["cloudownerhashedpersonid"] = None
# photos 8+ only, shared moments
self._dbphotos[uuid]["moment_share"] = None
# compute signatures for finding possible duplicates # compute signatures for finding possible duplicates
signature = self._duplicate_signature(uuid) signature = self._duplicate_signature(uuid)
try: try:
@ -1949,7 +1952,8 @@ class PhotosDB:
{asset_table}.ZSAVEDASSETTYPE, {asset_table}.ZSAVEDASSETTYPE,
{asset_table}.ZADDEDDATE, {asset_table}.ZADDEDDATE,
{asset_table}.Z_PK, {asset_table}.Z_PK,
{asset_table}.ZCLOUDOWNERHASHEDPERSONID {asset_table}.ZCLOUDOWNERHASHEDPERSONID,
{asset_table}.ZMOMENTSHARE
FROM {asset_table} FROM {asset_table}
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
ORDER BY {asset_table}.ZUUID """ ORDER BY {asset_table}.ZUUID """
@ -2000,6 +2004,7 @@ class PhotosDB:
# 41 ZGENERICASSET.ZADDEDDATE -- date item added to the library # 41 ZGENERICASSET.ZADDEDDATE -- date item added to the library
# 42 ZGENERICASSET.Z_PK -- primary key # 42 ZGENERICASSET.Z_PK -- primary key
# 43 ZGENERICASSET.ZCLOUDOWNERHASHEDPERSONID -- used to look up owner name (for shared photos) # 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: for row in c:
uuid = row[0] uuid = row[0]
@ -2187,6 +2192,8 @@ class PhotosDB:
info["pk"] = row[42] info["pk"] = row[42]
info["cloudownerhashedpersonid"] = row[43] info["cloudownerhashedpersonid"] = row[43]
info["moment_share"] = row[44]
# initialize import session info which will be filled in later # initialize import session info which will be filled in later
# not every photo has an import session so initialize all records now # not every photo has an import session so initialize all records now
info["import_session"] = None info["import_session"] = None
@ -3532,6 +3539,11 @@ class PhotosDB:
elif options.not_saved_to_library: elif options.not_saved_to_library:
photos = [p for p in photos if p.syndicated and not p.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: if options.function:
for function in options.function: for function in options.function:
photos = function[0](photos) photos = function[0](photos)

View File

@ -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.) 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 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 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 added_after: Optional[datetime.datetime] = None
@ -200,6 +202,8 @@ class QueryOptions:
not_syndicated: Optional[bool] = None not_syndicated: Optional[bool] = None
saved_to_library: Optional[bool] = None saved_to_library: Optional[bool] = None
not_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): def asdict(self):
return asdict(self) return asdict(self)
@ -271,6 +275,7 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions:
("deleted_only", "not_deleted"), ("deleted_only", "not_deleted"),
("syndicated", "not_syndicated"), ("syndicated", "not_syndicated"),
("saved_to_library", "not_saved_to_library"), ("saved_to_library", "not_saved_to_library"),
("shared_moment", "not_shared_moment"),
] ]
# TODO: add option to validate requiring at least one query arg # TODO: add option to validate requiring at least one query arg
for arg, not_arg in exclusive: for arg, not_arg in exclusive: