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