Fix wrong-picture bug: thumbs keyed by sha256 (was filename); single-item actions via fetch+remove (reliable)
This commit is contained in:
@@ -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,16 +231,21 @@ 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()
|
||||||
|
if not row:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
|
src = Path(row["path"])
|
||||||
|
if not src.exists():
|
||||||
|
raise HTTPException(404)
|
||||||
|
THUMB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
with Image.open(src) as im:
|
with Image.open(src) as im:
|
||||||
im.convert("RGB")
|
im.convert("RGB")
|
||||||
@@ -249,19 +256,6 @@ def thumb_file(name: str):
|
|||||||
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."""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user