Files
photo-pipeline/dashboard/app.py

218 lines
6.6 KiB
Python

"""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
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 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()
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)
else:
q += " AND status IN ('scanned','review')"
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")
def stats():
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 json.dumps({"total": total, "by_status": by_status, "by_source": by_source})
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)
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)}
)
@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.post("/review/bulk")
async def bulk_action(request: Request):
"""Bulk status update — DB only, instant. Body: {"action", "shas": []}"""
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")
status_map = {"approve": "approved", "reject": "rejected", "reset": "scanned"}
status = status_map[action]
conn = _conn()
updated = 0
missing = 0
for sha in shas:
cur = conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha))
if cur.rowcount:
updated += 1
else:
missing += 1
conn.commit()
conn.close()
return {"action": action, "updated": updated, "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)