Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675371f0d7 | ||
|
|
7e2d09bf12 | ||
|
|
28c681aa96 | ||
|
|
5d39aa92df | ||
|
|
b4dbad5e74 | ||
|
|
b1b099257f | ||
|
|
63e8410841 |
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.42.54](https://github.com/RhetTbull/osxphotos/compare/v0.42.52...v0.42.54)
|
||||
|
||||
> 2 July 2021
|
||||
|
||||
- Removed _applescript, #461 [`1d26ac9`](https://github.com/RhetTbull/osxphotos/commit/1d26ac9630dd0a414c01cc4f89a080e4efd7fd97)
|
||||
- Removed _applescript, #461 [`03b4f59`](https://github.com/RhetTbull/osxphotos/commit/03b4f59549de54da91c36feba613d69f9e86e47b)
|
||||
- Added get_selected() to REPL [`2e1c91c`](https://github.com/RhetTbull/osxphotos/commit/2e1c91cd672eefe84063933437e5d691f5ad1db1)
|
||||
|
||||
#### [v0.42.52](https://github.com/RhetTbull/osxphotos/compare/v0.42.51...v0.42.52)
|
||||
|
||||
> 2 July 2021
|
||||
|
||||
26
README.md
26
README.md
@@ -3,9 +3,7 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
|
||||

|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
|
||||
|
||||
@@ -891,6 +889,19 @@ Options:
|
||||
best quality, a value of 0.0 specifies maximum
|
||||
compression. Defaults to 1.0
|
||||
|
||||
--preview Export preview image generated by Photos. This
|
||||
is a lower-resolution image used by Photos to
|
||||
quickly preview the image.
|
||||
|
||||
--preview-suffix SUFFIX Optional suffix template for naming preview
|
||||
photos. Default name for preview photos is in
|
||||
form 'photoname_preview.ext'. For example,
|
||||
with '--preview-suffix _low_res', the preview
|
||||
photo would be named 'photoname_low_res.ext'.
|
||||
The default suffix is '_preview'. Multi-value
|
||||
templates (see Templating System) are not
|
||||
permitted with --preview-suffix.
|
||||
|
||||
--download-missing Attempt to download missing photos from
|
||||
iCloud. The current implementation uses
|
||||
Applescript to interact with Photos to export
|
||||
@@ -1756,7 +1767,7 @@ Substitution Description
|
||||
{lf} A line feed: '\n', alias for {newline}
|
||||
{cr} A carriage return: '\r'
|
||||
{crlf} a carriage return + line feed: '\r\n'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.42.52'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.42.57'
|
||||
{osxphotos_cmd_line} The full command line used to run osxphotos
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified for
|
||||
@@ -2670,6 +2681,9 @@ Returns the path to the live video component of a [live photo](#live_photo). If
|
||||
|
||||
**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud.
|
||||
|
||||
#### `path_edited_live_photo`
|
||||
Returns the path to the edited live video component of an edited [live photo](#live_photo). If photo is not a live photo or not edited, returns None.
|
||||
|
||||
#### `portrait`
|
||||
Returns True if photo was taken in iPhone portrait mode, otherwise False.
|
||||
|
||||
@@ -2781,11 +2795,11 @@ Returns a JSON representation of all photo info.
|
||||
Returns a dictionary representation of all photo info.
|
||||
|
||||
#### `export()`
|
||||
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||
`export(dest, filename=None, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||
|
||||
Export photo from the Photos library to another destination on disk.
|
||||
- dest: must be valid destination path as str (or exception raised).
|
||||
- *filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
|
||||
- filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
|
||||
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
|
||||
- export_as_hardlink: boolean; if True (default=False), will hardlink files instead of copying them
|
||||
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
|
||||
@@ -3578,7 +3592,7 @@ The following template field substitutions are availabe for use the templating s
|
||||
|{lf}|A line feed: '\n', alias for {newline}|
|
||||
|{cr}|A carriage return: '\r'|
|
||||
|{crlf}|a carriage return + line feed: '\r\n'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.42.52'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.42.57'|
|
||||
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|
||||
|{album}|Album(s) photo is contained in|
|
||||
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|
||||
|
||||
@@ -210,6 +210,9 @@ DEFAULT_EDITED_SUFFIX = "_edited"
|
||||
# Default suffix to add to original images
|
||||
DEFAULT_ORIGINAL_SUFFIX = ""
|
||||
|
||||
# Default suffix to add to preview images
|
||||
DEFAULT_PREVIEW_SUFFIX = "_preview"
|
||||
|
||||
# Colors for print CLI messages
|
||||
CLI_COLOR_ERROR = "red"
|
||||
CLI_COLOR_WARNING = "yellow"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.54"
|
||||
__version__ = "0.42.57"
|
||||
|
||||
145
osxphotos/cli.py
145
osxphotos/cli.py
@@ -17,6 +17,7 @@ import bitmath
|
||||
import click
|
||||
import osxmetadata
|
||||
import photoscript
|
||||
import rich.traceback
|
||||
import yaml
|
||||
from rich import pretty
|
||||
|
||||
@@ -32,6 +33,7 @@ from ._constants import (
|
||||
DEFAULT_EDITED_SUFFIX,
|
||||
DEFAULT_JPEG_QUALITY,
|
||||
DEFAULT_ORIGINAL_SUFFIX,
|
||||
DEFAULT_PREVIEW_SUFFIX,
|
||||
EXTENDED_ATTRIBUTE_NAMES,
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
@@ -65,6 +67,8 @@ from .utils import expand_and_validate_filepath, load_function
|
||||
# set via --verbose/-V
|
||||
VERBOSE = False
|
||||
|
||||
rich.traceback.install()
|
||||
|
||||
|
||||
def verbose_(*args, **kwargs):
|
||||
"""print output if verbose flag set"""
|
||||
@@ -701,6 +705,20 @@ def cli(ctx, db, json_, debug):
|
||||
"a value of 0.0 specifies maximum compression. "
|
||||
f"Defaults to {DEFAULT_JPEG_QUALITY}",
|
||||
)
|
||||
@click.option(
|
||||
"--preview",
|
||||
is_flag=True,
|
||||
help="Export preview image generated by Photos. "
|
||||
"This is a lower-resolution image used by Photos to quickly preview the image.",
|
||||
)
|
||||
@click.option(
|
||||
"--preview-suffix",
|
||||
metavar="SUFFIX",
|
||||
help="Optional suffix template for naming preview photos. Default name for preview photos is in form "
|
||||
f"'photoname{DEFAULT_PREVIEW_SUFFIX}.ext'. For example, with '--preview-suffix _low_res', the preview photo "
|
||||
f"would be named 'photoname_low_res.ext'. The default suffix is '{DEFAULT_PREVIEW_SUFFIX}'. "
|
||||
"Multi-value templates (see Templating System) are not permitted with --preview-suffix.",
|
||||
)
|
||||
@click.option(
|
||||
"--download-missing",
|
||||
is_flag=True,
|
||||
@@ -1160,6 +1178,8 @@ def export(
|
||||
duplicate,
|
||||
post_command,
|
||||
post_function,
|
||||
preview,
|
||||
preview_suffix,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1320,6 +1340,8 @@ def export(
|
||||
duplicate = cfg.duplicate
|
||||
post_command = cfg.post_command
|
||||
post_function = cfg.post_function
|
||||
preview = cfg.preview
|
||||
preview_suffix = cfg.preview_suffix
|
||||
|
||||
# config file might have changed verbose
|
||||
VERBOSE = bool(verbose)
|
||||
@@ -1409,6 +1431,9 @@ def export(
|
||||
original_suffix = (
|
||||
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
|
||||
)
|
||||
preview_suffix = (
|
||||
DEFAULT_PREVIEW_SUFFIX if preview_suffix is None else preview_suffix
|
||||
)
|
||||
retry = 0 if not retry else retry
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
@@ -1726,6 +1751,8 @@ def export(
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=dest,
|
||||
export_preview=preview,
|
||||
preview_suffix=preview_suffix,
|
||||
)
|
||||
|
||||
if post_function:
|
||||
@@ -2380,6 +2407,8 @@ def export_photo(
|
||||
replace_keywords=False,
|
||||
retry=0,
|
||||
export_dir=None,
|
||||
export_preview=False,
|
||||
preview_suffix=None,
|
||||
):
|
||||
"""Helper function for export that does the actual export
|
||||
|
||||
@@ -2421,6 +2450,8 @@ def export_photo(
|
||||
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
|
||||
retry: retry up to retry # of times if there's an error
|
||||
export_dir: top-level export directory for {export_dir} template
|
||||
export_preview: export the preview image generated by Photos
|
||||
preview_suffix: str, template to use as suffix for preview images
|
||||
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
@@ -2481,27 +2512,12 @@ def export_photo(
|
||||
if "exiftool" in sidecar:
|
||||
sidecar_flags |= SIDECAR_EXIFTOOL
|
||||
|
||||
rendered_suffix = ""
|
||||
if original_suffix:
|
||||
try:
|
||||
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
|
||||
rendered_suffix, unmatched = photo.render_template(original_suffix, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix '{original_suffix}': {e}",
|
||||
)
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unknown field={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix: may not use multi-valued templates: '{original_suffix}': results={rendered_suffix}",
|
||||
)
|
||||
rendered_suffix = rendered_suffix[0]
|
||||
rendered_suffix = _render_suffix_template(
|
||||
original_suffix, "original_suffix", "--original-suffix", strip, dest, photo
|
||||
)
|
||||
rendered_preview_suffix = _render_suffix_template(
|
||||
preview_suffix, "preview_suffix", "--preview-suffix", strip, dest, photo
|
||||
)
|
||||
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
@@ -2580,6 +2596,8 @@ def export_photo(
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=export_dir,
|
||||
export_preview=export_preview,
|
||||
preview_suffix=rendered_preview_suffix,
|
||||
)
|
||||
|
||||
if export_edited and photo.hasadjustments:
|
||||
@@ -2611,35 +2629,12 @@ def export_photo(
|
||||
):
|
||||
edited_ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
|
||||
|
||||
if edited_suffix:
|
||||
try:
|
||||
options = RenderOptions(
|
||||
filename=True,
|
||||
strip=strip,
|
||||
export_dir=dest,
|
||||
)
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
edited_suffix, options
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix '{edited_suffix}': {e}",
|
||||
)
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix '{edited_suffix}': unknown field={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix: may not use multi-valued templates: '{edited_suffix}': results={rendered_suffix}",
|
||||
)
|
||||
rendered_suffix = rendered_suffix[0]
|
||||
edited_filename = f"{edited_filename.stem}{rendered_suffix}{edited_ext}"
|
||||
else:
|
||||
edited_filename = f"{edited_filename.stem}{edited_ext}"
|
||||
rendered_edited_suffix = _render_suffix_template(
|
||||
edited_suffix, "edited_suffix", "--edited-suffix", strip, dest, photo
|
||||
)
|
||||
edited_filename = (
|
||||
f"{edited_filename.stem}{rendered_edited_suffix}{edited_ext}"
|
||||
)
|
||||
|
||||
verbose_(
|
||||
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
||||
@@ -2655,13 +2650,13 @@ def export_photo(
|
||||
dest=dest,
|
||||
dry_run=dry_run,
|
||||
strip=strip,
|
||||
export_original=export_original,
|
||||
export_original=False,
|
||||
missing=missing_edited,
|
||||
verbose=verbose,
|
||||
sidecar_flags=sidecar_flags,
|
||||
sidecar_flags=sidecar_flags if not export_original else 0,
|
||||
sidecar_drop_ext=sidecar_drop_ext,
|
||||
export_live=export_live,
|
||||
export_raw=export_raw,
|
||||
export_raw=not export_original and export_raw,
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
overwrite=overwrite,
|
||||
exiftool=exiftool,
|
||||
@@ -2685,11 +2680,43 @@ def export_photo(
|
||||
replace_keywords=replace_keywords,
|
||||
retry=retry,
|
||||
export_dir=export_dir,
|
||||
export_preview=not export_original and export_preview,
|
||||
preview_suffix=rendered_preview_suffix,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _render_suffix_template(suffix_template, var_name, option_name, strip, dest, photo):
|
||||
"""render suffix template
|
||||
|
||||
Returns:
|
||||
rendered template
|
||||
"""
|
||||
if not suffix_template:
|
||||
return ""
|
||||
|
||||
try:
|
||||
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
|
||||
rendered_suffix, unmatched = photo.render_template(suffix_template, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
var_name,
|
||||
f"Invalid template for {option_name} '{suffix_template}': {e}",
|
||||
)
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
var_name,
|
||||
f"Invalid template for {option_name} '{suffix_template}': results={rendered_suffix} unknown field={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
var_name,
|
||||
f"Invalid template for {option_name}: may not use multi-valued templates: '{suffix_template}': results={rendered_suffix}",
|
||||
)
|
||||
return rendered_suffix[0]
|
||||
|
||||
|
||||
def export_photo_with_template(
|
||||
photo,
|
||||
filename,
|
||||
@@ -2730,6 +2757,8 @@ def export_photo_with_template(
|
||||
replace_keywords,
|
||||
retry,
|
||||
export_dir,
|
||||
export_preview,
|
||||
preview_suffix,
|
||||
):
|
||||
"""Evaluate directory template then export photo to each directory"""
|
||||
|
||||
@@ -2789,8 +2818,10 @@ def export_photo_with_template(
|
||||
try:
|
||||
export_results = photo.export2(
|
||||
dest_path,
|
||||
filename,
|
||||
original_filename=filename,
|
||||
edited=edited,
|
||||
original=export_original,
|
||||
edited_filename=filename,
|
||||
sidecar=sidecar_flags,
|
||||
sidecar_drop_ext=sidecar_drop_ext,
|
||||
live_photo=export_live,
|
||||
@@ -2820,6 +2851,8 @@ def export_photo_with_template(
|
||||
jpeg_ext=jpeg_ext,
|
||||
replace_keywords=replace_keywords,
|
||||
render_options=render_options,
|
||||
preview=export_preview,
|
||||
preview_suffix=preview_suffix,
|
||||
)
|
||||
for warning_ in export_results.exiftool_warning:
|
||||
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
|
||||
@@ -3949,7 +3982,9 @@ def repl(ctx, cli_obj, db):
|
||||
)
|
||||
print(f"\nThe following functions may be helpful:")
|
||||
print(f"- get_photo(uuid): return a PhotoInfo object for photo with uuid")
|
||||
print(f"- get_selected(): return list of PhotoInfo objects for photos selected in Photos")
|
||||
print(
|
||||
f"- get_selected(): return list of PhotoInfo objects for photos selected in Photos"
|
||||
)
|
||||
print(f"- show(photo): open a photo object in the default viewer")
|
||||
print(
|
||||
f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
|
||||
|
||||
@@ -37,6 +37,7 @@ from .._constants import (
|
||||
_UNKNOWN_PERSON,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
_XMP_TEMPLATE_NAME_BETA,
|
||||
DEFAULT_PREVIEW_SUFFIX,
|
||||
LIVE_VIDEO_EXTENSIONS,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
@@ -379,7 +380,7 @@ def rename_jpeg_files(files, jpeg_ext, fileutil):
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
*filename,
|
||||
filename=None,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
raw_photo=False,
|
||||
@@ -410,12 +411,12 @@ def export(
|
||||
silently ignored).
|
||||
e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version
|
||||
(or raise exception if no edited version)
|
||||
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
||||
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
|
||||
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
|
||||
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
|
||||
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
|
||||
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||
overwrite: (boolean, default=False); if True will overwrite files if they already exist
|
||||
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
|
||||
@@ -449,10 +450,25 @@ def export(
|
||||
if sidecar_xmp:
|
||||
sidecar |= SIDECAR_XMP
|
||||
|
||||
if not filename:
|
||||
if not edited:
|
||||
filename = self.original_filename
|
||||
else:
|
||||
original_name = pathlib.Path(self.original_filename)
|
||||
if self.path_edited:
|
||||
ext = pathlib.Path(self.path_edited).suffix
|
||||
else:
|
||||
uti = self.uti_edited if edited and self.uti_edited else self.uti
|
||||
ext = get_preferred_uti_extension(uti)
|
||||
ext = "." + ext
|
||||
filename = original_name.stem + "_edited" + ext
|
||||
|
||||
results = self.export2(
|
||||
dest,
|
||||
*filename,
|
||||
original=not edited,
|
||||
original_filename=filename,
|
||||
edited=edited,
|
||||
edited_filename=filename,
|
||||
live_photo=live_photo,
|
||||
raw_photo=raw_photo,
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
@@ -466,7 +482,7 @@ def export(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
render_options = render_options,
|
||||
render_options=render_options,
|
||||
)
|
||||
|
||||
return results.exported
|
||||
@@ -475,8 +491,10 @@ def export(
|
||||
def export2(
|
||||
self,
|
||||
dest,
|
||||
*filename,
|
||||
original=True,
|
||||
original_filename=None,
|
||||
edited=False,
|
||||
edited_filename=None,
|
||||
live_photo=False,
|
||||
raw_photo=False,
|
||||
export_as_hardlink=False,
|
||||
@@ -509,7 +527,9 @@ def export2(
|
||||
persons=True,
|
||||
location=True,
|
||||
replace_keywords=False,
|
||||
render_options: Optional[RenderOptions] = None
|
||||
preview=False,
|
||||
preview_suffix=DEFAULT_PREVIEW_SUFFIX,
|
||||
render_options: Optional[RenderOptions] = None,
|
||||
):
|
||||
"""export photo, like export but with update and dry_run options
|
||||
dest: must be valid destination path or exception raised
|
||||
@@ -521,8 +541,8 @@ def export2(
|
||||
in which case export will use the extension provided by Photos upon export.
|
||||
e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
original: (boolean, default=True); if True, will export the original version of the photo
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
(or raise exception if no edited version)
|
||||
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
|
||||
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
|
||||
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
|
||||
@@ -565,6 +585,8 @@ def export2(
|
||||
persons: if True, include persons in exported metadata
|
||||
location: if True, include location in exported metadata
|
||||
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
|
||||
preview: if True, also exports preview image
|
||||
preview_suffix: optional string to append to end of filename for preview images
|
||||
render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
|
||||
|
||||
Returns: ExportResults class
|
||||
@@ -609,206 +631,280 @@ def export2(
|
||||
|
||||
self._render_options = render_options or RenderOptions()
|
||||
|
||||
# suffix to add to edited files
|
||||
# e.g. name will be filename_edited.jpg
|
||||
edited_identifier = "_edited"
|
||||
|
||||
# check edited and raise exception trying to export edited version of
|
||||
# photo that hasn't been edited
|
||||
export_original = original
|
||||
export_edited = edited
|
||||
if edited and not self.hasadjustments:
|
||||
raise ValueError(
|
||||
"Photo does not have adjustments, cannot export edited version"
|
||||
)
|
||||
|
||||
# check arguments and get destination path and filename (if provided)
|
||||
if filename and len(filename) > 2:
|
||||
raise TypeError(
|
||||
"Too many positional arguments. Should be at most two: destination, filename."
|
||||
)
|
||||
|
||||
# verify destination is a valid path
|
||||
if dest is None:
|
||||
raise ValueError("Destination must not be None")
|
||||
raise ValueError("dest must not be None")
|
||||
elif not dry_run and not os.path.isdir(dest):
|
||||
raise FileNotFoundError("Invalid path passed to export")
|
||||
|
||||
if filename and len(filename) == 1:
|
||||
# if filename passed, use it
|
||||
fname = filename[0]
|
||||
else:
|
||||
# no filename provided so use the default
|
||||
# if edited file requested, use filename but add _edited
|
||||
# need to use file extension from edited file as Photos saves a jpeg once edited
|
||||
if edited and not use_photos_export:
|
||||
# verify we have a valid path_edited and use that to get filename
|
||||
if not self.path_edited:
|
||||
raise FileNotFoundError(
|
||||
"edited=True but path_edited is none; hasadjustments: "
|
||||
f" {self.hasadjustments}"
|
||||
)
|
||||
edited_name = pathlib.Path(self.path_edited).name
|
||||
edited_suffix = pathlib.Path(edited_name).suffix
|
||||
fname = (
|
||||
pathlib.Path(self.original_filename).stem
|
||||
+ edited_identifier
|
||||
+ edited_suffix
|
||||
)
|
||||
original_filename = original_filename or self.original_filename
|
||||
dest_original = pathlib.Path(dest) / original_filename
|
||||
|
||||
if not edited_filename:
|
||||
if not edited:
|
||||
edited_filename = self.original_filename
|
||||
else:
|
||||
fname = self.original_filename
|
||||
original_name = pathlib.Path(self.original_filename)
|
||||
if self.path_edited:
|
||||
ext = pathlib.Path(self.path_edited).suffix
|
||||
else:
|
||||
uti = self.uti_edited if edited and self.uti_edited else self.uti
|
||||
ext = get_preferred_uti_extension(uti)
|
||||
ext = "." + ext
|
||||
edited_filename = original_name.stem + "_edited" + ext
|
||||
dest_edited = pathlib.Path(dest) / edited_filename
|
||||
|
||||
uti = self.uti if edited else self.uti_original
|
||||
if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
|
||||
# not a jpeg but will convert to jpeg upon export so fix file extension
|
||||
fname_new = pathlib.Path(fname)
|
||||
if convert_to_jpeg and self.isphoto:
|
||||
something_to_convert = False
|
||||
ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
|
||||
fname = str(fname_new.parent / f"{fname_new.stem}{ext}")
|
||||
if export_original and self.uti_original != "public.jpeg":
|
||||
# not a jpeg but will convert to jpeg upon export so fix file extension
|
||||
something_to_convert = True
|
||||
dest_original = dest_original.parent / f"{dest_original.stem}{ext}"
|
||||
if export_edited and self.uti != "public.jpeg":
|
||||
# in Big Sur+, edited HEICs are HEIC
|
||||
something_to_convert = True
|
||||
dest_edited = dest_edited.parent / f"{dest_edited.stem}{ext}"
|
||||
convert_to_jpeg = something_to_convert
|
||||
else:
|
||||
# nothing to convert
|
||||
convert_to_jpeg = False
|
||||
|
||||
# check destination path
|
||||
dest = pathlib.Path(dest)
|
||||
fname = pathlib.Path(fname)
|
||||
dest = dest / fname
|
||||
|
||||
# check to see if file exists and if so, add (1), (2), etc until we find one that works
|
||||
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
|
||||
# e.g. exporting sidecar for file1.png and file1.jpeg
|
||||
# if file1.png exists and exporting file1.jpeg,
|
||||
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
|
||||
count = 0
|
||||
if not update and increment and not overwrite:
|
||||
count = 1
|
||||
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
|
||||
dest_files = findfiles(f"{dest_original.stem}*", str(dest_original.parent))
|
||||
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
dest_new = dest_original.stem
|
||||
while dest_new.lower() in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
dest_new = f"{dest_original.stem} ({count})"
|
||||
dest_original = dest_original.parent / f"{dest_new}{dest_original.suffix}"
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest.exists() and not update and not overwrite and not increment:
|
||||
if (
|
||||
dest_original.exists()
|
||||
and export_original
|
||||
and not update
|
||||
and not overwrite
|
||||
and not increment
|
||||
):
|
||||
raise FileExistsError(
|
||||
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
|
||||
f"destination exists ({dest_original}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
self._render_options.filepath = str(dest)
|
||||
if export_edited:
|
||||
if not update and increment and not overwrite:
|
||||
dest_files = findfiles(f"{dest_edited.stem}*", str(dest_edited.parent))
|
||||
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
|
||||
dest_new = dest_edited.stem
|
||||
if count:
|
||||
# incremented above when checking original destination
|
||||
dest_new = f"{dest_new} ({count})"
|
||||
while dest_new.lower() in dest_files:
|
||||
count += 1
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
dest_edited = dest_edited.parent / f"{dest_new}{dest_edited.suffix}"
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest_edited.exists() and not update and not overwrite and not increment:
|
||||
raise FileExistsError(
|
||||
f"destination exists ({dest_edited}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
self._render_options.filepath = (
|
||||
str(dest_original) if export_original else str(dest_edited)
|
||||
)
|
||||
all_results = ExportResults()
|
||||
if not use_photos_export:
|
||||
|
||||
if use_photos_export:
|
||||
# TODO: collapse these into a single call (refactor _export_photo_with_photos_export)
|
||||
if original:
|
||||
self._export_photo_with_photos_export(
|
||||
dest_original,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
use_photokit=use_photokit,
|
||||
dry_run=dry_run,
|
||||
timeout=timeout,
|
||||
jpeg_ext=jpeg_ext,
|
||||
touch_file=touch_file,
|
||||
update=update,
|
||||
overwrite=overwrite,
|
||||
live_photo=live_photo,
|
||||
edited=False,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
)
|
||||
if edited:
|
||||
self._export_photo_with_photos_export(
|
||||
dest_edited,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
use_photokit=use_photokit,
|
||||
dry_run=dry_run,
|
||||
timeout=timeout,
|
||||
jpeg_ext=jpeg_ext,
|
||||
touch_file=touch_file,
|
||||
update=update,
|
||||
overwrite=overwrite,
|
||||
live_photo=live_photo,
|
||||
edited=True,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
)
|
||||
else:
|
||||
# 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?
|
||||
export_src_dest = []
|
||||
if edited:
|
||||
if self.path_edited is not None:
|
||||
src = self.path_edited
|
||||
export_src_dest.append((self.path_edited, dest_edited))
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Cannot export edited photo if path_edited is None"
|
||||
)
|
||||
else:
|
||||
if self.path is not None:
|
||||
src = self.path
|
||||
export_src_dest.append((self.path, dest_original))
|
||||
else:
|
||||
raise FileNotFoundError("Cannot export photo if path is None")
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError(f"{src} does not appear to exist")
|
||||
for src, dest in export_src_dest:
|
||||
if not pathlib.Path(src).is_file():
|
||||
raise FileNotFoundError(f"{src} does not appear to exist")
|
||||
|
||||
# found source now try to find right destination
|
||||
if update and dest.exists():
|
||||
# destination exists, check to see if destination is the right UUID
|
||||
dest_uuid = export_db.get_uuid_for_file(dest)
|
||||
if dest_uuid is None and fileutil.cmp(src, dest):
|
||||
# might be exporting into a pre-ExportDB folder or the DB got deleted
|
||||
dest_uuid = self.uuid
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.uuid,
|
||||
orig_stat=fileutil.file_sig(dest),
|
||||
exif_stat=(None, None, None),
|
||||
converted_stat=(None, None, None),
|
||||
edited_stat=(None, None, None),
|
||||
info_json=self.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
if dest_uuid != self.uuid:
|
||||
# not the right file, find the right one
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
|
||||
dest_files = glob.glob(glob_str)
|
||||
found_match = False
|
||||
for file_ in dest_files:
|
||||
dest_uuid = export_db.get_uuid_for_file(file_)
|
||||
if dest_uuid == self.uuid:
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
break
|
||||
elif dest_uuid is None and fileutil.cmp(src, file_):
|
||||
# files match, update the UUID
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.uuid,
|
||||
orig_stat=fileutil.file_sig(dest),
|
||||
exif_stat=(None, None, None),
|
||||
converted_stat=(None, None, None),
|
||||
edited_stat=(None, None, None),
|
||||
info_json=self.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
# increment the destination file
|
||||
# found source now try to find right destination
|
||||
if update and dest.exists():
|
||||
# destination exists, check to see if destination is the right UUID
|
||||
dest_uuid = export_db.get_uuid_for_file(dest)
|
||||
if dest_uuid is None and fileutil.cmp(src, dest):
|
||||
# might be exporting into a pre-ExportDB folder or the DB got deleted
|
||||
dest_uuid = self.uuid
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.uuid,
|
||||
orig_stat=fileutil.file_sig(dest),
|
||||
exif_stat=(None, None, None),
|
||||
converted_stat=(None, None, None),
|
||||
edited_stat=(None, None, None),
|
||||
info_json=self.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
if dest_uuid != self.uuid:
|
||||
# not the right file, find the right one
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem}*")
|
||||
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
|
||||
dest_files = glob.glob(glob_str)
|
||||
dest_files = [pathlib.Path(f).stem for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
found_match = False
|
||||
for file_ in dest_files:
|
||||
dest_uuid = export_db.get_uuid_for_file(file_)
|
||||
if dest_uuid == self.uuid:
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
break
|
||||
elif dest_uuid is None and fileutil.cmp(src, file_):
|
||||
# files match, update the UUID
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
export_db.set_data(
|
||||
filename=dest,
|
||||
uuid=self.uuid,
|
||||
orig_stat=fileutil.file_sig(dest),
|
||||
exif_stat=(None, None, None),
|
||||
converted_stat=(None, None, None),
|
||||
edited_stat=(None, None, None),
|
||||
info_json=self.json(),
|
||||
exif_json=None,
|
||||
)
|
||||
break
|
||||
|
||||
# export the dest file
|
||||
results = self._export_photo(
|
||||
src,
|
||||
dest,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
convert_to_jpeg,
|
||||
fileutil=fileutil,
|
||||
edited=edited,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
if not found_match:
|
||||
# increment the destination file
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem}*")
|
||||
dest_files = glob.glob(glob_str)
|
||||
dest_files = [pathlib.Path(f).stem for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
|
||||
# export the dest file
|
||||
results = self._export_photo(
|
||||
src,
|
||||
dest,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
convert_to_jpeg,
|
||||
fileutil=fileutil,
|
||||
edited=edited,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
dest = dest_original if export_original else dest_edited
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
if export_original and live_photo and self.live_photo and self.path_live_photo:
|
||||
live_name = dest.parent / f"{dest.stem}.mov"
|
||||
src_live = self.path_live_photo
|
||||
results = self._export_photo(
|
||||
src_live,
|
||||
live_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
False,
|
||||
fileutil=fileutil,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
if src_live is not None:
|
||||
results = self._export_photo(
|
||||
src_live,
|
||||
live_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
False,
|
||||
fileutil=fileutil,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
if (
|
||||
export_edited
|
||||
and live_photo
|
||||
and self.live_photo
|
||||
and self.path_edited_live_photo
|
||||
):
|
||||
live_name = dest.parent / f"{dest_edited.stem}.mov"
|
||||
src_live = self.path_edited_live_photo
|
||||
results = self._export_photo(
|
||||
src_live,
|
||||
live_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
False,
|
||||
fileutil=fileutil,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
# copy associated RAW image if requested
|
||||
if raw_photo and self.has_raw:
|
||||
@@ -831,26 +927,30 @@ def export2(
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
else:
|
||||
self._export_photo_with_photos_export(
|
||||
dest,
|
||||
filename,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
use_photokit=use_photokit,
|
||||
dry_run=dry_run,
|
||||
timeout=timeout,
|
||||
jpeg_ext=jpeg_ext,
|
||||
touch_file=touch_file,
|
||||
update=update,
|
||||
overwrite=overwrite,
|
||||
live_photo=live_photo,
|
||||
edited=edited,
|
||||
edited_identifier=edited_identifier,
|
||||
convert_to_jpeg=convert_to_jpeg,
|
||||
jpeg_quality=jpeg_quality,
|
||||
)
|
||||
|
||||
# copy preview image if requested
|
||||
if preview and self.path_derivatives:
|
||||
# Photos keeps multiple different derivatives and path_derivatives returns list of them
|
||||
# first derivative is the largest so export that one
|
||||
preview_path = pathlib.Path(self.path_derivatives[0])
|
||||
preview_ext = preview_path.suffix
|
||||
preview_name = dest.parent / f"{dest.stem}{preview_suffix}{preview_ext}"
|
||||
if preview_path is not None:
|
||||
results = self._export_photo(
|
||||
preview_path,
|
||||
preview_name,
|
||||
update,
|
||||
export_db,
|
||||
overwrite,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
convert_to_jpeg,
|
||||
fileutil=fileutil,
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
all_results += results
|
||||
|
||||
# export metadata
|
||||
sidecars = []
|
||||
@@ -861,6 +961,7 @@ def export2(
|
||||
sidecar_xmp_files_skipped = []
|
||||
sidecar_xmp_files_written = []
|
||||
|
||||
dest = dest_original if export_original else dest_edited
|
||||
dest_suffix = "" if sidecar_drop_ext else dest.suffix
|
||||
if sidecar & SIDECAR_JSON:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
|
||||
@@ -1111,7 +1212,6 @@ def export2(
|
||||
def _export_photo_with_photos_export(
|
||||
self,
|
||||
dest,
|
||||
filename,
|
||||
all_results,
|
||||
fileutil,
|
||||
export_db,
|
||||
@@ -1124,7 +1224,6 @@ def _export_photo_with_photos_export(
|
||||
overwrite=None,
|
||||
live_photo=None,
|
||||
edited=None,
|
||||
edited_identifier=None,
|
||||
convert_to_jpeg=None,
|
||||
jpeg_quality=1.0,
|
||||
):
|
||||
@@ -1137,15 +1236,10 @@ def _export_photo_with_photos_export(
|
||||
# shared photos (in shared albums) show up as not having adjustments (not edited)
|
||||
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
|
||||
# so tell Photos to export the current version in this case
|
||||
if filename:
|
||||
# use filename stem provided
|
||||
filestem = dest.stem
|
||||
else:
|
||||
# didn't get passed a filename, add _edited
|
||||
filestem = f"{dest.stem}{edited_identifier}"
|
||||
uti = self.uti_edited if edited and self.uti_edited else self.uti
|
||||
ext = get_preferred_uti_extension(uti)
|
||||
dest = dest.parent / f"{filestem}{ext}"
|
||||
# didn't get passed a filename, add _edited
|
||||
uti = self.uti_edited if edited and self.uti_edited else self.uti
|
||||
ext = get_preferred_uti_extension(uti)
|
||||
dest = dest.parent / f"{dest.stem}.{ext}"
|
||||
|
||||
if use_photokit:
|
||||
photolib = PhotoLibrary()
|
||||
@@ -1190,7 +1284,7 @@ def _export_photo_with_photos_export(
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
filestem=dest.stem,
|
||||
original=False,
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
@@ -1204,7 +1298,6 @@ def _export_photo_with_photos_export(
|
||||
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
|
||||
else:
|
||||
# export original version and not edited
|
||||
filestem = dest.stem
|
||||
if use_photokit:
|
||||
photolib = PhotoLibrary()
|
||||
photo = None
|
||||
@@ -1241,7 +1334,7 @@ def _export_photo_with_photos_export(
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
filestem=dest.stem,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=live_photo,
|
||||
@@ -1608,7 +1701,9 @@ def _exiftool_dict(
|
||||
)
|
||||
|
||||
if description_template is not None:
|
||||
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, expand_inplace=True, inplace_sep=", "
|
||||
)
|
||||
rendered = self.render_template(description_template, options)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
exif["EXIF:ImageDescription"] = description
|
||||
@@ -1647,7 +1742,9 @@ def _exiftool_dict(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
for template_str in keyword_template:
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
@@ -1925,7 +2022,9 @@ def _xmp_sidecar(
|
||||
extension = extension.suffix[1:] if extension.suffix else None
|
||||
|
||||
if description_template is not None:
|
||||
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, expand_inplace=True, inplace_sep=", "
|
||||
)
|
||||
rendered = self.render_template(description_template, options)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
else:
|
||||
@@ -1958,7 +2057,9 @@ def _xmp_sidecar(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
|
||||
options = dataclasses.replace(
|
||||
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
for template_str in keyword_template:
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
|
||||
@@ -342,6 +342,37 @@ class PhotoInfo:
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_edited_live_photo(self):
|
||||
"""return path to edited version of live photo movie; only valid for Photos 5+"""
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._path_edited_live_photo
|
||||
except AttributeError:
|
||||
self._path_edited_live_photo = self._path_edited_5_live_photo()
|
||||
return self._path_edited_live_photo
|
||||
|
||||
def _path_edited_5_live_photo(self):
|
||||
"""return path_edited_live_photo for Photos >= 5"""
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
raise RuntimeError("Wrong database format!")
|
||||
|
||||
if self.live_photo and self._info["hasAdjustments"]:
|
||||
library = self._db._library_path
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
filename = f"{self._uuid}_2_100_a.mov"
|
||||
photopath = os.path.join(
|
||||
library, "resources", "renders", directory, filename
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_raw(self):
|
||||
"""absolute path of associated RAW image or None if there is not one"""
|
||||
@@ -852,20 +883,35 @@ class PhotoInfo:
|
||||
@property
|
||||
def path_derivatives(self):
|
||||
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return self._path_derivatives_4()
|
||||
try:
|
||||
return self._path_derivatives
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._path_derivatives = self._path_derivatives_4()
|
||||
return self._path_derivatives
|
||||
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
derivative_path = (
|
||||
pathlib.Path(self._db._library_path)
|
||||
/ "resources"
|
||||
/ "derivatives"
|
||||
/ directory
|
||||
)
|
||||
files = derivative_path.glob(f"{self.uuid}*.*")
|
||||
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
|
||||
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
|
||||
return [str(filename) for filename in files if filename.suffix != ".THM"]
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
derivative_path = (
|
||||
pathlib.Path(self._db._library_path)
|
||||
/ "resources"
|
||||
/ "derivatives"
|
||||
/ directory
|
||||
)
|
||||
files = derivative_path.glob(f"{self.uuid}*.*")
|
||||
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
|
||||
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
|
||||
derivatives = [
|
||||
str(filename) for filename in files if filename.suffix != ".THM"
|
||||
]
|
||||
if (
|
||||
self.isphoto
|
||||
and len(derivatives) > 1
|
||||
and derivatives[0].endswith(".mov")
|
||||
):
|
||||
derivatives[1], derivatives[0] = derivatives[0], derivatives[1]
|
||||
|
||||
self._path_derivatives = derivatives
|
||||
return self._path_derivatives
|
||||
|
||||
def _path_derivatives_4(self):
|
||||
"""Return paths to all derivative (preview) files for Photos <= 4"""
|
||||
|
||||
@@ -127,6 +127,8 @@ UUID_DICT_LOCAL = {
|
||||
"burst_not_selected": "89E235DD-B9AC-4E8D-BDA2-986981CA7582", # IMG_9813.JPG
|
||||
"burst_default": "F5E6BD24-B493-44E9-BDA2-7AD9D2CC8C9D", # IMG_9816.JPG
|
||||
"burst_not_default": "75154738-83AA-4DCD-A913-632D5D1C0FEE", # IMG_9814.JPG
|
||||
"live_edited": "54A01B04-16D7-4FDE-8860-19F2A641E433", # IMG_3203.HEIC
|
||||
"live": "8EC216A2-0032-4934-BD3F-04C6259B3304", # IMG_3259.HEIC
|
||||
}
|
||||
|
||||
UUID_PUMPKIN_FARM = [
|
||||
@@ -1273,6 +1275,20 @@ def test_burst_default_pic(photosdb_local):
|
||||
assert not photo.burst_default_pick
|
||||
|
||||
|
||||
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
|
||||
def test_path_edited_live_photo(photosdb_local):
|
||||
"""test path_edited_live_photo (needs image from local library)"""
|
||||
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["live_edited"])
|
||||
assert photo.path_edited_live_photo is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
|
||||
def test_path_edited_live_photo_not_edited(photosdb_local):
|
||||
"""test path_edited_live_photo for a live photo that's not edited (needs image from local library)"""
|
||||
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["live"])
|
||||
assert photo.path_edited_live_photo is None
|
||||
|
||||
|
||||
def test_is_reference(photosdb):
|
||||
"""test isreference"""
|
||||
|
||||
@@ -1377,7 +1393,7 @@ def test_duplicates_2(photosdb):
|
||||
|
||||
|
||||
def test_compound_query(photosdb):
|
||||
""" test photos() with multiple query terms """
|
||||
"""test photos() with multiple query terms"""
|
||||
photos = photosdb.photos(persons=["Katie", "Maria"], albums=["Multi Keyword"])
|
||||
|
||||
assert len(photos) == 2
|
||||
@@ -1386,21 +1402,21 @@ def test_compound_query(photosdb):
|
||||
|
||||
|
||||
def test_multi_keyword(photosdb):
|
||||
""" test photos() with multiple keywords """
|
||||
"""test photos() with multiple keywords"""
|
||||
photos = photosdb.photos(keywords=["Kids", "wedding"])
|
||||
|
||||
assert len(photos) == 6
|
||||
|
||||
|
||||
def test_multi_album(photosdb):
|
||||
""" test photos() with multiple albums """
|
||||
"""test photos() with multiple albums"""
|
||||
photos = photosdb.photos(albums=["Pumpkin Farm", "Test Album"])
|
||||
|
||||
assert len(photos) == 3
|
||||
|
||||
|
||||
def test_multi_uuid(photosdb):
|
||||
""" test photos() with multiple uuids """
|
||||
"""test photos() with multiple uuids"""
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["favorite"], UUID_DICT["not_favorite"]])
|
||||
|
||||
assert len(photos) == 2
|
||||
|
||||
@@ -134,6 +134,7 @@ CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
|
||||
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}"
|
||||
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
|
||||
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}"
|
||||
CLI_EXPORT_PREVIEW_SUFFIX = "_lowres"
|
||||
|
||||
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
|
||||
"Pumkins1.jpg",
|
||||
@@ -430,6 +431,8 @@ CLI_EXPORT_UUID_KEYWORD_PATHSEP = "7783E8E6-9CAC-40F3-BE22-81FB7051C266"
|
||||
CLI_EXPORT_UUID_LONG_DESCRIPTION = "8846E3E6-8AC8-4857-8448-E3D025784410"
|
||||
|
||||
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
|
||||
CLI_EXPORT_UUID_FILENAME_PREVIEW = "Pumkins2_preview.jpeg"
|
||||
CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE = "Pumkins2_lowres.jpeg"
|
||||
|
||||
CLI_EXPORT_BY_DATE_TOUCH_UUID = [
|
||||
"1EB2B765-0765-43BA-A90C-0D0580E6172C", # Pumpkins3.jpg
|
||||
@@ -1242,6 +1245,66 @@ def test_export_uuid_from_file():
|
||||
assert sorted(files) == sorted(CLI_EXPORT_UUID_FROM_FILE_FILENAMES)
|
||||
|
||||
|
||||
def test_export_preview():
|
||||
"""test export with --preview"""
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.cli import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--preview",
|
||||
"--uuid",
|
||||
CLI_EXPORT_UUID,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert CLI_EXPORT_UUID_FILENAME_PREVIEW in files
|
||||
|
||||
|
||||
def test_export_preview_suffix():
|
||||
"""test export with --preview and --preview-suffix"""
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.cli import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--preview",
|
||||
"--preview-suffix",
|
||||
CLI_EXPORT_PREVIEW_SUFFIX,
|
||||
"--uuid",
|
||||
CLI_EXPORT_UUID,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE in files
|
||||
|
||||
|
||||
def test_export_as_hardlink():
|
||||
import glob
|
||||
import os
|
||||
|
||||
Reference in New Issue
Block a user