Bootstrap project and safe work-item workflow
This commit is contained in:
123
nsfwtag/exif.py
Normal file
123
nsfwtag/exif.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""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 ""))
|
||||
Reference in New Issue
Block a user