diff --git a/dashboard/app.py b/dashboard/app.py index ee700a8..7e3343b 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -5,10 +5,13 @@ Reads photo_pipeline.db + staging dirs on .13. Serves: /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) + /stats — JSON stats + +Review actions update the DB ONLY (instant). File moves happen at import time +(via the immich-import / process-staging flow) — this keeps review responsive +even for large batches; cross-filesystem moves are slow. """ -import shutil import sqlite3 from pathlib import Path @@ -82,7 +85,6 @@ def index(request: Request): ).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 @@ -104,6 +106,8 @@ def review(request: Request, source: str = None, status: str = None): if status: q += " AND status=?" params.append(status) + else: + q += " AND status IN ('scanned','review')" q += " ORDER BY added_at DESC LIMIT 300" rows = conn.execute(q, params).fetchall() conn.close() @@ -114,9 +118,8 @@ def review(request: Request, source: str = None, status: str = None): ) -@app.get("/stats", response_class=FileResponse) +@app.get("/stats") def stats(): - """JSON stats for widgets.""" import json conn = _conn() @@ -126,64 +129,43 @@ def stats(): 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})) + return 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.""" +def _set_status(sha: str, status: str, request: Request) -> HTMLResponse: + """Update DB status ONLY — instant. File moves happen at import time.""" 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.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) conn.commit() + row = conn.execute("SELECT * FROM image_hashes WHERE sha256=?", (sha,)).fetchone() conn.close() 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): - # approved → move to 01_keep (ready for Immich import) - return _set_status(sha, "approved", "01_keep", request) + return _set_status(sha, "approved", 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) + return _set_status(sha, "rejected", 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) + return _set_status(sha, "scanned", 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. - """ + """Bulk status update — DB only, instant. Body: {"action", "shas": []}""" import json body = json.loads(await request.body()) @@ -191,39 +173,21 @@ async def bulk_action(request: Request): 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 + updated = 0 missing = 0 for sha in shas: - row = conn.execute("SELECT path FROM image_hashes WHERE sha256=?", (sha,)).fetchone() - if not row: + cur = conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) + if cur.rowcount: + updated += 1 + else: 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)} + return {"action": action, "updated": updated, "missing": missing, "total": len(shas)} @app.get("/file/{sha}") diff --git a/dashboard/templates/_card.html b/dashboard/templates/_card.html index c2d9558..386b8d7 100644 --- a/dashboard/templates/_card.html +++ b/dashboard/templates/_card.html @@ -17,8 +17,8 @@ {% if item["size_mb"] %} · {{ item["size_mb"] }}MB{% endif %}
{{ items|length }} images