Ships Plans 2 and 3 of the web UI /api/* rollout. Plan 2 delivers the paginated library reads and search; Plan 3 delivers the cover + stream byte-serving endpoints that Plan 2's DTOs already reference.
GET /api/albums/{id}/cover — cover art bytes; explicit cover_art_path takes precedence, else sidecar lookup (cover.{jpg,jpeg,png} then folder.{jpg,jpeg,png}) next to the first track's file. Content-Type from extension.
GET /api/tracks/{id}/stream — raw audio bytes with Range / If-Modified-Since / ETag via http.ServeContent. Content-Type from track.file_format, Accept-Ranges: bytes.
Both under RequireUser. Vanished-file races are 404, not 500. MIME tables duplicated locally rather than shared with internal/subsonic — deliberate per project direction (subsonic stays frozen). audioContentType intentionally diverges from subsonic (opus→audio/ogg, aac→audio/aac), documented inline.
Ships Plans 2 and 3 of the web UI `/api/*` rollout. Plan 2 delivers the paginated library reads and search; Plan 3 delivers the cover + stream byte-serving endpoints that Plan 2's DTOs already reference.
## Plan 3 — media endpoints
Spec: `docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.md`
Plan: `docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.md`
- `GET /api/albums/{id}/cover` — cover art bytes; explicit `cover_art_path` takes precedence, else sidecar lookup (`cover.{jpg,jpeg,png}` then `folder.{jpg,jpeg,png}`) next to the first track's file. `Content-Type` from extension.
- `GET /api/tracks/{id}/stream` — raw audio bytes with Range / `If-Modified-Since` / ETag via `http.ServeContent`. `Content-Type` from `track.file_format`, `Accept-Ranges: bytes`.
Both under `RequireUser`. Vanished-file races are 404, not 500. MIME tables duplicated locally rather than shared with `internal/subsonic` — deliberate per project direction (subsonic stays frozen). `audioContentType` intentionally diverges from subsonic (opus→`audio/ogg`, aac→`audio/aac`), documented inline.
## Plan 2 — library reads + search
Spec: `docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md`
Plan: `docs/superpowers/plans/2026-04-20-web-ui-library-reads.md`
- `GET /api/artists` — paginated list, `sort=alpha|newest`, default `limit=50`, clamped to `[1,200]`
- `GET /api/artists/{id}` — artist detail with nested album refs
- `GET /api/albums/{id}` — album detail with nested track refs (aggregates `duration_sec`)
- `GET /api/tracks/{id}` — track detail with parent album + artist metadata
- `GET /api/search?q=…` — three-facet paginated search; each facet carries its own `total`
### Plan 2 highlights
- Enveloped pagination: `{items, total, limit, offset}` uniform across list and every search facet
- Ref/Detail DTO split mirroring `internal/subsonic` (packages stay independent — duplication is intentional)
- Empty slices always render as `[]`, never `null` (`make([]T, 0, len(x))` and asserted in tests)
- Out-of-range `limit`/`offset` silently clamp; only non-numeric values return 400
- `AlbumRef.duration_sec` is `0` in nested browse contexts, populated on direct album GET (per spec data flow)
## Test plan
- [x] `internal/api/...` test suite green (includes 20+ new tests for Plan 3 handlers + helpers)
- [x] `TestRoutesRegisteredInMount` verifies all 7 `/api/*` library/search/media routes wired under `RequireUser`
- [x] Build + vet clean across all packages
- [x] Two-stage review (spec compliance + code quality) per task, plus final end-to-end review
- [ ] Manual browser smoke after merge: `<img src="/api/albums/{id}/cover">` renders, `<audio src="/api/tracks/{id}/stream">` plays and seeks
- [ ] Manual curl: `Range: bytes=0-99` → 206 with `Content-Range`, repeat with `If-Modified-Since` → 304
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ships Plans 2 and 3 of the web UI
/api/*rollout. Plan 2 delivers the paginated library reads and search; Plan 3 delivers the cover + stream byte-serving endpoints that Plan 2's DTOs already reference.Plan 3 — media endpoints
Spec:
docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.mdPlan:
docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.mdGET /api/albums/{id}/cover— cover art bytes; explicitcover_art_pathtakes precedence, else sidecar lookup (cover.{jpg,jpeg,png}thenfolder.{jpg,jpeg,png}) next to the first track's file.Content-Typefrom extension.GET /api/tracks/{id}/stream— raw audio bytes with Range /If-Modified-Since/ ETag viahttp.ServeContent.Content-Typefromtrack.file_format,Accept-Ranges: bytes.Both under
RequireUser. Vanished-file races are 404, not 500. MIME tables duplicated locally rather than shared withinternal/subsonic— deliberate per project direction (subsonic stays frozen).audioContentTypeintentionally diverges from subsonic (opus→audio/ogg, aac→audio/aac), documented inline.Plan 2 — library reads + search
Spec:
docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.mdPlan:
docs/superpowers/plans/2026-04-20-web-ui-library-reads.mdGET /api/artists— paginated list,sort=alpha|newest, defaultlimit=50, clamped to[1,200]GET /api/artists/{id}— artist detail with nested album refsGET /api/albums/{id}— album detail with nested track refs (aggregatesduration_sec)GET /api/tracks/{id}— track detail with parent album + artist metadataGET /api/search?q=…— three-facet paginated search; each facet carries its owntotalPlan 2 highlights
{items, total, limit, offset}uniform across list and every search facetinternal/subsonic(packages stay independent — duplication is intentional)[], nevernull(make([]T, 0, len(x))and asserted in tests)limit/offsetsilently clamp; only non-numeric values return 400AlbumRef.duration_secis0in nested browse contexts, populated on direct album GET (per spec data flow)Test plan
internal/api/...test suite green (includes 20+ new tests for Plan 3 handlers + helpers)TestRoutesRegisteredInMountverifies all 7/api/*library/search/media routes wired underRequireUser<img src="/api/albums/{id}/cover">renders,<audio src="/api/tracks/{id}/stream">plays and seeksRange: bytes=0-99→ 206 withContent-Range, repeat withIf-Modified-Since→ 304🤖 Generated with Claude Code