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

@@ -0,0 +1,90 @@
# API Contract: Upload Thumbnails
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
---
## New endpoint
### `GET /api/v1/images/{image_id}/thumbnail`
Returns the thumbnail content for the given image. If no thumbnail was generated
(image pre-dates the feature or generation failed), falls back to the full-size
original.
**Path parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `image_id` | UUID | Unique identifier of the image |
**Responses**
#### `200 OK` — Thumbnail (or original fallback) content
| Header | Value | Notes |
|--------|-------|-------|
| `Content-Type` | `image/webp` | Always WebP when a thumbnail exists; original `mime_type` when falling back to the original |
| `ETag` | `"{sha256-hex}"` | Same hash as the original image — content is immutable |
| `Cache-Control` | `public, max-age=31536000, immutable` | Safe: thumbnail content never changes |
Body: raw image bytes (WebP thumbnail, or original bytes as fallback).
#### `404 Not Found` — Image not found
```json
{ "detail": "Image not found", "code": "image_not_found" }
```
#### `500 Internal Server Error` — Storage retrieval failure
```json
{ "detail": "Failed to retrieve image content", "code": "storage_error" }
```
---
## Changed endpoint: `POST /api/v1/images`
The upload response body gains one new field:
| Field | Type | Notes |
|-------|------|-------|
| `thumbnail_key` | `string \| null` | S3 key of the generated thumbnail. `null` if generation failed. |
All existing fields are unchanged.
**Example response** (new field only shown):
```json
{
"id": "...",
"thumbnail_key": "abc123…-thumb",
...
}
```
---
## Changed endpoint: `GET /api/v1/images` and `GET /api/v1/images/{id}`
Both metadata responses gain the same `thumbnail_key` field (`string | null`).
---
## UI contract
The Angular `ImageService` gains one new method:
```
getThumbnailUrl(id: string): string
→ '/api/v1/images/{id}/thumbnail'
```
The `ImageRecord` interface gains:
```
thumbnail_key: string | null;
```
The library grid component uses `getThumbnailUrl(image.id)` as the `src` for
every grid cell. The detail component continues using `getFileUrl(image.id)`.