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 from tools.executables import require_executable def require_exiftool() -> str: return require_executable("exiftool") def _use_windows_argument_file() -> bool: return os.name == "nt" @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(): 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 # the actual paths and options. 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") 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( executable: str, 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): chunk = paths[start : start + 80] args = [ "-j", "-G", ] if quicktime_utc: args.extend(["-api", "QuickTimeUTC=1"]) args.extend(str(path) for path in chunk) 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 def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None: if not args: return command = [ "-overwrite_original", *args, str(path), ] 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}'}")