Feat: Pre-generate WebP thumbnails on upload for faster library load

- Add Pillow dependency and thumbnail.py with generate_thumbnail() — produces
  WebP ≤400px, preserves aspect ratio, never upscales, handles GIF frame 0
- Alembic migration 002 adds nullable thumbnail_key column to images table
- Upload route generates thumbnail via asyncio.to_thread (non-blocking),
  stores at {hash}-thumb; failure is tolerated and upload succeeds with null key
- New GET /api/v1/images/{id}/thumbnail endpoint: serves WebP thumbnail or
  falls back to original for pre-feature images; ETag + immutable cache headers
- Delete route cleans up thumbnail storage object alongside original
- Library grid switches from /file to /thumbnail for all image src bindings
- 59 tests passing (46 existing + 13 new across unit, upload, serving, delete)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 17:26:16 +00:00
parent cd89ba5dea
commit f953c88984
24 changed files with 1270 additions and 5 deletions

View File

@@ -7,6 +7,16 @@ import io
import uuid
import pytest
from PIL import Image as PILImage
from sqlalchemy import update
from app.models import Image
def _real_jpeg() -> bytes:
buf = io.BytesIO()
PILImage.new("RGB", (200, 150), color=(120, 80, 200)).save(buf, format="JPEG")
return buf.getvalue()
def _minimal_webp() -> bytes:
@@ -62,3 +72,53 @@ async def test_file_response_exposes_no_storage_details(client):
assert "minio" not in response.text.lower()
assert "s3://" not in response.text.lower()
assert "amazonaws.com" not in response.text.lower()
@pytest.mark.asyncio
async def test_thumbnail_returns_webp(client):
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
)
assert upload.status_code == 201
body = upload.json()
image_id = body["id"]
image_hash = body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
assert response.status_code == 200
assert response.headers["content-type"] == "image/webp"
assert response.headers["etag"] == f'"{image_hash}"'
assert "immutable" in response.headers["cache-control"]
assert len(response.content) > 0
@pytest.mark.asyncio
async def test_thumbnail_fallback_returns_original(client, db_session):
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
)
assert upload.status_code == 201
image_id = upload.json()["id"]
await db_session.execute(
update(Image).where(Image.id == uuid.UUID(image_id)).values(thumbnail_key=None)
)
await db_session.flush()
db_session.expire_all()
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
assert response.status_code == 200
assert "image/jpeg" in response.headers["content-type"]
assert len(response.content) > 0
@pytest.mark.asyncio
async def test_thumbnail_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/thumbnail")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"