Refactor photo metadata workflow

This commit is contained in:
ajp_anton
2026-08-24 16:34:20 +00:00
parent 100cad1cb5
commit eb5937a629
15 changed files with 2111 additions and 1727 deletions
+7 -4
View File
@@ -20,23 +20,26 @@ Current features:
- reads and writes metadata through `exiftool` from `PATH`
- optional direct time shifts, such as `+1:30`, `-02:00:00`, or `+1h 2m`
- optional reference-clock correction using a source photo timestamp or filename plus the correct time
- timezone handling for photos and local-time video filenames
- keyboard-driven settings table with timezone gap filling and optional fixed timezone offsets
- artist/author set, clear, or leave unchanged
- timestamp-based renaming to `YYYYMMDD_HHMMSS.ext`
- timestamp-based rename modes: adjust timestamps already in filenames, add one, or replace the stem
- video filenames use the beginning timestamp by subtracting rounded duration from the video metadata timestamp
- Sony-style video sidecars like `C0011.MP4` plus `C0011M01.XML` are moved and renamed with the video
- automatic photo burst/HDR grouping per camera
- optional panorama project (`.pto`) grouping and reference updates
- inferred subsecond metadata for grouped same-second photo bursts
- inferred subsecond metadata for grouped same-second photo bursts when the group has no existing subseconds
- optional capture-time inference from a safe timestamp filename, then `ModifyDate` as a fallback
The working directory is determined from the dragged items. A single dragged folder is the working directory. For multiple dragged files/folders, their last common ancestor is the working directory. Automatic burst/HDR and `.pto` group folders are created inside that working directory.
If dragged folders contain files in subdirectories and grouping is enabled, ungrouped files are moved into the working directory too. This makes the run organize everything into a deterministic flat reset state, with grouped files in their group folders and the remaining files directly in the working directory. If grouping is disabled, the script asks whether to flatten subdirectory files into the working directory.
The `Organize/group` setting controls all file movement. When enabled, ungrouped files are moved into the working directory and groups are created there; when disabled, files remain in their original directories. Panorama-project grouping is also enabled from that row.
Panorama project files use the `.pto` extension. When enabled, every media file referenced by a `.pto` file belongs to the same group. If those files already belong to burst/HDR groups, the groups are merged. `.pto` files are moved into the group folder with their referenced files, renamed from the first and last referenced media stems, and their file references are rewritten to the new relative paths.
Reference-clock correction is useful when one photo shows a reliable clock. For example, if `DSC01234.JPG` has camera metadata `2026:06:02 22:10:00`, but the clock in the photo shows `22:13:25`, choose reference mode, enter `DSC01234.JPG` as the source, and enter `22:13:25` as the correct time. The script computes a `+00:03:25` shift and applies that correction to the selected files. If you omit the date in the correct time, the script chooses the date closest to the source timestamp, within 12 hours.
The settings table requires an ANSI-capable terminal because it uses arrow keys and inline editing. Use Up/Down to select a row, Left/Right to select an option, Space to toggle an option, and Enter to continue. The preview separates common changes, inferred timestamps, warnings, and file operations before asking for confirmation.
### `grouping.py`
Fast manual grouping for selected files:
+57 -1578
View File
File diff suppressed because it is too large Load Diff
+179 -30
View File
@@ -1,14 +1,19 @@
from __future__ import annotations
import os
import select
import shutil
import sys
import textwrap
import time
from collections import deque
from contextlib import contextmanager
from math import floor, log10
from threading import Lock
_WINDOWS_ANSI_ENABLED: bool | None = None
_WINDOWS_CONSOLE_OUTPUT: bool | None = None
def prompt_input(prompt: str) -> str:
@@ -44,7 +49,7 @@ def supports_color() -> bool:
def supports_ansi() -> bool:
if not sys.stdout.isatty():
if not stream_is_interactive(sys.stdout):
return False
if os.name != "nt":
return os.environ.get("TERM", "dumb") != "dumb"
@@ -56,6 +61,10 @@ def supports_ansi() -> bool:
)
def stream_is_interactive(stream) -> bool:
return stream.isatty() or (os.name == "nt" and stream is sys.stdout and _windows_console_output())
def color(text: object, code: str) -> str:
value = str(text)
if not supports_color():
@@ -79,12 +88,74 @@ def red_strikethrough(text: object) -> str:
return color(text, "91;9")
def clear_screen() -> None:
if not sys.stdout.isatty():
def clear_screen(*, scrollback: bool = False) -> None:
if not stream_is_interactive(sys.stdout):
return
if supports_ansi():
sys.stdout.write("\x1b[3J" if scrollback else "")
sys.stdout.write("\x1b[H\x1b[2J")
sys.stdout.flush()
return
os.system("cls" if os.name == "nt" else "clear")
def require_terminal_ui() -> None:
if not (stream_is_interactive(sys.stdin) and stream_is_interactive(sys.stdout) and supports_ansi()):
raise RuntimeError("This settings dialog requires an ANSI-capable interactive terminal.")
@contextmanager
def raw_key_input():
require_terminal_ui()
if os.name == "nt":
yield read_key
return
import termios
import tty
fd = sys.stdin.fileno()
settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
yield read_key
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, settings)
def read_key() -> str:
if os.name == "nt":
import msvcrt
key = msvcrt.getwch()
if key in {"\x00", "\xe0"}:
return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(msvcrt.getwch(), "")
else:
key = sys.stdin.read(1)
if key == "\x1b" and select.select([sys.stdin], [], [], 0.03)[0]:
key += sys.stdin.read(2)
return {"\x1b[A": "up", "\x1b[B": "down", "\x1b[D": "left", "\x1b[C": "right"}.get(key, "esc")
return {"\r": "enter", "\n": "enter", "\x1b": "esc", "\x08": "backspace", "\x7f": "backspace", " ": "space"}.get(key, key)
def draw_screen(lines: list[str]) -> None:
require_terminal_ui()
sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(lines))
sys.stdout.flush()
def inverse(text: str) -> str:
return f"\x1b[30;47m{text}\x1b[0m"
def dim(text: str) -> str:
return f"\x1b[90m{text}\x1b[0m"
def dark_field(text: str) -> str:
return f"\x1b[30;100m{text}\x1b[0m"
def _enable_windows_ansi() -> bool:
global _WINDOWS_ANSI_ENABLED
if _WINDOWS_ANSI_ENABLED is not None:
@@ -105,6 +176,23 @@ def _enable_windows_ansi() -> bool:
return False
def _windows_console_output() -> bool:
global _WINDOWS_CONSOLE_OUTPUT
if _WINDOWS_CONSOLE_OUTPUT is not None:
return _WINDOWS_CONSOLE_OUTPUT
try:
import ctypes
mode = ctypes.c_uint32()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
_WINDOWS_CONSOLE_OUTPUT = bool(
ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode))
)
except Exception:
_WINDOWS_CONSOLE_OUTPUT = False
return _WINDOWS_CONSOLE_OUTPUT
def format_duration(seconds: float) -> str:
seconds = max(0, int(round(seconds)))
minutes, second = divmod(seconds, 60)
@@ -120,30 +208,42 @@ class ProgressView:
total: int,
label: str,
*,
total_work: float | None = None,
stream=None,
embedded_percent: bool = False,
show_rate: bool = False,
show_elapsed: bool = True,
bar_width: int = 24,
) -> None:
self.total = max(0, total)
self.total_work = total if total_work is None else max(0.0, total_work)
self.label = label
self.stream = stream or sys.stdout
self.started_at = time.monotonic()
self.samples: deque[tuple[int, float]] = deque()
self.samples: deque[tuple[float, float]] = deque()
self.last_width = 0
self.last_line_count = 0
self.embedded_percent = embedded_percent
self.show_rate = show_rate
self.show_elapsed = show_elapsed
self.bar_width = bar_width
def update(self, processed: int, detail: str = "") -> None:
self._write(self.update_line(processed, detail))
def update(
self, processed: int, detail: str = "", *, completed_work: float | None = None
) -> None:
self._write(self.update_line(processed, detail, completed_work=completed_work))
def update_line(self, processed: int, detail: str = "") -> str:
def update_line(
self, processed: int, detail: str = "", *, completed_work: float | None = None
) -> str:
processed = max(0, min(processed, self.total))
work_done = min(self.total_work, completed_work if completed_work is not None else processed)
now = time.monotonic()
if not self.samples or self.samples[-1][0] != processed:
self.samples.append((processed, now))
if not self.samples or self.samples[-1][0] != work_done:
self.samples.append((work_done, now))
while len(self.samples) > max(2, processed // 2 + 2):
self.samples.popleft()
return self._line(processed, detail, now)
return self._line(processed, detail, now, completed_work=work_done)
def update_external(
self,
@@ -167,25 +267,60 @@ class ProgressView:
return self._line(processed, detail, None, elapsed=elapsed, eta=eta)
def finish(self, *, keep: bool = False) -> None:
if self.stream.isatty():
if stream_is_interactive(self.stream):
if keep:
self.stream.write("\n")
else:
self.stream.write("\r" + " " * self.last_width + "\r")
self._clear_live_lines()
self.stream.flush()
elif self.last_width and not keep:
self.stream.write("\n")
self.last_width = 0
self.last_line_count = 0
def _write(self, line: str) -> None:
if self.stream.isatty():
def write_lines(self, lines: list[str]) -> None:
self._write(lines)
def _write(self, content: str | list[str]) -> None:
lines = [content] if isinstance(content, str) else content
if stream_is_interactive(self.stream) and supports_ansi():
lines = [wrapped for line in lines for wrapped in _wrap_to_terminal(line, self.stream)]
if self.last_line_count > 1:
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
rows = max(self.last_line_count, len(lines))
for index in range(rows):
self.stream.write("\r\x1b[2K")
if index < len(lines):
self.stream.write(lines[index])
if index < rows - 1:
self.stream.write("\n")
if rows > len(lines):
self.stream.write(f"\x1b[{rows - len(lines)}F")
self.stream.flush()
self.last_width = max(map(len, lines), default=0)
self.last_line_count = len(lines)
elif stream_is_interactive(self.stream):
line = " ".join(lines)
padding = max(0, self.last_width - len(line))
self.stream.write("\r" + line + (" " * padding))
self.stream.flush()
self.last_width = len(line)
else:
self.stream.write(line + "\n")
self.last_width = len(line)
self.stream.write("\n".join(lines) + "\n")
self.last_width = max(map(len, lines), default=0)
def _clear_live_lines(self) -> None:
if not self.last_line_count or not supports_ansi():
self.stream.write("\r" + " " * self.last_width + "\r")
return
if self.last_line_count > 1:
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
for index in range(self.last_line_count):
self.stream.write("\r\x1b[2K")
if index < self.last_line_count - 1:
self.stream.write("\n")
if self.last_line_count > 1:
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
def _line(
self,
@@ -195,13 +330,15 @@ class ProgressView:
*,
elapsed: float | None = None,
eta: float | None = None,
completed_work: float | None = None,
) -> str:
prefix = self.label if not detail else f"{self.label}: {detail}"
prefix = self.label
if self.total <= 2:
return f"{prefix} {processed}/{self.total}"
return " ".join(part for part in (prefix, f"{processed}/{self.total}", detail) if part)
percent = 0.0 if self.total == 0 else processed / self.total
bar = _progress_bar(percent, embedded_percent=self.embedded_percent)
work_done = processed if completed_work is None else completed_work
percent = 0.0 if self.total_work == 0 else work_done / self.total_work
bar = _progress_bar(percent, embedded_percent=self.embedded_percent, width=self.bar_width)
elapsed_value = elapsed if elapsed is not None else (now or time.monotonic()) - self.started_at
parts = [
prefix,
@@ -212,22 +349,25 @@ class ProgressView:
parts.append(f"{percent * 100:5.1f}%")
if self.show_rate and processed > 0 and elapsed_value > 0:
parts.append(f"{_format_significant(processed / elapsed_value, 2)} fps")
parts.append(f"elapsed {format_duration(elapsed_value)}")
eta_value = eta if eta is not None else self._eta(processed)
if self.show_elapsed:
parts.append(f"elapsed {format_duration(elapsed_value)}")
eta_value = eta if eta is not None else self._eta(processed, work_done)
if processed >= 2 and processed < self.total and eta_value is not None:
parts.append(f"ETA {format_duration(eta_value)}")
if detail:
parts.append(detail)
return " ".join(parts)
def _eta(self, processed: int) -> float | None:
def _eta(self, processed: int, work_done: float) -> float | None:
if processed < 2 or self.total <= processed or len(self.samples) < 2:
return None
oldest_processed, oldest_time = self.samples[0]
newest_processed, newest_time = self.samples[-1]
delta_items = newest_processed - oldest_processed
oldest_work, oldest_time = self.samples[0]
newest_work, newest_time = self.samples[-1]
delta_work = newest_work - oldest_work
delta_time = newest_time - oldest_time
if delta_items <= 0 or delta_time <= 0:
if delta_work <= 0 or delta_time <= 0:
return None
return (self.total - processed) * (delta_time / delta_items)
return (self.total_work - work_done) * (delta_time / delta_work)
class PipelineProgressView:
@@ -311,8 +451,7 @@ class PipelineProgressView:
self._shown = True
def _progress_bar(percent: float, *, embedded_percent: bool) -> str:
width = 24
def _progress_bar(percent: float, *, embedded_percent: bool, width: int = 24) -> str:
done = round(max(0.0, min(percent, 1.0)) * width)
if not embedded_percent:
return "[" + ("#" * done).ljust(width, "-") + "]"
@@ -335,6 +474,16 @@ def _center_progress_text(text: str, width: int, fill: str) -> str:
return content.center(width, fill)
def _wrap_to_terminal(line: str, stream) -> list[str]:
try:
width = shutil.get_terminal_size().columns
except OSError:
return [line]
if width <= 0 or len(line) <= width:
return [line]
return textwrap.wrap(line, width=width, break_long_words=False, break_on_hyphens=False) or [line]
def _format_significant(value: float, digits: int) -> str:
if value == 0:
return "0"
+71 -21
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@@ -18,15 +21,12 @@ def _use_windows_argument_file() -> bool:
return os.name == "nt"
def run_exiftool_command(
executable: str,
args: list[str],
**run_kwargs: Any,
) -> subprocess.CompletedProcess:
"""Run ExifTool with Unicode-safe filename arguments on Windows."""
@contextmanager
def _prepared_command(executable: str, args: list[str]) -> Iterator[tuple[list[str], str | None]]:
command = [executable, "-charset", "filename=UTF8", *args]
if not _use_windows_argument_file():
return subprocess.run(command, **run_kwargs)
yield command, None
return
# ExifTool can't reliably receive arbitrary Unicode paths on the Windows
# command line. The outer path is ASCII and the UTF-8 argument file holds
@@ -34,11 +34,20 @@ def run_exiftool_command(
with tempfile.TemporaryDirectory(prefix="mbt-exiftool-") as temp_dir:
argument_file = Path(temp_dir) / "arguments.txt"
argument_file.write_text("\n".join(args) + "\n", encoding="utf-8")
return subprocess.run(
[executable, "-charset", "filename=UTF8", "-@", argument_file.name],
cwd=temp_dir,
**run_kwargs,
)
yield [executable, "-charset", "filename=UTF8", "-@", argument_file.name], temp_dir
def run_exiftool_command(
executable: str,
args: list[str],
**run_kwargs: Any,
) -> subprocess.CompletedProcess:
"""Run ExifTool with Unicode-safe filename arguments on Windows."""
with _prepared_command(executable, args) as (command, cwd):
return subprocess.run(command, cwd=cwd, **run_kwargs)
_PROGRESS_RE = re.compile(r"^======== .* \[\d+/(\d+)\]$")
def run_exiftool_json(
@@ -46,6 +55,7 @@ def run_exiftool_json(
paths: list[Path],
*,
quicktime_utc: bool = True,
progress: Callable[[], None] | None = None,
) -> list[dict]:
results: list[dict] = []
for start in range(0, len(paths), 80):
@@ -57,14 +67,45 @@ def run_exiftool_json(
if quicktime_utc:
args.extend(["-api", "QuickTimeUTC=1"])
args.extend(str(path) for path in chunk)
completed = run_exiftool_command(
executable,
args,
check=True,
capture_output=True,
text=True,
)
results.extend(json.loads(completed.stdout or "[]"))
if progress is None:
completed = run_exiftool_command(
executable,
args,
check=True,
capture_output=True,
text=True,
)
results.extend(json.loads(completed.stdout or "[]"))
continue
with _prepared_command(executable, ["-progress", *args]) as (command, cwd):
with tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as output:
process = subprocess.Popen(
command,
cwd=cwd,
stdout=output,
stderr=subprocess.PIPE,
text=True,
)
assert process.stderr is not None
errors = []
started_file = False
for line in process.stderr:
if _PROGRESS_RE.match(line.rstrip("\r\n")):
# ExifTool emits this marker before reading the named
# file, so it confirms that the previous one finished.
if started_file:
progress()
started_file = True
else:
errors.append(line)
return_code = process.wait()
if return_code:
raise subprocess.CalledProcessError(return_code, command, stderr="".join(errors))
if started_file:
progress()
output.seek(0)
results.extend(json.load(output))
return results
@@ -76,4 +117,13 @@ def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None:
*args,
str(path),
]
run_exiftool_command(executable, command, check=True)
completed = run_exiftool_command(
executable,
command,
check=False,
capture_output=True,
text=True,
)
if completed.returncode:
detail = (completed.stderr or completed.stdout or "").strip()
raise RuntimeError(f"ExifTool failed: {detail or f'exit code {completed.returncode}'}")
+63
View File
@@ -0,0 +1,63 @@
"""Small, shared helpers for values returned by ExifTool."""
from __future__ import annotations
import math
import re
from collections.abc import Iterable, Mapping
from datetime import datetime, timedelta, timezone
DATETIME_RE = re.compile(
r"(?P<Y>\d{4})[:\-]?(?P<M>\d{2})[:\-]?(?P<D>\d{2})"
r"(?:[ T_])?"
r"(?P<h>\d{2}):?(?P<m>\d{2}):?(?P<s>\d{2})"
r"(?:[.,](?P<sub>\d+))?"
r"(?:\s*(?P<tz>Z|[+-]\d{2}:?\d{2}))?"
)
def first_tag(metadata: Mapping[str, object], tags: Iterable[str]) -> object | None:
for tag in tags:
if tag in metadata:
return metadata[tag]
return None
def tag_value(metadata: Mapping[str, object], tag: str) -> str | None:
value = metadata.get(tag)
return None if value is None else str(value)
def parse_exif_datetime(value: object, assume_utc: bool = False) -> datetime | None:
if value is None:
return None
match = DATETIME_RE.search(str(value).strip())
if not match:
return None
try:
tz_text = match.group("tz")
if tz_text == "Z":
tzinfo = timezone.utc
elif tz_text:
sign = 1 if tz_text[0] == "+" else -1
digits = tz_text[1:].replace(":", "")
tzinfo = timezone(sign * timedelta(hours=int(digits[:2]), minutes=int(digits[2:])))
else:
tzinfo = None
parsed = datetime(
int(match.group("Y")),
int(match.group("M")),
int(match.group("D")),
int(match.group("h")),
int(match.group("m")),
int(match.group("s")),
tzinfo=tzinfo,
)
return parsed.replace(tzinfo=timezone.utc) if assume_utc and tzinfo is None else parsed
except ValueError:
return None
def round_half_up(value: float) -> int:
return math.floor(value + 0.5)
+6 -46
View File
@@ -1,11 +1,8 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from tools.exiftool import run_exiftool_command
from tools.filesystem import unique_path
EXCLUDED_COPY_TAGS = [
@@ -70,15 +67,12 @@ def copy_meaningful_metadata(
*,
extra_excluded_tags: list[str] | None = None,
) -> None:
if destination.suffix.lower() in {".mp4", ".mov"}:
copy_meaningful_metadata_with_exiftool(
exiftool,
source,
destination,
extra_excluded_tags=extra_excluded_tags,
)
return
copy_container_metadata_with_ffmpeg(source, destination)
copy_meaningful_metadata_with_exiftool(
exiftool,
source,
destination,
extra_excluded_tags=extra_excluded_tags,
)
def copy_meaningful_metadata_with_exiftool(
@@ -104,37 +98,3 @@ def copy_meaningful_metadata_with_exiftool(
capture_output=True,
text=True,
)
def copy_container_metadata_with_ffmpeg(source: Path, destination: Path) -> None:
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
print(" metadata: ffmpeg not found; skipped best-effort metadata remux")
return
temp_output = destination.with_name(f"{destination.stem}.metadata-copy{destination.suffix}")
temp_output = unique_path(temp_output)
command = [
ffmpeg,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(destination),
"-i",
str(source),
"-map",
"0",
"-map_metadata",
"1",
"-c",
"copy",
str(temp_output),
]
try:
subprocess.run(command, check=True, capture_output=True, text=True)
temp_output.replace(destination)
finally:
if temp_output.exists():
temp_output.unlink()
+166
View File
@@ -0,0 +1,166 @@
"""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}")
+136
View File
@@ -0,0 +1,136 @@
"""Burst/HDR group detection for photo metadata cleanup."""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from tools.filenames import naive_wall_time
if TYPE_CHECKING:
from photo_metadata import MediaRecord
def photo_sort_key(record: MediaRecord) -> tuple:
assert record.adjusted_time is not None
return (
naive_wall_time(record.adjusted_time),
record.subsec if record.subsec is not None else -1,
record.sequence if record.sequence is not None else -1,
record.path.name.lower(),
)
def group_sort_key(record: MediaRecord) -> tuple:
timestamp = naive_wall_time(record.adjusted_time) if record.adjusted_time 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], min_size: int = 2) -> list[list[MediaRecord]]:
by_camera: dict[str, list[MediaRecord]] = defaultdict(list)
for record in records:
if record.is_image and record.adjusted_time is not None:
by_camera[record.camera_key].append(record)
return [
group
for camera_records in by_camera.values()
for group in detect_groups_for_camera(sorted(camera_records, key=photo_sort_key), min_size)
]
def detect_groups_for_camera(records: list[MediaRecord], min_size: int) -> list[list[MediaRecord]]:
by_second: dict[datetime, list[MediaRecord]] = defaultdict(list)
for record in records:
assert record.adjusted_time is not None
by_second[naive_wall_time(record.adjusted_time).replace(microsecond=0)].append(record)
seconds = sorted(by_second)
spans: list[list[datetime]] = []
for second in seconds:
if not spans or second != spans[-1][-1] + timedelta(seconds=1):
spans.append([])
spans[-1].append(second)
groups: list[list[MediaRecord]] = []
for span in spans:
if max(len(by_second[second]) for second in span) < 2:
continue
span_records = [record for second in span for record in by_second[second]]
if len(span_records) >= 2 and all(record.sequence is not None for record in span_records):
groups.extend(sequence_groups(span_records, min_size))
else:
groups.extend(timestamp_groups(span, by_second, min_size))
return [group for group in groups if len(group) >= min_size]
def sequence_groups(records: list[MediaRecord], min_size: int) -> list[list[MediaRecord]]:
groups: list[list[MediaRecord]] = []
for record in sorted(records, key=photo_sort_key):
if not groups or record.sequence <= groups[-1][-1].sequence:
groups.append([])
groups[-1].append(record)
return [group for group in groups if len(group) >= min_size]
def timestamp_groups(
span: list[datetime], by_second: dict[datetime, list[MediaRecord]], min_size: int
) -> list[list[MediaRecord]]:
multi_indices = [index for index, second in enumerate(span) if len(by_second[second]) >= 2]
if not multi_indices:
return []
clusters: list[list[int]] = [[multi_indices[0]]]
for index in multi_indices[1:]:
if index - clusters[-1][-1] > 2:
clusters.append([])
clusters[-1].append(index)
groups: list[list[MediaRecord]] = []
used_seconds: set[datetime] = set()
for cluster in clusters:
start, end = cluster[0], cluster[-1]
if start and len(by_second[span[start - 1]]) == 1:
start -= 1
if end + 1 < len(span) and len(by_second[span[end + 1]]) == 1:
end += 1
seconds = [second for second in span[start : end + 1] if second not in used_seconds]
used_seconds.update(seconds)
records = [record for second in seconds for record in sorted(by_second[second], key=photo_sort_key)]
if len(records) >= min_size:
groups.append(records)
return groups
def infer_group_subseconds(groups: list[list[MediaRecord]]) -> None:
for group in groups:
if any(record.subsec is not None for record in group):
continue
by_second: dict[datetime, list[MediaRecord]] = defaultdict(list)
for record in group:
if record.is_image and record.adjusted_time is not None:
by_second[naive_wall_time(record.adjusted_time).replace(microsecond=0)].append(record)
seconds = sorted(by_second)
counts = {second: len(records) for second, records in by_second.items()}
for index, second in enumerate(seconds):
records = sorted(by_second[second], key=photo_sort_key)
count = len(records)
if count <= 1:
continue
if len(seconds) == 2:
fps = max(counts.values())
elif index == 0:
fps = max(count, counts[seconds[1]])
elif index == len(seconds) - 1:
fps = max(count, counts[seconds[-2]])
else:
fps = count
start_slot = fps - count if index == 0 and fps > count else 0
for item_index, record in enumerate(records):
record.inferred_subsec = int((start_slot + item_index) * 1000 / fps)
+383
View File
@@ -0,0 +1,383 @@
"""Pure planning for photo metadata updates, grouping, and PTO projects."""
from __future__ import annotations
import os
import re
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.filenames import format_filename_stem, group_stem_from_name, naive_wall_time, normalized_extension
from tools.media_metadata import first_tag, parse_exif_datetime, round_half_up, tag_value
from tools.photo_grouping import detect_photo_groups, group_sort_key, infer_group_subseconds
from tools.photo_records import FILENAME_TIMESTAMP_RE, IMAGE_MODIFY_TIME_TAGS, MediaRecord, TagWrite, parse_filename_timestamp, parse_subsec
from tools.timezones import timezone_to_string
IMAGE_WRITE_TIME_TAGS = (
"EXIF:DateTimeOriginal", "EXIF:CreateDate", "XMP:DateTimeOriginal", "XMP:CreationDate",
"XMP:CreateDate", "EXIF:ModifyDate", "XMP:ModifyDate",
)
OFFSET_TAG_FOR_TIME_TAG = {
"EXIF:DateTimeOriginal": "EXIF:OffsetTimeOriginal",
"EXIF:CreateDate": "EXIF:OffsetTimeDigitized",
"EXIF:ModifyDate": "EXIF:OffsetTime",
}
CAMERA_RANGE_STEM_RE = re.compile(r"^(?P<prefix>[A-Za-z]*)(?P<first>\d+)-(?P=prefix)(?P<last>\d+)(?P<suffix>_.*)$")
PTO_QUOTED_VALUE_RE = re.compile(r"([\"'])(.*?)(\1)")
@dataclass
class UserChoices:
working_dir: Path
time_offset: timedelta | None
fixed_timezone: timezone | None
fill_timezone_gaps: bool
artist_action: str
artist_value: str | None
rename_mode: str
organize_files: bool
group_min_size: int
process_pto_files: bool
infer_missing_timestamps: bool
@property
def group_photos(self) -> bool:
return self.organize_files and self.group_min_size >= 2
@property
def move_to_working_dir(self) -> bool:
return self.organize_files
@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]
def apply_time_offset(records: list[MediaRecord], offset: timedelta | None) -> None:
if offset is not None:
for record in records:
if record.adjusted_time is not None:
record.adjusted_time += offset
def infer_missing_timestamps(records: list[MediaRecord], enabled: bool) -> None:
if not enabled:
return
for record in records:
if record.original_time is not None:
continue
parsed, source = parse_filename_timestamp(record), "filename"
if parsed is None and record.is_image:
parsed, source = parse_exif_datetime(first_tag(record.metadata, IMAGE_MODIFY_TIME_TAGS)), "ModifyDate"
if parsed is None:
continue
if record.is_video:
if record.resolved_timezone is None or record.duration_seconds is None:
continue
parsed = parsed.replace(tzinfo=record.resolved_timezone).astimezone(timezone.utc)
parsed += timedelta(seconds=record.duration_seconds)
record.original_time = record.adjusted_time = parsed
record.inferred_timestamp_source = source
record.parsed_from_filename = source == "filename"
def filename_timestamp_stem(stem: str, timestamp: datetime, mode: str) -> str:
matches = [(match, parse_exif_datetime(match.group(0))) for match in FILENAME_TIMESTAMP_RE.finditer(stem)]
matches = [(match, value) for match, value in matches if value is not None]
if mode.startswith("adjust") and matches:
first_time = matches[0][1]
pieces, position, base = [], 0, naive_wall_time(timestamp)
for match, old_time in matches:
replacement = format_filename_stem(base + (old_time - first_time)).replace("_", match.group(0)[8])
pieces.extend((stem[position:match.start()], replacement))
position = match.end()
return "".join((*pieces, stem[position:]))
formatted = format_filename_stem(timestamp)
return f"{formatted} {stem}" if mode.endswith("add") else formatted
def plan_names(records: list[MediaRecord], choices: UserChoices) -> None:
by_stem = _unique_records_by_stem(records)
for record in records:
record.new_stem = record.rename_skip_reason = None
if record.adjusted_time is None:
record.new_stem = _camera_range_stem(record, by_stem)
record.rename_skip_reason = None if record.new_stem else "missing timestamp"
elif record.is_video:
if record.duration_seconds is None:
record.rename_skip_reason = "missing duration"
elif record.resolved_timezone is None:
record.rename_skip_reason = "missing video timezone"
else:
begin = record.adjusted_time if record.video_time_is_beginning else record.adjusted_time - timedelta(seconds=round_half_up(record.duration_seconds))
if begin.tzinfo is None:
begin = begin.replace(tzinfo=timezone.utc)
record.new_stem = filename_timestamp_stem(record.path.stem, begin.astimezone(record.resolved_timezone), choices.rename_mode)
else:
record.new_stem = filename_timestamp_stem(record.path.stem, record.adjusted_time, choices.rename_mode)
_assign_unique_names(records)
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 _camera_range_stem(record: MediaRecord, by_stem: dict[str, MediaRecord]) -> str | None:
match = CAMERA_RANGE_STEM_RE.match(record.path.stem)
if not match or int(match.group("last")) <= int(match.group("first")):
return None
prefix = match.group("prefix")
first = by_stem.get(f"{prefix}{match.group('first')}".lower())
last = by_stem.get(f"{prefix}{match.group('last')}".lower())
start = record.adjusted_time or (first.adjusted_time if first else None) or (last.adjusted_time if last else None)
end = (last.adjusted_time if last else None) or (first.adjusted_time if first else None) or start
return f"{format_filename_stem(start)}-{format_filename_stem(end)}{match.group('suffix')}" if start and end else None
def _assign_unique_names(records: list[MediaRecord]) -> None:
buckets: dict[tuple[int | str, str], list[MediaRecord]] = defaultdict(list)
for record in records:
if record.new_stem:
buckets[(record.group_id if record.group_id is not None else str(record.path.parent.resolve()), record.new_stem.lower())].append(record)
for bucket in buckets.values():
if len(bucket) == 1:
bucket[0].final_name = f"{bucket[0].new_stem}{normalized_extension(bucket[0].path)}"
continue
for index, record in enumerate(sorted(bucket, key=group_sort_key), 1):
record.final_name = f"{record.new_stem}-{index}{normalized_extension(record.path)}"
def merge_record_groups(records: list[MediaRecord], candidates: list[list[MediaRecord]]) -> list[list[MediaRecord]]:
indexes = {record.path.resolve(): index for index, record in enumerate(records)}
parents = list(range(len(records)))
grouped: set[int] = set()
def find(index: int) -> int:
while parents[index] != index:
parents[index] = parents[parents[index]]
index = parents[index]
return index
for candidate in candidates:
members = [indexes[record.path.resolve()] for record in candidate if record.path.resolve() in indexes]
if members:
grouped.update(members)
for index in members[1:]:
parents[find(index)] = find(members[0])
merged: dict[int, list[MediaRecord]] = defaultdict(list)
for index in grouped:
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 = record.group_name = None
for index, group in enumerate(groups, 1):
for record in group:
record.group_id = index
def _group_name(base: str, working_dir: Path, used: set[str]) -> str:
if (working_dir / base).exists() or base not in used:
used.add(base)
return base
for index in range(1, 10000):
name = f"{base}-{index}"
if name not in used and not (working_dir / name).exists():
used.add(name)
return name
raise RuntimeError(f"Could not find an available group directory name for {base}")
def assign_group_names(groups: list[list[MediaRecord]], working_dir: Path) -> None:
used: set[str] = set()
for group in groups:
ordered = sorted(group, key=group_sort_key)
first, last = (group_stem_from_name(record.final_name or record.path.name) for record in (ordered[0], ordered[-1]))
name = _group_name(f"{first}-{last}", working_dir, used)
for record in group:
record.group_name = name
def plan_targets(records: list[MediaRecord], choices: UserChoices) -> None:
for record in records:
directory = choices.working_dir if choices.organize_files else record.path.parent
if record.group_name:
directory /= record.group_name
name = record.final_name or record.path.name
record.target_path = directory / name
if record.sidecar:
record.target_sidecar = directory / f"{Path(name).stem}M01.XML"
def normalize_reference(value: str) -> str:
return value.replace("\\", "/")
def _pto_mapping(pto_path: Path, records: list[MediaRecord]) -> dict[str, MediaRecord]:
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:
candidates = {normalize_reference(str(record.path.resolve())), normalize_reference(os.path.relpath(record.path.resolve(), pto_path.parent))}
if basename_counts[record.path.name] == 1:
candidates.add(record.path.name)
for candidate in candidates:
mapping[normalize_reference(candidate)] = record
return mapping
def _pto_target(path: Path, directory: Path, base: str, used: set[Path]) -> Path:
for index in range(10000):
target = directory / f"{base}{'' if index == 0 else f'-{index}'}.pto"
if target.resolve() == path.resolve() or (not target.exists() and target not in used):
used.add(target)
return target
raise RuntimeError(f"Could not find an available .pto name for {base}")
def read_pto_references(pto_files: list[Path], records: list[MediaRecord]) -> list[PtoReference]:
references = []
for path in pto_files:
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
text = path.read_text(encoding="utf-8", errors="replace")
mapping = _pto_mapping(path, records)
found = []
for match in PTO_QUOTED_VALUE_RE.finditer(text):
record = mapping.get(normalize_reference(match.group(2)))
if record is not None and record not in found:
found.append(record)
if found:
references.append(PtoReference(path, text, found))
return references
def plan_pto_updates(references: list[PtoReference], records: list[MediaRecord]) -> list[PtoPlan]:
plans, used = [], set()
for reference in references:
referenced = sorted(reference.records, key=group_sort_key)
target_dir = referenced[0].target_path.parent if referenced[0].group_name and referenced[0].target_path else reference.path.parent
mapping, replacements = _pto_mapping(reference.path, records), []
def replace(match: re.Match) -> str:
value = match.group(2)
record = mapping.get(normalize_reference(value))
if record is None or record.target_path is None:
return match.group(0)
replacement = normalize_reference(os.path.relpath(record.target_path.resolve(), target_dir))
if value != replacement:
replacements.append((value, replacement))
return f"{match.group(1)}{replacement}{match.group(1)}"
text = PTO_QUOTED_VALUE_RE.sub(replace, reference.original_text)
base = f"{group_stem_from_name(referenced[0].target_path.name)}-{group_stem_from_name(referenced[-1].target_path.name)}"
target = _pto_target(reference.path, target_dir, base, used)
if text != reference.original_text or target.resolve() != reference.path.resolve():
plans.append(PtoPlan(reference.path, target, referenced, text, replacements))
return plans
def _same_text(current: str | None, value: str) -> bool:
return (current or "") == value
def _same_datetime(current: str | None, value: str) -> bool:
current_time, new_time = parse_exif_datetime(current), parse_exif_datetime(value)
return current == value if current_time is None or new_time is None else naive_wall_time(current_time) == naive_wall_time(new_time)
def _same_subsec(current: str | None, value: str) -> bool:
parsed = parse_subsec(current)
return current == value if parsed is None else f"{parsed:03d}" == value
def _add_write(writes: list[TagWrite], label: str, tag: str, current: str | None, value: str, same=_same_text) -> None:
writes.append(TagWrite(label, f"-{tag}={value}", current, value, not same(current, value)))
def build_write_plan(record: MediaRecord, choices: UserChoices) -> list[TagWrite]:
writes: list[TagWrite] = []
if choices.artist_action != "leave":
value = choices.artist_value or ""
for label, tag in (("Artist", "Artist"), ("Author", "Author")):
_add_write(writes, label, tag, tag_value(record.metadata, f"EXIF:{tag}") or tag_value(record.metadata, f"XMP:{tag}"), value)
if record.is_image:
capture_tags: set[str] = set()
if record.inferred_timestamp_source and record.adjusted_time:
capture_tags.add("EXIF:DateTimeOriginal")
_add_write(writes, "DateTimeOriginal", "EXIF:DateTimeOriginal", tag_value(record.metadata, "EXIF:DateTimeOriginal"), record.adjusted_time.strftime("%Y:%m:%d %H:%M:%S"), _same_datetime)
if record.original_time and record.adjusted_time:
original, adjusted = naive_wall_time(record.original_time), naive_wall_time(record.adjusted_time)
for tag in IMAGE_WRITE_TIME_TAGS:
current = parse_exif_datetime(tag_value(record.metadata, tag))
if current is None:
continue
current_time = naive_wall_time(current)
matches_capture = abs((current_time - original).total_seconds()) <= 1
if matches_capture:
capture_tags.add(tag)
target = current_time + choices.time_offset if choices.time_offset and matches_capture else current_time
if tag.endswith("ModifyDate") and target < adjusted:
target, capture_tags = adjusted, capture_tags | {tag}
if "ModifyDate was earlier than capture time and will be clamped" not in record.warnings:
record.warnings.append("ModifyDate was earlier than capture time and will be clamped")
if target != current_time:
_add_write(writes, tag.split(":", 1)[1], tag, tag_value(record.metadata, tag), target.strftime("%Y:%m:%d %H:%M:%S"), _same_datetime)
if record.resolved_timezone and (choices.fixed_timezone or record.timezone_value is None):
for time_tag, offset_tag in OFFSET_TAG_FOR_TIME_TAG.items():
if time_tag in capture_tags:
_add_write(writes, offset_tag.split(":", 1)[1], offset_tag, tag_value(record.metadata, offset_tag), timezone_to_string(record.resolved_timezone))
if record.inferred_subsec is not None:
for label, tag in (("SubSecTimeOriginal", "EXIF:SubSecTimeOriginal"), ("SubSecTimeDigitized", "EXIF:SubSecTimeDigitized"), ("SubSecTime", "EXIF:SubSecTime")):
_add_write(writes, label, tag, tag_value(record.metadata, tag), f"{record.inferred_subsec:03d}", _same_subsec)
elif record.is_video and (choices.time_offset or record.inferred_timestamp_source) and record.adjusted_time:
value = record.adjusted_time.replace(tzinfo=record.adjusted_time.tzinfo or timezone.utc).astimezone(timezone.utc).strftime("%Y:%m:%d %H:%M:%S")
for tag in ("QuickTime:CreateDate", "QuickTime:ModifyDate", "QuickTime:TrackCreateDate", "QuickTime:TrackModifyDate", "QuickTime:MediaCreateDate", "QuickTime:MediaModifyDate"):
_add_write(writes, tag.split(":", 1)[1], tag, tag_value(record.metadata, tag), value, _same_datetime)
return writes
def build_write_args(record: MediaRecord, choices: UserChoices) -> list[str]:
if not record.write_plan:
record.write_plan = build_write_plan(record, choices)
return [write.arg for write in record.write_plan if write.will_write]
def prepare_plan(records: list[MediaRecord], choices: UserChoices, references: list[PtoReference]) -> list[list[MediaRecord]]:
infer_missing_timestamps(records, choices.infer_missing_timestamps)
apply_time_offset(records, choices.time_offset)
burst_groups = detect_photo_groups(records, choices.group_min_size) if choices.group_photos else []
groups = merge_record_groups(records, [*burst_groups, *(reference.records for reference in references if choices.process_pto_files)])
if groups:
assign_groups(records, groups)
if choices.organize_files:
infer_group_subseconds(burst_groups)
plan_names(records, choices)
if groups:
assign_group_names(groups, choices.working_dir)
plan_targets(records, choices)
for record in records:
record.write_plan = build_write_plan(record, choices)
return groups
+251
View File
@@ -0,0 +1,251 @@
"""Photo/video record types and ExifTool metadata decoding."""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.filenames import naive_wall_time
from tools.media_metadata import first_tag, parse_exif_datetime
from tools.timezones import parse_timezone_offset
IMAGE_EXTS = {".jpg", ".jpeg", ".heic", ".arw"}
VIDEO_EXTS = {".mp4", ".mov", ".mts"}
SUPPORTED_EXTS = IMAGE_EXTS | VIDEO_EXTS
IMAGE_CAPTURE_TIME_TAGS = (
"Composite:SubSecDateTimeOriginal",
"Composite:SubSecCreateDate",
"EXIF:DateTimeOriginal",
"XMP:DateTimeOriginal",
"XMP:CreationDate",
"XMP:CreateDate",
"EXIF:CreateDate",
)
IMAGE_MODIFY_TIME_TAGS = ("Composite:SubSecModifyDate", "EXIF:ModifyDate", "XMP:ModifyDate")
IMAGE_OFFSET_TAGS = (
"EXIF:OffsetTimeOriginal",
"EXIF:OffsetTimeDigitized",
"EXIF:OffsetTime",
)
VIDEO_TIME_TAGS = (
"QuickTime:MediaCreateDate",
"QuickTime:TrackCreateDate",
"QuickTime:CreateDate",
"QuickTime:MediaModifyDate",
"QuickTime:TrackModifyDate",
"QuickTime:ModifyDate",
"XMP:DateTimeOriginal",
"XMP:CreateDate",
)
VIDEO_BEGIN_TIME_TAGS = ("H264:DateTimeOriginal",)
VIDEO_DURATION_TAGS = (
"QuickTime:Duration",
"Composite:Duration",
"File:Duration",
"M2TS:Duration",
)
VIDEO_TIMEZONE_TAGS = ("QuickTime:AndroidTimeZone", "QuickTime:Keys:AndroidTimeZone")
SEQUENCE_TAGS = (
"MakerNotes:SequenceNumber",
"MakerNotes:SequenceNumberOriginal",
"MakerNotes:ImageNumber",
"EXIF:ImageNumber",
"SequenceNumber",
)
FILENAME_TIMESTAMP_RE = re.compile(r"(?P<date>\d{8})[-_](?P<time>\d{6})")
@dataclass
class XmlSidecar:
path: Path
creation: datetime | None = None
duration_seconds: float | None = None
timezone_value: timezone | None = None
device: str | None = None
@dataclass
class TagWrite:
label: str
arg: str
current_value: str | None
new_value: str
will_write: bool
@dataclass
class MediaRecord:
path: Path
metadata: dict
kind: str
original_time: datetime | None = None
adjusted_time: datetime | None = None
timezone_value: timezone | None = None
resolved_timezone: timezone | None = None
inferred_timestamp_source: str | None = None
subsec: int | None = None
inferred_subsec: int | None = None
sequence: int | None = None
camera_key: str = ""
duration_seconds: float | None = None
video_time_is_beginning: bool = False
parsed_from_filename: bool = False
sidecar: XmlSidecar | None = None
new_stem: str | None = None
final_name: str | None = None
group_id: int | None = None
group_name: str | None = None
target_path: Path | None = None
target_sidecar: Path | None = None
rename_skip_reason: str | None = None
write_plan: list[TagWrite] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def is_image(self) -> bool:
return self.kind == "image"
@property
def is_video(self) -> bool:
return self.kind == "video"
def parse_subsec(value: object) -> int | None:
match = re.search(r"\d+", str(value)) if value is not None else None
return int(match.group(0)[:3].ljust(3, "0")) if match else None
def parse_sequence(value: object) -> int | None:
match = re.search(r"\d+", str(value)) if value is not None else None
return int(match.group(0)) if match else None
def parse_duration_seconds(value: object) -> float | None:
if value is None:
return None
text = str(value).strip()
match = re.match(r"^([0-9]+(?:\.[0-9]+)?)\s*(?:s|sec|seconds)?$", text, re.I)
if match:
return float(match.group(1))
match = re.match(r"^(?:(?P<h>\d+):)?(?P<m>\d{1,2}):(?P<s>\d{1,2}(?:\.\d+)?)$", text)
if match:
return int(match.group("h") or 0) * 3600 + int(match.group("m")) * 60 + float(match.group("s"))
match = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*s", text, re.I)
return float(match.group(1)) if match else None
def parse_filename_timestamp(record: MediaRecord) -> datetime | None:
return parse_filename_timestamp_text(record.path.stem)
def parse_filename_timestamp_text(text: str) -> datetime | None:
match = FILENAME_TIMESTAMP_RE.search(text)
return parse_exif_datetime(match.group(0)) if match else None
def find_sidecar(video_path: Path) -> XmlSidecar | None:
expected = f"{video_path.stem}M01.XML"
for child in video_path.parent.iterdir():
if child.is_file() and child.name.lower() == expected.lower():
return parse_xml_sidecar(child)
return None
def parse_xml_sidecar(path: Path) -> XmlSidecar:
sidecar = XmlSidecar(path)
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
sidecar.device = f"unreadable XML: {exc}"
return sidecar
duration = fps = None
for element in root.iter():
name = element.tag.rsplit("}", 1)[-1]
if name == "CreationDate" and (value := element.attrib.get("value")):
sidecar.creation = parse_exif_datetime(value)
if sidecar.creation and sidecar.creation.tzinfo:
sidecar.timezone_value = sidecar.creation.tzinfo
elif name == "Duration" and (value := element.attrib.get("value")):
duration = parse_duration_seconds(value)
elif name == "VideoFrame":
match = re.search(r"\d+(?:\.\d+)?", element.attrib.get("captureFps") or element.attrib.get("formatFps") or "")
fps = float(match.group(0)) if match else fps
elif name == "Device":
sidecar.device = " ".join(filter(None, (element.attrib.get("manufacturer"), element.attrib.get("modelName"))))
sidecar.duration_seconds = duration / fps if duration is not None and fps else duration
return sidecar
def camera_key(record: MediaRecord) -> str:
metadata = record.metadata
make = first_tag(metadata, ("EXIF:Make", "QuickTime:Make", "MakerNotes:Make", "H264:Make")) or ""
model = first_tag(metadata, ("EXIF:Model", "QuickTime:Model", "MakerNotes:Model", "H264:Model")) or ""
serial = first_tag(metadata, (
"EXIF:SerialNumber", "MakerNotes:SerialNumber", "MakerNotes:InternalSerialNumber", "Composite:SerialNumber",
))
return "|".join(str(part).strip() for part in (make, model, serial) if part) or f"dir:{record.path.parent.resolve()}"
def infer_video_timezone(record: MediaRecord) -> timezone | None:
if record.original_time is None or record.duration_seconds is None:
return None
filename_time = parse_filename_timestamp(record)
if filename_time is None:
return None
begin = record.original_time - timedelta(seconds=record.duration_seconds)
offset_seconds = (filename_time - naive_wall_time(begin.astimezone(timezone.utc))).total_seconds()
offset_minutes = round(offset_seconds / 60)
if abs(offset_seconds - offset_minutes * 60) > 2 or abs(offset_minutes) > 14 * 60:
return None
return timezone(timedelta(minutes=offset_minutes))
def build_records(paths: list[Path], metadata_items: list[dict]) -> list[MediaRecord]:
by_source = {Path(item["SourceFile"]).resolve(): item for item in metadata_items if item.get("SourceFile")}
records: list[MediaRecord] = []
for path in paths:
metadata = by_source.get(path.resolve(), {})
record = MediaRecord(path, metadata, "video" if path.suffix.lower() in VIDEO_EXTS else "image")
if record.is_image:
record.original_time = parse_exif_datetime(first_tag(metadata, IMAGE_CAPTURE_TIME_TAGS))
record.adjusted_time = record.original_time
offset = first_tag(metadata, IMAGE_OFFSET_TAGS)
if offset:
try:
record.timezone_value = parse_timezone_offset(str(offset))
except ValueError:
record.warnings.append(f"Invalid timezone offset metadata: {offset}")
record.subsec = parse_subsec(first_tag(metadata, (
"EXIF:SubSecTimeOriginal", "EXIF:SubSecTimeDigitized", "EXIF:SubSecTime",
)))
else:
begin = first_tag(metadata, VIDEO_BEGIN_TIME_TAGS)
if path.suffix.lower() == ".mts" and begin is not None:
record.original_time = parse_exif_datetime(begin, assume_utc=True)
record.video_time_is_beginning = True
else:
record.original_time = parse_exif_datetime(first_tag(metadata, VIDEO_TIME_TAGS), assume_utc=True)
record.adjusted_time = record.original_time
record.duration_seconds = parse_duration_seconds(first_tag(metadata, VIDEO_DURATION_TAGS))
offset = first_tag(metadata, VIDEO_TIMEZONE_TAGS)
if offset:
try:
record.timezone_value = parse_timezone_offset(str(offset))
except ValueError:
record.warnings.append(f"Invalid video timezone metadata: {offset}")
record.timezone_value = record.timezone_value or infer_video_timezone(record)
record.sidecar = find_sidecar(path)
if record.sidecar and record.duration_seconds is not None:
xml_duration = record.sidecar.duration_seconds
if xml_duration is not None and abs(xml_duration - record.duration_seconds) > 1:
record.warnings.append(f"XML duration {xml_duration:g}s differs from metadata duration {record.duration_seconds:g}s")
record.sequence = parse_sequence(first_tag(metadata, SEQUENCE_TAGS))
record.camera_key = camera_key(record)
records.append(record)
return records
+543
View File
@@ -0,0 +1,543 @@
"""Raw-key settings UI for photo metadata cleanup."""
from __future__ import annotations
import copy
import os
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.console import dark_field, dim, draw_screen, inverse, light_red, raw_key_input
from tools.exiftool import run_exiftool_json
from tools.filenames import naive_wall_time
from tools.media_metadata import DATETIME_RE
from tools.photo_planning import UserChoices
from tools.photo_records import IMAGE_EXTS, MediaRecord, build_records
from tools.timezones import local_timezone, parse_timezone_offset, timezone_to_string
TIME_ONLY_RE = re.compile(r"^(?P<h>\d{1,2})(?::?(?P<m>\d{2}))(?::?(?P<s>\d{2}))?$")
TOKEN_SHIFT_RE = re.compile(r"(\d+|[a-zA-Z]+)")
def parse_datetime(value: str, default_date: datetime | None = None) -> datetime:
text = value.strip()
match = DATETIME_RE.search(text)
if match:
tzinfo = None
tz_text = match.group("tz")
if tz_text == "Z":
tzinfo = timezone.utc
elif tz_text:
tzinfo = parse_timezone_offset(tz_text)
return datetime(
int(match.group("Y")),
int(match.group("M")),
int(match.group("D")),
int(match.group("h")),
int(match.group("m")),
int(match.group("s")),
tzinfo=tzinfo,
)
match = TIME_ONLY_RE.match(text)
if match and default_date is not None:
return default_date.replace(
hour=int(match.group("h")),
minute=int(match.group("m")),
second=int(match.group("s") or "0"),
microsecond=0,
)
raise ValueError("timestamp must include YYYYMMDD_HHMMSS or a time with reference date")
def parse_timeshift(value: str) -> timedelta:
text = value.strip().replace(" ", "")
if not text or text[0] not in "+-":
raise ValueError("time shift must start with + or -")
sign = 1 if text[0] == "+" else -1
body = text[1:]
if re.fullmatch(r"\d+(?::\d{2}){0,2}", body):
parts = [int(part) for part in body.split(":")]
parts = [0] * (3 - len(parts)) + parts
return sign * timedelta(hours=parts[0], minutes=parts[1], seconds=parts[2])
units = {
"h": 3600,
"hr": 3600,
"hrs": 3600,
"hour": 3600,
"hours": 3600,
"m": 60,
"min": 60,
"mins": 60,
"minute": 60,
"minutes": 60,
"s": 1,
"sec": 1,
"secs": 1,
"second": 1,
"seconds": 1,
}
tokens = TOKEN_SHIFT_RE.findall(body)
if not tokens or len(tokens) % 2 != 0:
raise ValueError("time shift units must look like +1h30m or -2 minutes")
seconds = 0
for amount, unit in zip(tokens[::2], tokens[1::2]):
if not amount.isdigit() or unit.lower() not in units:
raise ValueError("invalid time shift unit")
seconds += int(amount) * units[unit.lower()]
return sign * timedelta(seconds=seconds)
def infer_reference_target(source_time: datetime, target_text: str) -> datetime:
try:
return naive_wall_time(parse_datetime(target_text))
except ValueError:
pass
base = naive_wall_time(source_time)
target_same_date = parse_datetime(target_text, default_date=base)
candidates = [
target_same_date - timedelta(days=1),
target_same_date,
target_same_date + timedelta(days=1),
]
def score(candidate: datetime) -> tuple[int, float]:
delta_hours = abs((candidate - base).total_seconds()) / 3600
same_date_penalty = 0 if candidate.date() == base.date() else 1
return (0 if delta_hours <= 12 else 1, delta_hours + same_date_penalty)
return min(candidates, key=score)
def format_timedelta(value: timedelta) -> str:
total_seconds = int(value.total_seconds())
sign = "+" if total_seconds >= 0 else "-"
total_seconds = abs(total_seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}"
@dataclass
class TimezoneAnalysis:
ordered_zones: list[timezone]
gap_count: int
conflicting_gap_count: int
@property
def shifts(self) -> int:
return max(0, len(self.ordered_zones) - 1)
@dataclass
class SettingsState:
time_offset: timedelta | None
infer_missing_timestamps: bool
fixed_timezone: timezone
fixed_timezone_enabled: bool
fill_timezone_gaps: bool
rename_mode: str = "adjust_replace"
artist_enabled: bool = False
artist_value: str = ""
organize_files: bool = False
group_min_size: int = 2
process_pto_files: bool = True
def timeline_key(record: MediaRecord) -> tuple[datetime, str]:
value = record.original_time
if value is None:
return datetime.max, record.path.name.lower()
if value.tzinfo is not None:
value = value.astimezone(timezone.utc)
return naive_wall_time(value), record.path.name.lower()
def analyze_timezones(records: list[MediaRecord]) -> TimezoneAnalysis:
ordered = sorted(records, key=timeline_key)
runs: list[timezone] = []
for record in ordered:
if record.timezone_value is not None and (not runs or record.timezone_value != runs[-1]):
runs.append(record.timezone_value)
gaps, conflicts = timezone_gaps(ordered)
return TimezoneAnalysis(runs, len(gaps), len(conflicts))
def timezone_gaps(records: list[MediaRecord]) -> tuple[list[list[MediaRecord]], list[list[MediaRecord]]]:
gaps: list[list[MediaRecord]] = []
conflicts: list[list[MediaRecord]] = []
index = 0
while index < len(records):
if records[index].timezone_value is not None:
index += 1
continue
start = index
while index < len(records) and records[index].timezone_value is None:
index += 1
gap = records[start:index]
before = records[start - 1].timezone_value if start else None
after = records[index].timezone_value if index < len(records) else None
if before is not None and after is not None and before != after:
conflicts.append(gap)
else:
gaps.append(gap)
return gaps, conflicts
def apply_timezone_settings(records: list[MediaRecord], state: SettingsState) -> None:
for record in records:
record.resolved_timezone = state.fixed_timezone if state.fixed_timezone_enabled else record.timezone_value
if not state.fill_timezone_gaps or state.fixed_timezone_enabled:
return
ordered = sorted(records, key=timeline_key)
for gap in timezone_gaps(ordered)[0]:
start = ordered.index(gap[0])
end = ordered.index(gap[-1]) + 1
before = ordered[start - 1].resolved_timezone if start else None
after = ordered[end].resolved_timezone if end < len(ordered) else None
zone = before or after
if zone is not None:
for record in gap:
record.resolved_timezone = zone
def parse_clock_time(value: str, reference: datetime) -> datetime:
match = re.fullmatch(r"(\d{1,2}):(\d{1,2}):(\d{1,2})", value.strip())
if match is None:
raise ValueError("Time must be h:mm:ss.")
hour, minute, second = (int(part) for part in match.groups())
if hour > 23 or minute > 59 or second > 59:
raise ValueError("Time is outside the 24-hour range.")
return infer_reference_target(reference, f"{hour:02d}:{minute:02d}:{second:02d}")
def reference_record(executable: str, records: list[MediaRecord], value: str) -> MediaRecord:
text = value.strip().strip('"')
absolute = os.path.isabs(text) or bool(re.match(r"^[A-Za-z]:[\\/]", text) or text.startswith("\\\\"))
candidates = records if absolute else [record for record in records if record.path.name.lower() == text.lower()]
if not candidates and not absolute:
candidates = [record for record in records if record.path.stem.lower() == text.lower()]
if absolute:
path = Path(text)
if not path.is_file():
raise ValueError("File not found")
if path.suffix.lower() not in IMAGE_EXTS:
raise ValueError("Only photos are supported")
metadata = run_exiftool_json(executable, [path.resolve()])
candidates = build_records([path.resolve()], metadata)
photos = [record for record in candidates if record.is_image]
if not photos:
if candidates:
raise ValueError("Only photos are supported")
raise ValueError("Timeshift syntax error, or no such file was given")
if len(photos) != 1:
raise ValueError("Multiple photos matched. Please use an absolute path")
if photos[0].original_time is None:
raise ValueError("Reference photo has no capture timestamp")
return photos[0]
def option_text(text: str, selected: bool, active: bool = True) -> str:
value = f"{text}"
if not active:
return dim(value)
return inverse(value) if selected else value
def timezone_label(analysis: TimezoneAnalysis) -> str:
if not analysis.ordered_zones:
return "none"
if analysis.shifts > 2:
return "many timezones"
return ", ".join(timezone_to_string(zone) for zone in analysis.ordered_zones)
def default_artist(records: list[MediaRecord]) -> str:
values = {
str(value).strip()
for record in records
for tag in ("EXIF:Artist", "XMP:Artist", "EXIF:Author", "XMP:Author")
if (value := record.metadata.get(tag)) and str(value).strip()
}
return values.pop() if len(values) == 1 else ""
def settings_lines(
state: SettingsState,
analysis: TimezoneAnalysis,
working_dir: Path,
rows: list[str],
row: int,
option: int,
*,
editor: str | None = None,
text: str = "",
message: str = "",
reference: MediaRecord | None = None,
) -> list[str]:
selected = rows[row]
time_value = "none" if state.time_offset is None else format_timedelta(state.time_offset)
time_options = [
option_text("none", option == 0),
option_text(time_value if state.time_offset else "shift", option == 1),
]
rename_names = ["Adjust/replace", "Adjust/add", "Replace", "Add"]
rename_options = [option_text(name, option == index) for index, name in enumerate(rename_names)]
fixed = timezone_to_string(state.fixed_timezone)
fixed_option = f"Set {fixed} ({'on' if state.fixed_timezone_enabled else 'off'})"
timezone_options = [
option_text(fixed_option, option == 0),
option_text("Fill gaps", option == 1, analysis.gap_count > 0),
]
group_options = [option_text("Yes" if state.organize_files else "No", option == 0)]
if selected == "organize" or state.organize_files:
group_options.extend(
(
option_text(f"{state.group_min_size}+ files", option == 1, state.organize_files),
option_text(".pto", option == 2, state.organize_files and state.process_pto_files),
)
)
if selected != "time":
time_options = [time_value]
if selected != "rename":
rename_options = [rename_names[("adjust_replace", "adjust_add", "replace", "add").index(state.rename_mode)]]
if selected != "timezone":
enabled = []
if state.fixed_timezone_enabled:
enabled.append(f"Set {fixed}")
if state.fill_timezone_gaps:
enabled.append("Fill gaps")
timezone_options = enabled or ["none"]
artist = state.artist_value if state.artist_enabled else dim("no change")
if state.artist_enabled and not artist:
artist = light_red("(remove)")
if selected == "artist":
artist = option_text("no change", True) if not state.artist_enabled else option_text(artist, True)
if selected != "organize" and not state.organize_files:
group_options = ["No"]
lines = [
f"Working directory: {working_dir}",
"",
f"Time correction: {' '.join(time_options)}",
]
if "infer" in rows:
lines.append(f"Infer missing timestamps: {option_text('Yes' if state.infer_missing_timestamps else 'No', selected == 'infer')}")
lines.extend(
[
f"Timezone offsets: Current offsets: {timezone_label(analysis)}",
f"{'':28}{' '.join(timezone_options)}",
f"Rename files to timestamps: {' '.join(rename_options)}",
f"Artist/author: {artist}",
f"Organize/group: {' '.join(group_options)}",
]
)
if "confirm" in rows:
confirm_options = [option_text("Confirm", option == 0), option_text("Exit", option == 1)]
lines.extend(["", f"{'':28}{' '.join(confirm_options) if selected == 'confirm' else 'Confirm Exit'}"])
if editor:
lines.extend(["", light_red(message)] if message else [""])
if editor == "time-source":
lines.extend(["Enter timeshift (+/-h:mm:ss or +/-m:ss),", "or filename for photo used as a reference:"])
elif editor == "time-clock":
assert reference is not None
lines.extend([f'File "{reference.path.name}" selected as reference.', "Enter the time that this file should have (h:mm:ss):"])
lines.append("> " + (dark_field(text.ljust(6)) if editor == "timezone" else text))
elif message:
lines.extend(["", message])
controls = ["Enter: confirm"]
if not editor:
controls.insert(0, "Arrow keys: navigate")
if not editor and selected in {"infer", "timezone", "artist", "organize"}:
controls.append("Space: toggle")
if editor:
controls.append("Esc: undo")
lines.extend(["", " ".join(controls)])
return lines
def prompt_choices(
executable: str,
records: list[MediaRecord],
working_dir: Path,
has_pto_files: bool,
initial_state: SettingsState | None = None,
) -> tuple[UserChoices | None, SettingsState]:
analysis = analyze_timezones(records)
first_zone = analysis.ordered_zones[0] if analysis.ordered_zones else local_timezone()
state = copy.deepcopy(initial_state) if initial_state else SettingsState(
time_offset=None,
infer_missing_timestamps=True,
fixed_timezone=first_zone,
fixed_timezone_enabled=False,
fill_timezone_gaps=analysis.gap_count > 0 and analysis.shifts <= 2 and not analysis.conflicting_gap_count,
process_pto_files=has_pto_files,
artist_value=default_artist(records),
)
rows = ["time"]
if any(record.original_time is None for record in records):
rows.append("infer")
rows.extend(["timezone", "rename", "artist", "organize", "confirm"])
row = 0
option = 1 if state.time_offset is not None else 0
editor = None
text = message = ""
reference = None
def enter_row(index: int) -> None:
nonlocal row, option
row = index % len(rows)
if rows[row] == "rename":
option = ("adjust_replace", "adjust_add", "replace", "add").index(state.rename_mode)
elif rows[row] == "time":
option = 1 if state.time_offset is not None else 0
else:
option = 0
def finish_editor() -> None:
nonlocal editor, text, message, reference
editor = None
text = message = ""
reference = None
with raw_key_input() as next_key:
while True:
draw_screen(settings_lines(state, analysis, working_dir, rows, row, option, editor=editor, text=text, message=message, reference=reference))
key = next_key()
if editor:
if key == "esc":
finish_editor()
elif key == "backspace":
text = text[:-1]
elif key == "enter":
if editor == "timezone":
try:
state.fixed_timezone = parse_timezone_offset(text)
except ValueError:
message = "Invalid timezone offset"
else:
state.fixed_timezone_enabled = True
finish_editor()
elif editor == "artist":
state.artist_value = text
state.artist_enabled = True
finish_editor()
elif editor == "group":
if text.isdigit() and int(text) >= 2:
state.group_min_size = int(text)
finish_editor()
else:
message = "Group size must be 2 or more"
elif editor == "time-source":
try:
state.time_offset = parse_timeshift(text)
except ValueError:
try:
reference = reference_record(executable, records, text)
except ValueError as exc:
message = str(exc)
else:
editor, text, message = "time-clock", "", ""
else:
finish_editor()
enter_row(row + 1)
else:
try:
assert reference is not None and reference.original_time is not None
state.time_offset = parse_clock_time(text, naive_wall_time(reference.original_time)) - naive_wall_time(reference.original_time)
except ValueError as exc:
message = str(exc)
else:
finish_editor()
enter_row(row + 1)
elif key == "space" and editor != "timezone":
text += " "
elif len(key) == 1 and (editor != "timezone" or key in "+-:0123456789"):
if editor != "timezone" or len(text) < 6:
text += key
continue
if key == "up":
enter_row(row - 1)
continue
current = rows[row]
if current == "confirm" and key == "enter":
if option == 1:
return None, state
apply_timezone_settings(records, state)
artist_action = "leave" if not state.artist_enabled else ("set" if state.artist_value else "clear")
return UserChoices(
working_dir=working_dir,
time_offset=state.time_offset,
fixed_timezone=state.fixed_timezone if state.fixed_timezone_enabled else None,
fill_timezone_gaps=state.fill_timezone_gaps,
artist_action=artist_action,
artist_value=state.artist_value or None,
rename_mode=state.rename_mode,
organize_files=state.organize_files,
group_min_size=state.group_min_size,
process_pto_files=state.organize_files and state.process_pto_files,
infer_missing_timestamps=state.infer_missing_timestamps,
), state
if key == "down" or (key == "enter" and current != "time"):
enter_row(row + 1)
continue
if current == "time":
if key == "left":
option = 0
state.time_offset = None
elif key == "right":
option = 1
if state.time_offset is None:
editor, text = "time-source", ""
elif key == "enter" and option == 1:
editor, text = "time-source", ""
elif key == "enter":
enter_row(row + 1)
elif current == "infer" and key in {"left", "right", "space"}:
state.infer_missing_timestamps = not state.infer_missing_timestamps
elif current == "timezone":
if key == "left":
option = max(0, option - 1)
elif key == "right":
option = min(1 if analysis.gap_count and not analysis.conflicting_gap_count else 0, option + 1)
elif key == "space":
if option == 0:
state.fixed_timezone_enabled = not state.fixed_timezone_enabled
elif analysis.gap_count and not analysis.conflicting_gap_count:
state.fill_timezone_gaps = not state.fill_timezone_gaps
elif option == 0 and key in "+-0123456789":
editor, text = "timezone", ("+" if key.isdigit() else "") + key
elif current == "rename":
if key == "left":
option = max(0, option - 1)
elif key == "right":
option = min(3, option + 1)
state.rename_mode = ("adjust_replace", "adjust_add", "replace", "add")[option]
elif current == "artist":
if key == "space":
state.artist_enabled = not state.artist_enabled
elif len(key) == 1:
editor, text = "artist", key
elif current == "organize":
if key == "left":
option = max(0, option - 1)
elif key == "right" and state.organize_files:
option = min(2, option + 1)
elif key == "space":
if option == 0:
state.organize_files = not state.organize_files
elif option == 2:
state.process_pto_files = not state.process_pto_files
elif option == 1 and key.isdigit():
editor, text = "group", key
elif current == "confirm" and key in {"left", "right"}:
option = 1 - option
+1 -46
View File
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.executables import require_executable
from tools.media_metadata import first_tag, parse_exif_datetime, round_half_up
VIDEO_TIME_TAGS = (
@@ -49,13 +50,6 @@ GPS_POSITION_TAGS = (
"Composite:GPSCoordinates",
)
DATETIME_RE = re.compile(
r"(?P<Y>\d{4})[:\-]?(?P<M>\d{2})[:\-]?(?P<D>\d{2})"
r"(?:[ T_])?"
r"(?P<h>\d{2}):?(?P<m>\d{2}):?(?P<s>\d{2})"
r"(?:[.,](?P<sub>\d+))?"
r"(?:\s*(?P<tz>Z|[+-]\d{2}:?\d{2}))?"
)
DMS_RE = re.compile(
r"(?P<deg>-?\d+(?:\.\d+)?)\D+"
r"(?P<min>\d+(?:\.\d+)?)?\D*"
@@ -385,13 +379,6 @@ def summarize_frame_timing(timestamps: list[float]) -> FrameTimingSummary:
)
def first_tag(metadata: dict, tags: tuple[str, ...]) -> object | None:
for tag in tags:
if tag in metadata:
return metadata[tag]
return None
def _gps_coordinates(metadata: dict) -> tuple[float | None, float | None]:
latitude = _parse_gps_coordinate(first_tag(metadata, GPS_LATITUDE_TAGS))
longitude = _parse_gps_coordinate(first_tag(metadata, GPS_LONGITUDE_TAGS))
@@ -449,34 +436,6 @@ def _parse_gps_coordinate(value: object) -> float | None:
return sign * (degrees + minutes / 60.0 + seconds / 3600.0)
def parse_exif_datetime(value: object, assume_utc: bool = False) -> datetime | None:
if value is None:
return None
match = DATETIME_RE.search(str(value).strip())
if not match:
return None
tzinfo = None
tz_text = match.group("tz")
if tz_text == "Z":
tzinfo = timezone.utc
elif tz_text:
tzinfo = _parse_timezone_offset(tz_text)
parsed = datetime(
int(match.group("Y")),
int(match.group("M")),
int(match.group("D")),
int(match.group("h")),
int(match.group("m")),
int(match.group("s")),
tzinfo=tzinfo,
)
if assume_utc and parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _parse_timezone_offset(value: str) -> timezone:
text = value.replace(":", "")
sign = 1 if text[0] == "+" else -1
@@ -495,10 +454,6 @@ def _video_timezone(metadata: dict) -> timezone | None:
return _parse_timezone_offset(text)
def round_half_up(value: float) -> int:
return int(value + 0.5)
def _to_float(value: object) -> float | None:
if value is None or value == "N/A":
return None
+33 -1
View File
@@ -5,7 +5,9 @@ import unittest
from pathlib import Path
from unittest.mock import patch
from tools.exiftool import run_exiftool_command
from tools.exiftool import run_exiftool_command, run_exiftool_write
from tools.media_metadata import parse_exif_datetime
from tools.metadata_copy import copy_meaningful_metadata
class ExiftoolInvocationTests(unittest.TestCase):
@@ -34,6 +36,36 @@ class ExiftoolInvocationTests(unittest.TestCase):
)
self.assertEqual(captured["contents"], "-j\nC:\\Photos\\M\u00e4rchen.jpg\n")
def test_write_captures_success_output(self) -> None:
with patch(
"tools.exiftool.subprocess.run",
return_value=subprocess.CompletedProcess([], 0, "1 image files updated\n", ""),
) as run:
run_exiftool_write("exiftool", Path("photo.jpg"), ["-Artist=Test"])
self.assertTrue(run.call_args.kwargs["capture_output"])
self.assertTrue(run.call_args.kwargs["text"])
def test_write_includes_exiftool_failure_output(self) -> None:
with patch(
"tools.exiftool.subprocess.run",
return_value=subprocess.CompletedProcess([], 1, "", "Error renaming temporary file"),
):
with self.assertRaisesRegex(RuntimeError, "Error renaming temporary file"):
run_exiftool_write("exiftool", Path("photo.jpg"), ["-Artist=Test"])
def test_metadata_copy_uses_exiftool_for_photos(self) -> None:
with patch("tools.metadata_copy.run_exiftool_command") as run:
copy_meaningful_metadata("exiftool", Path("source.jpg"), Path("target.jpg"))
args = run.call_args.args[1]
self.assertEqual(args[:4], ["-overwrite_original", "-TagsFromFile", "source.jpg", "-all:all"])
self.assertEqual(args[-1], "target.jpg")
def test_metadata_datetime_can_assume_utc(self) -> None:
parsed = parse_exif_datetime("2026:07:04 14:13:36", assume_utc=True)
self.assertEqual(str(parsed.tzinfo), "UTC")
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.photo_grouping import detect_photo_groups, infer_group_subseconds
from tools.photo_planning import (
UserChoices,
build_write_plan,
filename_timestamp_stem,
infer_missing_timestamps,
merge_record_groups,
)
from tools.photo_records import (
MediaRecord,
infer_video_timezone,
)
from tools.photo_settings import default_artist
class PhotoMetadataLogicTests(unittest.TestCase):
def choices(self, **values):
base = dict(
working_dir=Path("."),
time_offset=None,
fixed_timezone=None,
fill_timezone_gaps=False,
artist_action="leave",
artist_value=None,
rename_mode="adjust_replace",
organize_files=False,
group_min_size=2,
process_pto_files=False,
infer_missing_timestamps=True,
)
base.update(values)
return UserChoices(**base)
def test_adjusted_filename_preserves_each_timestamp_interval(self) -> None:
stem = "20260516_130011-20260516_130102_fused"
result = filename_timestamp_stem(stem, datetime(2026, 6, 7, 14, 0, 0), "adjust_replace")
self.assertEqual(result, "20260607_140000-20260607_140051_fused")
def test_uses_one_existing_artist_as_the_disabled_default(self) -> None:
records = [
MediaRecord(Path("a.jpg"), {"EXIF:Artist": "Anton"}, "image"),
MediaRecord(Path("b.jpg"), {"XMP:Author": "Anton"}, "image"),
]
self.assertEqual(default_artist(records), "Anton")
def test_infers_missing_photo_time_from_filename_before_modify_date(self) -> None:
record = MediaRecord(
Path("20260607_140011.jpg"),
{"EXIF:ModifyDate": "2020:01:01 00:00:00"},
"image",
)
infer_missing_timestamps([record], True)
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
self.assertEqual(record.inferred_timestamp_source, "filename")
def test_infers_missing_photo_time_from_modify_date(self) -> None:
record = MediaRecord(
Path("DSC0001.jpg"),
{"EXIF:ModifyDate": "2026:06:07 14:00:11"},
"image",
)
infer_missing_timestamps([record], True)
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
self.assertEqual(record.inferred_timestamp_source, "ModifyDate")
def test_infers_video_timezone_from_filename_and_utc_end_time(self) -> None:
record = MediaRecord(
Path("20260704_161323.mp4"),
{},
"video",
original_time=datetime(2026, 7, 4, 14, 13, 36, tzinfo=timezone.utc),
duration_seconds=13,
)
self.assertEqual(infer_video_timezone(record), timezone(timedelta(hours=2)))
def test_existing_subseconds_prevent_burst_inference(self) -> None:
records = [
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0), subsec=100),
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0)),
]
infer_group_subseconds([records])
self.assertIsNone(records[1].inferred_subsec)
def test_burst_detection_stays_per_camera(self) -> None:
timestamp = datetime(2026, 6, 7, 14, 0, 0)
records = [
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
MediaRecord(Path("c.jpg"), {}, "image", adjusted_time=timestamp, camera_key="B"),
]
self.assertEqual([[record.path.name for record in group] for group in detect_photo_groups(records)], [["a.jpg", "b.jpg"]])
def test_single_file_pto_group_is_preserved(self) -> None:
record = MediaRecord(Path("a.jpg"), {}, "image")
self.assertEqual(merge_record_groups([record], [[record]]), [[record]])
def test_shift_keeps_later_modify_date_but_clamps_an_earlier_one(self) -> None:
original = datetime(2026, 6, 7, 14, 0, 0)
record = MediaRecord(
Path("photo.jpg"),
{
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
"EXIF:CreateDate": "2026:06:07 14:00:00",
"EXIF:ModifyDate": "2026:06:07 13:00:00",
},
"image",
original_time=original,
adjusted_time=original + timedelta(hours=2),
)
writes = build_write_plan(record, self.choices(time_offset=timedelta(hours=2)))
changed = {write.label: write.new_value for write in writes if write.will_write}
self.assertEqual(changed["DateTimeOriginal"], "2026:06:07 16:00:00")
self.assertEqual(changed["CreateDate"], "2026:06:07 16:00:00")
self.assertEqual(changed["ModifyDate"], "2026:06:07 16:00:00")
self.assertTrue(any("ModifyDate" in warning for warning in record.warnings))
def test_timezone_only_follows_capture_related_dates(self) -> None:
captured = datetime(2026, 6, 7, 14, 0, 0)
record = MediaRecord(
Path("photo.jpg"),
{
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
"EXIF:CreateDate": "2026:06:07 14:00:00",
"EXIF:ModifyDate": "2026:07:07 14:00:00",
"EXIF:OffsetTimeOriginal": "+00:00",
"EXIF:OffsetTimeDigitized": "+00:00",
"EXIF:OffsetTime": "+00:00",
},
"image",
original_time=captured,
adjusted_time=captured,
resolved_timezone=timezone(timedelta(hours=2)),
)
writes = build_write_plan(record, self.choices(fixed_timezone=record.resolved_timezone))
changed = {write.arg for write in writes if write.will_write}
self.assertIn("-EXIF:OffsetTimeOriginal=+02:00", changed)
self.assertIn("-EXIF:OffsetTimeDigitized=+02:00", changed)
self.assertNotIn("-EXIF:OffsetTime=+02:00", changed)
if __name__ == "__main__":
unittest.main()
+67 -1
View File
@@ -87,7 +87,7 @@ from tools.video_probe import (
)
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
from tools.video_reporting import _format_source_span, format_validation_summary_lines
from tools.console import PipelineProgressView, ProgressView, _progress_bar
from tools.console import PipelineProgressView, ProgressView, _progress_bar, stream_is_interactive
from tools.video_encode_output import _update_renderer_progress
from tools.video_timeline import OutputFrameTiming, OutputTimeline, build_output_timeline
from video_encode import (
@@ -681,6 +681,72 @@ class VideoTimelineTests(unittest.TestCase):
"[## 50.0% ###------------]",
)
def test_progress_can_weight_percent_and_eta_by_work(self) -> None:
progress = ProgressView(4, "Applying changes", total_work=10, stream=StringIO())
line = progress.update_line(1, completed_work=5)
self.assertIn("1/4", line)
self.assertIn("50.0%", line)
def test_windows_console_progress_does_not_require_stdout_isatty(self) -> None:
class ConsoleBuffer(StringIO):
def isatty(self) -> bool:
return False
output = ConsoleBuffer()
with (
patch("tools.console.os.name", "nt"),
patch("tools.console.sys.stdout", output),
patch("tools.console._windows_console_output", return_value=True),
):
self.assertTrue(stream_is_interactive(output))
progress = ProgressView(3, "Applying changes", stream=output)
progress.update(1)
progress.update(2)
progress.finish(keep=True)
self.assertEqual(output.getvalue().count("\n"), 1)
def test_single_line_ansi_progress_never_moves_up_zero_lines(self) -> None:
class TtyBuffer(StringIO):
def isatty(self) -> bool:
return True
output = TtyBuffer()
with patch("tools.console.supports_ansi", return_value=True):
progress = ProgressView(3, "Reading metadata", stream=output)
progress.update(1)
progress.update(2)
progress.finish(keep=True)
self.assertNotIn("\x1b[0F", output.getvalue())
def test_progress_redraws_a_wrapped_multi_line_block(self) -> None:
class TtyBuffer(StringIO):
def isatty(self) -> bool:
return True
output = TtyBuffer()
with (
patch("tools.console.supports_ansi", return_value=True),
patch.dict("os.environ", {"COLUMNS": "30"}),
):
progress = ProgressView(3, "Progress", stream=output)
progress.write_lines(
[
"Applying changes",
" Results: succeeded 1 failed 0 metadata 1 no-op 0",
" File ops: rename 0 move 0 rename+move 0",
]
)
progress.write_lines(["Applying changes", " Results: succeeded 2 failed 0 metadata 2 no-op 0"])
progress.finish(keep=True)
rendered = output.getvalue()
self.assertIn("\x1b[2K", rendered)
self.assertIn("\x1b[", rendered)
def test_encoding_progress_matches_persistent_validation_style(self) -> None:
class TtyBuffer(StringIO):
def isatty(self) -> bool: