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] %}
- {% if item["thumb"] %} - - {% else %} -
- {% endif %} + + {% if item["thumb"] %} + + {% else %} +
+ {% endif %} +
- {{ item["path"].split("/")[-1] }}
+ {{ item["name"] }}
{{ item["status"] }} · {{ item["source"] }} + {% if item["size_mb"] %} · {{ item["size_mb"] }}MB{% endif %}
diff --git a/dashboard/templates/index.html b/dashboard/templates/index.html index ec11817..e63c315 100644 --- a/dashboard/templates/index.html +++ b/dashboard/templates/index.html @@ -12,6 +12,10 @@ header h1 { font-size: 1.2rem; margin: 0; } header a { color: #6cf; text-decoration: none; } main { padding: 1.5rem; max-width: 1100px; margin: 0 auto; } + .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0 2rem; } + .card-stat { background: #1a1a1a; border: 1px solid #333; border-radius: 10px; padding: 1rem; } + .card-stat .num { font-size: 2.2rem; font-weight: 700; } + .card-stat .lbl { color: #999; font-size: .85rem; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid #222; } th { color: #999; font-size: .8rem; text-transform: uppercase; } @@ -20,17 +24,9 @@ .badge.approved { background: #1d4; color: #031; } .badge.rejected { background: #d43; color: #fff; } .badge.review { background: #da4; color: #321; } - .stat { font-size: 2rem; font-weight: 700; } - .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; } - .card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden; } - .card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; } - .card .meta { padding: .5rem; font-size: .75rem; color: #aaa; word-break: break-all; } - .card .actions { display: flex; gap: .25rem; padding: .5rem; } - .card button { flex: 1; border: 0; border-radius: 4px; padding: .4rem; cursor: pointer; font-size: .8rem; } - .approve { background: #1d4; color: #031; } - .reject { background: #d43; color: #fff; } - .reset { background: #444; color: #eee; } - .none { color: #666; font-style: italic; padding: 2rem; text-align: center; } + h2 { margin-top: 2rem; font-size: 1.1rem; } + .folder { display: flex; justify-content: space-between; padding: .5rem .75rem; border-radius: 8px; background: #1a1a1a; margin-bottom: .5rem; border: 1px solid #333; } + .folder .path { color: #6cf; font-family: monospace; font-size: .85rem; } @@ -38,11 +34,17 @@

📸 photo-pipeline

Overview Review queue + Approved
-

Library status

-

{{ total }}

-

images fingerprinted

+
+
{{ total }}
images fingerprinted
+
{{ folders["01_keep"] }}
in 01_keep
+
{{ folders["02_review"] }}
in 02_review
+
{{ folders["03_delete"] }}
in 03_delete
+
+ +

By source & status

{% for r in rows %} @@ -52,9 +54,14 @@ {% else %} - + {% endfor %}
SourceStatusCount
{{ r["n"] }}
No images yet — run photo-ingest first
No images yet — run photo-ingest first
+ +

Staging folders (targets)

+
✅ Keep — approved, ready for Immich import/mnt/data/01_keep
+
🔍 Review — flagged, needs your decision/mnt/data/02_review
+
🗑 Delete candidates — holding, NEVER auto-deleted/mnt/data/03_delete
diff --git a/dashboard/templates/review.html b/dashboard/templates/review.html index 79b5a06..b7a7449 100644 --- a/dashboard/templates/review.html +++ b/dashboard/templates/review.html @@ -11,8 +11,9 @@ header { padding: 1rem 1.5rem; border-bottom: 1px solid #333; display: flex; gap: 1.5rem; align-items: baseline; } header h1 { font-size: 1.2rem; margin: 0; } header a { color: #6cf; text-decoration: none; } - .filters { padding: 1rem 1.5rem; display: flex; gap: .5rem; } - .filters select, .filters button { background: #222; color: #eee; border: 1px solid #444; border-radius: 6px; padding: .4rem .8rem; } + .filters { padding: 1rem 1.5rem; display: flex; gap: .5rem; flex-wrap: wrap; } + .filters select, .filters input, .filters button { background: #222; color: #eee; border: 1px solid #444; border-radius: 6px; padding: .4rem .8rem; } + .count { padding: 0 1.5rem .5rem; color: #999; font-size: .9rem; } main { padding: 1.5rem; max-width: 1400px; margin: 0 auto; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; } .card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden; } @@ -29,6 +30,7 @@ .reject { background: #d43; color: #fff; } .reset { background: #444; color: #eee; } .none { color: #666; font-style: italic; padding: 2rem; text-align: center; } + .toast { position: fixed; bottom: 1rem; right: 1rem; background: #1d4; color: #031; padding: .75rem 1rem; border-radius: 8px; display: none; } @@ -36,6 +38,7 @@

📸 photo-pipeline

Overview Review queue + Approved
@@ -49,26 +52,11 @@
+

{{ items|length }} images

{% for item in items %} -
- {% if item["thumb"] %} - - {% else %} -
- {% endif %} -
- {{ item["path"].split("/")[-1] }}
- {{ item["status"] }} - · {{ item["source"] }} -
-
- - - -
-
+ {% include "_card.html" %} {% else %}

No images match the filter.

{% endfor %}