Added download-missing option to CLI export

This commit is contained in:
Rhet Turnbull
2020-01-12 08:46:57 -08:00
parent 66cabf1af2
commit a2dd648c89
5 changed files with 170 additions and 84 deletions

View File

@@ -648,7 +648,7 @@ Returns the path to the live video component of a [live photo](#live_photo). If
#### `json()` #### `json()`
Returns a JSON representation of all photo info Returns a JSON representation of all photo info
#### `export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)` #### `export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False, use_photos_export=False, timeout=120)`
Export photo from the Photos library to another destination on disk. Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised). - dest: must be valid destination path as str (or exception raised).
@@ -656,7 +656,9 @@ Export photo from the Photos library to another destination on disk.
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version) - edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist - overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
- increment: boolean; if True (default=True), will increment file name until a non-existant name is found - increment: boolean; if True (default=True), will increment file name until a non-existant name is found
- sidecar: boolean; if True (default=False) will also write a json sidecar file with EXIF data in format readable by [exiftool](https://exiftool.org/); filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) - sidecar: boolean; if True (default=False) will also write a json sidecar file with EXIF data in format readable by [exiftool](https://exiftool.org/); filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg). Note: this is not an XMP sidecar.
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos--useful for forcing download of missing photos
- timeout: (int, default=120) timeout in seconds used with use_photos_export
The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example: The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example:

View File

@@ -266,10 +266,26 @@ def list_libraries(cli_obj):
@click.option( @click.option(
"--not-live", is_flag=True, help="Search for photos that are not Apple live photos" "--not-live", is_flag=True, help="Search for photos that are not Apple live photos"
) )
@click.option("--cloudasset", is_flag=True, help="Search for photos that are part of an iCloud library") @click.option(
@click.option("--not-cloudasset", is_flag=True, help="Search for photos that are not part of an iCloud library") "--cloudasset",
@click.option("--incloud", is_flag=True, help="Search for photos that are in iCloud (have been synched)") is_flag=True,
@click.option("--not-incloud", is_flag=True, help="Search for photos that are not in iCloud (have not been synched)") help="Search for photos that are part of an iCloud library",
)
@click.option(
"--not-cloudasset",
is_flag=True,
help="Search for photos that are not part of an iCloud library",
)
@click.option(
"--incloud",
is_flag=True,
help="Search for photos that are in iCloud (have been synched)",
)
@click.option(
"--not-incloud",
is_flag=True,
help="Search for photos that are not in iCloud (have not been synched)",
)
@click.option( @click.option(
"--only-movies", "--only-movies",
is_flag=True, is_flag=True,
@@ -445,7 +461,7 @@ def query(
cloudasset, cloudasset,
not_cloudasset, not_cloudasset,
incloud, incloud,
not_incloud not_incloud,
) )
print_photo_info(photos, cli_obj.json or json) print_photo_info(photos, cli_obj.json or json)
@@ -550,11 +566,12 @@ def query(
@click.option( @click.option(
"--sidecar", "--sidecar",
is_flag=True, is_flag=True,
help="Create json sidecar for each photo exported " help="Create JSON sidecar for each photo exported "
f"in format useable by exiftool ({_EXIF_TOOL_URL}) " f"in format useable by exiftool ({_EXIF_TOOL_URL}) "
"The sidecar file can be used to apply metadata to the file with exiftool, for example: " "The sidecar file can be used to apply metadata to the file with exiftool, for example: "
'"exiftool -j=photoname.jpg.json photoname.jpg" ' '"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).", "The sidecar file is named in format photoname.ext.json where ext is extension of the photo (e.g. jpg). "
"Note: this does not create an XMP sidecar as used by Lightroom, etc.",
) )
@click.option( @click.option(
"--only-movies", "--only-movies",
@@ -566,6 +583,14 @@ def query(
is_flag=True, 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(
"--download-missing",
is_flag=True,
help="Attempt to download missing photos from iCloud. The current implementation uses Applescript "
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
"the photo does not exist on disk. This will be slow and will require internet connection. "
"This obviously only works if the Photos library is synched to iCloud.",
)
@click.argument("dest", nargs=1) @click.argument("dest", nargs=1)
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
@@ -604,6 +629,7 @@ def export(
not_burst, not_burst,
live, live,
not_live, not_live,
download_missing,
dest, dest,
): ):
""" Export photos from the Photos database. """ Export photos from the Photos database.
@@ -682,6 +708,10 @@ def export(
not_burst, not_burst,
live, live,
not_live, not_live,
False, # cloudasset
False, # not_cloudasset
False, # incloud
False # not_incloud
) )
if photos: if photos:
@@ -709,6 +739,7 @@ def export(
export_edited, export_edited,
original_name, original_name,
export_live, export_live,
download_missing
) )
else: else:
for p in photos: for p in photos:
@@ -722,6 +753,7 @@ def export(
export_edited, export_edited,
original_name, original_name,
export_live, export_live,
download_missing
) )
if export_path: if export_path:
click.echo(f"Exported {p.filename} to {export_path}") click.echo(f"Exported {p.filename} to {export_path}")
@@ -970,6 +1002,7 @@ def export_photo(
export_edited, export_edited,
original_name, original_name,
export_live, export_live,
download_missing,
): ):
""" Helper function for export that does the actual export """ Helper function for export that does the actual export
photo: PhotoInfo object photo: PhotoInfo object
@@ -981,9 +1014,11 @@ def export_photo(
original_name: boolean; use original filename instead of current filename original_name: boolean; use original filename instead of current filename
export_live: boolean; also export live video component if photo is a live photo export_live: boolean; also export live video component if photo is a live photo
live video will have same name as photo but with .mov extension live video will have same name as photo but with .mov extension
download_missing: attempt download of missing iCloud photos
returns destination path of exported photo or None if photo was missing returns destination path of exported photo or None if photo was missing
""" """
if not download_missing:
if photo.ismissing: if photo.ismissing:
space = " " if not verbose else "" space = " " if not verbose else ""
click.echo(f"{space}Skipping missing photo {photo.filename}") click.echo(f"{space}Skipping missing photo {photo.filename}")
@@ -995,6 +1030,9 @@ def export_photo(
f"skipping {photo.filename}" f"skipping {photo.filename}"
) )
return None return None
elif photo.ismissing and not photo.iscloudasset or not photo.incloud:
click.echo(f"Skipping missing {photo.filename}: not iCloud asset or missing from cloud")
return None
filename = None filename = None
if original_name: if original_name:
@@ -1009,18 +1047,32 @@ def export_photo(
date_created = photo.date.timetuple() date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created) dest = create_path_by_date(dest, date_created)
photo_path = photo.export(dest, filename, sidecar=sidecar, overwrite=overwrite) photo_path = photo.export(
dest,
filename,
sidecar=sidecar,
overwrite=overwrite,
use_photos_export=download_missing,
)
# if export-edited, also export the edited version # if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception # verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments and photo.path_edited is not None: if export_edited and photo.hasadjustments:
if download_missing or photo.path_edited is not None:
edited_name = pathlib.Path(filename) edited_name = pathlib.Path(filename)
edited_name = f"{edited_name.stem}_edited{edited_name.suffix}" edited_name = f"{edited_name.stem}_edited{edited_name.suffix}"
if verbose: if verbose:
click.echo(f"Exporting edited version of {filename} as {edited_name}") click.echo(f"Exporting edited version of {filename} as {edited_name}")
photo.export( photo.export(
dest, edited_name, sidecar=sidecar, overwrite=overwrite, edited=True dest,
edited_name,
sidecar=sidecar,
overwrite=overwrite,
edited=True,
use_photos_export=download_missing,
) )
else:
click.echo(f"Skipping missing edited photo for {filename}")
if export_live and photo.live_photo and photo.path_live_photo is not None: if export_live and photo.live_photo and photo.path_live_photo is not None:
# if destination exists, will be overwritten regardless of overwrite # if destination exists, will be overwritten regardless of overwrite
@@ -1031,10 +1083,13 @@ def export_photo(
src_live = photo.path_live_photo src_live = photo.path_live_photo
dest_live = pathlib.Path(photo_path).parent / pathlib.Path(live_name) dest_live = pathlib.Path(photo_path).parent / pathlib.Path(live_name)
if src_live is not None:
if verbose: if verbose:
click.echo(f"Exporting live photo video of {filename} as {live_name}") click.echo(f"Exporting live photo video of {filename} as {live_name}")
_copy_file(src_live, str(dest_live)) _copy_file(src_live, str(dest_live))
else:
click.echo(f"Skipping missing live movie for {filename}")
return photo_path return photo_path

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.21.4" __version__ = "0.21.5"

View File

@@ -16,9 +16,18 @@ from pprint import pformat
import yaml import yaml
from ._constants import (_MOVIE_TYPE, _PHOTO_TYPE, _PHOTOS_5_SHARED_PHOTO_PATH, from ._constants import (
_PHOTOS_5_VERSION) _MOVIE_TYPE,
from .utils import _copy_file, _get_resource_loc, dd_to_dms_str _PHOTO_TYPE,
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
)
from .utils import (
_copy_file,
_get_resource_loc,
dd_to_dms_str,
_export_photo_uuid_applescript,
)
# TODO: check pylint output # TODO: check pylint output
@@ -151,11 +160,8 @@ class PhotoInfo:
if not os.path.isfile(photopath): if not os.path.isfile(photopath):
rootdir = os.path.join( rootdir = os.path.join(
library, library, "resources", "media", "version", folder_id
"resources", )
"media",
"version",
folder_id)
for dirname, _, filelist in os.walk(rootdir): for dirname, _, filelist in os.walk(rootdir):
if filename in filelist: if filename in filelist:
@@ -492,6 +498,28 @@ class PhotoInfo:
else: else:
filename = self.filename filename = self.filename
# check destination path
dest = pathlib.Path(dest)
filename = pathlib.Path(filename)
dest = dest / filename
# check to see if file exists and if so, add (1), (2), etc until we find one that works
if increment and not overwrite:
count = 1
dest_new = dest
while dest_new.exists():
dest_new = dest.parent / f"{dest.stem} ({count}){dest.suffix}"
count += 1
dest = dest_new
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
if not use_photos_export:
# find the source file on disk and export
# get path to source file and verify it's not None and is valid file # get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases? # TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited: if edited:
@@ -523,31 +551,28 @@ class PhotoInfo:
if not os.path.isfile(src): if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist") raise FileNotFoundError(f"{src} does not appear to exist")
dest = pathlib.Path(dest)
filename = pathlib.Path(filename)
dest = dest / filename
# check to see if file exists and if so, add (1), (2), etc until we find one that works
if increment and not overwrite:
count = 1
dest_new = dest
while dest_new.exists():
dest_new = dest.parent / f"{dest.stem} ({count}){dest.suffix}"
count += 1
dest = dest_new
logging.debug( logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, incremetn={increment}, dest exists: {dest.exists()}" f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
)
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
) )
# copy the file, _copy_file uses ditto to preserve Mac extended attributes # copy the file, _copy_file uses ditto to preserve Mac extended attributes
_copy_file(src, dest) _copy_file(src, dest)
else:
# use_photo_export
exported = None
if edited:
# exported edited version and not original
exported = _export_photo_uuid_applescript(
self.uuid, dest, original=False, edited=True, timeout=timeout
)
else:
# export original version and not edited
exported = _export_photo_uuid_applescript(
self.uuid, dest, original=True, edited=False, timeout=timeout
)
if exported is None:
logging.warning(f"Error exporting photo {photo.uuid} to {dest}")
if sidecar: if sidecar:
logging.debug("writing exiftool_json_sidecar") logging.debug("writing exiftool_json_sidecar")

View File

@@ -300,6 +300,7 @@ def _export_photo_uuid_applescript(
edited: (boolean) if True, export edited photo; default = False edited: (boolean) if True, export edited photo; default = False
will produce an error if image does not have edits/adjustments will produce an error if image does not have edits/adjustments
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
Returns: path to exported file or None if export failed
""" """
# setup the applescript to do the export # setup the applescript to do the export
@@ -345,7 +346,10 @@ def _export_photo_uuid_applescript(
return None return None
if filename is not None: if filename is not None:
path = os.path.join(tmpdir.name, filename) # need to find actual filename as sometimes Photos renames JPG to jpeg on export
# this assumes only a single file in export folder, which should be true as
# TemporaryDirectory will cleanup on return
path = glob.glob(os.path.join(tmpdir.name, "*"))[0]
_copy_file(path, dest) _copy_file(path, dest)
if os.path.isdir(dest): if os.path.isdir(dest):
new_path = os.path.join(dest, filename) new_path = os.path.join(dest, filename)