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.
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.

View File

@ -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",

View File

@ -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")),

View File

@ -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

View File

@ -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)

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.)
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: