"""Local, stdlib-only HTTP server for the Photo Analyzer web UI. Serves one page + JSON endpoints, all bound to 127.0.0.1. Reads the analysis DB (via query.py), serves thumbnails/EXIF for the lightbox (reusing nsfwtag's readers), and drives analysis runs (runner.py). Path args on /img and /exif are validated against the DB, so the server can't be pointed at arbitrary files. """ import http.server import json import mimetypes import sys import time import webbrowser from collections import deque from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse from . import PAGE_SIZE, page, query from .runner import Runner, progress as run_progress try: from nsfwtag.exif import read_exif # reuse the lightbox EXIF reader except Exception: # nsfwtag not importable → degrade def read_exif(_p): return {} def serve(db_path: str, library: str | None, host: str = "127.0.0.1", open_browser=True): conn = query.connect(db_path) conn.execute("PRAGMA busy_timeout=3000") # tolerate the analyzer writing concurrently lib = Path(library).expanduser().resolve() if library else None log_entries = deque(maxlen=2000) def log(m): log_entries.append({"t": time.strftime("%H:%M:%S"), "m": m}) print(m, file=sys.stderr) runner = Runner(db_path, log) def allowed(fp: str) -> bool: return conn.execute("SELECT 1 FROM photos WHERE path=? LIMIT 1", (fp,)).fetchone() is not None def filters_from(qd: dict) -> dict: g = lambda k: qd.get(k, [""])[0] return {k: g(k) for k in ("q", "setting", "tod", "season", "people", "year_min", "year_max", "has_location", "status", "album", "sort")} total0 = conn.execute("SELECT COUNT(*) FROM photos").fetchone()[0] page_bytes = page.render_page(total0, str(lib) if lib else (library or ""), db_path).encode("utf-8") log(f"web ui ready — {total0:,} photos indexed") class H(http.server.BaseHTTPRequestHandler): def log_message(self, *a): 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 _json(self, obj, code=200): self._send(code, "application/json", json.dumps(obj).encode("utf-8")) def do_GET(self): u = urlparse(self.path) qd = parse_qs(u.query) p = u.path if p == "/": return self._send(200, "text/html; charset=utf-8", page_bytes) if p == "/img": fp = unquote(qd.get("path", [""])[0]) if allowed(fp) and Path(fp).exists(): ctype = mimetypes.guess_type(fp)[0] or "application/octet-stream" return self._send(200, ctype, Path(fp).read_bytes()) return self._send(404, "text/plain", b"no image") if p == "/exif": fp = unquote(qd.get("path", [""])[0]) if allowed(fp) and Path(fp).exists(): return self._json(read_exif(fp)) return self._json({}) if p == "/photo": fp = unquote(qd.get("path", [""])[0]) return self._json(query.photo(conn, fp) or {}) if p == "/search": f = filters_from(qd) try: offset = int(qd.get("offset", ["0"])[0]) except ValueError: offset = 0 return self._json(query.search( conn, q=f.pop("q"), filters=f, sort=f.get("sort") or "relevance", offset=offset, limit=PAGE_SIZE)) if p == "/facets": return self._json(query.facets(conn, lib)) if p == "/stats": return self._json(query.stats(conn, lib)) if p == "/progress": return self._json(run_progress(conn, lib, runner.running())) if p == "/log": return self._json(list(log_entries)) if p == "/balance": return self._json(_balance()) if p == "/quota-check": return self._json({"ok": _quota()}) return self._send(404, "text/plain", b"not found") def do_POST(self): u = urlparse(self.path) n = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(n) if n else b"{}" if u.path == "/run": try: opts = json.loads(body or b"{}") except ValueError: return self._json({"error": "bad request"}, 400) return self._json(runner.start(opts)) if u.path == "/stop": return self._json(runner.stop()) return self._send(404, "text/plain", b"not found") def _balance(): try: import photo_analyzer as pa pa.load_env_file() import os key = os.environ.get("LLM_API_KEY") or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") d = pa.fetch_balance(key) if not d: return {"text": ""} return {"text": f"available {d.get('available_balance')} · " f"cash {d.get('cash_balance')} · voucher {d.get('voucher_balance')}"} except Exception as e: log(f"balance error: {e}") return {"text": ""} def _quota(): try: import os import photo_analyzer as pa pa.load_env_file() from openai import OpenAI key = os.environ.get("LLM_API_KEY") or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") client = OpenAI(api_key=key or "x", base_url=pa.LLM_BASE_URL) return pa.check_quota(client) except Exception as e: log(f"quota error: {e}") return False srv = http.server.ThreadingHTTPServer((host, 0), H) url = f"http://{host}:{srv.server_address[1]}/" print(f"\nPhoto Analyzer web UI: {url}\n Ctrl-C here to stop the server.\n", file=sys.stderr) if open_browser: webbrowser.open(url) try: srv.serve_forever() except KeyboardInterrupt: print("\nstopped.", file=sys.stderr) finally: srv.server_close()