"""photo-pipeline dashboard — FastAPI + htmx review UI. Reads photo_pipeline.db + staging dirs on .13. Serves: / — overview: source/batch stats + verdict counts + folder targets /review — thumbnail review grid with keep/reject/reset (htmx) /file/{sha} — full-size original image /thumbs/... — generated thumbnails /stats — JSON stats (for future dashboard widgets) """ import shutil 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" STAGING = Path("/mnt/data") THUMB_DIR = Path("/mnt/data/.thumbs") THUMB_SIZE = (320, 320) 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): 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"], "name": p.name, "status": row["status"], "source": row["source"], "thumb": _thumb(row["path"]) if _is_image(p) else None, "exists": p.exists(), "size_mb": round(p.stat().st_size / 1e6, 1) if p.exists() else None, } @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() total = conn.execute("SELECT COUNT(*) FROM image_hashes").fetchone()[0] conn.close() # folder targets (staging dirs) folders = {} for name in ("01_keep", "02_review", "03_delete"): d = STAGING / name folders[name] = sum(1 for _ in d.rglob("*") if _.is_file()) if d.exists() else 0 return templates.TemplateResponse( request, "index.html", {"rows": rows, "total": total, "folders": folders}, ) @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 300" 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}, ) @app.get("/stats", response_class=FileResponse) def stats(): """JSON stats for widgets.""" import json conn = _conn() total = conn.execute("SELECT COUNT(*) FROM image_hashes").fetchone()[0] by_status = dict(conn.execute( "SELECT status, COUNT(*) FROM image_hashes GROUP BY status").fetchall()) by_source = dict(conn.execute( "SELECT source, COUNT(*) FROM image_hashes GROUP BY source").fetchall()) conn.close() return FileResponse(path=None, content=json.dumps( {"total": total, "by_status": by_status, "by_source": by_source})) def _set_status(sha: str, status: str, move_to: str | None, request: Request): """Update DB status AND (optionally) move the file into a staging folder.""" conn = _conn() row = conn.execute("SELECT * FROM image_hashes WHERE sha256=?", (sha,)).fetchone() if not row: conn.close() raise HTTPException(404) src = Path(row["path"]) # move file if requested and source exists if move_to and src.exists(): dest_dir = STAGING / move_to dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / src.name try: shutil.move(str(src), str(dest)) conn.execute( "UPDATE image_hashes SET path=?, status=? WHERE sha256=?", (str(dest), status, sha)) except Exception as e: conn.close() raise HTTPException(500, detail=f"move failed: {e}") else: conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) conn.commit() conn.close() return templates.TemplateResponse( request, "_card.html", {"item": _load_item(row)}) @app.post("/review/{sha}/approve") def approve(sha: str, request: Request): # approved → move to 01_keep (ready for Immich import) return _set_status(sha, "approved", "01_keep", request) @app.post("/review/{sha}/reject") def reject(sha: str, request: Request): # rejected → move to 03_delete (holding, never auto-deleted) return _set_status(sha, "rejected", "03_delete", request) @app.post("/review/{sha}/reset") def reset_status(sha: str, request: Request): # undo: move back to a neutral status without moving the file return _set_status(sha, "scanned", None, request) @app.post("/review/bulk") async def bulk_action(request: Request): """Bulk keep/reject/reset for a list of sha256 values. Body: JSON {"action": "approve"|"reject"|"reset", "shas": ["..."]} Moves files accordingly; returns counts. """ import json body = json.loads(await request.body()) action = body.get("action") shas = body.get("shas", []) if action not in ("approve", "reject", "reset"): raise HTTPException(400, "action must be approve/reject/reset") move_map = {"approve": "01_keep", "reject": "03_delete", "reset": None} status_map = {"approve": "approved", "reject": "rejected", "reset": "scanned"} move_to = move_map[action] status = status_map[action] conn = _conn() moved = 0 missing = 0 for sha in shas: row = conn.execute("SELECT path FROM image_hashes WHERE sha256=?", (sha,)).fetchone() if not row: missing += 1 continue src = Path(row["path"]) if move_to and src.exists(): dest_dir = STAGING / move_to dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / src.name try: shutil.move(str(src), str(dest)) conn.execute( "UPDATE image_hashes SET path=?, status=? WHERE sha256=?", (str(dest), status, sha)) moved += 1 continue except Exception: pass conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) moved += 1 conn.commit() conn.close() return {"action": action, "moved": moved, "missing": missing, "total": len(shas)} @app.get("/file/{sha}") def full_file(sha: str): conn = _conn() row = conn.execute("SELECT path FROM image_hashes WHERE sha256=?", (sha,)).fetchone() conn.close() if not row: raise HTTPException(404) p = Path(row["path"]) if not p.exists(): raise HTTPException(404) return FileResponse(p) @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)