Added live-photo option to CLI query and export
This commit is contained in:
53
README.md
53
README.md
@@ -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.
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.20.00"
|
||||
__version__ = "0.21.00"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 """
|
||||
|
||||
Reference in New Issue
Block a user