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