Added live-photo option to CLI query and export

This commit is contained in:
Rhet Turnbull
2020-01-04 10:15:56 -08:00
parent 5099fd7715
commit 6f6d37ceac
5 changed files with 135 additions and 26 deletions

View File

@@ -119,14 +119,16 @@ Commands:
To get help on a specific command, use `osxphotos help <command_name>`
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.
```

View File

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

View File

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

View File

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

View File

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