#!/usr/bin/env python3 """ Photo collection metadata cleanup. Drag files or directories onto this script, or run: python3 photo_metadata.py path/to/file/or/directory [...] The script reads metadata with exiftool, builds a full preview, then edits files in place only after confirmation. """ from __future__ import annotations import json import subprocess import sys from tools.console import ( ProgressView, clear_screen, pause_if_interactive, prompt_yes_no as ask_yes_no, ) from tools.exiftool import require_exiftool, run_exiftool_json from tools.photo_planning import ( plan_pto_updates, prepare_plan, read_pto_references, ) from tools.photo_execution import apply_changes, apply_pto_changes, print_preview, print_removed_dirs from tools.photo_records import SUPPORTED_EXTS, build_records from tools.photo_settings import prompt_choices from tools.filesystem import ( cleanup_roots_from_args, collect_files, determine_working_dir, remove_empty_dirs, ) def run(argv: list[str]) -> int: if len(argv) < 2: print("Usage: python3 photo_metadata.py path/to/file/or/directory [...]") return 2 try: working_dir = determine_working_dir(argv[1:]) except RuntimeError as exc: print(exc) return 1 cleanup_roots = cleanup_roots_from_args(argv[1:]) files = collect_files(argv[1:], allowed_exts=SUPPORTED_EXTS, print_missing=True) pto_files = collect_files(argv[1:], allowed_exts={".pto"}) if not files: print("No supported media files found.") return 1 try: exiftool = require_exiftool() except RuntimeError as exc: print(exc) return 1 print(f"Found {len(files)} supported media file(s).") progress = ProgressView(len(files), "Reading metadata") read_count = 0 def update_read_progress() -> None: nonlocal read_count read_count += 1 progress.update(read_count) try: metadata = run_exiftool_json(exiftool, files, progress=update_read_progress) except (subprocess.CalledProcessError, json.JSONDecodeError) as exc: progress.finish() print(f"Failed to read metadata with exiftool: {exc}") return 1 progress.finish(keep=True) settings_state = None while True: records = build_records(files, metadata) try: choices, settings_state = prompt_choices( exiftool, records, working_dir, has_pto_files=bool(pto_files), initial_state=settings_state, ) except RuntimeError as exc: print(exc) return 1 if choices is None: print("Nothing changed.") return 0 pto_references = read_pto_references(pto_files, records) if choices.process_pto_files else [] prepare_plan(records, choices, pto_references) pto_plans = plan_pto_updates(pto_references, records) if choices.process_pto_files else [] clear_screen(scrollback=True) print_preview(records, choices, pto_plans) try: if ask_yes_no("Proceed with these changes?", default=True): break except RuntimeError as exc: print(exc) return 1 failures = apply_changes(exiftool, records, choices) failures.extend(apply_pto_changes(pto_plans)) removed_dirs = remove_empty_dirs(cleanup_roots, keep_dirs={working_dir.resolve()}) print_removed_dirs(removed_dirs) if failures: print("\nSome files failed:") for failure in failures: print(f" - {failure}") return 1 print("\nDone.") return 0 def main(argv: list[str]) -> int: try: return run(argv) finally: pause_if_interactive() if __name__ == "__main__": raise SystemExit(main(sys.argv))