Refactor shared script helpers

This commit is contained in:
ajp_anton
2026-06-08 02:55:31 +00:00
parent ae05bc9710
commit b6024d8b65
10 changed files with 855 additions and 356 deletions
+408 -227
View File
@@ -11,7 +11,6 @@ place only after confirmation.
from __future__ import annotations
import json
import math
import os
import re
@@ -23,9 +22,25 @@ from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from shutil import which
from typing import Iterable
from tools.console import pause_if_interactive, prompt_input
from tools.exiftool import require_exiftool, run_exiftool_json, run_exiftool_write
from tools.filenames import (
format_filename_stem,
group_stem_from_name,
naive_wall_time,
normalized_extension,
)
from tools.filesystem import (
cleanup_roots_from_args,
collect_files,
determine_working_dir,
display_path_from_working_dir,
remove_empty_dirs,
unique_existing_target,
)
IMAGE_EXTS = {".jpg", ".jpeg", ".heic", ".arw"}
VIDEO_EXTS = {".mp4", ".mov", ".mts"}
@@ -85,6 +100,12 @@ TZ_RE = re.compile(r"^(?P<sign>[+-])(?P<h>\d{1,2})(?::?(?P<m>\d{2}))?$")
OFFSET_RE = re.compile(r"^([+-])(?:(\d+):)?(\d{1,2})(?::(\d{2}))?$")
TOKEN_SHIFT_RE = re.compile(r"(\d+|[a-zA-Z]+)")
ORDERED_TIMESTAMP_STEM_RE = re.compile(r"^(?P<timestamp>\d{8}_\d{6})-[1-9]\d*$")
TIMESTAMP_RANGE_STEM_RE = re.compile(
r"^(?P<start>\d{8}_\d{6})-(?P<end>\d{8}_\d{6})(?P<suffix>_.*)?$"
)
CAMERA_RANGE_STEM_RE = re.compile(
r"^(?P<prefix>[A-Za-z]*)(?P<first>\d+)-(?P=prefix)(?P<last>\d+)(?P<suffix>_.*)$"
)
@dataclass
@@ -140,6 +161,22 @@ class MediaRecord:
return self.kind == "video"
@dataclass
class PtoPlan:
path: Path
target_path: Path
referenced_records: list[MediaRecord]
updated_text: str
replacements: list[tuple[str, str]]
@dataclass
class PtoReference:
path: Path
original_text: str
records: list[MediaRecord]
@dataclass
class UserChoices:
working_dir: Path
@@ -153,6 +190,7 @@ class UserChoices:
rename_files: bool
group_photos: bool
move_to_working_dir: bool
process_pto_files: bool
apply_filename_timestamps: bool
@@ -169,13 +207,6 @@ def ask_yes_no(prompt: str, default: bool) -> bool:
print("Please answer y or n.")
def prompt_input(prompt: str) -> str:
try:
return input(prompt)
except EOFError as exc:
raise RuntimeError("Interactive input ended before choices were complete.") from exc
def parse_timezone_offset(value: str) -> timezone:
match = TZ_RE.match(value.strip())
if not match:
@@ -330,118 +361,12 @@ def parse_timeshift(value: str) -> timedelta:
return sign * timedelta(seconds=seconds)
def naive_wall_time(dt: datetime) -> datetime:
return dt.replace(tzinfo=None)
def collect_media_files(args: list[str]) -> list[Path]:
files: list[Path] = []
seen: set[Path] = set()
for arg in args:
path = Path(arg).expanduser()
if path.is_file():
candidates = [path] if path.suffix.lower() in SUPPORTED_EXTS else []
elif path.is_dir():
candidates = [
child
for child in path.rglob("*")
if child.is_file() and child.suffix.lower() in SUPPORTED_EXTS
]
else:
print(f"Skipping missing path: {arg}")
continue
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in seen:
seen.add(resolved)
files.append(resolved)
return sorted(files, key=lambda p: str(p).lower())
return collect_files(args, allowed_exts=SUPPORTED_EXTS, print_missing=True)
def existing_input_paths(args: list[str]) -> list[Path]:
paths: list[Path] = []
for arg in args:
path = Path(arg).expanduser()
if path.exists():
paths.append(path.resolve())
return paths
def determine_working_dir(args: list[str]) -> Path:
paths = existing_input_paths(args)
if not paths:
raise RuntimeError("No existing input paths were provided.")
if len(paths) == 1:
path = paths[0]
return path if path.is_dir() else path.parent
try:
common = Path(os.path.commonpath([str(path) for path in paths]))
except ValueError as exc:
raise RuntimeError("Input paths do not have a common ancestor.") from exc
if str(common) == common.anchor:
raise RuntimeError("Input paths only share the filesystem root; choose items closer together.")
return common
def cleanup_roots_from_args(args: list[str]) -> list[Path]:
roots: list[Path] = []
seen: set[Path] = set()
for arg in args:
path = Path(arg).expanduser()
if path.is_dir():
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
roots.append(resolved)
return roots
def require_exiftool() -> str:
executable = which("exiftool")
if executable:
return executable
raise RuntimeError(
"exiftool was not found on PATH. Install ExifTool and make sure the "
"'exiftool' command works before running this script."
)
def run_exiftool_json(executable: str, paths: list[Path]) -> list[dict]:
results: list[dict] = []
for start in range(0, len(paths), 80):
chunk = paths[start : start + 80]
command = [
executable,
"-j",
"-G",
"-api",
"QuickTimeUTC=1",
"-charset",
"filename=UTF8",
*[str(path) for path in chunk],
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
results.extend(json.loads(completed.stdout or "[]"))
return results
def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None:
if not args:
return
command = [
executable,
"-overwrite_original",
"-charset",
"filename=UTF8",
*args,
str(path),
]
subprocess.run(command, check=True)
def collect_pto_files(args: list[str]) -> list[Path]:
return collect_files(args, allowed_exts={".pto"})
def first_tag(metadata: dict, tags: Iterable[str]) -> object | None:
@@ -793,7 +718,7 @@ def prompt_artist_action() -> tuple[str, str | None]:
print("Please choose Enter, s, or c.")
def prompt_choices(records: list[MediaRecord], working_dir: Path) -> UserChoices:
def prompt_choices(records: list[MediaRecord], working_dir: Path, has_pto_files: bool) -> UserChoices:
missing_time = [record for record in records if record.original_time is None]
apply_filename_timestamps = False
if missing_time:
@@ -818,13 +743,24 @@ def prompt_choices(records: list[MediaRecord], working_dir: Path) -> UserChoices
if sum(1 for record in records if record.is_image and record.adjusted_time) >= 2:
group_photos = ask_yes_no("Create automatic burst/HDR groups?", default=True)
move_to_working_dir = False
if any(record.path.parent.resolve() != working_dir for record in records):
move_to_working_dir = ask_yes_no(
f"Move ungrouped files from subdirectories into the working directory ({working_dir})?",
default=False,
process_pto_files = False
if has_pto_files:
process_pto_files = ask_yes_no(
"Use panorama project files (.pto) for grouping and update their references?",
default=True,
)
move_to_working_dir = False
has_subdir_media = any(record.path.parent.resolve() != working_dir for record in records)
if has_subdir_media:
if group_photos or process_pto_files:
move_to_working_dir = True
else:
move_to_working_dir = ask_yes_no(
f"Move files from subdirectories into the working directory ({working_dir})?",
default=False,
)
return UserChoices(
working_dir=working_dir,
time_offset=time_offset,
@@ -837,6 +773,7 @@ def prompt_choices(records: list[MediaRecord], working_dir: Path) -> UserChoices
rename_files=rename_files,
group_photos=group_photos,
move_to_working_dir=move_to_working_dir,
process_pto_files=process_pto_files,
apply_filename_timestamps=apply_filename_timestamps,
)
@@ -859,6 +796,16 @@ def photo_sort_key(record: MediaRecord) -> tuple:
)
def group_sort_key(record: MediaRecord) -> tuple:
timestamp = naive_wall_time(record.adjusted_time) if record.adjusted_time is not None else datetime.max
return (
timestamp,
record.subsec if record.subsec is not None else -1,
record.sequence if record.sequence is not None else -1,
str(record.path).lower(),
)
def detect_photo_groups(records: list[MediaRecord]) -> list[list[MediaRecord]]:
groups: list[list[MediaRecord]] = []
by_camera: dict[str, list[MediaRecord]] = defaultdict(list)
@@ -955,7 +902,8 @@ def infer_group_subseconds(groups: list[list[MediaRecord]]) -> None:
for group in groups:
by_second: dict[datetime, list[MediaRecord]] = defaultdict(list)
for record in group:
assert record.adjusted_time is not None
if not record.is_image or record.adjusted_time is None:
continue
second = naive_wall_time(record.adjusted_time).replace(microsecond=0)
by_second[second].append(record)
@@ -982,20 +930,65 @@ def infer_group_subseconds(groups: list[list[MediaRecord]]) -> None:
record.inferred_subsec = int((start_slot + item_index) * 1000 / fps)
def format_filename_stem(dt: datetime) -> str:
return naive_wall_time(dt).strftime("%Y%m%d_%H%M%S")
def filename_range_stem(original_stem: str, base_dt: datetime) -> str | None:
match = TIMESTAMP_RANGE_STEM_RE.match(original_stem)
if not match:
return None
try:
old_start = parse_datetime(match.group("start"))
old_end = parse_datetime(match.group("end"))
except ValueError:
return None
interval = old_end - old_start
new_start = naive_wall_time(base_dt)
new_end = new_start + interval
suffix = match.group("suffix") or ""
return f"{format_filename_stem(new_start)}-{format_filename_stem(new_end)}{suffix}"
def normalized_extension(path: Path) -> str:
return path.suffix.lower()
def unique_records_by_stem(records: list[MediaRecord]) -> dict[str, MediaRecord]:
buckets: dict[str, list[MediaRecord]] = defaultdict(list)
for record in records:
buckets[record.path.stem.lower()].append(record)
return {stem: bucket[0] for stem, bucket in buckets.items() if len(bucket) == 1}
def group_stem_from_name(name: str) -> str:
stem = Path(name).stem
match = ORDERED_TIMESTAMP_STEM_RE.match(stem)
if match:
return match.group("timestamp")
return stem
def camera_range_stem(record: MediaRecord, records_by_stem: dict[str, MediaRecord]) -> str | None:
match = CAMERA_RANGE_STEM_RE.match(record.path.stem)
if not match:
return None
first_number = int(match.group("first"))
last_number = int(match.group("last"))
if last_number <= first_number:
return None
prefix = match.group("prefix")
first_record = records_by_stem.get(f"{prefix}{match.group('first')}".lower())
last_record = records_by_stem.get(f"{prefix}{match.group('last')}".lower())
first_time = first_record.adjusted_time if first_record else None
last_time = last_record.adjusted_time if last_record else None
own_time = record.adjusted_time
if own_time is not None:
start_time = own_time
end_time = last_time or own_time
else:
start_time = first_time or last_time
end_time = last_time or first_time
if start_time is None or end_time is None:
return None
suffix = match.group("suffix")
return f"{format_filename_stem(start_time)}-{format_filename_stem(end_time)}{suffix}"
def filename_stem_for_dt(record: MediaRecord, dt: datetime) -> str:
range_stem = filename_range_stem(record.path.stem, dt)
if range_stem is not None:
return range_stem
return format_filename_stem(dt)
def plan_names(records: list[MediaRecord], choices: UserChoices) -> None:
@@ -1004,9 +997,14 @@ def plan_names(records: list[MediaRecord], choices: UserChoices) -> None:
record.final_name = record.path.name
return
records_by_stem = unique_records_by_stem(records)
for record in records:
if record.adjusted_time is None:
record.rename_skip_reason = "missing timestamp"
range_stem = camera_range_stem(record, records_by_stem)
if range_stem is not None:
record.new_stem = range_stem
else:
record.rename_skip_reason = "missing timestamp"
continue
if record.is_video:
if record.duration_seconds is None and not record.parsed_from_filename:
@@ -1025,9 +1023,11 @@ def plan_names(records: list[MediaRecord], choices: UserChoices) -> None:
if begin.tzinfo is None:
begin = begin.replace(tzinfo=timezone.utc)
local_begin = begin.astimezone(choices.video_filename_timezone)
record.new_stem = format_filename_stem(local_begin)
record.new_stem = filename_stem_for_dt(record, local_begin)
else:
record.new_stem = format_filename_stem(record.adjusted_time)
record.new_stem = camera_range_stem(record, records_by_stem) or filename_stem_for_dt(
record, record.adjusted_time
)
assign_unique_names(records)
@@ -1055,15 +1055,64 @@ def assign_unique_names(records: list[MediaRecord]) -> None:
record.final_name = f"{stem}-{index}{ext}"
def merge_record_groups(
records: list[MediaRecord], group_candidates: list[list[MediaRecord]]
) -> list[list[MediaRecord]]:
record_indexes = {record.path.resolve(): index for index, record in enumerate(records)}
parents = list(range(len(records)))
grouped_indexes: set[int] = set()
def find(index: int) -> int:
while parents[index] != index:
parents[index] = parents[parents[index]]
index = parents[index]
return index
def union(left: int, right: int) -> None:
left_root = find(left)
right_root = find(right)
if left_root != right_root:
parents[right_root] = left_root
for candidate in group_candidates:
indexes = [
record_indexes[record.path.resolve()]
for record in candidate
if record.path.resolve() in record_indexes
]
if not indexes:
continue
grouped_indexes.update(indexes)
first = indexes[0]
for index in indexes[1:]:
union(first, index)
merged: dict[int, list[MediaRecord]] = defaultdict(list)
for index in grouped_indexes:
merged[find(index)].append(records[index])
return sorted(
[sorted(group, key=group_sort_key) for group in merged.values()],
key=lambda group: group_sort_key(group[0]),
)
def assign_groups(records: list[MediaRecord], groups: list[list[MediaRecord]]) -> None:
for record in records:
record.group_id = None
record.group_name = None
for index, group in enumerate(groups, start=1):
ordered = sorted(group, key=photo_sort_key)
ordered = sorted(group, key=group_sort_key)
for record in ordered:
record.group_id = index
def unique_group_name(base_name: str, working_dir: Path, used_names: set[str]) -> str:
if base_name not in used_names and not (working_dir / base_name).exists():
if (working_dir / base_name).exists():
used_names.add(base_name)
return base_name
if base_name not in used_names:
used_names.add(base_name)
return base_name
@@ -1079,7 +1128,7 @@ def unique_group_name(base_name: str, working_dir: Path, used_names: set[str]) -
def assign_group_names(groups: list[list[MediaRecord]], working_dir: Path) -> None:
used_names: set[str] = set()
for group in groups:
ordered = sorted(group, key=photo_sort_key)
ordered = sorted(group, key=group_sort_key)
first = group_stem_from_name(ordered[0].final_name or ordered[0].path.name)
last = group_stem_from_name(ordered[-1].final_name or ordered[-1].path.name)
group_name = unique_group_name(f"{first}-{last}", working_dir, used_names)
@@ -1103,6 +1152,140 @@ def plan_targets(records: list[MediaRecord], choices: UserChoices) -> None:
record.target_sidecar = target_sidecar
def normalize_reference(value: str) -> str:
return value.replace("\\", "/")
def pto_target_for_base(
path: Path, target_dir: Path, base_name: str, used_targets: set[Path]
) -> Path:
desired = target_dir / f"{base_name}.pto"
if desired.resolve() == path.resolve() or (not desired.exists() and desired not in used_targets):
used_targets.add(desired)
return desired
for index in range(1, 10000):
candidate = target_dir / f"{base_name}-{index}.pto"
if candidate.resolve() == path.resolve() or (
not candidate.exists() and candidate not in used_targets
):
used_targets.add(candidate)
return candidate
raise RuntimeError(f"Could not find an available .pto name for {base_name}")
def pto_reference_map(pto_path: Path, records: list[MediaRecord]) -> dict[str, MediaRecord]:
pto_dir = pto_path.parent
basename_counts: dict[str, int] = defaultdict(int)
for record in records:
basename_counts[record.path.name] += 1
mapping: dict[str, MediaRecord] = {}
for record in records:
old_abs = record.path.resolve()
candidates = {
normalize_reference(str(old_abs)),
normalize_reference(os.path.relpath(old_abs, pto_dir)),
}
if basename_counts[record.path.name] == 1:
candidates.add(record.path.name)
for candidate in candidates:
mapping[candidate] = record
return mapping
PTO_QUOTED_VALUE_RE = re.compile(r"([\"'])(.*?)(\1)")
def read_pto_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return path.read_text(encoding="utf-8", errors="replace")
def read_pto_references(pto_files: list[Path], records: list[MediaRecord]) -> list[PtoReference]:
references: list[PtoReference] = []
for pto_path in pto_files:
original_text = read_pto_text(pto_path)
mapping = pto_reference_map(pto_path, records)
if not mapping:
continue
referenced: list[MediaRecord] = []
for match in PTO_QUOTED_VALUE_RE.finditer(original_text):
record = mapping.get(normalize_reference(match.group(2)))
if record is not None and record not in referenced:
referenced.append(record)
if referenced:
references.append(PtoReference(path=pto_path, original_text=original_text, records=referenced))
return references
def replacement_for_pto_value(
value: str, mapping: dict[str, MediaRecord], pto_target_dir: Path
) -> tuple[str, MediaRecord] | None:
normalized = normalize_reference(value)
record = mapping.get(normalized)
if record is None:
return None
assert record.target_path is not None
replacement = normalize_reference(os.path.relpath(record.target_path.resolve(), pto_target_dir))
return replacement, record
def plan_pto_updates(pto_references: list[PtoReference], records: list[MediaRecord]) -> list[PtoPlan]:
plans: list[PtoPlan] = []
used_targets: set[Path] = set()
for pto_reference in pto_references:
pto_path = pto_reference.path
original_text = pto_reference.original_text
referenced = sorted(pto_reference.records, key=group_sort_key)
if not referenced:
continue
target_dir = pto_path.parent
if referenced[0].group_name:
target_dir = referenced[0].target_path.parent if referenced[0].target_path else target_dir
mapping = pto_reference_map(pto_path, records)
if not mapping:
continue
replacements: list[tuple[str, str]] = []
def replace_match(match: re.Match) -> str:
quote = match.group(1)
value = match.group(2)
replacement = replacement_for_pto_value(value, mapping, target_dir)
if replacement is None:
return match.group(0)
new_value, record = replacement
if value != new_value:
replacements.append((value, new_value))
return f"{quote}{new_value}{quote}"
updated_text = PTO_QUOTED_VALUE_RE.sub(replace_match, original_text)
first = group_stem_from_name(referenced[0].target_path.name)
last = group_stem_from_name(referenced[-1].target_path.name)
target_path = pto_target_for_base(pto_path, target_dir, f"{first}-{last}", used_targets)
if updated_text == original_text and target_path.resolve() == pto_path.resolve():
continue
plans.append(
PtoPlan(
path=pto_path,
target_path=target_path,
referenced_records=referenced,
updated_text=updated_text,
replacements=replacements,
)
)
return plans
def build_write_plan(record: MediaRecord, choices: UserChoices) -> list[TagWrite]:
writes: list[TagWrite] = []
if choices.artist_action == "set":
@@ -1227,31 +1410,45 @@ def build_write_args(record: MediaRecord, choices: UserChoices) -> list[str]:
return [write.arg for write in record.write_plan if write.will_write]
def display_path_from_working_dir(path: Path, working_dir: Path) -> str:
try:
relative = path.resolve().relative_to(working_dir.resolve())
except ValueError:
return str(path)
return "." if str(relative) == "." else str(relative)
def path_display(path: Path, choices: UserChoices, use_relative: bool) -> str:
if use_relative:
return display_path_from_working_dir(path, choices.working_dir)
return str(path)
def metadata_preview_text(write: TagWrite) -> str:
current = write.current_value if write.current_value is not None else "<missing>"
if write.will_write:
return f"metadata {write.label}: {current} -> {write.new_value}"
return f"metadata {write.label}: {current} -> {write.new_value} (unchanged, skipped)"
def directory_display(path: Path, choices: UserChoices) -> str:
text = path_display(path, choices, use_relative=True)
if text != "." and not text.endswith(os.sep):
return f"{text}{os.sep}"
return text
def print_preview(records: list[MediaRecord], choices: UserChoices) -> None:
def move_rename_preview(
source: Path,
target: Path,
choices: UserChoices,
source_label: str | None = None,
) -> str | None:
is_moving = source.parent.resolve() != target.parent.resolve()
is_renaming = source.name != target.name
source_text = source_label or path_display(source, choices, use_relative=is_moving)
if is_moving and is_renaming:
target_text = path_display(target, choices, use_relative=True)
return f"move+rename {source_text} -> {target_text}"
if is_moving:
target_text = directory_display(target.parent, choices)
return f"move {source_text} -> {target_text}"
if is_renaming:
return f"rename {source_label or source.name} -> {target.name}"
return None
def print_preview(records: list[MediaRecord], choices: UserChoices, pto_plans: list[PtoPlan]) -> None:
print("\nPreview")
print(f"Working directory: {choices.working_dir}")
print(f"Move ungrouped files into working directory: {'yes' if choices.move_to_working_dir else 'no'}")
print(f"Panorama project files (.pto): {'group/update' if choices.process_pto_files else 'ignore'}")
print(f"Time shift: {format_timedelta(choices.time_offset) if choices.time_offset else 'none'}")
print(
"Photo timezone write: "
@@ -1276,27 +1473,12 @@ def print_preview(records: list[MediaRecord], choices: UserChoices) -> None:
else:
print("Artist/author: unchanged")
rows = []
for record in sorted(records, key=lambda item: str(item.path).lower()):
target = record.target_path or record.path
operation = []
source_parent = record.path.parent.resolve()
target_parent = target.parent.resolve()
source_name = record.path.name
target_name = target.name
is_moving = source_parent != target_parent
is_renaming = source_name != target_name
if is_moving:
source_display = path_display(record.path, choices, use_relative=True)
if is_renaming:
target_display = path_display(target, choices, use_relative=True)
else:
target_display = path_display(target.parent, choices, use_relative=True)
operation.append(f"move {source_display} -> {target_display}")
if is_renaming:
operation.append(f"rename {source_name} -> {target_name}")
for write in record.write_plan:
operation.append(metadata_preview_text(write))
operation_text = move_rename_preview(record.path, target, choices)
if operation_text:
operation.append(operation_text)
if record.rename_skip_reason:
operation.append(f"rename skipped: {record.rename_skip_reason}")
for warning in record.warnings:
@@ -1305,48 +1487,27 @@ def print_preview(records: list[MediaRecord], choices: UserChoices) -> None:
sidecar_source = path_display(record.sidecar.path, choices, use_relative=True)
sidecar_target = path_display(record.target_sidecar, choices, use_relative=True)
operation.append(f"sidecar move {sidecar_source} -> {sidecar_target}")
source_text = path_display(record.path, choices, use_relative=is_moving)
rows.append((source_text, "; ".join(operation) or "no change"))
if operation:
print("; ".join(operation))
path_width = min(max((len(row[0]) for row in rows), default=10), 70)
for source, operation in rows:
print(f"{source:<{path_width}} {operation}")
if pto_plans:
print("\nPTO updates:")
for plan in pto_plans:
source = path_display(plan.path, choices, use_relative=True)
operation = move_rename_preview(plan.path, plan.target_path, choices, source_label=source)
print(operation or source)
for old_ref, new_ref in plan.replacements:
print(f" {old_ref} -> {new_ref}")
def unique_existing_target(path: Path, planned_sources: set[Path]) -> Path:
if not path.exists() or path.resolve() in planned_sources:
return path
for index in range(1, 10000):
candidate = path.with_name(f"{path.stem}-{index}{path.suffix}")
if not candidate.exists() or candidate.resolve() in planned_sources:
return candidate
raise RuntimeError(f"Could not find available target name for {path}")
def remove_empty_dirs(cleanup_roots: list[Path], keep_dirs: set[Path]) -> list[Path]:
removed: list[Path] = []
candidates: set[Path] = set()
for root in cleanup_roots:
if not root.exists() or not root.is_dir():
continue
for directory, subdirs, _files in os.walk(root, topdown=False):
path = Path(directory).resolve()
candidates.add(path)
for subdir in subdirs:
child = (Path(directory) / subdir).resolve()
if child.exists() and child.is_dir():
candidates.add(child)
for directory in sorted(candidates, key=lambda path: len(path.parts), reverse=True):
if directory in keep_dirs:
continue
try:
directory.rmdir()
removed.append(directory)
except OSError:
pass
return removed
def has_planned_media_change(record: MediaRecord, write_args: list[str]) -> bool:
if write_args:
return True
if record.target_path and record.target_path.resolve() != record.path.resolve():
return True
if record.sidecar and record.target_sidecar and record.sidecar.path.exists():
return True
return False
def apply_changes(
@@ -1356,8 +1517,10 @@ def apply_changes(
planned_sources = {record.path.resolve() for record in records}
for index, record in enumerate(records, start=1):
changed = False
try:
write_args = build_write_args(record, choices)
changed = has_planned_media_change(record, write_args)
run_exiftool_write(executable, record.path, write_args)
if record.target_path and record.target_path != record.path:
@@ -1373,11 +1536,26 @@ def apply_changes(
shutil.move(str(record.sidecar.path), str(target_sidecar))
except (OSError, subprocess.CalledProcessError, RuntimeError) as exc:
failures.append(f"{record.path}: {exc}")
print(f"Processed {index} of {len(records)}")
action = "Processed" if changed else "Skipped"
print(f"{action} {index} of {len(records)}")
return failures
def apply_pto_changes(pto_plans: list[PtoPlan]) -> list[str]:
failures: list[str] = []
for plan in pto_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(removed_dirs: list[Path]) -> None:
if not removed_dirs:
return
@@ -1386,12 +1564,19 @@ def print_removed_dirs(removed_dirs: list[Path]) -> None:
print(f" - {directory}")
def prepare_plan(records: list[MediaRecord], choices: UserChoices) -> list[list[MediaRecord]]:
def prepare_plan(
records: list[MediaRecord], choices: UserChoices, pto_references: list[PtoReference]
) -> list[list[MediaRecord]]:
apply_time_offset(records, choices.time_offset)
groups: list[list[MediaRecord]] = []
group_candidates: list[list[MediaRecord]] = []
if choices.group_photos:
groups = detect_photo_groups(records)
group_candidates.extend(detect_photo_groups(records))
if choices.process_pto_files:
group_candidates.extend(reference.records for reference in pto_references)
groups = merge_record_groups(records, group_candidates)
if groups:
assign_groups(records, groups)
infer_group_subseconds(groups)
@@ -1404,14 +1589,6 @@ def prepare_plan(records: list[MediaRecord], choices: UserChoices) -> list[list[
return groups
def pause_if_interactive() -> None:
if sys.stdin.isatty():
try:
input("\nPress Enter to close...")
except EOFError:
pass
def run(argv: list[str]) -> int:
if len(argv) < 2:
print("Usage: python3 photo_metadata.py path/to/file/or/directory [...]")
@@ -1425,6 +1602,7 @@ def run(argv: list[str]) -> int:
cleanup_roots = cleanup_roots_from_args(argv[1:])
files = collect_media_files(argv[1:])
pto_files = collect_pto_files(argv[1:])
if not files:
print("No supported media files found.")
return 1
@@ -1444,13 +1622,15 @@ def run(argv: list[str]) -> int:
records = build_records(files, metadata)
try:
choices = prompt_choices(records, working_dir)
choices = prompt_choices(records, working_dir, has_pto_files=bool(pto_files))
except RuntimeError as exc:
print(exc)
return 1
prepare_plan(records, choices)
print_preview(records, choices)
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 []
print_preview(records, choices, pto_plans)
try:
proceed = ask_yes_no("Proceed with these changes?", default=True)
@@ -1463,6 +1643,7 @@ def run(argv: list[str]) -> int:
return 0
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)