124 lines
4.4 KiB
Python
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 ""))
|