- 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>
2.3 KiB
Data Model: Upload Thumbnails
Branch: 003-upload-thumbnails | Date: 2026-05-03
Schema change: images table
One nullable column is added to the existing images table.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
thumbnail_key |
VARCHAR(70) |
YES | NULL |
S3 object key for the WebP thumbnail. NULL = no thumbnail available (generation failed or pre-dates this feature). Derived value: {image.hash}-thumb. |
No other tables change. No new tables are added.
Migration
File: api/alembic/versions/002_add_thumbnail_key.py
upgrade: ALTER TABLE images ADD COLUMN thumbnail_key VARCHAR(70);
downgrade: ALTER TABLE images DROP COLUMN thumbnail_key;
ORM model change: Image
api/app/models.py — Image class gains one field:
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
New module: api/app/thumbnail.py
Contains the thumbnail generation logic. Not a model, but documented here because it defines the thumbnail's shape:
| Aspect | Value |
|---|---|
| Output format | WebP |
| Max dimension (longest side) | 400 px |
| Aspect ratio | Preserved (never upscaled) |
| Source formats supported | JPEG, PNG, GIF (frame 0), WebP |
| Key signature | async def generate_thumbnail(data: bytes, mime_type: str) -> bytes |
API response shape change
_image_to_dict() in api/app/routers/images.py adds "thumbnail_key" to its
output so the UI can determine whether a thumbnail is available:
{
"id": "...",
"hash": "...",
"thumbnail_key": "abc123...-thumb", ← new (null if no thumbnail)
...
}
The UI uses the presence of thumbnail_key to decide whether to call
/api/v1/images/{id}/thumbnail (with thumbnail) or fall back to
/api/v1/images/{id}/file (without). In practice the endpoint itself
handles the fallback, so the UI can always call /thumbnail.
Storage objects per image (after this feature)
| Object | Key | Format | Created at |
|---|---|---|---|
| Original | {sha256_hash} |
Original mime_type | Upload |
| Thumbnail | {sha256_hash}-thumb |
image/webp |
Upload (same request) |
Thumbnail object is deleted alongside original on image deletion.