Self-review fixups: make "first track" explicit (by disc/track order) and replace the awkward "so existing tests continue to compile" phrasing with a clearer description of why newLibraryRouter needs extending. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 KiB
Web UI Media Endpoints — Design Spec
Date: 2026-04-21
Status: Design approved
Follows: Web UI Library Reads — 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
/downloadattachment-mode variant./streamis 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 imageGET /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
- Parse
{id}viaparseUUID(the helper frominternal/api/convert.go). Malformed UUID → 400bad_request"invalid album id". q.GetAlbumByID(ctx, id).pgx.ErrNoRows→ 404not_found"album not found". Other err → 500server_error.resolveAlbumCoverPath(ctx, q, album):- If
album.CoverArtPath != niland the file stats successfully, return that path. - Otherwise, list tracks for the album (
q.ListTracksByAlbum, which orders by disc/track number). For the first track in that ordered result, look in its directory forcover.jpg,cover.jpeg,cover.png,folder.jpg,folder.jpeg,folder.pngin that order. Return the first file that exists; else return"".
- If
- Empty path return → 404
not_found"cover not found". os.Open+f.Stat. File vanished since resolve → 404not_found"cover not found". Stat err → 500.- Set
Content-TypefromimageContentType(path)(file-extension-based mapping). Callhttp.ServeContent(w, r, filepath.Base(path), info.ModTime(), f).
GET /api/tracks/{id}/stream
- Parse
{id}. Malformed UUID → 400bad_request"invalid track id". q.GetTrackByID(ctx, id).pgx.ErrNoRows→ 404not_found"track not found". Other err → 500.os.Open(track.FilePath). File vanished → 404not_found"track file not found". Other err → 500.f.Stat. Err → 500.- Set
Content-TypefromaudioContentType(track.FileFormat). SetAccept-Ranges: bytes. Callhttp.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)— writesfileBodyintot.TempDir()with the given extension, inserts a Track row whosefile_pathpoints at it, returns the track and the dir.
Cover tests
- Happy path (sidecar): seed album + track, drop a known-bytes
cover.jpgin the track's dir. GET cover → 200,Content-Type: image/jpeg, body equals the file bytes. - Happy path (explicit
cover_art_path): seed album withcover_art_pathpointing 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_pathto 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 → 404not_found"cover not found". - Bad UUID:
GET /api/albums/not-a-uuid/cover→ 400bad_request"invalid album id". - Unknown album:
GET /api/albums/<random UUID>/cover→ 404not_found"album not found". - Content-Type per extension: table-driven test invoking
imageContentTypeon.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,
.mp3extension,file_format="mp3"). GET → 200,Content-Type: audio/mpeg,Accept-Ranges: bytes, body equals the file bytes,Content-Lengthmatches. - 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-Sinceset to the file's mod time → 304. - Bad UUID:
GET /api/tracks/not-a-uuid/stream→ 400bad_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")→ 404not_found"track file not found". - Content-Type per format: table-driven test invoking
audioContentTypeon 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 the newLibraryRouter helper to register the media routes so integration tests can exercise them through the same test harness |
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
MountunderRequireUser. - Browser
<img src="/api/albums/{id}/cover">renders the art (or triggersonerrorcleanly 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
mainvia the existingdev→mainflow.