Files
media-batch-tools/tools/exiftool.py
T

130 lines
4.1 KiB
Python

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