130 lines
4.1 KiB
Python
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}'}")
|