From 6f6d37ceacf71a52a2c0216f0ad75afee244946a Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 4 Jan 2020 10:15:56 -0800 Subject: [PATCH] Added live-photo option to CLI query and export --- README.md | 53 ++++++++++++++++++++++++++++------ osxphotos/__main__.py | 65 ++++++++++++++++++++++++++++++++++++++---- osxphotos/_version.py | 2 +- osxphotos/photoinfo.py | 18 +++++------- osxphotos/utils.py | 23 +++++++++++++++ 5 files changed, 135 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 3d649aa2..3db5a9f6 100644 --- a/README.md +++ b/README.md @@ -119,14 +119,16 @@ Commands: To get help on a specific command, use `osxphotos help ` -Example: `osxphotos help query` +Example: `osxphotos help export` ``` -Usage: osxphotos help [OPTIONS] +Usage: osxphotos help [OPTIONS] DEST - Query the Photos database using 1 or more search options; if more than - one option is provided, they are treated as "AND" (e.g. search for photos - matching all options). + Export photos from the Photos database. Export path DEST is required. + Optionally, query the Photos database using 1 or more search options; if + more than one option is provided, they are treated as "AND" (e.g. search + for photos matching all options). If no query options are provided, all + photos will be exported. Options: --keyword TEXT Search for keyword(s). @@ -137,6 +139,8 @@ Options: --no-title Search for photos with no title. --description TEXT Search for TEXT in description of photo. --no-description Search for photos with no description. + --uti TEXT Search for photos whose uniform type identifier (UTI) + matches TEXT -i, --ignore-case Case insensitive search for title or description. Does not apply to keyword, person, or album. --edited Search for photos that have been edited. @@ -145,9 +149,42 @@ Options: --not-favorite Search for photos not marked favorite. --hidden Search for photos marked hidden. --not-hidden Search for photos not marked hidden. - --missing Search for photos missing from disk. - --not-missing Search for photos present on disk (e.g. not missing). - --json Print output in JSON format + --burst Search for photos that were taken in a burst. + --not-burst Search for photos that are not part of a burst. + --live Search for Apple live photos + --not-live Search for photos that are not Apple live photos + --shared Search for photos in shared iCloud album (Photos 5 + only). + --not-shared Search for photos not in shared iCloud album (Photos 5 + only). + -V, --verbose Print verbose output. + --overwrite Overwrite existing files. Default behavior is to add + (1), (2), etc to filename if file already exists. Use + this with caution as it may create name collisions on + export. (e.g. if two files happen to have the same name) + --export-by-date Automatically create output folders to organize photos + by date created (e.g. DEST/2019/12/20/photoname.jpg). + --export-edited Also export edited version of photo if an edited version + exists. Edited photo will be named in form of + "photoname_edited.ext" + --export-bursts If a photo is a burst photo export all associated burst + images in the library. + --export-live If a photo is a live photo export the associated live + video component. Live video will be named in form of + "photoname_live.mov" + --original-name Use photo's original filename instead of current + filename for export. + --sidecar Create json sidecar for each photo exported in format + useable by exiftool (https://exiftool.org/) The sidecar + file can be used to apply metadata to the file with + exiftool, for example: "exiftool -j=photoname.jpg.json + photoname.jpg" The sidecar file is named in format + photoname.ext.json where ext is extension of the photo + (e.g. jpg). + --only-movies Search only for movies (default searches both images and + movies). + --only-photos Search only for photos/images (default searches both + images and movies). -h, --help Show this message and exit. ``` diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index baf72ca1..87c05ea8 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -13,7 +13,7 @@ import osxphotos from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION from ._version import __version__ -from .utils import create_path_by_date +from .utils import create_path_by_date, _copy_file # TODO: add "--any" to search any field (e.g. keyword, description, title contains "wedding") (add case insensitive option) # TODO: add search for filename @@ -258,6 +258,8 @@ def list_libraries(cli_obj): ) @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("--live", is_flag=True, help="Search for Apple live photos") +@click.option("--not-live", is_flag=True, help="Search for photos that are not Apple live photos") @click.option( "--only-movies", is_flag=True, @@ -305,6 +307,8 @@ def query( uti, burst, not_burst, + live, + not_live, ): """ Query the Photos database using 1 or more search options; if more than one option is provided, they are treated as "AND" @@ -337,6 +341,8 @@ def query( uti, burst, not_burst, + live, + not_live, ] ): click.echo(cli.commands["query"].get_help(ctx)) @@ -369,6 +375,10 @@ def query( # can't search for both burst and not_burst click.echo(cli.commands["query"].get_help(ctx)) return + elif live and not_live: + # can't search for both live and not_live + click.echo(cli.commands["query"].get_help(ctx)) + return # actually have something to query isphoto = ismovie = True # default searches for everything @@ -403,7 +413,9 @@ def query( ismovie, uti, burst, - not_burst + not_burst, + live, + not_live, ) print_photo_info(photos, cli_obj.json or json) @@ -450,6 +462,8 @@ def query( @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("--live", is_flag=True, help="Search for Apple live photos") +@click.option("--not-live", is_flag=True, help="Search for photos that are not Apple live photos") @click.option( "--shared", is_flag=True, @@ -478,14 +492,20 @@ def query( @click.option( "--export-edited", is_flag=True, - help="Also export edited version of photo " - 'if an edited version exists. Edited photo will be named in form of "photoname_edited.ext"', + 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( + "--export-live", + is_flag=True, + help="If a photo is a live photo export the associated live video component." + ' Live video will be named in form of "photoname_live.mov"' +) @click.option( "--original-name", is_flag=True, @@ -539,12 +559,15 @@ def export( export_by_date, export_edited, export_bursts, + export_live, original_name, sidecar, only_photos, only_movies, burst, not_burst, + live, + not_live, dest, ): """ Export photos from the Photos database. @@ -555,8 +578,6 @@ def export( If no query options are provided, all photos will be exported. """ - # TODO: --export-edited, --export-original - if not os.path.isdir(dest): sys.exit("DEST must be valid path") @@ -585,6 +606,10 @@ def export( # can't search for both burst and not_burst click.echo(cli.commands["export"].get_help(ctx)) return + elif live and not_live: + # can't search for both live and not_live + click.echo(cli.commands["export"].get_help(ctx)) + return isphoto = ismovie = True # default searches for everything if only_movies: @@ -619,6 +644,8 @@ def export( uti, burst, not_burst, + live, + not_live, ) if photos: @@ -645,6 +672,7 @@ def export( overwrite, export_edited, original_name, + export_live, ) else: for p in photos: @@ -657,6 +685,7 @@ def export( overwrite, export_edited, original_name, + export_live, ) if export_path: click.echo(f"Exported {p.filename} to {export_path}") @@ -715,6 +744,8 @@ def print_photo_info(photos, json=False): "ismovie", "uti", "burst", + "live_photo", + "path_live_photo", ] ) for p in photos: @@ -743,6 +774,8 @@ def print_photo_info(photos, json=False): p.ismovie, p.uti, p.burst, + p.live_photo, + p.path_live_photo ] ) for row in dump: @@ -776,12 +809,16 @@ def _query( uti, burst, not_burst, + live, + not_live ): """ run a query against PhotosDB to extract the photos based on user supply criteria """ """ used by query and export commands """ """ arguments must be passed in same order as query and export """ """ if either is modified, need to ensure all three functions are updated """ + # TODO: this is getting too hairy -- need to change to named args + photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db) photos = photosdb.photos( keywords=keyword, @@ -861,6 +898,12 @@ def _query( elif not_burst: photos = [p for p in photos if not p.burst] + if live: + photos = [p for p in photos if p.live_photo] + elif not_live: + photos = [p for p in photos if not p.live_photo] + + return photos @@ -873,6 +916,7 @@ def export_photo( overwrite, export_edited, original_name, + export_live, ): """ Helper function for export that does the actual export photo: PhotoInfo object @@ -923,6 +967,15 @@ def export_photo( dest, edited_name, sidecar=sidecar, overwrite=overwrite, edited=True ) + if export_live and photo.live_photo and photo.path_live_photo is not None: + live_name = pathlib.Path(filename) + live_name = f"{live_name.stem}_live.mov" + + src_live = pathlib.Path(photo.path_live_photo) + dest_live = os.path.join(dest, live_name) + if verbose: + click.echo(f"Exporting live photo video of {filename} as {live_name}") + _copy_file(src_live, dest_live) return photo_path diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 88b6c8a4..e6db66a6 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.20.00" +__version__ = "0.21.00" diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 27a8b4e7..231001b0 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -22,7 +22,7 @@ from ._constants import ( _PHOTOS_5_SHARED_PHOTO_PATH, _PHOTOS_5_VERSION, ) -from .utils import _get_resource_loc, dd_to_dms_str +from .utils import _get_resource_loc, dd_to_dms_str, _copy_file # TODO: check pylint output @@ -504,16 +504,8 @@ class PhotoInfo: f"destination exists ({dest}); overwrite={overwrite}, increment={increment}" ) - # if error on copy, subprocess will raise CalledProcessError - try: - subprocess.run( - ["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE - ) - except subprocess.CalledProcessError as e: - logging.critical( - f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}" - ) - raise e + # copy the file, _copy_file uses ditto to preserve Mac extended attributes + _copy_file(src, dest) if sidecar: logging.debug("writing exiftool_json_sidecar") @@ -625,6 +617,8 @@ class PhotoInfo: "ismovie": self.ismovie, "uti": self.uti, "burst": self.burst, + "live_photo": self.live_photo, + "path_live_photo": self.path_live_photo, } return yaml.dump(info, sort_keys=False) @@ -654,6 +648,8 @@ class PhotoInfo: "ismovie": self.ismovie, "uti": self.uti, "burst": self.burst, + "live_photo": self.live_photo, + "path_live_photo": self.path_live_photo, } return json.dumps(pic) diff --git a/osxphotos/utils.py b/osxphotos/utils.py index 20a587b4..f93efc75 100644 --- a/osxphotos/utils.py +++ b/osxphotos/utils.py @@ -109,6 +109,29 @@ def _dd_to_dms(dd): return int(deg_), int(min_), sec_ +def _copy_file(src, dest): + """ Copies a file from src path to dest path + Uses ditto to perform copy + Raises exception if copy fails or either path is None """ + + if src is None or dest is None: + raise ValueError("src and dest must not be None", src, dest) + + if not os.path.isfile(src): + raise ValueError("src file does not appear to exist", src) + + # if error on copy, subprocess will raise CalledProcessError + try: + subprocess.run( + ["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE + ) + except subprocess.CalledProcessError as e: + logging.critical( + f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}" + ) + raise e + + def dd_to_dms_str(lat, lon): """ convert latitude, longitude in degrees to degrees, minutes, seconds as string """ """ lat: latitude in degrees """