Files
photoanalyzer/webapp/server.py

163 lines
6.4 KiB
Python

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