127 lines
6.1 KiB
Python
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()
|