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:
95
app/main.py
95
app/main.py
@@ -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
2
debian/build-deb.sh
vendored
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user