"""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 ""))