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:
@@ -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.
|
||||
Reference in New Issue
Block a user