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
+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}'}")