"""Local HTTP server for the review app (stdlib only). Routes: GET / the review page GET /img?path= a thumbnail (restricted to the scanned candidate set) POST /tagged -> which of the posted paths already carry the nsfw keyword POST /apply -> add the nsfw keyword to the posted paths POST /untag -> remove the nsfw keyword from the posted paths All POST routes are restricted to the allowed candidate set. """ import sys from pathlib import Path from .exif import read_exif, read_marks, read_tagged, remove_keyword, write_keyword from .webapp import render_page def serve_review(cands, threshold, host="127.0.0.1"): """Serve the review page and tag/untag confirmed images directly on request.""" import http.server import json import mimetypes import time import webbrowser from collections import deque from urllib.parse import parse_qs, unquote, urlparse allowed = {str(p) for p, _ in cands} # only these paths may be served or tagged page = render_page(cands, threshold).encode("utf-8") log_entries = deque(maxlen=2000) # shown in the browser's Activity log tab def log(msg): log_entries.append({"t": time.strftime("%H:%M:%S"), "m": msg}) print(msg, file=sys.stderr) def label(p): # folder + picture name for the log pp = Path(p) return f"{pp.parent.name}/{pp.name}" if pp.parent.name else pp.name log(f"review ready — {len(cands)} candidate images") class H(http.server.BaseHTTPRequestHandler): def log_message(self, *a): # quiet pass def _send(self, code, ctype, body): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): u = urlparse(self.path) if u.path == "/": return self._send(200, "text/html; charset=utf-8", page) if u.path == "/img": fp = unquote(parse_qs(u.query).get("path", [""])[0]) # ponytail: browsers won't render HEIC bytes; those thumbs show broken — score/tag still work. if fp in allowed and Path(fp).exists(): ctype = mimetypes.guess_type(fp)[0] or "application/octet-stream" return self._send(200, ctype, Path(fp).read_bytes()) if u.path == "/exif": # metadata panel for the lightbox fp = unquote(parse_qs(u.query).get("path", [""])[0]) if fp in allowed and Path(fp).exists(): return self._send(200, "application/json", json.dumps(read_exif(fp)).encode()) if u.path == "/log": # activity log tab return self._send(200, "application/json", json.dumps(list(log_entries)).encode()) return self._send(404, "text/plain", b"not found") def do_POST(self): u = urlparse(self.path) route = u.path n = int(self.headers.get("Content-Length", 0)) paths = [p for p in json.loads(self.rfile.read(n) or b"[]") if p in allowed] if route == "/tagged": # review state of these paths (lazy, chunked): nsfw + sfw m = read_marks(paths) return self._send(200, "application/json", json.dumps({"nsfw": list(m["nsfw"]), "sfw": list(m["sfw"])}).encode()) if route == "/mark": # persist the manual SFW decision (opposite of nsfw) state = parse_qs(u.query).get("state", [""])[0] if state == "sfw": # mark safe: write 'sfw', drop 'nsfw' for p in paths: write_keyword(p, "sfw"); remove_keyword(p, "nsfw") log(f"marked sfw — {label(p)}") elif state == "clear": # un-mark safe: drop 'sfw' for p in paths: remove_keyword(p, "sfw") log(f"cleared sfw — {label(p)}") return self._send(200, "application/json", json.dumps({"ok": len(paths)}).encode()) if route == "/apply": ok = 0 for p in paths: if write_keyword(p): remove_keyword(p, "sfw") # nsfw and sfw are mutually exclusive ok += 1; log(f"tagged nsfw — {label(p)}") return self._send(200, "application/json", json.dumps({"tagged": ok}).encode()) if route == "/untag": # clear the review keyword (nsfw or sfw) — back to un-decided ok = 0 for p in paths: remove_keyword(p, "nsfw"); remove_keyword(p, "sfw") ok += 1; log(f"removed tag — {label(p)}") return self._send(200, "application/json", json.dumps({"untagged": ok}).encode()) if route == "/toggle": # flip each path's tag both ways; other EXIF preserved cur = read_tagged(paths) # one batched read, then add/remove per file now_tagged = [] for p in paths: if p in cur: remove_keyword(p); log(f"removed nsfw — {label(p)}") elif write_keyword(p): remove_keyword(p, "sfw") # mutually exclusive now_tagged.append(p); log(f"tagged nsfw — {label(p)}") return self._send(200, "application/json", json.dumps({"tagged": now_tagged}).encode()) return self._send(404, "text/plain", b"not found") srv = http.server.ThreadingHTTPServer((host, 0), H) # port 0 = OS picks a free one url = f"http://{host}:{srv.server_address[1]}/" print(f"\nReview app: {url}\n Tick images, click 'Apply' — writes the 'nsfw' EXIF tag directly (idempotent, safe to re-apply).\n Press Ctrl-C here when done.", file=sys.stderr) webbrowser.open(url) try: srv.serve_forever() except KeyboardInterrupt: print("\nstopped.", file=sys.stderr) finally: srv.server_close()