"""photo-pipeline dashboard — FastAPI + htmx review UI. Reads photo_pipeline.db + staging dirs on .13. Serves: / — overview: batch/source stats + verdict counts /review — thumbnail review grid (files in 02_review / 03_delete) /thumbs/... — generated thumbnails htmx actions — POST /review/{sha}/approve, /reject, /reset (return updated card) """ import sqlite3 from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from PIL import Image BASE = Path(__file__).parent DB_PATH = BASE.parent / "photo_pipeline.db" THUMB_DIR = Path("/mnt/data/.thumbs") THUMB_SIZE = (240, 240) app = FastAPI(title="photo-pipeline dashboard") templates = Jinja2Templates(directory=str(BASE / "templates")) app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") EXT_IMAGES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic"} def _conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def _is_image(p: Path) -> bool: return p.suffix.lower() in EXT_IMAGES and p.exists() def _thumb(path: str): """Generate + return URL for a thumbnail of an image path.""" src = Path(path) if not src.exists(): return None key = src.stem + "_" + str(abs(hash(str(src))))[:8] + ".jpg" THUMB_DIR.mkdir(parents=True, exist_ok=True) dest = THUMB_DIR / key if not dest.exists(): try: with Image.open(src) as im: im.convert("RGB") im.thumbnail(THUMB_SIZE) im.save(dest, "JPEG", quality=70) except Exception: return None return f"/thumbs/{key}" def _load_item(row: sqlite3.Row) -> dict: p = Path(row["path"]) return { "sha256": row["sha256"], "path": row["path"], "status": row["status"], "source": row["source"], "thumb": _thumb(row["path"]) if _is_image(p) else None, "exists": p.exists(), } @app.get("/", response_class=HTMLResponse) def index(request: Request): conn = _conn() rows = conn.execute( """SELECT source, status, COUNT(*) as n FROM image_hashes GROUP BY source, status ORDER BY source, status""" ).fetchall() conn.close() return templates.TemplateResponse( request, "index.html", {"rows": rows, "total": sum(r["n"] for r in rows)} ) @app.get("/review", response_class=HTMLResponse) def review(request: Request, source: str = None, status: str = None): conn = _conn() q = "SELECT sha256, path, status, source FROM image_hashes WHERE 1=1" params = [] if source: q += " AND source=?" params.append(source) if status: q += " AND status=?" params.append(status) q += " ORDER BY added_at DESC LIMIT 200" rows = conn.execute(q, params).fetchall() conn.close() items = [_load_item(r) for r in rows] return templates.TemplateResponse( request, "review.html", {"items": items, "source": source, "status": status}, ) def _set_status(sha: str, status: str, request: Request) -> HTMLResponse: conn = _conn() row = conn.execute("SELECT * FROM image_hashes WHERE sha256=?", (sha,)).fetchone() if not row: conn.close() raise HTTPException(404) conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) conn.commit() conn.close() # return the fresh card (htmx swaps it in place) return templates.TemplateResponse( request, "_card.html", {"item": _load_item(row)} ) @app.post("/review/{sha}/approve") def approve(sha: str, request: Request): return _set_status(sha, "approved", request) @app.post("/review/{sha}/reject") def reject(sha: str, request: Request): return _set_status(sha, "rejected", request) @app.post("/review/{sha}/reset") def reset_status(sha: str, request: Request): return _set_status(sha, "scanned", request) @app.get("/thumbs/{name}") def thumb_file(name: str): f = THUMB_DIR / name if not f.exists(): raise HTTPException(404) return FileResponse(f) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8092)