From 1136f84d9b5ea454115ba3d2720625722671e63b Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Tue, 31 Dec 2019 21:14:53 -0800 Subject: [PATCH] Added support for bust photos; added export-bursts to CLI --- README.md | 66 ++++++++++++++++++++++++++++++++++++++---- osxphotos/__main__.py | 52 ++++++++++++++++++++++++++++++--- osxphotos/_version.py | 2 +- osxphotos/photoinfo.py | 5 ++-- osxphotos/photosdb.py | 53 ++++++++++++++++++++++----------- 5 files changed, 149 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d3489a8b..01cda074 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ - [`isphoto`](#isphoto) - [`ismovie`](#ismovie) - [`uti`](#uti) + - [`burst`](#burst) + - [`burst_photos`](#burst_photos) - [`json()`](#json) - [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse) + [Utility Functions](#utility-functions) @@ -388,7 +390,7 @@ Returns the version number for Photos library database. You likely won't need t photos = photosdb.photos([keywords=['keyword',]], [uuid=['uuid',]], [persons=['person',]], [albums=['album',]]) ``` -Returns a list of PhotoInfo objects. Each PhotoInfo object represents a photo in the Photos Libary. +Returns a list of [PhotoInfo](#PhotoInfo) objects. Each PhotoInfo object represents a photo in the Photos Libary. If called with no parameters, returns a list of every photo in the Photos library. @@ -461,6 +463,33 @@ To get only movies: ```python movies = photosdb.photos(images=False, movies=True) ``` +**Note** PhotosDB.photos() may return a different number of photos than Photos.app reports in the GUI. This is because photos() returns [hidden](#hidden) photos, [shared](#shared) photos, and for [burst](#burst) photos, all selected burst images even if non-selected burst images have not been deleted. Photos only reports 1 single photo for each set of burst images until you "finalize" the burst by selecting key photos and deleting the others using the "Make a selection" option. + +For example, in my library, Photos says I have 19,386 photos and 474 movies. However, PhotosDB.photos() reports 25,002 photos. The difference is due to 5,609 shared photos and 7 hidden photos. (*Note* Shared photos only valid for Photos 5). Similarly, filtering for just movies returns 625 results. The difference between 625 and 474 reported by Photos is due to 151 shared movies. + +```python +>>> import osxphotos +>>> photosdb = osxphotos.PhotosDB() +>>> photos = photosdb.photos() +>>> len(photos) +25002 +>>> shared = [p for p in photos if p.shared] +>>> len(shared) +5609 +>>> not_shared = [p for p in photos if not p.shared] +>>> len(not_shared) +19393 +>>> hidden = [p for p in photos if p.hidden] +>>> len(hidden) +7 +>>> movies = photosdb.photos(movies=True, images=False) +>>> len(movies) +625 +>>> shared_movies = [m for m in movies if m.shared] +>>> len(shared_movies) +151 +>>> +``` ### PhotoInfo PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library. @@ -469,10 +498,10 @@ PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object re Returns the universally unique identifier (uuid) of the photo. This is how Photos keeps track of individual photos within the database. #### `filename` -Returns the current filename of the photo on disk. See also `original_filename` +Returns the current filename of the photo on disk. See also [original_filename](#original_filename) #### `original_filename` -Returns the original filename of the photo when it was imported to Photos. **Note**: Photos 5.0+ renames the photo when it adds the file to the library using UUID. See also `filename` +Returns the original filename of the photo when it was imported to Photos. **Note**: Photos 5.0+ renames the photo when it adds the file to the library using UUID. See also [filename](#filename) #### `date` Returns the date of the photo as a datetime.datetime object @@ -493,10 +522,10 @@ 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`). If the file is missing on disk, path=`None` (see `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` and `hasadjustments`. +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). #### `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. @@ -530,6 +559,33 @@ Returns True if type is movie/video, otherwise False #### `uti` Returns Uniform Type Identifier (UTI) for the image, for example: 'public.jpeg' or 'com.apple.quicktime-movie' +#### `burst` +Returns True if photos is a burst image (e.g. part of a set of burst images), otherwise False. +See [burst_photos](#burst_photos) + +#### `burst_photos` +If photo is a burst image (see [burst](#burst)), returns a list of PhotoInfo objects for all other photos in the same burst set. If not a burst image, returns empty list. + +Example below gets list of all photos that are bursts, selects one of of them and prints out the names of the other images in the burst set. PhotosDB.photos() will only return the photos in the burst set that the user [selected](https://support.apple.com/guide/photos/view-photo-bursts-phtde06a275d/mac) using "Make a Selection..." in Photos or the key image Photos selected if the user has not yet made a selection. This is similar to how Photos displays and counts burst photos. Using `burst_photos` you can access the other images in the burst set to export them, etc. + +```python +>>> import osxphotos +>>> photosdb = osxphotos.PhotosDB() +>>> bursts = [p for p in photosdb.photos() if p.burst] +>>> burst_photo = bursts[5] +>>> len(burst_photo.burst_photos) +4 +>>> burst_photo.original_filename +'IMG_9851.JPG' +>>> for photo in burst_photo.burst_photos: +... print(photo.original_filename) +... +IMG_9853.JPG +IMG_9852.JPG +IMG_9854.JPG +IMG_9855.JPG +``` + #### `json()` Returns a JSON representation of all photo info diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 09ddf05b..baf72ca1 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -256,15 +256,17 @@ def list_libraries(cli_obj): is_flag=True, help="Search for photos not in shared iCloud album (Photos 5 only).", ) +@click.option("--burst", is_flag=True, help="Search for photos that were taken in a burst.") +@click.option("--not-burst", is_flag=True, help="Search for photos that are not part of a burst.") @click.option( "--only-movies", is_flag=True, - help="Search only for movies (default searches both images and movies)", + help="Search only for movies (default searches both images and movies).", ) @click.option( "--only-photos", is_flag=True, - help="Search only for photos/images (default searches both images and movies)", + help="Search only for photos/images (default searches both images and movies).", ) @click.option( "--json", @@ -301,6 +303,8 @@ def query( only_movies, only_photos, uti, + burst, + not_burst, ): """ Query the Photos database using 1 or more search options; if more than one option is provided, they are treated as "AND" @@ -331,6 +335,8 @@ def query( only_movies, only_photos, uti, + burst, + not_burst, ] ): click.echo(cli.commands["query"].get_help(ctx)) @@ -359,6 +365,10 @@ def query( # can't have only photos and only movies click.echo(cli.commands["query"].get_help(ctx)) return + elif burst and not_burst: + # can't search for both burst and not_burst + click.echo(cli.commands["query"].get_help(ctx)) + return # actually have something to query isphoto = ismovie = True # default searches for everything @@ -392,6 +402,8 @@ def query( isphoto, ismovie, uti, + burst, + not_burst ) print_photo_info(photos, cli_obj.json or json) @@ -436,6 +448,8 @@ def query( ) @click.option("--hidden", is_flag=True, help="Search for photos marked hidden.") @click.option("--not-hidden", is_flag=True, help="Search for photos not marked hidden.") +@click.option("--burst", is_flag=True, help="Search for photos that were taken in a burst.") +@click.option("--not-burst", is_flag=True, help="Search for photos that are not part of a burst.") @click.option( "--shared", is_flag=True, @@ -467,6 +481,11 @@ def query( help="Also export edited version of photo " 'if an edited version exists. Edited photo will be named in form of "photoname_edited.ext"', ) +@click.option( + "--export-bursts", + is_flag=True, + help="If a photo is a burst photo export all associated burst images in the library." +) @click.option( "--original-name", is_flag=True, @@ -484,12 +503,12 @@ def query( @click.option( "--only-movies", is_flag=True, - help="Search only for movies (default searches both images and movies)", + help="Search only for movies (default searches both images and movies).", ) @click.option( "--only-photos", is_flag=True, - help="Search only for photos/images (default searches both images and movies)", + help="Search only for photos/images (default searches both images and movies).", ) @click.argument("dest", nargs=1) @click.pass_obj @@ -519,10 +538,13 @@ def export( overwrite, export_by_date, export_edited, + export_bursts, original_name, sidecar, only_photos, only_movies, + burst, + not_burst, dest, ): """ Export photos from the Photos database. @@ -559,6 +581,10 @@ def export( # can't have only photos and only movies click.echo(cli.commands["export"].get_help(ctx)) return + elif burst and not_burst: + # can't search for both burst and not_burst + click.echo(cli.commands["export"].get_help(ctx)) + return isphoto = ismovie = True # default searches for everything if only_movies: @@ -591,9 +617,18 @@ def export( isphoto, ismovie, uti, + burst, + not_burst, ) if photos: + if export_bursts: + # add the burst_photos to the export set + photos_burst = [p for p in photos if p.burst] + for burst in photos_burst: + burst_set = [p for p in burst.burst_photos if not p.ismissing] + photos.extend(burst_set) + num_photos = len(photos) photo_str = "photos" if num_photos > 1 else "photo" click.echo(f"Exporting {num_photos} {photo_str} to {dest}...") @@ -679,6 +714,7 @@ def print_photo_info(photos, json=False): "isphoto", "ismovie", "uti", + "burst", ] ) for p in photos: @@ -706,6 +742,7 @@ def print_photo_info(photos, json=False): p.isphoto, p.ismovie, p.uti, + p.burst, ] ) for row in dump: @@ -737,6 +774,8 @@ def _query( isphoto, ismovie, uti, + burst, + not_burst, ): """ run a query against PhotosDB to extract the photos based on user supply criteria """ """ used by query and export commands """ @@ -817,6 +856,11 @@ def _query( if uti: photos = [p for p in photos if uti in p.uti] + if burst: + photos = [p for p in photos if p.burst] + elif not_burst: + photos = [p for p in photos if not p.burst] + return photos diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 4f304944..88b6c8a4 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.19.04" +__version__ = "0.20.00" diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 77c1505d..d80cc6e8 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -311,7 +311,6 @@ class PhotoInfo: @property def burst(self): """ Returns True if photo is part of a Burst photo set, otherwise False """ - # TODO: update for Photos 4 return self._info["burst"] @property @@ -320,7 +319,7 @@ class PhotoInfo: that are part of the same burst photo set; otherwise returns empty list. self is not included in the returned list """ if self._info["burst"]: - burst_uuid = self._info["avalancheUUID"] + burst_uuid = self._info["burstUUID"] burst_photos = [ PhotoInfo(db=self._db, uuid=u, info=self._db._dbphotos[u]) for u in self._db._dbphotos_burst[burst_uuid] @@ -561,6 +560,7 @@ class PhotoInfo: "isphoto": self.isphoto, "ismovie": self.ismovie, "uti": self.uti, + "burst": self.burst, } return yaml.dump(info, sort_keys=False) @@ -589,6 +589,7 @@ class PhotoInfo: "isphoto": self.isphoto, "ismovie": self.ismovie, "uti": self.uti, + "burst": self.burst, } return json.dumps(pic) diff --git a/osxphotos/photosdb.py b/osxphotos/photosdb.py index 76f14d14..9ff8277f 100644 --- a/osxphotos/photosdb.py +++ b/osxphotos/photosdb.py @@ -477,15 +477,16 @@ class PhotosDB: # Get photo details c.execute( - "select RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename, " - + "RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating, " - + "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.adjustmentUuid, RKVersion.type, RKMaster.UTI " - + "from RKVersion, RKMaster where RKVersion.isInTrash = 0 and " - + "RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf'" + """ SELECT RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename, + RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating, + 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.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' """ ) # order of results @@ -512,17 +513,14 @@ class PhotosDB: # 20 RKVersion.adjustmentUuid # 21 RKVersion.type # 22 RKMaster.UTI + # 23 RKVersion.burstUuid + # 24 RKVersion.burstPickType for row in c: uuid = row[0] if _debug(): logging.debug(f"uuid = '{uuid}, master = '{row[2]}") self._dbphotos[uuid] = {} - - # temp fix for burst until burst photos implemented for photos 4 - # TODO: fixme - self._dbphotos[uuid]["burst"] = self._dbphotos[uuid]["burst_key"] = None - self._dbphotos[uuid]["_uuid"] = uuid # stored here for easier debugging self._dbphotos[uuid]["modelID"] = row[1] self._dbphotos[uuid]["masterUuid"] = row[2] @@ -557,7 +555,7 @@ class PhotosDB: self._dbphotos[uuid]["adjustmentUuid"] = row[20] self._dbphotos[uuid]["adjustmentFormatID"] = None - # find type + # find type and UTI if row[21] == 2: # photo self._dbphotos[uuid]["type"] = _PHOTO_TYPE @@ -572,6 +570,26 @@ class PhotosDB: self._dbphotos[uuid]["UTI"] = row[22] + # handle burst photos + # if burst photo, determine whether or not it's a selected burst photo + self._dbphotos[uuid]["burstUUID"] = row[23] + self._dbphotos[uuid]["burstPickType"] = row[24] + if row[23] is not None: + # it's a burst photo + 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].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) + else: + 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 c.execute( "SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId, " @@ -917,11 +935,12 @@ class PhotosDB: info["type"] = None info["UTI"] = row[18] - info["avalancheUUID"] = row[19] - info["avalanchePickType"] = row[20] # 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 if row[19] is not None: # it's a burst photo info["burst"] = True