"""Preview and apply a prepared photo metadata plan.""" from __future__ import annotations import os import shutil import subprocess from concurrent.futures import Future, ThreadPoolExecutor from pathlib import Path from tools.console import ProgressView from tools.exiftool import run_exiftool_write from tools.filesystem import display_path_from_working_dir, unique_existing_target from tools.photo_planning import PtoPlan, UserChoices, build_write_args from tools.photo_records import MediaRecord from tools.timezones import timezone_to_string EXIFTOOL_WRITE_WORKERS = 4 METADATA_WORK_OVERHEAD = 512 * 1024 FILE_OPERATION_WORK = 16 * 1024 def _time_offset_text(value) -> str: seconds = int(value.total_seconds()) sign, seconds = ("+" if seconds >= 0 else "-"), abs(seconds) return f"{sign}{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}" def _display(path: Path, choices: UserChoices, relative: bool) -> str: return display_path_from_working_dir(path, choices.working_dir) if relative else str(path) def move_rename_preview(source: Path, target: Path, choices: UserChoices, label: str | None = None) -> str | None: moving, renaming = source.parent.resolve() != target.parent.resolve(), source.name != target.name source_text = label or _display(source, choices, moving) if moving and renaming: return f"move+rename {source_text} -> {_display(target, choices, True)}" if moving: destination = _display(target.parent, choices, True) return f"move {source_text} -> {destination if destination == '.' or destination.endswith(os.sep) else destination + os.sep}" return f"rename {label or source.name} -> {target.name}" if renaming else None def print_preview(records: list[MediaRecord], choices: UserChoices, pto_plans: list[PtoPlan]) -> None: print("\nPreview\n\nCommon changes") print(f" Working directory: {choices.working_dir}") print(f" Time correction: {_time_offset_text(choices.time_offset) if choices.time_offset else 'none'}") if choices.fixed_timezone: print(f" Timezone offset: {timezone_to_string(choices.fixed_timezone)}") elif choices.fill_timezone_gaps: print(" Timezone offsets: fill missing gaps") print(f" Filename mode: {choices.rename_mode.replace('_', '/')}") print(f" Organize/group: {'yes' if choices.organize_files else 'no'}") if choices.organize_files: print(f" Burst/HDR minimum: {choices.group_min_size}+ files") print(f" Panorama project files: {'yes' if choices.process_pto_files else 'no'}") if choices.artist_action != "leave": print(f" Artist/author: {'clear' if choices.artist_action == 'clear' else 'set'}") inferred = [record for record in records if record.inferred_timestamp_source] if inferred: print("\nInferred timestamps") for record in inferred: print(f" {record.path.name}: {record.adjusted_time} from {record.inferred_timestamp_source}") warnings = [(record.path.name, warning) for record in records for warning in record.warnings] if warnings: print("\nWarnings") for name, warning in warnings: print(f" {name}: {warning}") print("\nFile operations") for record in sorted(records, key=lambda item: str(item.path).lower()): operation = move_rename_preview(record.path, record.target_path or record.path, choices) if operation: print(operation) if record.rename_skip_reason: print(f" {record.path.name}: rename skipped: {record.rename_skip_reason}") if record.target_sidecar and record.sidecar: print(f" sidecar move {_display(record.sidecar.path, choices, True)} -> {_display(record.target_sidecar, choices, True)}") if pto_plans: print("\nPTO updates:") for plan in pto_plans: operation = move_rename_preview(plan.path, plan.target_path, choices, _display(plan.path, choices, True)) print(operation or _display(plan.path, choices, True)) for old, new in plan.replacements: print(f" {old} -> {new}") def _operation(record: MediaRecord) -> str: target = record.target_path or record.path if target.parent.resolve() != record.path.parent.resolve(): return "move+rename" if target.name != record.path.name else "move" return "rename" if target.name != record.path.name else "no-op" def _work(record: MediaRecord, args: list[str]) -> float: if not args: return FILE_OPERATION_WORK try: return FILE_OPERATION_WORK + METADATA_WORK_OVERHEAD + record.path.stat().st_size except OSError: return FILE_OPERATION_WORK + METADATA_WORK_OVERHEAD def _move_record(record: MediaRecord, planned_sources: set[Path]) -> None: if record.target_path and record.target_path != record.path: target = unique_existing_target(record.target_path, planned_sources) target.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(record.path), str(target)) record.target_path = target if record.sidecar and record.target_sidecar and record.sidecar.path.exists(): target = unique_existing_target(record.target_sidecar, planned_sources) target.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(record.sidecar.path), str(target)) def apply_changes(executable: str, records: list[MediaRecord], choices: UserChoices) -> list[str]: failures, sources = [], {record.path.resolve() for record in records} args = {record.path: build_write_args(record, choices) for record in records} work = {record.path: _work(record, args[record.path]) for record in records} progress = ProgressView(len(records), "Progress", total_work=sum(work.values()), show_elapsed=False, bar_width=16) counts = {key: 0 for key in ("succeeded", "failed", "metadata", "rename", "move", "move+rename", "no-op")} completed = 0.0 with ThreadPoolExecutor(max_workers=EXIFTOOL_WRITE_WORKERS) as workers: futures: dict[Path, Future[None]] = {record.path: workers.submit(run_exiftool_write, executable, record.path, write_args) for record in records if (write_args := args[record.path])} for index, record in enumerate(records, 1): operation = _operation(record) try: if args[record.path]: futures[record.path].result() counts["metadata"] += 1 _move_record(record, sources) counts["succeeded"] += 1 counts[operation] += 1 if operation != "no-op" or not args[record.path] else 0 except (OSError, subprocess.CalledProcessError, RuntimeError) as exc: counts["failed"] += 1 failures.append(f"{record.path}: {exc}") completed += work[record.path] progress.write_lines([ "Applying changes", f" {progress.update_line(index, completed_work=completed)}", f" Results: succeeded {counts['succeeded']} failed {counts['failed']} metadata {counts['metadata']} no-op {counts['no-op']}", f" File ops: rename {counts['rename']} move {counts['move']} rename+move {counts['move+rename']}", ]) progress.finish(keep=True) return failures def apply_pto_changes(plans: list[PtoPlan]) -> list[str]: failures = [] for plan in plans: try: plan.path.write_text(plan.updated_text, encoding="utf-8") if plan.target_path.resolve() != plan.path.resolve(): target = unique_existing_target(plan.target_path, {plan.path.resolve()}) target.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(plan.path), str(target)) except OSError as exc: failures.append(f"{plan.path}: {exc}") return failures def print_removed_dirs(directories: list[Path]) -> None: if directories: print("\nRemoved empty director" + ("y:" if len(directories) == 1 else "ies:")) for directory in directories: print(f" - {directory}")