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