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}")
@@ -951,12 +983,12 @@ def _query(
photos = [p for p in photos if p.iscloudasset] photos = [p for p in photos if p.iscloudasset]
elif not_cloudasset: elif not_cloudasset:
photos = [p for p in photos if not p.iscloudasset] photos = [p for p in photos if not p.iscloudasset]
if incloud: if incloud:
photos = [p for p in photos if p.incloud] photos = [p for p in photos if p.incloud]
elif not_incloud: elif not_incloud:
photos = [p for p in photos if not p.incloud] photos = [p for p in photos if not p.incloud]
return photos return photos
@@ -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,19 +1014,24 @@ 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 photo.ismissing: if not download_missing:
space = " " if not verbose else "" if photo.ismissing:
click.echo(f"{space}Skipping missing photo {photo.filename}") space = " " if not verbose else ""
return None click.echo(f"{space}Skipping missing photo {photo.filename}")
elif not os.path.exists(photo.path): return None
space = " " if not verbose else "" elif not os.path.exists(photo.path):
click.echo( space = " " if not verbose else ""
f"{space}WARNING: file {photo.path} is missing but ismissing=False, " click.echo(
f"skipping {photo.filename}" f"{space}WARNING: file {photo.path} is missing but ismissing=False, "
) f"skipping {photo.filename}"
)
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 return None
filename = None filename = None
@@ -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:
edited_name = pathlib.Path(filename) if download_missing or photo.path_edited is not None:
edited_name = f"{edited_name.stem}_edited{edited_name.suffix}" edited_name = pathlib.Path(filename)
if verbose: edited_name = f"{edited_name.stem}_edited{edited_name.suffix}"
click.echo(f"Exporting edited version of {filename} as {edited_name}") if verbose:
photo.export( click.echo(f"Exporting edited version of {filename} as {edited_name}")
dest, edited_name, sidecar=sidecar, overwrite=overwrite, edited=True photo.export(
) 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,11 +1083,14 @@ 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 verbose: if src_live is not None:
click.echo(f"Exporting live photo video of {filename} as {live_name}") if verbose:
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,18 +160,15 @@ 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:
photopath = os.path.join(dirname, filename) photopath = os.path.join(dirname, filename)
break break
# check again to see if we found a valid file # check again to see if we found a valid file
if not os.path.isfile(photopath): if not os.path.isfile(photopath):
logging.warning( logging.warning(
f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist" f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
@@ -492,37 +498,7 @@ class PhotoInfo:
else: else:
filename = self.filename filename = self.filename
# get path to source file and verify it's not None and is valid file # check destination path
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if not self.hasadjustments:
logging.warning(
"Attempting to export edited photo but hasadjustments=False"
)
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is None:
logging.warning(
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
)
raise FileNotFoundError("Cannot export photo if path is None")
else:
src = self.path
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
dest = pathlib.Path(dest) dest = pathlib.Path(dest)
filename = pathlib.Path(filename) filename = pathlib.Path(filename)
dest = dest / filename dest = dest / filename
@@ -536,18 +512,67 @@ class PhotoInfo:
count += 1 count += 1
dest = dest_new dest = dest_new
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, incremetn={increment}, dest exists: {dest.exists()}"
)
# if overwrite==False and #increment==False, export should fail if file exists # if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment: if dest.exists() and not overwrite and not increment:
raise FileExistsError( raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}" f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
) )
# copy the file, _copy_file uses ditto to preserve Mac extended attributes if not use_photos_export:
_copy_file(src, dest) # find the source file on disk and export
# 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?
if edited:
if not self.hasadjustments:
logging.warning(
"Attempting to export edited photo but hasadjustments=False"
)
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is None:
logging.warning(
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
)
raise FileNotFoundError("Cannot export photo if path is None")
else:
src = self.path
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
)
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
_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)