Files
photoanalyzer/nsfwtag/exif.py

124 lines
4.4 KiB
Python

"""EXIF keyword read/write via exiftool.
Only the Keywords + Subject fields are ever touched, so every other tag
(Caption, ImageDescription, dates, GPS, Artist, …) is preserved on tag/untag.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from . import KEYWORD
def write_keyword(path: str, kw: str = KEYWORD) -> bool:
"""Idempotently add a keyword to EXIF Keywords + Subject (no duplicates on re-run)."""
return subprocess.run(
["exiftool", "-m", "-overwrite_original",
f"-Keywords-={kw}", f"-Keywords+={kw}",
f"-Subject-={kw}", f"-Subject+={kw}", path],
capture_output=True, text=True,
).returncode == 0
def remove_keyword(path: str, kw: str = KEYWORD) -> bool:
"""Reverse of write_keyword: strip the keyword from EXIF Keywords + Subject."""
return subprocess.run(
["exiftool", "-m", "-overwrite_original",
f"-Keywords-={kw}", f"-Subject-={kw}", path],
capture_output=True, text=True,
).returncode == 0
def _read_keywords(paths) -> dict:
"""{path: set(lowercased Keywords+Subject values)} via one batched exiftool
call (paths fed via stdin). Empty dict if exiftool isn't available."""
paths = list(paths)
if not paths:
return {}
want = {os.path.normpath(p): p for p in paths}
try:
r = subprocess.run(
["exiftool", "-m", "-j", "-Keywords", "-Subject", "-@", "-"],
input="\n".join(paths), capture_output=True, text=True,
)
except FileNotFoundError:
return {}
out = {}
try:
for obj in json.loads(r.stdout or "[]"):
vals = []
for f in ("Keywords", "Subject"):
v = obj.get(f)
if isinstance(v, list):
vals += v
elif v is not None:
vals.append(v)
key = want.get(os.path.normpath(obj.get("SourceFile", "")))
if key is not None:
out[key] = {str(x).strip().lower() for x in vals}
except (ValueError, TypeError):
pass
return out
def read_tagged(paths, kw: str = KEYWORD) -> set:
"""Subset of `paths` whose EXIF already contains keyword `kw`."""
kw = kw.lower()
return {p for p, kws in _read_keywords(paths).items() if kw in kws}
def read_marks(paths) -> dict:
"""Which paths carry the 'nsfw' vs the 'sfw' review keyword, in one read:
{'nsfw': set, 'sfw': set}. They should be mutually exclusive; if a stray file
somehow has both, nsfw wins so it never reads as safe."""
got = _read_keywords(paths)
nsfw = {p for p, k in got.items() if "nsfw" in k}
sfw = {p for p, k in got.items() if "sfw" in k and p not in nsfw}
return {"nsfw": nsfw, "sfw": sfw}
_EXIF_NOISE = {
"SourceFile", "ExifToolVersion", "Directory", "FilePermissions",
"FileModifyDate", "FileAccessDate", "FileInodeChangeDate", "FileTypeExtension",
}
def read_exif(path: str) -> dict:
"""Human-relevant EXIF tags for one file (for the lightbox overlay), in
exiftool's order. Skips file-system noise and binary/oversized values. When
the photo is geotagged, a "Map" entry with a Google Maps URL is prepended.
`-c %+.6f` prints GPS coordinates as signed decimals (other tags unaffected)."""
r = subprocess.run(["exiftool", "-m", "-j", "-c", "%+.6f", path], capture_output=True, text=True)
try:
obj = json.loads(r.stdout or "[]")[0]
except (ValueError, IndexError):
return {}
out = {}
for k, v in obj.items():
if k in _EXIF_NOISE:
continue
s = ", ".join(str(x) for x in v) if isinstance(v, list) else str(v)
if not s or s.startswith("(Binary data") or len(s) > 300:
continue
out[k] = s
lat, lon = out.get("GPSLatitude"), out.get("GPSLongitude")
if lat and lon:
try:
out = {"Map": f"https://www.google.com/maps?q={float(lat):.6f},{float(lon):.6f}", **out}
except ValueError:
pass
return out
def apply_list(list_file: str):
"""Write the nsfw keyword to every path in a newline-delimited file."""
paths = [l.strip() for l in Path(list_file).read_text(encoding="utf-8").splitlines() if l.strip()]
ok = miss = 0
for p in paths:
if not Path(p).exists():
miss += 1; print(f" missing: {p}", file=sys.stderr); continue
ok += 1 if write_keyword(p) else 0
print(f"tagged 'nsfw' in EXIF: {ok}/{len(paths)}" + (f" ({miss} missing)" if miss else ""))