diff --git a/docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.md b/docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.md
new file mode 100644
index 00000000..89a2a8db
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.md
@@ -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 ` ` and `` 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 `. Browser ` ` and `` 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//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 ` ` renders the art (or triggers `onerror` cleanly on 404).
+- Browser `` 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.