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"}
|
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}")
|
@app.get("/api/thumb/{file_id}")
|
||||||
def get_thumb(file_id: int):
|
def get_thumb(file_id: int):
|
||||||
con = get_db()
|
con = get_db()
|
||||||
@@ -546,32 +599,34 @@ def get_thumb(file_id: int):
|
|||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
path = row["path"]
|
|
||||||
ext = (row["extension"] or "").lower()
|
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")
|
raise HTTPException(404, "File not on disk")
|
||||||
|
|
||||||
if ext in VIDEO_EXT:
|
if _generate_thumb(src, cached, ext):
|
||||||
# Try ffmpeg for first frame
|
return FileResponse(cached, media_type="image/jpeg")
|
||||||
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")
|
|
||||||
|
|
||||||
# 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"
|
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}")
|
@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 ────────────────────────────────────────────────────────────────────
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
PKG_NAME="dupfinder"
|
PKG_NAME="dupfinder"
|
||||||
PKG_VERSION="1.0.7"
|
PKG_VERSION="1.0.8"
|
||||||
PKG_ARCH="amd64"
|
PKG_ARCH="amd64"
|
||||||
DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb"
|
DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user