Files
minstrel/docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.md
T
bvandeusen 20c948a346 docs: clarify sidecar ordering and test helper extension in Plan 3 spec
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>
2026-04-21 21:52:07 -04:00

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 /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, which orders by disc/track number). For the first track in that ordered result, look in its directory for cover.jpg, cover.jpeg, cover.png, folder.jpg, folder.jpeg, folder.png in that order. Return the first file 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 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 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 devmain flow.