Files
photoanalyzer/photo_pipeline/integrations/exiftool.py

71 lines
2.6 KiB
Python

"""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
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,
)
except FileNotFoundError:
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 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)
return subprocess.run(args, capture_output=True, text=True).returncode == 0