"""exiftool adapter: read and apply EXIF keywords. Only the ``Keywords`` and ``Subject`` fields are read or written, so every other tag (caption, dates, GPS, camera, ratings) is preserved. Reading is batched over stdin; writing uses the idempotent ``-=``/``+=`` pattern so re-running never duplicates a keyword. Extracted from nsfwtag.exif (_read_keywords / write_keyword / remove_keyword) and photo_analyzer's batched keyword read (donor_ledger.yaml: nt-exif-marks, nt-apply-list, pa-nsfw-filter). No dependency on the archived entry points. """ from __future__ import annotations import json import os import subprocess from collections.abc import Iterable # A hung exiftool must not hang the worker with it: every call is bounded, and a # call that runs out of time is treated exactly like a failed one — no metadata # answer, nothing marked verified (US07-04). The knob exists because "slow" is a # property of the machine, not of the code: huge files on a slow network volume # legitimately take longer than the default. DEFAULT_TIMEOUT_SECONDS = 120.0 def _timeout() -> float: try: return float(os.environ.get("PHOTO_PIPELINE_EXIFTOOL_TIMEOUT", DEFAULT_TIMEOUT_SECONDS)) except ValueError: return DEFAULT_TIMEOUT_SECONDS def read_keyword_sets(paths: Iterable[str]) -> dict[str, set[str]]: """Map each path to its lowercased set of ``Keywords`` + ``Subject`` values. One batched exiftool call (paths fed via stdin). Returns ``{}`` if exiftool is unavailable — callers decide how to fail (analysis fails open, safety fails safe). """ paths = list(paths) if not paths: return {} want = {os.path.normpath(p): p for p in paths} try: result = subprocess.run( ["exiftool", "-m", "-j", "-Keywords", "-Subject", "-@", "-"], input="\n".join(paths), capture_output=True, text=True, timeout=_timeout(), ) except (FileNotFoundError, subprocess.TimeoutExpired): return {} out: dict[str, set[str]] = {} try: records = json.loads(result.stdout or "[]") except ValueError: return {} for record in records: values: list = [] for field in ("Keywords", "Subject"): value = record.get(field) if isinstance(value, list): values += value elif value is not None: values.append(value) key = want.get(os.path.normpath(record.get("SourceFile", ""))) if key is not None: out[key] = {str(v).strip().lower() for v in values} return out def read_all(path: str) -> dict | None: """Every tag exiftool can read from ``path``, or ``None`` when it cannot answer. This is the snapshot an EXIF checkpoint compares against: proving that a write preserved the fields it does not own requires knowing all of them, not just the ones being written (US07-03). ``None`` (exiftool missing, unreadable file, unparsable output) is not an empty snapshot — a caller must not read it as "nothing was there". """ try: result = subprocess.run( ["exiftool", "-m", "-j", "-G0:1", path], capture_output=True, text=True, timeout=_timeout(), ) except (FileNotFoundError, subprocess.TimeoutExpired): return None try: records = json.loads(result.stdout or "[]") except ValueError: return None if not records: return None return {k: v for k, v in records[0].items() if k != "SourceFile"} def apply_keywords(path: str, *, add: Iterable[str] = (), remove: Iterable[str] = ()) -> bool: """Idempotently add/remove keywords in Keywords + Subject; preserve all else.""" args = ["exiftool", "-m", "-overwrite_original"] for kw in remove: args += [f"-Keywords-={kw}", f"-Subject-={kw}"] for kw in add: # remove-then-add makes the add idempotent (no duplicate on re-run). args += [f"-Keywords-={kw}", f"-Keywords+={kw}", f"-Subject-={kw}", f"-Subject+={kw}"] if len(args) == 3: return True args.append(path) try: return subprocess.run( args, capture_output=True, text=True, timeout=_timeout() ).returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): # A write that never returned is not a write that succeeded. return False