Add workstation-local thumbnail cache + HEIC support

Thumbnails (256px JPEG, q80) generated on first request and cached at
/data/thumbs/<shard>/<file_id>.jpg — i.e. on the workstation's local SSD,
not the NAS. Subsequent requests serve straight from cache, never
re-fetching from /photos.

HEIC/HEIF decoded via pillow-heif so iPhone photos finally render.
Videos cached as a single ffmpeg-extracted frame, not regenerated each
request. New DELETE /api/thumb/cache endpoint to wipe it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos
2026-04-26 16:29:29 -04:00
parent 81b38cb5bb
commit 4c21e9fa1c
2 changed files with 76 additions and 21 deletions

View File

@@ -535,6 +535,59 @@ VIDEO_PLACEHOLDER_SVG = """<svg xmlns="http://www.w3.org/2000/svg" width="200" h
VIDEO_EXT = {".mp4", ".mov", ".avi", ".mkv", ".m4v", ".3gp", ".wmv", ".mts", ".m2ts"}
THUMB_CACHE_DIR = "/data/thumbs"
THUMB_MAX = 256 # square bounding box; preserves aspect
def _thumb_cache_path(file_id: int) -> str:
"""Sharded cache path so no directory holds more than ~1000 files."""
shard = file_id // 1000
d = os.path.join(THUMB_CACHE_DIR, str(shard))
os.makedirs(d, exist_ok=True)
return os.path.join(d, f"{file_id}.jpg")
def _generate_thumb(src_path: str, dest_path: str, ext: str) -> bool:
"""Generate a 256px JPEG thumbnail at dest_path. Returns True on success."""
try:
if ext in VIDEO_EXT:
# ffmpeg first frame, scaled to fit
result = subprocess.run(
[
"ffmpeg", "-y", "-i", src_path,
"-vframes", "1",
"-vf", f"scale='min({THUMB_MAX},iw)':'-1'",
"-q:v", "5",
dest_path,
],
capture_output=True, timeout=20,
)
return result.returncode == 0 and os.path.getsize(dest_path) > 0
# Image branch — Pillow handles JPEG/PNG/GIF/WebP/TIFF/BMP natively;
# pillow-heif registers HEIC/HEIF as a Pillow-readable format.
from PIL import Image, ImageOps
try:
import pillow_heif # noqa: F401 (registers HEIF opener)
pillow_heif.register_heif_opener()
except Exception:
pass
with Image.open(src_path) as im:
im = ImageOps.exif_transpose(im) # respect EXIF rotation
im.thumbnail((THUMB_MAX, THUMB_MAX))
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(dest_path, "JPEG", quality=80, optimize=True)
return True
except Exception:
# Cleanup partial write
try:
if os.path.exists(dest_path):
os.unlink(dest_path)
except Exception:
pass
return False
@app.get("/api/thumb/{file_id}")
def get_thumb(file_id: int):
con = get_db()
@@ -546,32 +599,34 @@ def get_thumb(file_id: int):
if not row:
raise HTTPException(404, "File not found")
path = row["path"]
ext = (row["extension"] or "").lower()
cached = _thumb_cache_path(file_id)
if not os.path.isfile(path):
# Cache hit — serve the local JPEG, never touches the NAS
if os.path.isfile(cached) and os.path.getsize(cached) > 0:
return FileResponse(cached, media_type="image/jpeg")
src = row["path"]
if not os.path.isfile(src):
raise HTTPException(404, "File not on disk")
if ext in VIDEO_EXT:
# Try ffmpeg for first frame
try:
result = subprocess.run(
[
"ffmpeg", "-i", path,
"-vframes", "1", "-f", "image2", "-vcodec", "mjpeg",
"pipe:1",
],
capture_output=True, timeout=10,
)
if result.returncode == 0 and result.stdout:
return Response(content=result.stdout, media_type="image/jpeg")
except Exception:
pass
return Response(content=VIDEO_PLACEHOLDER_SVG, media_type="image/svg+xml")
if _generate_thumb(src, cached, ext):
return FileResponse(cached, media_type="image/jpeg")
# Serve photo directly
# Final fallback: video placeholder for videos, original file for photos
if ext in VIDEO_EXT:
return Response(content=VIDEO_PLACEHOLDER_SVG, media_type="image/svg+xml")
mime = row["mime_type"] or "application/octet-stream"
return FileResponse(path, media_type=mime)
return FileResponse(src, media_type=mime)
@app.delete("/api/thumb/cache")
def clear_thumb_cache():
"""Wipe the thumbnail cache. Safe to call any time — they regenerate on demand."""
import shutil
if os.path.isdir(THUMB_CACHE_DIR):
shutil.rmtree(THUMB_CACHE_DIR, ignore_errors=True)
return {"cleared": True}
@app.get("/api/files/{file_id}")

2
debian/build-deb.sh vendored
View File

@@ -13,7 +13,7 @@ BUILD_DIR="$REPO_ROOT/build/deb"
# ── Config ────────────────────────────────────────────────────────────────────
PKG_NAME="dupfinder"
PKG_VERSION="1.0.7"
PKG_VERSION="1.0.8"
PKG_ARCH="amd64"
DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb"