Fix wrong-picture bug: thumbs keyed by sha256 (was filename); single-item actions via fetch+remove (reliable)

This commit is contained in:
2026-08-09 15:48:22 +10:00
parent 73f6f291f1
commit d2a58caca6
3 changed files with 54 additions and 41 deletions

View File

@@ -44,13 +44,15 @@ def _is_image(p: Path) -> bool:
return p.suffix.lower() in EXT_IMAGES and p.exists() return p.suffix.lower() in EXT_IMAGES and p.exists()
def _thumb(path: str): def _thumb(path: str, sha: str = None):
"""Return thumbnail URL WITHOUT generating (lazy — /thumbs/ generates on first hit).""" """Return thumbnail URL keyed by sha256 (the image's unique identity).
src = Path(path)
if not src.exists(): Never uses filename — duplicate filenames across folders caused wrong
thumbnails/full-size images. sha256 is unique per image content.
"""
if not sha:
return None return None
key = src.stem + "_" + str(abs(hash(str(src))))[:8] + ".jpg" return f"/thumbs/{sha}.jpg"
return f"/thumbs/{key}"
def _load_item(row: sqlite3.Row) -> dict: def _load_item(row: sqlite3.Row) -> dict:
@@ -61,7 +63,7 @@ def _load_item(row: sqlite3.Row) -> dict:
"name": p.name, "name": p.name,
"status": row["status"], "status": row["status"],
"source": row["source"], "source": row["source"],
"thumb": _thumb(row["path"]) if _is_image(p) else None, "thumb": _thumb(row["path"], row["sha256"]) if _is_image(p) else None,
"exists": p.exists(), "exists": p.exists(),
"size_mb": round(p.stat().st_size / 1e6, 1) if p.exists() else None, "size_mb": round(p.stat().st_size / 1e6, 1) if p.exists() else None,
"flag_reason": row["flag_reason"] if "flag_reason" in row.keys() else None, "flag_reason": row["flag_reason"] if "flag_reason" in row.keys() else None,
@@ -229,39 +231,31 @@ def full_file(sha: str):
@app.get("/thumbs/{name}") @app.get("/thumbs/{name}")
def thumb_file(name: str): def thumb_file(name: str):
"""Serve thumbnail; generate on first request (cached after).""" """Serve thumbnail keyed by sha256; generate on first request (cached)."""
f = THUMB_DIR / name f = THUMB_DIR / name
if not f.exists(): if f.exists():
# lazy generate — reconstruct source path from key (stem is orig filename) return FileResponse(f)
THUMB_DIR.mkdir(parents=True, exist_ok=True) # key = <sha256>.jpg — resolve exact image via the DB (unique identity)
# find the source image: key = <stem>_<hash8>.jpg sha = name.rsplit(".", 1)[0]
stem = name.rsplit("_", 1)[0] conn = _conn()
src = _find_source(stem) row = conn.execute("SELECT path FROM image_hashes WHERE sha256=?", (sha,)).fetchone()
if not src: conn.close()
raise HTTPException(404) if not row:
try: raise HTTPException(404)
with Image.open(src) as im: src = Path(row["path"])
im.convert("RGB") if not src.exists():
im.thumbnail(THUMB_SIZE) raise HTTPException(404)
im.save(f, "JPEG", quality=70) THUMB_DIR.mkdir(parents=True, exist_ok=True)
except Exception: try:
raise HTTPException(404) with Image.open(src) as im:
im.convert("RGB")
im.thumbnail(THUMB_SIZE)
im.save(f, "JPEG", quality=70)
except Exception:
raise HTTPException(404)
return FileResponse(f) return FileResponse(f)
def _find_source(stem: str):
"""Find the original image for a thumbnail key (by filename stem)."""
import os
for root_dir in ("/mnt/ubuntu_storage_3TB/archive/03_photos",
"/mnt/data/01_keep", "/mnt/data/02_review", "/mnt/data/03_delete"):
for dirpath, _, files in os.walk(root_dir):
for fn in files:
if fn.rsplit(".", 1)[0] == stem:
return Path(dirpath) / fn
return None
@app.get("/upload", response_class=HTMLResponse) @app.get("/upload", response_class=HTMLResponse)
def upload_page(request: Request): def upload_page(request: Request):
"""Upload page — drop files/archives into the incoming folder.""" """Upload page — drop files/archives into the incoming folder."""

View File

@@ -1,9 +1,9 @@
{% set sha8 = item["sha256"][:8] %} {% set sha8 = item["sha256"][:8] %}
<div class="card" id="card-{{ sha8 }}"> <div class="card" id="card-{{ sha8 }}" data-sha="{{ item["sha256"] }}">
<label class="check"> <label class="check">
<input type="checkbox" class="item-check" value="{{ item["sha256"] }}" data-name="{{ item["name"] }}"> <input type="checkbox" class="item-check" value="{{ item["sha256"] }}" data-name="{{ item["name"] }}">
</label> </label>
<a href="/file/{{ item["sha256"] }}" target="_blank"> <a href="/file/{{ item["sha256"] }}" target="_blank" title="{{ item["path"] }}">
{% if item["thumb"] %} {% if item["thumb"] %}
<img src="{{ item["thumb"] }}" loading="lazy" alt=""> <img src="{{ item["thumb"] }}" loading="lazy" alt="">
{% else %} {% else %}
@@ -18,8 +18,8 @@
{% if item.get("flag_reason") %}<span style="color:#d43"> · ⚠ {{ item["flag_reason"] }}</span>{% endif %} {% if item.get("flag_reason") %}<span style="color:#d43"> · ⚠ {{ item["flag_reason"] }}</span>{% endif %}
</div> </div>
<div class="actions"> <div class="actions">
<button class="approve" hx-post="/review/{{ item["sha256"] }}/approve" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful){this.closest('.card').remove();updateCount();showToast('Approved');}">✅ Keep</button> <button class="approve" onclick="cardAction('approve', this)">✅ Keep</button>
<button class="reject" hx-post="/review/{{ item["sha256"] }}/reject" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful){this.closest('.card').remove();updateCount();showToast('Rejected');}">🗑 Reject</button> <button class="reject" onclick="cardAction('reject', this)">🗑 Reject</button>
<button class="reset" hx-post="/review/{{ item["sha256"] }}/reset" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML"></button> <button class="reset" onclick="cardAction('reset', this)"></button>
</div> </div>
</div> </div>

View File

@@ -143,6 +143,25 @@
refresh(); refresh();
}); });
async function cardAction(action, btn) {
const card = btn.closest('.card');
const sha = card.dataset.sha;
try {
const resp = await fetch(`/review/${sha}/${action}`, { method: 'POST' });
if (resp.ok) {
card.remove();
updateCount();
showToast(action === 'approve' ? 'Approved' : action === 'reject' ? 'Rejected' : 'Reset');
// if reset, refresh the page (item returns to a different view)
if (action === 'reset') location.reload();
} else {
showToast('Action failed', true);
}
} catch (e) {
showToast('Action failed: ' + e, true);
}
}
async function bulk(action) { async function bulk(action) {
const shas = getSelected(); const shas = getSelected();
if (shas.length === 0) return; if (shas.length === 0) return;