diff --git a/app/main.py b/app/main.py index 4cecf05..f6d7b78 100644 --- a/app/main.py +++ b/app/main.py @@ -535,6 +535,59 @@ VIDEO_PLACEHOLDER_SVG = """ 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}") diff --git a/debian/build-deb.sh b/debian/build-deb.sh index 1fd5f53..6039870 100644 --- a/debian/build-deb.sh +++ b/debian/build-deb.sh @@ -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"