Files
osxphotos/osxphotos/cli/exiftool_cli.py
2022-05-21 08:08:25 -07:00

409 lines
15 KiB
Python

"""exiftool command for osxphotos CLI to update an previous export with exiftool metadata"""
import pathlib
import sys
from typing import Callable
import click
from osxphotos import PhotosDB
from osxphotos._constants import OSXPHOTOS_EXPORT_DB
from osxphotos._version import __version__
from osxphotos.configoptions import ConfigOptions, ConfigOptionsLoadError
from osxphotos.export_db import ExportDB, ExportDBInMemory
from osxphotos.export_db_utils import export_db_get_config
from osxphotos.fileutil import FileUtil, FileUtilNoOp
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
from osxphotos.utils import pluralize
from .click_rich_echo import (
rich_click_echo,
rich_echo,
rich_echo_error,
set_rich_console,
set_rich_theme,
set_rich_timestamp,
)
from .color_themes import get_theme
from .common import DB_OPTION, THEME_OPTION, get_photos_db
from .export import export, render_and_validate_report
from .param_types import ExportDBType, TemplateString
from .report_writer import ReportWriterNoOp, report_writer_factory
from .rich_progress import rich_progress
from .verbose import get_verbose_console, verbose_print
@click.command(name="exiftool")
@click.option(
"--db-config",
is_flag=True,
help="Load configuration options from the export database to match the last export; "
"If any other command line options are used in conjunction with --db-config, "
"they will override the corresponding values loaded from the export database; "
"see also --load-config.",
)
@click.option(
"--load-config",
required=False,
metavar="CONFIG_FILE",
default=None,
help=(
"Load options from file as written with --save-config. "
"If any other command line options are used in conjunction with --load-config, "
"they will override the corresponding values in the config file; "
"see also --db-config."
),
type=click.Path(exists=True),
)
@click.option(
"--save-config",
required=False,
metavar="CONFIG_FILE",
default=None,
help="Save options to file for use with --load-config. File format is TOML. ",
type=click.Path(),
)
@click.option(
"--exiftool-path",
metavar="EXIFTOOL_PATH",
type=click.Path(exists=True),
help="Optionally specify path to exiftool; if not provided, will look for exiftool in $PATH.",
)
@click.option(
"--exiftool-option",
multiple=True,
metavar="OPTION",
help="Optional flag/option to pass to exiftool when using --exiftool. "
"For example, --exiftool-option '-m' to ignore minor warnings. "
"Specify these as you would on the exiftool command line. "
"See exiftool docs at https://exiftool.org/exiftool_pod.html for full list of options. "
"More than one option may be specified by repeating the option, e.g. "
"--exiftool-option '-m' --exiftool-option '-F'. ",
)
@click.option(
"--exiftool-merge-keywords",
is_flag=True,
help="Merge any keywords found in the original file with keywords used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--exiftool-merge-persons",
is_flag=True,
help="Merge any persons found in the original file with persons used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--ignore-date-modified",
is_flag=True,
help="If used with --exiftool or --sidecar, will ignore the photo "
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
)
@click.option(
"--person-keyword",
is_flag=True,
help="Use person in image as keyword/tag when exporting metadata.",
)
@click.option(
"--album-keyword",
is_flag=True,
help="Use album name as keyword/tag when exporting metadata.",
)
@click.option(
"--keyword-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"keyword in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to add "
"the full path to the folder and album photo is contained in as a keyword when exporting "
'you could specify --keyword-template "{folder_album}" '
'You may specify more than one template, for example --keyword-template "{folder_album}" '
'--keyword-template "{created.year}". '
"See '--replace-keywords' and Templating System below.",
type=TemplateString(),
)
@click.option(
"--replace-keywords",
is_flag=True,
help="Replace keywords with any values specified with --keyword-template. "
"By default, --keyword-template will add keywords to any keywords already associated "
"with the photo. If --replace-keywords is specified, values from --keyword-template "
"will replace any existing keywords instead of adding additional keywords.",
)
@click.option(
"--description-template",
metavar="TEMPLATE",
multiple=False,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"description in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to append "
"'exported with osxphotos on [today's date]' to the description, you could specify "
'--description-template "{descr} exported with osxphotos on {today.date}" '
"See Templating System below.",
type=TemplateString(),
)
@click.option(
"--exportdb",
help="Optional path to export database (if not in the default location in the export directory).",
type=ExportDBType(),
)
@click.option(
"--report",
metavar="REPORT_FILE",
help="Write a report of all files that were exported. "
"The extension of the report filename will be used to determine the format. "
"Valid extensions are: "
".csv (CSV file), .json (JSON), .db and .sqlite (SQLite database). "
"REPORT_FILE may be a template string (see Templating System), for example, "
"--report 'export_{today.date}.csv' will write a CSV report file named with today's date. "
"See also --append.",
type=TemplateString(),
)
@click.option(
"--append",
is_flag=True,
help="If used with --report, add data to existing report file instead of overwriting it. "
"See also --report.",
)
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output")
@click.option(
"--dry-run",
is_flag=True,
help="Run in dry-run mode (don't actually update files), e.g. for use with --update-signatures.",
)
@THEME_OPTION
@DB_OPTION
@click.argument(
"export_dir",
metavar="EXPORT_DIRECTORY",
nargs=1,
type=click.Path(exists=True, file_okay=False),
)
def exiftool(
album_keyword,
append,
db_config,
db,
description_template,
dry_run,
exiftool_merge_keywords,
exiftool_merge_persons,
exiftool_option,
exiftool_path,
export_dir,
exportdb,
ignore_date_modified,
keyword_template,
load_config,
person_keyword,
replace_keywords,
report,
save_config,
theme,
timestamp,
verbose,
):
"""Run exiftool on previously exported files to update metadata.
If you previously exported photos with `osxphotos export` but did not include the
`--exiftool` option and you now want to update the metadata of the exported files with
exiftool, you can use this command to do so.
If you simply re-run the `osxphotos export` with `--update` and `--exiftool`, osxphotos will
re-export all photos because it will detect that the previously exported photos do not have the
exiftool metadata updates. This command will run exiftool on the previously exported photos
to update all metadata then will update the export database so that using `--exiftool --update`
with `osxphotos export` in the future will work correctly and not unnecessarily re-export photos.
"""
# save locals for initializing config options
locals_ = locals()
if load_config and db_config:
raise click.UsageError("Cannot specify both --load-config and --db-config")
exportdb = exportdb or pathlib.Path(export_dir) / OSXPHOTOS_EXPORT_DB
if not exportdb.exists():
raise click.UsageError(f"Export database {exportdb} does not exist")
# grab all the variables we need from the export command
# export is a click Command so can walk through it's params to get the option names
for param in export.params:
if param.name not in locals_:
locals_[param.name] = None
# need to ensure --exiftool is true in the config options
locals_["exiftool"] = True
config = ConfigOptions(
"export",
locals_,
ignore=[
"cli_obj",
"config_only",
"ctx",
"db_config",
"dest",
"export_dir",
"load_config",
"save_config",
],
)
color_theme = get_theme(theme)
verbose_ = verbose_print(
verbose, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
# load config options from either file or export database
# values already set in config will take precedence over any values
# in the config file or database
if load_config:
try:
config.load_from_file(load_config)
except ConfigOptionsLoadError as e:
rich_click_echo(
f"[error]Error parsing {load_config} config file: {e.message}", err=True
)
sys.exit(1)
verbose_(f"Loaded options from file [filepath]{load_config}")
elif db_config:
config = export_db_get_config(exportdb, config)
verbose_("Loaded options from export database")
# from here on out, use config.param_name instead of using the params passed into the function
# as the values may have been updated from config file or database
if load_config or db_config:
# config file might have changed verbose
color_theme = get_theme(config.theme)
verbose_ = verbose_print(
config.verbose,
config.timestamp,
rich=True,
theme=color_theme,
highlight=False,
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_timestamp(config.timestamp)
# validate options
if append and not report:
raise click.UsageError("--append requires --report")
# need to ensure we have a photos database
config.db = get_photos_db(config.db)
if save_config:
verbose_(f"Saving options to config file '[filepath]{save_config}'")
config.write_to_file(save_config)
process_files(exportdb, export_dir, verbose=verbose_, options=config)
def process_files(
exportdb: str, export_dir: str, verbose: Callable, options: ConfigOptions
):
"""Process files in the export database.
Args:
exportdb: Path to export database.
export_dir: Path to export directory.
verbose: Callable for verbose output.
options: ConfigOptions
"""
if options.report:
report = render_and_validate_report(
options.report, options.exiftool_path, export_dir
)
report_writer = report_writer_factory(report, options.append)
else:
report_writer = ReportWriterNoOp()
photosdb = PhotosDB(options.db, verbose=verbose)
if options.dry_run:
export_db = ExportDBInMemory(exportdb, export_dir)
fileutil = FileUtilNoOp
else:
export_db = ExportDB(exportdb, export_dir)
fileutil = FileUtil
# get_exported_files is a generator which returns tuple of (uuid, filepath)
files = list(export_db.get_exported_files())
# filter out sidecar files
files = [
(u, f)
for u, f in files
if pathlib.Path(f).suffix.lower() not in [".json", ".xmp"]
]
total = len(files)
count = 1
all_results = ExportResults()
with rich_progress(console=get_verbose_console(), mock=options.verbose) as progress:
task = progress.add_task("Processing files", total=total)
for uuid, file in files:
if not pathlib.Path(file).exists():
verbose(f"Skipping missing file [filepath]{file}[/]")
report_writer.write(ExportResults(missing=[file]))
continue
# TODO: zzz put in check for hardlink
verbose(f"Processing file [filepath]{file}[/] ([num]{count}/{total}[/num])")
photo = photosdb.get_photo(uuid)
export_options = ExportOptions(
description_template=options.description_template,
dry_run=options.dry_run,
exiftool_flags=options.exiftool_option,
exiftool=True,
export_db=export_db,
ignore_date_modified=options.ignore_date_modified,
keyword_template=options.keyword_template,
merge_exif_keywords=options.exiftool_merge_keywords,
merge_exif_persons=options.exiftool_merge_persons,
replace_keywords=options.replace_keywords,
use_albums_as_keywords=options.album_keyword,
use_persons_as_keywords=options.person_keyword,
verbose=verbose,
)
exporter = PhotoExporter(photo)
results = exporter.write_exiftool_metadata_to_file(
src=file, dest=file, options=export_options
)
all_results += results
for warning_ in results.exiftool_warning:
verbose(
f"[warning]exiftool warning for file {warning_[0]}: {warning_[1]}"
)
for error_ in results.exiftool_error:
rich_echo_error(
f"[error]exiftool error for file {error_[0]}: {error_[1]}"
)
for result in results.exif_updated:
verbose(f"Updated EXIF metadata for [filepath]{result}")
# update the database
with export_db.get_file_record(file) as rec:
rec.dest_sig = fileutil.file_sig(file)
rec.export_options = export_options.bit_flags
rec.exifdata = exporter.exiftool_json_sidecar(export_options)
report_writer.write(results)
count += 1
progress.advance(task)
photo_str_total = pluralize(total, "photo", "photos")
summary = (
f"Processed: [num]{total}[/] {photo_str_total}, "
f"skipped: [num]{len(all_results.skipped)}[/], "
f"updated EXIF data: [num]{len(all_results.exif_updated)}[/], "
)
verbose(summary)
if options.report:
verbose(f"Wrote export report to [filepath]{report}")
report_writer.close()