docs: add Plan 3 web UI media endpoints spec

Covers /api/albums/{id}/cover and /api/tracks/{id}/stream — the byte-serving
endpoints Plan 2's cover_url / stream_url forward references point at.
Duplicates protocol-agnostic helpers locally per subsonic-is-legacy direction
rather than extracting a shared package.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 21:51:29 -04:00
parent b94b7514f9
commit 7fcee05c1e
@@ -0,0 +1,177 @@
# Web UI Media Endpoints — Design Spec
**Date:** 2026-04-21
**Status:** Design approved
**Follows:** [Web UI Library Reads](2026-04-20-web-ui-library-reads-design.md) — this spec implements the `cover_url` and `stream_url` forward references shipped in Plan 2.
## Goal
Ship the two byte-serving endpoints the web SPA (and later the Flutter client) need to render album art and play tracks. When this lands, every `<img src>` and `<audio src>` tag that Plan 2's DTOs produced will resolve.
## Non-goals
Explicit YAGNI — documented here so they don't drift back in during implementation:
- No cover resizing (`?size=` param). Serving one size matches subsonic's current behavior; re-sizing is a future optimization when the SPA shows it's needed.
- No audio transcoding (`?format=`, `?maxBitRate=`). Raw bytes only.
- No `/download` attachment-mode variant. `/stream` is enough for the SPA; a separate download endpoint can land later if a concrete caller needs it.
- No scanner changes to populate `albums.cover_art_path`. The sidecar fallback covers the common real-world case today; improving scanner coverage is its own plan.
## Architecture
Two endpoints, both under `RequireUser` in the existing `/api` Mount:
- `GET /api/albums/{id}/cover` → album cover image
- `GET /api/tracks/{id}/stream` → raw audio bytes, Range-capable
**Package isolation.** `internal/subsonic/stream.go` already implements the same two behaviors for `/rest/*`. Per project direction, subsonic is long-term legacy and stays frozen. The protocol-agnostic helpers (mime tables, sidecar lookup, `http.ServeContent` glue) are duplicated into `internal/api/media.go` rather than extracted into a shared package. The only shared layer is `internal/db/dbq` (already) and `http.ServeContent` from stdlib.
**Auth.** No new middleware. `auth.RequireUser` (in `internal/auth/session.go`) already accepts both the `minstrel_session` cookie and `Authorization: Bearer <token>`. Browser `<img>` and `<audio>` tags send cookies automatically; the Flutter client will carry the bearer token.
**Range / ETag / If-Modified-Since.** Delegated to `http.ServeContent`, which reads headers from the request and writes the correct 206/304/200 response plus `Content-Range` / `ETag` / `Last-Modified`. No hand-rolled Range parsing.
## Routes
| Method | Path | Auth | Success response |
|---|---|---|---|
| GET | `/api/albums/{id}/cover` | `RequireUser` | 200 (or 304) with image bytes; `Content-Type` inferred from file extension (`image/jpeg`, `image/png`, `image/webp`, `image/gif`) |
| GET | `/api/tracks/{id}/stream` | `RequireUser` | 200 / 206 / 304 with audio bytes; `Content-Type` from `track.file_format` (e.g. `audio/mpeg`, `audio/flac`, `audio/ogg`); `Accept-Ranges: bytes`; `Content-Length` set by `http.ServeContent` |
Both routes register inside the existing `authed` chi group in `internal/api/api.go` (alongside the Plan 2 library routes). Production `Mount` wires them; the test-only `newLibraryRouter` helper is extended to include them so integration tests can exercise the routes end-to-end.
## Data flow
### `GET /api/albums/{id}/cover`
1. Parse `{id}` via `parseUUID` (the helper from `internal/api/convert.go`). Malformed UUID → 400 `bad_request` "invalid album id".
2. `q.GetAlbumByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "album not found". Other err → 500 `server_error`.
3. `resolveAlbumCoverPath(ctx, q, album)`:
- If `album.CoverArtPath != nil` and the file stats successfully, return that path.
- Otherwise, list tracks for the album (`q.ListTracksByAlbum`) and, for the first track, look in its directory for `cover.jpg`, `cover.jpeg`, `cover.png`, `folder.jpg`, `folder.jpeg`, `folder.png` in that order. Return the first that exists; else return `""`.
4. Empty path return → 404 `not_found` "cover not found".
5. `os.Open` + `f.Stat`. File vanished since resolve → 404 `not_found` "cover not found". Stat err → 500.
6. Set `Content-Type` from `imageContentType(path)` (file-extension-based mapping). Call `http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)`.
### `GET /api/tracks/{id}/stream`
1. Parse `{id}`. Malformed UUID → 400 `bad_request` "invalid track id".
2. `q.GetTrackByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "track not found". Other err → 500.
3. `os.Open(track.FilePath)`. File vanished → 404 `not_found` "track file not found". Other err → 500.
4. `f.Stat`. Err → 500.
5. Set `Content-Type` from `audioContentType(track.FileFormat)`. Set `Accept-Ranges: bytes`. Call `http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)`.
## Error shape
All errors use the existing `writeErr` helper → `{"error":{"code":"...","message":"..."}}`, matching the rest of `/api/*`. Codes reused from Plan 2: `bad_request`, `not_found`, `server_error`.
| Condition | Status | Code | Message |
|---|---|---|---|
| Malformed UUID in path | 400 | `bad_request` | `invalid album id` / `invalid track id` |
| Unknown album / track | 404 | `not_found` | `album not found` / `track not found` |
| Album has no resolvable cover | 404 | `not_found` | `cover not found` |
| Track file missing on disk | 404 | `not_found` | `track file not found` |
| DB lookup failure | 500 | `server_error` | `server error` |
| `os.Stat` failure on known path | 500 | `server_error` | `server error` |
## Components
Single new file: `internal/api/media.go`. Contains both handlers and their duplicated helpers.
```
handlers
handleGetCover(w, r)
handleGetStream(w, r)
free functions (package-private)
resolveAlbumCoverPath(ctx, q, album) string
findSidecarCover(trackDir string) string
audioContentType(format string) string
imageContentType(path string) string
```
`audioContentType` table (mirrors subsonic's `contentTypeForFormat`, duplicated verbatim):
| file_format | Content-Type |
|---|---|
| `mp3` | `audio/mpeg` |
| `flac` | `audio/flac` |
| `ogg` | `audio/ogg` |
| `opus` | `audio/ogg` |
| `m4a` | `audio/mp4` |
| `aac` | `audio/aac` |
| `wav` | `audio/wav` |
| *(other)* | `application/octet-stream` |
`imageContentType` table:
| extension (lowercase) | Content-Type |
|---|---|
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.png` | `image/png` |
| `.webp` | `image/webp` |
| `.gif` | `image/gif` |
| *(other)* | `application/octet-stream` |
Sidecar lookup order (first match wins):
```
cover.jpg, cover.jpeg, cover.png,
folder.jpg, folder.jpeg, folder.png
```
## Testing
Integration tests in `internal/api/media_test.go`. They reuse the existing `testHandlers(t)`, `truncateLibrary(t, pool)`, `seedArtist`, `seedAlbum`, `seedTrack` helpers from `library_fixtures_test.go`, and add a new helper:
- `seedTrackWithFile(t, pool, albumID, artistID, title string, fileBody []byte, ext string) (dbq.Track, string)` — writes `fileBody` into `t.TempDir()` with the given extension, inserts a Track row whose `file_path` points at it, returns the track and the dir.
### Cover tests
- **Happy path (sidecar):** seed album + track, drop a known-bytes `cover.jpg` in the track's dir. GET cover → 200, `Content-Type: image/jpeg`, body equals the file bytes.
- **Happy path (explicit `cover_art_path`):** seed album with `cover_art_path` pointing at a separate image file (no sidecar). GET → 200 with the right file's bytes.
- **Explicit path missing on disk falls through to sidecar:** set `cover_art_path` to a non-existent path, drop a sidecar. GET → 200 with the sidecar bytes.
- **No art anywhere:** seed album + track, no sidecar, no `cover_art_path`. GET → 404 `not_found` "cover not found".
- **Bad UUID:** `GET /api/albums/not-a-uuid/cover` → 400 `bad_request` "invalid album id".
- **Unknown album:** `GET /api/albums/<random UUID>/cover` → 404 `not_found` "album not found".
- **Content-Type per extension:** table-driven test invoking `imageContentType` on `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, and a bogus extension.
### Stream tests
- **Happy path:** seed track with a known-bytes payload (32 KB of random-but-fixed bytes, `.mp3` extension, `file_format="mp3"`). GET → 200, `Content-Type: audio/mpeg`, `Accept-Ranges: bytes`, body equals the file bytes, `Content-Length` matches.
- **Range request:** `Range: bytes=0-99` → 206 Partial Content, body is exactly the first 100 bytes, `Content-Range: bytes 0-99/32768`.
- **Range at end:** `Range: bytes=32000-` → 206, body is the tail, `Content-Range: bytes 32000-32767/32768`.
- **If-Modified-Since pass-through:** second GET with `If-Modified-Since` set to the file's mod time → 304.
- **Bad UUID:** `GET /api/tracks/not-a-uuid/stream` → 400 `bad_request` "invalid track id".
- **Unknown track:** random UUID → 404 `not_found` "track not found".
- **Vanished file:** seed track pointing at `filepath.Join(tempDir, "does-not-exist.mp3")` → 404 `not_found` "track file not found".
- **Content-Type per format:** table-driven test invoking `audioContentType` on all seven known formats plus `"unknown"`.
### Route registration regression
Extend `TestRoutesRegisteredInMount` (from Plan 2's Task 11) to also assert the two new paths return 401 (not 404) unauthenticated:
```
"/api/albums/00000000-0000-0000-0000-000000000001/cover",
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
```
## File structure
| File | Change | Purpose |
|---|---|---|
| `internal/api/media.go` | Create | Both handlers + locally-duplicated helpers |
| `internal/api/media_test.go` | Create | Integration tests for both endpoints, unit tests for mime helpers |
| `internal/api/library_fixtures_test.go` | Modify | Add `seedTrackWithFile` helper |
| `internal/api/api.go` | Modify | Register both new routes inside the `authed` group in `Mount` |
| `internal/api/library_test.go` | Modify | Extend `TestRoutesRegisteredInMount` to include the two new paths; extend `newLibraryRouter` so existing tests continue to compile |
No migrations. No new sqlc queries (existing `GetAlbumByID`, `GetTrackByID`, `ListTracksByAlbum` are sufficient). No package-level dependencies beyond the stdlib and what `/api` already imports.
## Success criteria
- Both endpoints reachable through the production `Mount` under `RequireUser`.
- Browser `<img src="/api/albums/{id}/cover">` renders the art (or triggers `onerror` cleanly on 404).
- Browser `<audio src="/api/tracks/{id}/stream">` plays and can seek (verifies Range).
- Integration test suite green, no flakes.
- Manual smoke: log in, curl each endpoint, verify returns + Range behavior.
- PR merges to `main` via the existing `dev``main` flow.