Added support for burst photos; added export-bursts to CLI

This commit is contained in:
Rhet Turnbull
2019-12-31 21:14:53 -08:00
parent 2e1a8d2500
commit 593983a099
5 changed files with 149 additions and 29 deletions

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.19.04"
__version__ = "0.20.00"

View File

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

View File

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