Library status
-{{ total }}
-images fingerprinted
+By source & status
| Source | Status | Count | {{ r["n"] }} | {% else %} -
|---|---|---|
| No images yet — run photo-ingest first | ||
| No images yet — run photo-ingest first | ||
diff --git a/dashboard/app.py b/dashboard/app.py index e61e654..174faf1 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -1,12 +1,14 @@ """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) + / — 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 - htmx actions — POST /review/{sha}/approve, /reject, /reset (return updated card) + /stats — JSON stats (for future dashboard widgets) """ +import shutil import sqlite3 from pathlib import Path @@ -18,8 +20,9 @@ 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 = (240, 240) +THUMB_SIZE = (320, 320) app = FastAPI(title="photo-pipeline dashboard") templates = Jinja2Templates(directory=str(BASE / "templates")) @@ -39,7 +42,6 @@ def _is_image(p: Path) -> bool: def _thumb(path: str): - """Generate + return URL for a thumbnail of an image path.""" src = Path(path) if not src.exists(): return None @@ -62,10 +64,12 @@ def _load_item(row: sqlite3.Row) -> dict: 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, } @@ -76,9 +80,16 @@ def index(request: Request): """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": sum(r["n"] for r in rows)} + request, "index.html", + {"rows": rows, "total": total, "folders": folders}, ) @@ -93,7 +104,7 @@ def review(request: Request, source: str = None, status: str = None): if status: q += " AND status=?" params.append(status) - q += " ORDER BY added_at DESC LIMIT 200" + q += " ORDER BY added_at DESC LIMIT 300" rows = conn.execute(q, params).fetchall() conn.close() items = [_load_item(r) for r in rows] @@ -103,34 +114,80 @@ def review(request: Request, source: str = None, status: str = None): ) -def _set_status(sha: str, status: str, request: Request) -> HTMLResponse: +@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) - conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) + 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 the fresh card (htmx swaps it in place) return templates.TemplateResponse( - request, "_card.html", {"item": _load_item(row)} - ) + request, "_card.html", {"item": _load_item(row)}) @app.post("/review/{sha}/approve") def approve(sha: str, request: Request): - return _set_status(sha, "approved", 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): - return _set_status(sha, "rejected", 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): - return _set_status(sha, "scanned", request) + # undo: move back to a neutral status without moving the file + return _set_status(sha, "scanned", None, request) + + +@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}") diff --git a/dashboard/templates/_card.html b/dashboard/templates/_card.html index a955f21..ddaba39 100644 --- a/dashboard/templates/_card.html +++ b/dashboard/templates/_card.html @@ -1,14 +1,17 @@ {% set sha8 = item["sha256"][:8] %}
{{ total }}
-images fingerprinted
+| Source | Status | Count | {{ r["n"] }} | {% else %} -
|---|---|---|
| No images yet — run photo-ingest first | ||
| No images yet — run photo-ingest first | ||