Files
photoanalyzer/nsfwtag/server.py

127 lines
6.1 KiB
Python

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