- 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>
91 lines
2.2 KiB
Markdown
91 lines
2.2 KiB
Markdown
# 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)`.
|