diff --git a/docs/superpowers/plans/2026-04-20-web-ui-library-reads.md b/docs/superpowers/plans/2026-04-20-web-ui-library-reads.md
new file mode 100644
index 00000000..2cee0f8d
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-web-ui-library-reads.md
@@ -0,0 +1,2124 @@
+# Web UI Library Reads Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add five read-only JSON endpoints to `/api/*` — paged artist list (alpha + newest), artist/album/track detail, and unified search — so the SvelteKit SPA can render browse and search views.
+
+**Architecture:** Handlers live in `internal/api` alongside existing auth code, gated by `RequireUser`. New SQL queries added to `internal/db/queries/` and regenerated via sqlc. Response DTOs follow a Ref/Detail split; lists use a generic `Page[T]` envelope; forward-reference stream/cover URLs are embedded now so Plan 3 can land transparently. Tiny conversion helpers are duplicated inline rather than shared with `internal/subsonic` — the two packages shape responses differently and the helpers are ~5 lines each.
+
+**Tech Stack:** Go 1.23, chi/v5, pgx/v5, sqlc 1.27, pgxpool, pgtype.
+
+**Spec:** `docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md`
+
+---
+
+## File Structure
+
+New files:
+
+| File | Responsibility |
+|---|---|
+| `internal/api/library.go` | Artist/album/track list + detail handlers |
+| `internal/api/search.go` | Unified search handler |
+| `internal/api/convert.go` | UUID↔string, year-from-pgdate, URL builders, dbq→ref projections |
+| `internal/api/library_fixtures_test.go` | Shared seed helpers for integration tests |
+| `internal/api/library_test.go` | Integration tests for library endpoints |
+| `internal/api/search_test.go` | Integration tests for search |
+
+Modified files:
+
+| File | Change |
+|---|---|
+| `internal/api/api.go` | Register new routes inside the `authed` group |
+| `internal/api/types.go` | Add `Page[T]`, `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `SearchResponse` |
+| `internal/db/queries/artists.sql` | Add `ListArtistsAlpha`, `ListArtistsNewest`, `CountArtists`, `CountArtistsMatching` |
+| `internal/db/queries/albums.sql` | Add `CountAlbumsMatching` |
+| `internal/db/queries/tracks.sql` | Add `CountTracksMatching` |
+| `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go` | sqlc-regenerated; do not hand-edit |
+
+---
+
+## Working Conventions
+
+- **Commit after each green test step.** One task = one commit minimum; split further if a task produces multiple logical units.
+- **Integration tests require a live Postgres** at `MINSTREL_TEST_DATABASE_URL`. The existing `docker-compose.yml` spins one up; tests skip cleanly when the env var is unset.
+- **sqlc regeneration:** run `make generate` after changing any `*.sql` file. Never hand-edit `internal/db/dbq/*.go`.
+- **Run the full test suite before committing any task:** `MINSTREL_TEST_DATABASE_URL=postgres://... go test ./...`. Use `-short` only when explicitly noted.
+- **`pgtype` imports:** add `"github.com/jackc/pgx/v5/pgtype"` only where needed; Go won't compile with unused imports.
+
+---
+
+## Task 1: Add SQL queries
+
+**Files:**
+- Modify: `internal/db/queries/artists.sql`
+- Modify: `internal/db/queries/albums.sql`
+- Modify: `internal/db/queries/tracks.sql`
+- Regenerate: `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go`
+
+- [ ] **Step 1: Append to `internal/db/queries/artists.sql`**
+
+Append these four queries (keep existing `ListArtists` untouched — subsonic's `buildArtistIndexes` still uses it):
+
+```sql
+
+-- name: ListArtistsAlpha :many
+SELECT * FROM artists ORDER BY sort_name, name, id LIMIT $1 OFFSET $2;
+
+-- name: ListArtistsNewest :many
+-- Secondary id tiebreaker keeps pagination stable when created_at ties.
+SELECT * FROM artists ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2;
+
+-- name: CountArtists :one
+SELECT COUNT(*) FROM artists;
+
+-- name: CountArtistsMatching :one
+SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%';
+```
+
+- [ ] **Step 2: Append to `internal/db/queries/albums.sql`**
+
+```sql
+
+-- name: CountAlbumsMatching :one
+SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
+```
+
+- [ ] **Step 3: Append to `internal/db/queries/tracks.sql`**
+
+```sql
+
+-- name: CountTracksMatching :one
+SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%';
+```
+
+- [ ] **Step 4: Regenerate sqlc code**
+
+Run: `make generate`
+Expected: no errors; `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go` are updated with the new functions `ListArtistsAlpha`, `ListArtistsNewest`, `CountArtists`, `CountArtistsMatching`, `CountAlbumsMatching`, `CountTracksMatching`.
+
+- [ ] **Step 5: Verify the package builds**
+
+Run: `go build ./internal/db/...`
+Expected: exit 0, no output.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/db/queries/*.sql internal/db/dbq/*.sql.go
+git commit -m "feat(db): add paged + count queries for library reads"
+```
+
+---
+
+## Task 2: Add DTO types
+
+**Files:**
+- Modify: `internal/api/types.go`
+
+- [ ] **Step 1: Extend `internal/api/types.go`**
+
+Append to the existing `internal/api/types.go` (keep `LoginRequest`, `LoginResponse`, `UserView`):
+
+```go
+
+// Page is the generic pagination envelope used by list and search endpoints.
+// Items is always non-nil at JSON encode time — handlers must initialize
+// empty slices explicitly so absent results render as [] rather than null.
+type Page[T any] struct {
+ Items []T `json:"items"`
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+// ArtistRef is the lightweight artist shape used in lists and search results.
+type ArtistRef struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ AlbumCount int `json:"album_count"`
+}
+
+// AlbumRef is the lightweight album shape used in lists, artist details,
+// and search results. CoverURL points to /api/albums/{id}/cover which
+// Plan 3 implements.
+type AlbumRef struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ ArtistID string `json:"artist_id"`
+ ArtistName string `json:"artist_name"`
+ Year int `json:"year,omitempty"`
+ TrackCount int `json:"track_count"`
+ DurationSec int `json:"duration_sec"`
+ CoverURL string `json:"cover_url"`
+}
+
+// TrackRef is the lightweight track shape used in album details and search.
+// StreamURL points to /api/tracks/{id}/stream which Plan 3 implements.
+type TrackRef struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ AlbumID string `json:"album_id"`
+ AlbumTitle string `json:"album_title"`
+ ArtistID string `json:"artist_id"`
+ ArtistName string `json:"artist_name"`
+ TrackNumber int `json:"track_number,omitempty"`
+ DiscNumber int `json:"disc_number,omitempty"`
+ DurationSec int `json:"duration_sec"`
+ StreamURL string `json:"stream_url"`
+}
+
+// ArtistDetail is the response body of GET /api/artists/{id}. Embeds the ref
+// so callers read id/name/album_count from the same path as list entries.
+type ArtistDetail struct {
+ ArtistRef
+ Albums []AlbumRef `json:"albums"`
+}
+
+// AlbumDetail is the response body of GET /api/albums/{id}.
+type AlbumDetail struct {
+ AlbumRef
+ Tracks []TrackRef `json:"tracks"`
+}
+
+// SearchResponse is the body of GET /api/search. Each facet carries its own
+// total + limit + offset; the client sends one shared limit/offset and the
+// server applies it uniformly to each facet's LIMIT/OFFSET query.
+type SearchResponse struct {
+ Artists Page[ArtistRef] `json:"artists"`
+ Albums Page[AlbumRef] `json:"albums"`
+ Tracks Page[TrackRef] `json:"tracks"`
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `go build ./internal/api/...`
+Expected: exit 0.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/api/types.go
+git commit -m "feat(api): add DTOs for library reads and search"
+```
+
+---
+
+## Task 3: Add conversion helpers
+
+**Files:**
+- Create: `internal/api/convert.go`
+- Create: `internal/api/convert_test.go`
+
+- [ ] **Step 1: Write failing test**
+
+Create `internal/api/convert_test.go`:
+
+```go
+package api
+
+import (
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func TestUUIDToString(t *testing.T) {
+ var u pgtype.UUID
+ if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ got := uuidToString(u)
+ want := "00112233-4455-6677-8899-aabbccddeeff"
+ if got != want {
+ t.Errorf("uuidToString = %q, want %q", got, want)
+ }
+
+ var zero pgtype.UUID
+ if s := uuidToString(zero); s != "" {
+ t.Errorf("uuidToString(invalid) = %q, want empty", s)
+ }
+}
+
+func TestParseUUID(t *testing.T) {
+ u, ok := parseUUID("00112233-4455-6677-8899-aabbccddeeff")
+ if !ok || !u.Valid {
+ t.Fatalf("parseUUID valid: ok=%v valid=%v", ok, u.Valid)
+ }
+ if _, ok := parseUUID("not-a-uuid"); ok {
+ t.Error("parseUUID accepted garbage")
+ }
+ if _, ok := parseUUID(""); ok {
+ t.Error("parseUUID accepted empty string")
+ }
+}
+
+func TestYearFromDate(t *testing.T) {
+ var d pgtype.Date
+ if y := yearFromDate(d); y != 0 {
+ t.Errorf("yearFromDate(invalid) = %d, want 0", y)
+ }
+}
+
+func TestDurationMsToSec(t *testing.T) {
+ cases := []struct {
+ ms int32
+ want int
+ }{
+ {0, 0},
+ {500, 1}, // rounds up
+ {499, 0}, // rounds down
+ {1500, 2}, // rounds up
+ {60000, 60},
+ }
+ for _, c := range cases {
+ if got := durationMsToSec(c.ms); got != c.want {
+ t.Errorf("durationMsToSec(%d) = %d, want %d", c.ms, got, c.want)
+ }
+ }
+}
+
+func TestCoverURLAndStreamURL(t *testing.T) {
+ var u pgtype.UUID
+ if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if got := coverURL(u); got != "/api/albums/00112233-4455-6677-8899-aabbccddeeff/cover" {
+ t.Errorf("coverURL = %q", got)
+ }
+ if got := streamURL(u); got != "/api/tracks/00112233-4455-6677-8899-aabbccddeeff/stream" {
+ t.Errorf("streamURL = %q", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL**
+
+Run: `go test ./internal/api/ -run 'TestUUIDToString|TestParseUUID|TestYearFromDate|TestDurationMsToSec|TestCoverURLAndStreamURL' -count=1`
+Expected: compile error — `undefined: uuidToString` etc.
+
+- [ ] **Step 3: Create `internal/api/convert.go`**
+
+```go
+package api
+
+import (
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+// uuidToString renders a pgtype.UUID as a canonical hyphenated string.
+// Returns "" when the UUID is invalid (unset). Duplicated from the subsonic
+// package intentionally — the two packages must not depend on each other.
+func uuidToString(u pgtype.UUID) string {
+ if !u.Valid {
+ return ""
+ }
+ b := u.Bytes
+ const hex = "0123456789abcdef"
+ out := make([]byte, 36)
+ j := 0
+ for i, x := range b {
+ if i == 4 || i == 6 || i == 8 || i == 10 {
+ out[j] = '-'
+ j++
+ }
+ out[j] = hex[x>>4]
+ out[j+1] = hex[x&0x0f]
+ j += 2
+ }
+ return string(out)
+}
+
+// parseUUID accepts the canonical hyphenated form produced by uuidToString.
+// Returns ok=false on any malformed input so handlers can 400 cleanly.
+func parseUUID(s string) (pgtype.UUID, bool) {
+ var u pgtype.UUID
+ if err := u.Scan(s); err != nil {
+ return pgtype.UUID{}, false
+ }
+ return u, u.Valid
+}
+
+// yearFromDate returns the 4-digit year from a pgtype.Date, or 0 when the
+// date is unset. Album release years are optional at scan time.
+func yearFromDate(d pgtype.Date) int {
+ if !d.Valid {
+ return 0
+ }
+ return d.Time.Year()
+}
+
+// durationMsToSec converts millisecond durations (as stored in tracks.duration_ms)
+// to seconds, rounding to nearest. Callers display seconds; sub-second precision
+// is not useful in UI.
+func durationMsToSec(ms int32) int {
+ return int((ms + 500) / 1000)
+}
+
+// coverURL returns the /api relative URL for an album's cover art. The
+// endpoint ships in Plan 3; until then it 404s, and the SPA does not wire
+//
to it.
+func coverURL(albumID pgtype.UUID) string {
+ return "/api/albums/" + uuidToString(albumID) + "/cover"
+}
+
+// streamURL returns the /api relative URL for a track's audio stream. The
+// endpoint ships in Plan 3.
+func streamURL(trackID pgtype.UUID) string {
+ return "/api/tracks/" + uuidToString(trackID) + "/stream"
+}
+```
+
+- [ ] **Step 4: Run — expect PASS**
+
+Run: `go test ./internal/api/ -run 'TestUUIDToString|TestParseUUID|TestYearFromDate|TestDurationMsToSec|TestCoverURLAndStreamURL' -count=1 -v`
+Expected: all five tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/api/convert.go internal/api/convert_test.go
+git commit -m "feat(api): add conversion helpers for library DTOs"
+```
+
+---
+
+## Task 4: Add ref/detail projection helpers
+
+These project dbq rows into the response DTOs. Keeping them next to the DTO types and conversion helpers means handlers stay thin.
+
+**Files:**
+- Modify: `internal/api/convert.go` (append)
+- Modify: `internal/api/convert_test.go` (append)
+
+- [ ] **Step 1: Append failing tests to `internal/api/convert_test.go`**
+
+```go
+
+func TestArtistRefFrom(t *testing.T) {
+ var id pgtype.UUID
+ _ = id.Scan("00112233-4455-6677-8899-aabbccddeeff")
+ a := dbq.Artist{ID: id, Name: "Beatles", SortName: "Beatles"}
+ got := artistRefFrom(a, 7)
+ if got.ID != "00112233-4455-6677-8899-aabbccddeeff" {
+ t.Errorf("ID = %q", got.ID)
+ }
+ if got.Name != "Beatles" || got.AlbumCount != 7 {
+ t.Errorf("ref = %+v", got)
+ }
+}
+
+func TestAlbumRefFrom(t *testing.T) {
+ var aid, artID pgtype.UUID
+ _ = aid.Scan("00000000-0000-0000-0000-000000000001")
+ _ = artID.Scan("00000000-0000-0000-0000-000000000002")
+ var rel pgtype.Date
+ _ = rel.Scan("1969-09-26")
+ a := dbq.Album{ID: aid, Title: "Abbey Road", ArtistID: artID, ReleaseDate: rel}
+ got := albumRefFrom(a, "Beatles", 17, 2820)
+ if got.Title != "Abbey Road" || got.ArtistName != "Beatles" {
+ t.Errorf("ref = %+v", got)
+ }
+ if got.TrackCount != 17 || got.DurationSec != 2820 {
+ t.Errorf("counts = %+v", got)
+ }
+ if got.Year != 1969 {
+ t.Errorf("year = %d", got.Year)
+ }
+ if got.CoverURL != "/api/albums/00000000-0000-0000-0000-000000000001/cover" {
+ t.Errorf("cover_url = %q", got.CoverURL)
+ }
+}
+
+func TestTrackRefFrom(t *testing.T) {
+ var tid, aid, artID pgtype.UUID
+ _ = tid.Scan("00000000-0000-0000-0000-000000000010")
+ _ = aid.Scan("00000000-0000-0000-0000-000000000001")
+ _ = artID.Scan("00000000-0000-0000-0000-000000000002")
+ trackNum := int32(3)
+ discNum := int32(1)
+ t2 := dbq.Track{
+ ID: tid, Title: "Something", AlbumID: aid, ArtistID: artID,
+ TrackNumber: &trackNum, DiscNumber: &discNum,
+ DurationMs: 183_000,
+ }
+ got := trackRefFrom(t2, "Abbey Road", "Beatles")
+ if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" {
+ t.Errorf("ref = %+v", got)
+ }
+ if got.TrackNumber != 3 || got.DiscNumber != 1 {
+ t.Errorf("positions = %+v", got)
+ }
+ if got.DurationSec != 183 {
+ t.Errorf("duration = %d, want 183", got.DurationSec)
+ }
+ if got.StreamURL != "/api/tracks/00000000-0000-0000-0000-000000000010/stream" {
+ t.Errorf("stream_url = %q", got.StreamURL)
+ }
+}
+
+func TestTrackRefFromNilPositions(t *testing.T) {
+ var tid, aid, artID pgtype.UUID
+ _ = tid.Scan("00000000-0000-0000-0000-000000000010")
+ _ = aid.Scan("00000000-0000-0000-0000-000000000001")
+ _ = artID.Scan("00000000-0000-0000-0000-000000000002")
+ t2 := dbq.Track{ID: tid, AlbumID: aid, ArtistID: artID, DurationMs: 1000}
+ got := trackRefFrom(t2, "A", "B")
+ if got.TrackNumber != 0 || got.DiscNumber != 0 {
+ t.Errorf("nil positions should be 0, got %+v", got)
+ }
+}
+```
+
+Add these imports to `internal/api/convert_test.go` (or merge into existing import block):
+
+```go
+import (
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+```
+
+- [ ] **Step 2: Run — expect FAIL**
+
+Run: `go test ./internal/api/ -run 'TestArtistRefFrom|TestAlbumRefFrom|TestTrackRefFrom' -count=1`
+Expected: compile error — `undefined: artistRefFrom` etc.
+
+- [ ] **Step 3: Append to `internal/api/convert.go`**
+
+Extend the existing import block to include `dbq`:
+
+```go
+import (
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+```
+
+Append to the file:
+
+```go
+
+// artistRefFrom projects a dbq.Artist into an ArtistRef. albumCount must be
+// pre-computed by the caller (one query per artist in lists).
+func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef {
+ return ArtistRef{
+ ID: uuidToString(a.ID),
+ Name: a.Name,
+ AlbumCount: albumCount,
+ }
+}
+
+// albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be
+// pre-resolved because Album only carries artist_id. trackCount and
+// durationSec may be 0 when the caller does not compute them.
+func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) AlbumRef {
+ return AlbumRef{
+ ID: uuidToString(a.ID),
+ Title: a.Title,
+ ArtistID: uuidToString(a.ArtistID),
+ ArtistName: artistName,
+ Year: yearFromDate(a.ReleaseDate),
+ TrackCount: trackCount,
+ DurationSec: durationSec,
+ CoverURL: coverURL(a.ID),
+ }
+}
+
+// trackRefFrom projects a dbq.Track into a TrackRef. Parent names must be
+// pre-resolved because Track only carries album_id and artist_id.
+func trackRefFrom(t dbq.Track, albumTitle, artistName string) TrackRef {
+ ref := TrackRef{
+ ID: uuidToString(t.ID),
+ Title: t.Title,
+ AlbumID: uuidToString(t.AlbumID),
+ AlbumTitle: albumTitle,
+ ArtistID: uuidToString(t.ArtistID),
+ ArtistName: artistName,
+ DurationSec: durationMsToSec(t.DurationMs),
+ StreamURL: streamURL(t.ID),
+ }
+ if t.TrackNumber != nil {
+ ref.TrackNumber = int(*t.TrackNumber)
+ }
+ if t.DiscNumber != nil {
+ ref.DiscNumber = int(*t.DiscNumber)
+ }
+ return ref
+}
+```
+
+- [ ] **Step 4: Run — expect PASS**
+
+Run: `go test ./internal/api/ -run 'TestArtistRefFrom|TestAlbumRefFrom|TestTrackRefFrom' -count=1 -v`
+Expected: four tests PASS.
+
+- [ ] **Step 5: Full package builds clean**
+
+Run: `go build ./internal/api/...`
+Expected: exit 0, no output.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/api/convert.go internal/api/convert_test.go
+git commit -m "feat(api): add dbq→ref projection helpers"
+```
+
+---
+
+## Task 5: Test fixtures helper
+
+Shared seed helpers that library_test.go and search_test.go both use. Putting them in `library_fixtures_test.go` means they compile only in the test binary and don't pollute the production package.
+
+**Files:**
+- Create: `internal/api/library_fixtures_test.go`
+
+- [ ] **Step 1: Create `internal/api/library_fixtures_test.go`**
+
+```go
+package api
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// truncateLibrary clears the music tables between tests. Sessions/users are
+// cleared by testHandlers. CASCADE covers tracks→albums→artists FK chain.
+func truncateLibrary(t *testing.T, pool *pgxpool.Pool) {
+ t.Helper()
+ if _, err := pool.Exec(context.Background(),
+ "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+}
+
+// seedArtist inserts an artist with deterministic sort_name = name.
+func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist {
+ t.Helper()
+ a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
+ Name: name,
+ SortName: name,
+ Mbid: nil,
+ })
+ if err != nil {
+ t.Fatalf("UpsertArtist(%s): %v", name, err)
+ }
+ return a
+}
+
+// seedAlbum inserts an album belonging to artistID. releaseYear=0 leaves
+// release_date unset.
+func seedAlbum(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string, releaseYear int) dbq.Album {
+ t.Helper()
+ var rel pgtype.Date
+ if releaseYear > 0 {
+ rel = pgtype.Date{Time: time.Date(releaseYear, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
+ }
+ a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
+ Title: title,
+ SortTitle: title,
+ ArtistID: artistID,
+ ReleaseDate: rel,
+ Mbid: nil,
+ CoverArtPath: nil,
+ })
+ if err != nil {
+ t.Fatalf("UpsertAlbum(%s): %v", title, err)
+ }
+ return a
+}
+
+// seedTrack inserts a track. trackNumber<=0 leaves it NULL. filePath is
+// synthesized from title to stay unique across seeds.
+func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32) dbq.Track {
+ t.Helper()
+ var tn *int32
+ if trackNumber > 0 {
+ v := int32(trackNumber)
+ tn = &v
+ }
+ tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
+ Title: title,
+ AlbumID: albumID,
+ ArtistID: artistID,
+ TrackNumber: tn,
+ DiscNumber: nil,
+ DurationMs: durationMs,
+ FilePath: "/seed/" + title + ".flac",
+ FileSize: 1024,
+ FileFormat: "flac",
+ Bitrate: nil,
+ Mbid: nil,
+ Genre: nil,
+ })
+ if err != nil {
+ t.Fatalf("UpsertTrack(%s): %v", title, err)
+ }
+ return tr
+}
+```
+
+- [ ] **Step 2: Verify test binary compiles**
+
+Run: `go test -run '^$' ./internal/api/... -count=1`
+Expected: no output and exit 0 (no tests match `^$`, but the package must compile).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/api/library_fixtures_test.go
+git commit -m "test(api): add library seed fixtures"
+```
+
+---
+
+## Task 6: Implement GET /api/tracks/{id}
+
+Starting with the simplest detail endpoint so the route-wiring pattern is in place before the more complex ones. TDD: write tests, verify fail, implement.
+
+**Files:**
+- Create: `internal/api/library.go`
+- Create: `internal/api/library_test.go`
+- Modify: `internal/api/api.go`
+
+- [ ] **Step 1: Write failing test in `internal/api/library_test.go`**
+
+```go
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// newLibraryRouter builds a test-only chi router with the library handlers
+// mounted at their path-style routes. Tests hit this router rather than
+// calling handler methods directly so chi URL params populate correctly.
+// RequireUser is NOT applied — library handlers don't read user context.
+func newLibraryRouter(h *handlers) chi.Router {
+ r := chi.NewRouter()
+ r.Get("/api/artists", h.handleListArtists)
+ r.Get("/api/artists/{id}", h.handleGetArtist)
+ r.Get("/api/albums/{id}", h.handleGetAlbum)
+ r.Get("/api/tracks/{id}", h.handleGetTrack)
+ r.Get("/api/search", h.handleSearch)
+ return r
+}
+
+func TestHandleGetTrack_HappyPath(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "Beatles")
+ album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
+ track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID), nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ var got TrackRef
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" {
+ t.Errorf("ref = %+v", got)
+ }
+ if got.TrackNumber != 3 || got.DurationSec != 183 {
+ t.Errorf("positions = %+v", got)
+ }
+ want := "/api/tracks/" + uuidToString(track.ID) + "/stream"
+ if got.StreamURL != want {
+ t.Errorf("stream_url = %q, want %q", got.StreamURL, want)
+ }
+}
+
+func TestHandleGetTrack_BadUUID(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleGetTrack_NotFound(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ req := httptest.NewRequest(http.MethodGet,
+ "/api/tracks/00000000-0000-0000-0000-000000000001", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetTrack' -count=1`
+Expected: compile error — `h.handleGetTrack` undefined, plus sibling methods referenced in `newLibraryRouter`.
+
+- [ ] **Step 3: Create `internal/api/library.go` with all route method stubs + `handleGetTrack` implemented**
+
+Stubbing sibling methods (`handleListArtists`, `handleGetArtist`, `handleGetAlbum`, `handleSearch`) lets `newLibraryRouter` compile even though subsequent tasks flesh them out.
+
+```go
+package api
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// handleGetTrack implements GET /api/tracks/{id}. Resolves parent album +
+// artist names so the response is self-contained — clients should not need
+// a follow-up request to render a track outside an album view.
+func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
+ return
+ }
+ q := dbq.New(h.pool)
+ track, err := q.GetTrackByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "track not found")
+ return
+ }
+ h.logger.Error("api: get track failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
+ if err != nil {
+ h.logger.Error("api: get track album failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
+ if err != nil {
+ h.logger.Error("api: get track artist failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ writeJSON(w, http.StatusOK, trackRefFrom(track, album.Title, artist.Name))
+}
+
+// handleGetAlbum is implemented in Task 7.
+func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
+}
+
+// handleGetArtist is implemented in Task 8.
+func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
+}
+
+// handleListArtists is implemented in Task 9.
+func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
+}
+
+// handleSearch is implemented in Task 10 (file: search.go).
+func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
+}
+```
+
+- [ ] **Step 4: Add `writeJSON` helper to `internal/api/errors.go`**
+
+Open `internal/api/errors.go` and append:
+
+```go
+
+// writeJSON emits a 2xx response with the given body JSON-encoded. Mirrors
+// writeErr's shape so handlers have one shape for success and one for
+// failure.
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(body)
+}
+```
+
+- [ ] **Step 5: Run — expect PASS**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetTrack' -count=1 -v`
+Expected: three tests PASS (`_HappyPath`, `_BadUUID`, `_NotFound`).
+
+- [ ] **Step 6: Full test suite stays green**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
+Expected: all existing and new tests PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/api/library.go internal/api/library_test.go internal/api/errors.go
+git commit -m "feat(api): GET /api/tracks/{id} handler with parent metadata"
+```
+
+---
+
+## Task 7: Implement GET /api/albums/{id}
+
+**Files:**
+- Modify: `internal/api/library.go` (replace the `handleGetAlbum` stub)
+- Modify: `internal/api/library_test.go` (append tests)
+
+- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`**
+
+```go
+
+func TestHandleGetAlbum_HappyPath(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "Beatles")
+ album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
+ seedTrack(t, pool, album.ID, artist.ID, "Come Together", 1, 259_000)
+ seedTrack(t, pool, album.ID, artist.ID, "Something", 2, 183_000)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ var got AlbumDetail
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ if got.Title != "Abbey Road" || got.ArtistName != "Beatles" || got.Year != 1969 {
+ t.Errorf("album = %+v", got)
+ }
+ if len(got.Tracks) != 2 {
+ t.Fatalf("tracks len = %d, want 2", len(got.Tracks))
+ }
+ if got.Tracks[0].Title != "Come Together" || got.Tracks[1].Title != "Something" {
+ t.Errorf("tracks = %+v", got.Tracks)
+ }
+ if got.TrackCount != 2 || got.DurationSec != 442 { // 259 + 183
+ t.Errorf("counts = track=%d duration=%d", got.TrackCount, got.DurationSec)
+ }
+ want := "/api/albums/" + uuidToString(album.ID) + "/cover"
+ if got.CoverURL != want {
+ t.Errorf("cover_url = %q", got.CoverURL)
+ }
+ // StreamURL on nested tracks must be set
+ if got.Tracks[0].StreamURL == "" {
+ t.Error("nested track missing stream_url")
+ }
+}
+
+func TestHandleGetAlbum_NoTracks(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "Beatles")
+ album := seedAlbum(t, pool, artist.ID, "Empty", 0)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ var got AlbumDetail
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if got.Tracks == nil {
+ t.Error("tracks = nil, want [] for JSON encoding")
+ }
+ if len(got.Tracks) != 0 {
+ t.Errorf("tracks len = %d, want 0", len(got.Tracks))
+ }
+ // Year omitempty: releaseYear=0 => Year=0 => field should be absent from JSON
+ if got.Year != 0 {
+ t.Errorf("year = %d, want 0", got.Year)
+ }
+}
+
+func TestHandleGetAlbum_BadUUID(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleGetAlbum_NotFound(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ req := httptest.NewRequest(http.MethodGet,
+ "/api/albums/00000000-0000-0000-0000-000000000001", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL (stub returns 501)**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetAlbum' -count=1`
+Expected: all four fail because the stub returns 501.
+
+- [ ] **Step 3: Replace `handleGetAlbum` stub in `internal/api/library.go`**
+
+Find the stub:
+
+```go
+// handleGetAlbum is implemented in Task 7.
+func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
+}
+```
+
+Replace with:
+
+```go
+// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
+// tracks (ordered by disc/track number via the underlying query) with
+// duration summed from the track list — keeps one source of truth.
+func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
+ return
+ }
+ q := dbq.New(h.pool)
+ album, err := q.GetAlbumByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "album not found")
+ return
+ }
+ h.logger.Error("api: get album failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
+ if err != nil {
+ h.logger.Error("api: get album artist failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ tracks, err := q.ListTracksByAlbum(r.Context(), id)
+ if err != nil {
+ h.logger.Error("api: list tracks failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ refs := make([]TrackRef, 0, len(tracks))
+ durSec := 0
+ for _, t := range tracks {
+ ref := trackRefFrom(t, album.Title, artist.Name)
+ refs = append(refs, ref)
+ durSec += ref.DurationSec
+ }
+ detail := AlbumDetail{
+ AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec),
+ Tracks: refs,
+ }
+ writeJSON(w, http.StatusOK, detail)
+}
+```
+
+- [ ] **Step 4: Run — expect PASS**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetAlbum' -count=1 -v`
+Expected: four tests PASS.
+
+- [ ] **Step 5: Full api suite stays green**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
+Expected: all tests PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/api/library.go internal/api/library_test.go
+git commit -m "feat(api): GET /api/albums/{id} with nested tracks"
+```
+
+---
+
+## Task 8: Implement GET /api/artists/{id}
+
+**Files:**
+- Modify: `internal/api/library.go` (replace the `handleGetArtist` stub)
+- Modify: `internal/api/library_test.go` (append tests)
+
+- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`**
+
+```go
+
+func TestHandleGetArtist_HappyPath(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "Beatles")
+ album1 := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
+ album2 := seedAlbum(t, pool, artist.ID, "Let It Be", 1970)
+ seedTrack(t, pool, album1.ID, artist.ID, "Something", 3, 183_000)
+ seedTrack(t, pool, album2.ID, artist.ID, "Get Back", 1, 189_000)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ var got ArtistDetail
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ if got.Name != "Beatles" {
+ t.Errorf("name = %q", got.Name)
+ }
+ if got.AlbumCount != 2 {
+ t.Errorf("album_count = %d, want 2", got.AlbumCount)
+ }
+ if len(got.Albums) != 2 {
+ t.Fatalf("albums len = %d, want 2", len(got.Albums))
+ }
+ // ListAlbumsByArtist orders by release_date then sort_title; Abbey Road (1969) first.
+ if got.Albums[0].Title != "Abbey Road" {
+ t.Errorf("first album = %q, want Abbey Road", got.Albums[0].Title)
+ }
+ for _, a := range got.Albums {
+ if a.ArtistName != "Beatles" {
+ t.Errorf("album.artist_name = %q", a.ArtistName)
+ }
+ if a.CoverURL == "" {
+ t.Error("album missing cover_url")
+ }
+ if a.TrackCount != 1 {
+ t.Errorf("album.track_count = %d, want 1", a.TrackCount)
+ }
+ }
+}
+
+func TestHandleGetArtist_NoAlbums(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "Solo")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var got ArtistDetail
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if got.Albums == nil {
+ t.Error("albums = nil, want []")
+ }
+ if got.AlbumCount != 0 {
+ t.Errorf("album_count = %d, want 0", got.AlbumCount)
+ }
+}
+
+func TestHandleGetArtist_BadUUID(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/artists/not-a-uuid", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleGetArtist_NotFound(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ req := httptest.NewRequest(http.MethodGet,
+ "/api/artists/00000000-0000-0000-0000-000000000001", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL (stub 501)**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetArtist' -count=1`
+Expected: four fail against the stub.
+
+- [ ] **Step 3: Replace `handleGetArtist` stub in `internal/api/library.go`**
+
+Replace with:
+
+```go
+// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
+// each album carries its own track_count (one count query per album, same
+// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine.
+func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
+ return
+ }
+ q := dbq.New(h.pool)
+ artist, err := q.GetArtistByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "artist not found")
+ return
+ }
+ h.logger.Error("api: get artist failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ albums, err := q.ListAlbumsByArtist(r.Context(), id)
+ if err != nil {
+ h.logger.Error("api: list albums by artist failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ refs := make([]AlbumRef, 0, len(albums))
+ for _, a := range albums {
+ count, cerr := q.CountTracksByAlbum(r.Context(), a.ID)
+ if cerr != nil {
+ h.logger.Error("api: count tracks failed", "err", cerr)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0))
+ }
+ detail := ArtistDetail{
+ ArtistRef: artistRefFrom(artist, len(albums)),
+ Albums: refs,
+ }
+ writeJSON(w, http.StatusOK, detail)
+}
+```
+
+- [ ] **Step 4: Run — expect PASS**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetArtist' -count=1 -v`
+Expected: four tests PASS.
+
+- [ ] **Step 5: Full api suite stays green**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/api/library.go internal/api/library_test.go
+git commit -m "feat(api): GET /api/artists/{id} with nested albums"
+```
+
+---
+
+## Task 9: Implement GET /api/artists (paged list with sort)
+
+**Files:**
+- Modify: `internal/api/library.go` (replace the `handleListArtists` stub; add pagination helper)
+- Modify: `internal/api/library_test.go` (append tests)
+
+- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`**
+
+```go
+
+func TestHandleListArtists_DefaultAlpha(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ seedArtist(t, pool, "Beatles")
+ seedArtist(t, pool, "ABBA")
+ seedArtist(t, pool, "Zeppelin")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ var got Page[ArtistRef]
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Total != 3 {
+ t.Errorf("total = %d, want 3", got.Total)
+ }
+ if got.Limit != 50 || got.Offset != 0 {
+ t.Errorf("pagination defaults = limit=%d offset=%d, want 50/0", got.Limit, got.Offset)
+ }
+ if len(got.Items) != 3 {
+ t.Fatalf("items len = %d", len(got.Items))
+ }
+ if got.Items[0].Name != "ABBA" || got.Items[2].Name != "Zeppelin" {
+ t.Errorf("alpha order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name)
+ }
+}
+
+func TestHandleListArtists_SortNewest(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ // Seeded in this order; created_at DESC means Third first.
+ seedArtist(t, pool, "First")
+ seedArtist(t, pool, "Second")
+ seedArtist(t, pool, "Third")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=newest", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var got Page[ArtistRef]
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if len(got.Items) != 3 {
+ t.Fatalf("items len = %d", len(got.Items))
+ }
+ if got.Items[0].Name != "Third" || got.Items[2].Name != "First" {
+ t.Errorf("newest order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name)
+ }
+}
+
+func TestHandleListArtists_InvalidSort(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ seedArtist(t, pool, "Only")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=garbage", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleListArtists_Pagination(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ for _, n := range []string{"A", "B", "C", "D", "E"} {
+ seedArtist(t, pool, n)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=2&offset=2", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var got Page[ArtistRef]
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if got.Total != 5 {
+ t.Errorf("total = %d, want 5", got.Total)
+ }
+ if got.Limit != 2 || got.Offset != 2 {
+ t.Errorf("page = limit=%d offset=%d", got.Limit, got.Offset)
+ }
+ if len(got.Items) != 2 {
+ t.Fatalf("items len = %d", len(got.Items))
+ }
+ if got.Items[0].Name != "C" || got.Items[1].Name != "D" {
+ t.Errorf("slice = %q, %q", got.Items[0].Name, got.Items[1].Name)
+ }
+}
+
+func TestHandleListArtists_LimitClamped(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ seedArtist(t, pool, "One")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=9999", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var got Page[ArtistRef]
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if got.Limit != 200 {
+ t.Errorf("limit = %d, want 200 (max clamp)", got.Limit)
+ }
+}
+
+func TestHandleListArtists_LimitNonNumeric(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=abc", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleListArtists_AlbumCount(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ seedAlbum(t, pool, artist.ID, "X", 0)
+ seedAlbum(t, pool, artist.ID, "Y", 0)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ var got Page[ArtistRef]
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if len(got.Items) != 1 || got.Items[0].AlbumCount != 2 {
+ t.Errorf("items = %+v", got.Items)
+ }
+}
+
+func TestHandleListArtists_EmptyDB(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var got Page[ArtistRef]
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if got.Items == nil {
+ t.Error("items = nil, want []")
+ }
+ if got.Total != 0 {
+ t.Errorf("total = %d", got.Total)
+ }
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleListArtists' -count=1`
+Expected: all eight fail against the stub.
+
+- [ ] **Step 3: Add pagination parsing helper to `internal/api/convert.go`**
+
+Append:
+
+```go
+
+// parsePaging reads limit/offset from the query string, applying defaults
+// and clamping. Returns a 400-worthy error when the values are non-numeric
+// or negative; out-of-range values silently clamp (deliberate — UX).
+func parsePaging(raw url.Values) (limit, offset int, err error) {
+ const (
+ defLimit = 50
+ maxLimit = 200
+ )
+ limit = defLimit
+ if s := strings.TrimSpace(raw.Get("limit")); s != "" {
+ n, perr := strconv.Atoi(s)
+ if perr != nil {
+ return 0, 0, errBadPaging
+ }
+ if n < 1 {
+ n = 1
+ }
+ if n > maxLimit {
+ n = maxLimit
+ }
+ limit = n
+ }
+ if s := strings.TrimSpace(raw.Get("offset")); s != "" {
+ n, perr := strconv.Atoi(s)
+ if perr != nil {
+ return 0, 0, errBadPaging
+ }
+ if n < 0 {
+ return 0, 0, errBadPaging
+ }
+ offset = n
+ }
+ return limit, offset, nil
+}
+```
+
+Extend the import block at the top of `internal/api/convert.go` with the new imports + `errBadPaging`:
+
+```go
+import (
+ "errors"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+var errBadPaging = errors.New("invalid limit or offset")
+```
+
+- [ ] **Step 4: Replace `handleListArtists` stub in `internal/api/library.go`**
+
+Replace the stub with:
+
+```go
+// handleListArtists implements GET /api/artists with two sort modes
+// (alpha|newest) and enveloped pagination. album_count per artist is
+// computed via an N+1 — acceptable at limit=200.
+func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
+ sort := r.URL.Query().Get("sort")
+ if sort == "" {
+ sort = "alpha"
+ }
+ if sort != "alpha" && sort != "newest" {
+ writeErr(w, http.StatusBadRequest, "bad_request", "sort must be alpha or newest")
+ return
+ }
+ limit, offset, err := parsePaging(r.URL.Query())
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
+ return
+ }
+
+ q := dbq.New(h.pool)
+ var artists []dbq.Artist
+ switch sort {
+ case "newest":
+ artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{
+ Limit: int32(limit), Offset: int32(offset),
+ })
+ default: // alpha
+ artists, err = q.ListArtistsAlpha(r.Context(), dbq.ListArtistsAlphaParams{
+ Limit: int32(limit), Offset: int32(offset),
+ })
+ }
+ if err != nil {
+ h.logger.Error("api: list artists failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ total, err := q.CountArtists(r.Context())
+ if err != nil {
+ h.logger.Error("api: count artists failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
+ return
+ }
+
+ items := make([]ArtistRef, 0, len(artists))
+ for _, a := range artists {
+ albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
+ if aerr != nil {
+ h.logger.Error("api: list albums for artist failed", "err", aerr)
+ writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
+ return
+ }
+ items = append(items, artistRefFrom(a, len(albums)))
+ }
+ writeJSON(w, http.StatusOK, Page[ArtistRef]{
+ Items: items,
+ Total: int(total),
+ Limit: limit,
+ Offset: offset,
+ })
+}
+```
+
+- [ ] **Step 5: Run — expect PASS**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleListArtists' -count=1 -v`
+Expected: all eight tests PASS.
+
+- [ ] **Step 6: Full api suite stays green**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
+Expected: all PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/api/library.go internal/api/library_test.go internal/api/convert.go
+git commit -m "feat(api): GET /api/artists with alpha/newest sort and paging"
+```
+
+---
+
+## Task 10: Implement GET /api/search
+
+**Files:**
+- Create: `internal/api/search.go`
+- Create: `internal/api/search_test.go`
+- Modify: `internal/api/library.go` (remove `handleSearch` stub)
+
+- [ ] **Step 1: Write failing tests in `internal/api/search_test.go`**
+
+```go
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestHandleSearch_ThreeFacets(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "Beatles")
+ album := seedAlbum(t, pool, artist.ID, "Beatles Greatest", 1982)
+ seedTrack(t, pool, album.ID, artist.ID, "Beatles Jam", 1, 60_000)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/search?q=beatles", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ var got SearchResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Artists.Total != 1 || len(got.Artists.Items) != 1 {
+ t.Errorf("artists = %+v", got.Artists)
+ }
+ if got.Albums.Total != 1 || len(got.Albums.Items) != 1 {
+ t.Errorf("albums = %+v", got.Albums)
+ }
+ if got.Tracks.Total != 1 || len(got.Tracks.Items) != 1 {
+ t.Errorf("tracks = %+v", got.Tracks)
+ }
+ if got.Artists.Items[0].Name != "Beatles" {
+ t.Errorf("artist name = %q", got.Artists.Items[0].Name)
+ }
+ if got.Albums.Items[0].ArtistName != "Beatles" {
+ t.Errorf("album.artist_name = %q", got.Albums.Items[0].ArtistName)
+ }
+ if got.Tracks.Items[0].AlbumTitle != "Beatles Greatest" {
+ t.Errorf("track.album_title = %q", got.Tracks.Items[0].AlbumTitle)
+ }
+}
+
+func TestHandleSearch_NoMatches(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ seedArtist(t, pool, "Beatles")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/search?q=nothinghere", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var got SearchResponse
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if got.Artists.Total != 0 || got.Albums.Total != 0 || got.Tracks.Total != 0 {
+ t.Errorf("totals = %+v", got)
+ }
+ // All three item slices must JSON-encode as [] not null.
+ if got.Artists.Items == nil || got.Albums.Items == nil || got.Tracks.Items == nil {
+ t.Errorf("nil item slice: %+v", got)
+ }
+}
+
+func TestHandleSearch_EmptyQuery(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/search", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleSearch_BlankQuery(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/search?q=%20%20", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleSearch_PagingAppliedPerFacet(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ // Create three matching artists so paging shows.
+ a1 := seedArtist(t, pool, "Match One")
+ a2 := seedArtist(t, pool, "Match Two")
+ a3 := seedArtist(t, pool, "Match Three")
+ _ = a1
+ _ = a2
+ _ = a3
+
+ req := httptest.NewRequest(http.MethodGet, "/api/search?q=Match&limit=2&offset=1", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var got SearchResponse
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if got.Artists.Total != 3 {
+ t.Errorf("total = %d, want 3", got.Artists.Total)
+ }
+ if got.Artists.Limit != 2 || got.Artists.Offset != 1 {
+ t.Errorf("page = limit=%d offset=%d", got.Artists.Limit, got.Artists.Offset)
+ }
+ if len(got.Artists.Items) != 2 {
+ t.Errorf("items len = %d, want 2", len(got.Artists.Items))
+ }
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL (stub returns 501)**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleSearch' -count=1`
+Expected: five fail against stub.
+
+- [ ] **Step 3: Create `internal/api/search.go`**
+
+```go
+package api
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// handleSearch implements GET /api/search. Runs three paged facets sharing
+// one limit/offset. Each facet returns its own total reflecting the full
+// match count (not just the page). Nil slices are explicitly [] so JSON
+// doesn't emit null.
+func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+ if q == "" {
+ writeErr(w, http.StatusBadRequest, "bad_request", "q is required")
+ return
+ }
+ limit, offset, err := parsePaging(r.URL.Query())
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
+ return
+ }
+
+ dbQ := dbq.New(h.pool)
+ needle := &q
+
+ artists, err := dbQ.SearchArtists(r.Context(), dbq.SearchArtistsParams{
+ Column1: needle, Limit: int32(limit), Offset: int32(offset),
+ })
+ if err != nil {
+ h.logger.Error("api: search artists failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+ artistTotal, err := dbQ.CountArtistsMatching(r.Context(), q)
+ if err != nil {
+ h.logger.Error("api: count artists matching failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+ artistItems := make([]ArtistRef, 0, len(artists))
+ for _, a := range artists {
+ albums, aerr := dbQ.ListAlbumsByArtist(r.Context(), a.ID)
+ if aerr != nil {
+ h.logger.Error("api: list albums for search artist failed", "err", aerr)
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+ artistItems = append(artistItems, artistRefFrom(a, len(albums)))
+ }
+
+ albums, err := dbQ.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{
+ Column1: needle, Limit: int32(limit), Offset: int32(offset),
+ })
+ if err != nil {
+ h.logger.Error("api: search albums failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+ albumTotal, err := dbQ.CountAlbumsMatching(r.Context(), q)
+ if err != nil {
+ h.logger.Error("api: count albums matching failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+ albumItems, err := h.resolveAlbumRefs(r.Context(), dbQ, albums)
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+
+ tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{
+ Column1: needle, Limit: int32(limit), Offset: int32(offset),
+ })
+ if err != nil {
+ h.logger.Error("api: search tracks failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+ trackTotal, err := dbQ.CountTracksMatching(r.Context(), q)
+ if err != nil {
+ h.logger.Error("api: count tracks matching failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+ trackItems, err := h.resolveTrackRefs(r.Context(), dbQ, tracks)
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, SearchResponse{
+ Artists: Page[ArtistRef]{Items: artistItems, Total: int(artistTotal), Limit: limit, Offset: offset},
+ Albums: Page[AlbumRef]{Items: albumItems, Total: int(albumTotal), Limit: limit, Offset: offset},
+ Tracks: Page[TrackRef]{Items: trackItems, Total: int(trackTotal), Limit: limit, Offset: offset},
+ })
+}
+
+// resolveAlbumRefs builds AlbumRefs from dbq.Album rows: resolves artist
+// name (one query per distinct artist) and track count per album (one
+// query each). Acceptable at limit=200 page size.
+func (h *handlers) resolveAlbumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) {
+ items := make([]AlbumRef, 0, len(albums))
+ names, err := resolveArtistNames(ctx, q, collectArtistIDs(albums))
+ if err != nil {
+ h.logger.Error("api: resolve artist names failed", "err", err)
+ return nil, err
+ }
+ for _, a := range albums {
+ count, cerr := q.CountTracksByAlbum(ctx, a.ID)
+ if cerr != nil {
+ h.logger.Error("api: count tracks for album failed", "err", cerr)
+ return nil, cerr
+ }
+ items = append(items, albumRefFrom(a, names[uuidToString(a.ArtistID)], int(count), 0))
+ }
+ return items, nil
+}
+
+// resolveTrackRefs builds TrackRefs from dbq.Track rows: looks up the
+// parent album + artist by id for each track (pair of queries per track).
+// At limit=200 this is 400 queries worst case — acceptable for M1.5.
+func (h *handlers) resolveTrackRefs(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) ([]TrackRef, error) {
+ items := make([]TrackRef, 0, len(tracks))
+ for _, t := range tracks {
+ album, aerr := q.GetAlbumByID(ctx, t.AlbumID)
+ if aerr != nil {
+ h.logger.Error("api: get album for track failed", "err", aerr)
+ return nil, aerr
+ }
+ artist, aerr := q.GetArtistByID(ctx, t.ArtistID)
+ if aerr != nil {
+ h.logger.Error("api: get artist for track failed", "err", aerr)
+ return nil, aerr
+ }
+ items = append(items, trackRefFrom(t, album.Title, artist.Name))
+ }
+ return items, nil
+}
+
+// collectArtistIDs extracts the distinct-preserving ordered list of artist
+// ids from a slice of albums. resolveArtistNames dedupes internally.
+func collectArtistIDs(albums []dbq.Album) []pgtype.UUID {
+ ids := make([]pgtype.UUID, 0, len(albums))
+ for _, a := range albums {
+ ids = append(ids, a.ArtistID)
+ }
+ return ids
+}
+
+// resolveArtistNames fetches each distinct artist id and returns a
+// uuid-string → name map. Duplicated from subsonic intentionally.
+func resolveArtistNames(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[string]string, error) {
+ seen := make(map[string]struct{}, len(ids))
+ out := make(map[string]string, len(ids))
+ for _, id := range ids {
+ key := uuidToString(id)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ a, err := q.GetArtistByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ out[key] = a.Name
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 4: Remove the `handleSearch` stub from `internal/api/library.go`**
+
+Delete this block from `library.go`:
+
+```go
+// handleSearch is implemented in Task 10 (file: search.go).
+func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
+}
+```
+
+- [ ] **Step 5: Run — expect PASS**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleSearch' -count=1 -v`
+Expected: five tests PASS.
+
+- [ ] **Step 6: Full api suite stays green**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
+Expected: all PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/api/search.go internal/api/search_test.go internal/api/library.go
+git commit -m "feat(api): GET /api/search with three paged facets"
+```
+
+---
+
+## Task 11: Register routes in production mount
+
+The tests exercise a test-only router; the production `api.Mount` must also wire all five routes so the SPA can hit them. Once wired, `RequireUser` gates them.
+
+**Files:**
+- Modify: `internal/api/api.go`
+
+- [ ] **Step 1: Write failing test for production route registration**
+
+Append to `internal/api/library_test.go`:
+
+```go
+
+// TestRoutesRegisteredInMount verifies the production Mount() wires every
+// library route. We hit each path through the real Mount (no RequireUser
+// stripped — we expect 401 from the middleware, which is fine — that
+// proves the route reached the authenticated group).
+func TestRoutesRegisteredInMount(t *testing.T) {
+ h, _ := testHandlers(t)
+ r := chi.NewRouter()
+ Mount(r, h.pool, h.logger)
+
+ paths := []string{
+ "/api/artists",
+ "/api/artists/00000000-0000-0000-0000-000000000001",
+ "/api/albums/00000000-0000-0000-0000-000000000001",
+ "/api/tracks/00000000-0000-0000-0000-000000000001",
+ "/api/search?q=x",
+ }
+ for _, p := range paths {
+ req := httptest.NewRequest(http.MethodGet, p, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ // Unauthenticated → 401. What we must NOT see is 404 (route missing).
+ if w.Code == http.StatusNotFound {
+ t.Errorf("path %s returned 404 — route not registered", p)
+ }
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("path %s status = %d, want 401 (unauth)", p, w.Code)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestRoutesRegisteredInMount' -count=1`
+Expected: 404s — routes not in Mount yet.
+
+- [ ] **Step 3: Update `internal/api/api.go`**
+
+Replace the existing `Mount` function with:
+
+```go
+// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
+// RequireUser; everything else is gated by the middleware.
+func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
+ h := &handlers{pool: pool, logger: logger}
+
+ r.Route("/api", func(api chi.Router) {
+ api.Post("/auth/login", h.handleLogin)
+ api.Group(func(authed chi.Router) {
+ authed.Use(auth.RequireUser(pool))
+ authed.Post("/auth/logout", h.handleLogout)
+ authed.Get("/me", h.handleGetMe)
+
+ authed.Get("/artists", h.handleListArtists)
+ authed.Get("/artists/{id}", h.handleGetArtist)
+ authed.Get("/albums/{id}", h.handleGetAlbum)
+ authed.Get("/tracks/{id}", h.handleGetTrack)
+ authed.Get("/search", h.handleSearch)
+ })
+ })
+}
+```
+
+- [ ] **Step 4: Run — expect PASS**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestRoutesRegisteredInMount' -count=1 -v`
+Expected: all paths return 401, test PASSes.
+
+- [ ] **Step 5: api package suite stays green**
+
+Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
+Expected: all api tests PASS.
+
+Optionally run `go test ./... -count=1` to catch cross-package regressions. Known flaky integration tests outside `internal/api/` (bootstrap, scanner) may fail independently — those are pre-existing and out of scope for this plan.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/api/api.go internal/api/library_test.go
+git commit -m "feat(api): register library + search routes under RequireUser"
+```
+
+---
+
+## Task 12: Manual smoke + PR
+
+Live-binary verification: start the server and hit the endpoints end-to-end, then open a PR.
+
+- [ ] **Step 1: Build and run the server**
+
+Run: `make build && ./bin/minstrel server` (or whatever the invocation is on your branch — check the Makefile's `build` target).
+
+Expected: server listens on the configured port without errors in the log.
+
+- [ ] **Step 2: Log in to capture a session cookie + token**
+
+Run:
+
+```bash
+curl -s -i -X POST http://localhost:8080/api/auth/login \
+ -H 'content-type: application/json' \
+ -d '{"username":"admin","password":""}'
+```
+
+Expected: `200 OK`, `Set-Cookie: minstrel_session=...`, JSON body with `token` and `user`.
+
+Copy the token from the JSON body; call it `$TOKEN` below.
+
+- [ ] **Step 3: Smoke each endpoint**
+
+Run:
+
+```bash
+curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/artists | jq .
+curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/artists?sort=newest&limit=5' | jq .
+curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/search?q=a&limit=3' | jq .
+# Grab a real artist id from /api/artists output, substitute below:
+curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/artists/ | jq .
+# Grab a real album id from the artist detail output:
+curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/albums/ | jq .
+# Grab a real track id from the album detail output:
+curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tracks/ | jq .
+```
+
+Expected:
+- Each returns 200 with valid JSON in the documented shape.
+- List response contains `items`, `total`, `limit`, `offset`.
+- Album detail's `cover_url` ends in `/cover` and tracks' `stream_url` end in `/stream`.
+- Error path: `curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/albums/not-a-uuid | jq .` returns 400 with `{error:{code:"bad_request",...}}`.
+
+- [ ] **Step 4: Verify unauth paths are 401**
+
+Run: `curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/artists`
+Expected: `401`.
+
+- [ ] **Step 5: Push branch and open PR**
+
+Run:
+
+```bash
+git push -u origin dev
+```
+
+Then open a PR against `main` via `gh` (or whatever the project uses; the memory notes Forgejo MCP):
+
+PR title: `feat: library reads + search API (/api/artists, albums, tracks, search)`
+
+PR body:
+
+```
+Implements Plan 2 of the web UI rollout: paginated artist list with alpha/newest sort, artist/album/track detail, and unified search.
+
+Spec: docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md
+
+Highlights:
+- Enveloped pagination: {items, total, limit, offset}
+- Ref/Detail response split mirroring subsonic
+- Forward-reference stream/cover URLs embedded (Plan 3 implements the endpoints)
+- Empty slices always render as [] (never null)
+
+Test plan:
+- [x] Unit tests for conversion helpers
+- [x] Integration tests per endpoint (happy path, 400/404 paths, paging, sort)
+- [x] Route-registration test verifies all five routes wired under RequireUser
+- [x] Manual smoke verified against running server
+```
+
+- [ ] **Step 6: Wait for CI**
+
+Check Forgejo Actions; fix any lint/test failures surfaced there and push. Once green, merge per project workflow (dev → protected main).
+
+---
+
+## Self-Review Checklist (run before handing off)
+
+1. **Spec coverage:**
+ - Routes: Tasks 6 (tracks), 7 (albums), 8 (artist detail), 9 (artist list), 10 (search), 11 (mount) ✓
+ - Pagination envelope `Page[T]`: Task 2 ✓
+ - Sort modes alpha/newest: Task 1 (SQL), Task 9 (handler + tests) ✓
+ - URL embedding: Task 3 (helpers), projection in Task 4 ✓
+ - Error taxonomy (400/404/401/500): exercised per-endpoint test ✓
+ - Empty-slice-never-null rule: Tasks 7, 8, 9, 10 assert explicitly ✓
+
+2. **Placeholder scan:** no TBD/TODO/"add appropriate error handling" — every step shows code or exact commands.
+
+3. **Type consistency:** Helpers named `artistRefFrom`, `albumRefFrom`, `trackRefFrom` consistently across Tasks 4–10. `parsePaging` signature `(raw map[string][]string) (limit, offset int, err error)` used identically in Tasks 9 and 10. `newLibraryRouter` and `withUserCtx` helpers defined in Task 6 are reused in Tasks 7–11.
diff --git a/docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.md b/docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.md
new file mode 100644
index 00000000..14a069f2
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-21-web-ui-media-endpoints.md
@@ -0,0 +1,1066 @@
+# Web UI Media Endpoints Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship `/api/albums/{id}/cover` and `/api/tracks/{id}/stream` so Plan 2's forward-reference `cover_url` and `stream_url` fields resolve in the SPA.
+
+**Architecture:** One new file `internal/api/media.go` holds both handlers plus locally-duplicated filesystem/MIME helpers — no shared package with `internal/subsonic`, per the subsonic-is-long-term-legacy direction. Byte-serving uses `http.ServeContent`, which handles Range / If-Modified-Since / ETag automatically. Auth is the existing `RequireUser` middleware (cookie or bearer token).
+
+**Tech Stack:** Go 1.23, chi/v5, pgx/v5, pgxpool, stdlib `net/http`, `os`, `path/filepath`.
+
+**Spec:** `docs/superpowers/specs/2026-04-21-web-ui-media-endpoints-design.md`
+
+---
+
+## File Structure
+
+New files:
+
+| File | Responsibility |
+|---|---|
+| `internal/api/media.go` | Both handlers + helpers (`resolveAlbumCoverPath`, `findSidecarCover`, `audioContentType`, `imageContentType`) |
+| `internal/api/media_test.go` | Unit tests for MIME helpers + integration tests for both endpoints |
+
+Modified files:
+
+| File | Change |
+|---|---|
+| `internal/api/library_fixtures_test.go` | Add `seedTrackWithFile` helper that materializes bytes on disk |
+| `internal/api/library_test.go` | Extend `newLibraryRouter` (register `/cover` + `/stream`) and `TestRoutesRegisteredInMount` (assert production Mount wires both) |
+| `internal/api/api.go` | Register both routes inside the existing `authed` chi group |
+
+No migrations. No new sqlc queries — `GetAlbumByID`, `GetTrackByID`, and `ListTracksByAlbum` already exist.
+
+---
+
+## Working Conventions
+
+- **Commit after each green step.** One task = at least one commit. Multiple commits per task if convenient.
+- **Integration tests require a live Postgres** at `MINSTREL_TEST_DATABASE_URL`. `docker-compose.yml` spins one up; tests skip cleanly when the env var is unset. Run the full suite with `MINSTREL_TEST_DATABASE_URL=postgres://… go test ./...` before committing.
+- **Do NOT touch `internal/subsonic/*`**. If you notice the helpers here duplicate code in `internal/subsonic/stream.go`, that's intentional — see the memory note `project_subsonic_legacy.md`.
+- **Go unused-import rule:** only add an import once the code actually uses it; the compiler rejects unused imports.
+- **File-format and extension tables** in this plan are authoritative — they are the ones the spec approved, which differ slightly from subsonic's table. Do not "fix" them to match subsonic.
+
+---
+
+## Task 1: Scaffold `media.go` with helpers + unit tests
+
+**Files:**
+- Create: `internal/api/media.go`
+- Create: `internal/api/media_test.go`
+- Modify: `internal/api/library_fixtures_test.go`
+
+This task produces the four package-private helpers — no handlers yet. The handlers land in Tasks 2 and 3. Separating them keeps each commit's diff tight.
+
+- [ ] **Step 1: Create `internal/api/media.go` with helpers only**
+
+```go
+// Package api — media endpoints serve raw bytes (cover art, audio streams).
+// The helpers below duplicate shape with internal/subsonic/stream.go by
+// design; the two packages are kept independent so subsonic can freeze as a
+// compatibility surface while /api evolves. See project memory
+// `project_subsonic_legacy.md` for the rationale.
+
+package api
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// sidecarNames is the lookup order for cover art living next to audio files.
+// Matches common library conventions: cover.* wins over folder.*; JPEG wins
+// over PNG when both exist.
+var sidecarNames = []string{
+ "cover.jpg", "cover.jpeg", "cover.png",
+ "folder.jpg", "folder.jpeg", "folder.png",
+}
+
+// findSidecarCover looks for a conventional cover image in the directory that
+// contains trackPath. Returns "" if no sidecar exists.
+func findSidecarCover(trackPath string) string {
+ dir := filepath.Dir(trackPath)
+ for _, name := range sidecarNames {
+ candidate := filepath.Join(dir, name)
+ if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
+ return candidate
+ }
+ }
+ return ""
+}
+
+// resolveAlbumCoverPath returns the filesystem path to the album's cover art.
+// It prefers an explicit cover_art_path (set by the scanner in a future
+// milestone) and falls back to a sidecar next to the first track in the
+// album's directory. "" means no art was found.
+func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
+ if album.CoverArtPath != nil && *album.CoverArtPath != "" {
+ if _, err := os.Stat(*album.CoverArtPath); err == nil {
+ return *album.CoverArtPath
+ }
+ }
+ tracks, err := q.ListTracksByAlbum(ctx, album.ID)
+ if err != nil || len(tracks) == 0 {
+ return ""
+ }
+ return findSidecarCover(tracks[0].FilePath)
+}
+
+// audioContentType maps the short file_format recorded on tracks (mp3, flac,
+// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
+// Unknown formats fall back to octet-stream so the browser downloads them
+// rather than attempting to decode.
+func audioContentType(format string) string {
+ switch strings.ToLower(format) {
+ case "mp3":
+ return "audio/mpeg"
+ case "flac":
+ return "audio/flac"
+ case "ogg", "opus":
+ return "audio/ogg"
+ case "m4a":
+ return "audio/mp4"
+ case "aac":
+ return "audio/aac"
+ case "wav":
+ return "audio/wav"
+ }
+ return "application/octet-stream"
+}
+
+// imageContentType maps a file extension to a MIME type for cover art.
+// Unknown extensions fall back to octet-stream.
+func imageContentType(path string) string {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".jpg", ".jpeg":
+ return "image/jpeg"
+ case ".png":
+ return "image/png"
+ case ".webp":
+ return "image/webp"
+ case ".gif":
+ return "image/gif"
+ }
+ return "application/octet-stream"
+}
+```
+
+- [ ] **Step 2: Append `seedTrackWithFile` to `internal/api/library_fixtures_test.go`**
+
+Inside the same `package api` file, add (alongside the existing helpers):
+
+```go
+// seedTrackWithFile creates a fresh temp directory, writes fileBody to
+// /., and inserts a Track row whose file_path points at it.
+// Returns the inserted Track and the directory (callers drop sidecar covers
+// into this dir when a test needs them). FileFormat is ext lowercased.
+func seedTrackWithFile(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, fileBody []byte, ext string) (dbq.Track, string) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, title+"."+ext)
+ if err := os.WriteFile(path, fileBody, 0o644); err != nil {
+ t.Fatalf("WriteFile(%s): %v", path, err)
+ }
+ one := int32(1)
+ tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
+ Title: title,
+ AlbumID: albumID,
+ ArtistID: artistID,
+ TrackNumber: &one,
+ DiscNumber: nil,
+ DurationMs: int32(len(fileBody)),
+ FilePath: path,
+ FileSize: int64(len(fileBody)),
+ FileFormat: strings.ToLower(ext),
+ Bitrate: nil,
+ Mbid: nil,
+ Genre: nil,
+ })
+ if err != nil {
+ t.Fatalf("UpsertTrack(%s): %v", title, err)
+ }
+ return tr, dir
+}
+```
+
+Add these imports to `library_fixtures_test.go` (keep existing imports; add only the new ones):
+
+```go
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+```
+
+(`os`, `path/filepath`, `strings` are the additions.)
+
+- [ ] **Step 3: Create `internal/api/media_test.go` with unit tests for the MIME helpers**
+
+```go
+package api
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestAudioContentType(t *testing.T) {
+ cases := map[string]string{
+ "mp3": "audio/mpeg",
+ "MP3": "audio/mpeg",
+ "flac": "audio/flac",
+ "ogg": "audio/ogg",
+ "opus": "audio/ogg",
+ "m4a": "audio/mp4",
+ "aac": "audio/aac",
+ "wav": "audio/wav",
+ "unknown": "application/octet-stream",
+ "": "application/octet-stream",
+ }
+ for in, want := range cases {
+ if got := audioContentType(in); got != want {
+ t.Errorf("audioContentType(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestImageContentType(t *testing.T) {
+ cases := map[string]string{
+ "/a/cover.jpg": "image/jpeg",
+ "/a/cover.JPG": "image/jpeg",
+ "/a/cover.jpeg": "image/jpeg",
+ "/a/cover.png": "image/png",
+ "/a/cover.webp": "image/webp",
+ "/a/cover.gif": "image/gif",
+ "/a/cover.bmp": "application/octet-stream",
+ "/a/cover": "application/octet-stream",
+ }
+ for in, want := range cases {
+ if got := imageContentType(in); got != want {
+ t.Errorf("imageContentType(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestFindSidecarCover_PrefersCoverOverFolder(t *testing.T) {
+ dir := t.TempDir()
+ // Both present — cover.jpg wins.
+ if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("c"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("f"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got := findSidecarCover(filepath.Join(dir, "any-track.flac"))
+ if got != filepath.Join(dir, "cover.jpg") {
+ t.Errorf("got %q, want cover.jpg path", got)
+ }
+}
+
+func TestFindSidecarCover_FallsBackToFolder(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "folder.png"), []byte("f"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got := findSidecarCover(filepath.Join(dir, "t.flac"))
+ if got != filepath.Join(dir, "folder.png") {
+ t.Errorf("got %q, want folder.png path", got)
+ }
+}
+
+func TestFindSidecarCover_NoneFound(t *testing.T) {
+ dir := t.TempDir()
+ if got := findSidecarCover(filepath.Join(dir, "t.flac")); got != "" {
+ t.Errorf("got %q, want empty string", got)
+ }
+}
+
+func TestResolveAlbumCoverPath_ExplicitPresent(t *testing.T) {
+ _, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+
+ // Put an explicit cover file on disk, then write its path to album.cover_art_path.
+ dir := t.TempDir()
+ explicit := filepath.Join(dir, "explicit.jpg")
+ if err := os.WriteFile(explicit, []byte("e"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(context.Background(),
+ `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil {
+ t.Fatalf("UPDATE albums: %v", err)
+ }
+ // Refetch so album.CoverArtPath reflects the update.
+ got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID))
+ if got != explicit {
+ t.Errorf("got %q, want %q", got, explicit)
+ }
+}
+
+func TestResolveAlbumCoverPath_ExplicitMissingFallsThroughToSidecar(t *testing.T) {
+ _, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+
+ // Seed track first (helper owns the temp dir), then drop the sidecar in
+ // that same dir. Explicit cover_art_path points at a file we never create.
+ _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
+ sidecar := filepath.Join(dir, "cover.jpg")
+ if err := os.WriteFile(sidecar, []byte("s"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(context.Background(),
+ `UPDATE albums SET cover_art_path = $1 WHERE id = $2`,
+ filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil {
+ t.Fatalf("UPDATE albums: %v", err)
+ }
+
+ got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID))
+ if got != sidecar {
+ t.Errorf("got %q, want %q", got, sidecar)
+ }
+}
+
+func TestResolveAlbumCoverPath_NoArt(t *testing.T) {
+ _, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+ seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic /seed path, no real file
+ got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), album)
+ if got != "" {
+ t.Errorf("got %q, want empty string", got)
+ }
+}
+
+// fetchAlbum reloads the album row so tests see updates made via raw SQL.
+func fetchAlbum(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) dbq.Album {
+ t.Helper()
+ a, err := dbq.New(pool).GetAlbumByID(context.Background(), id)
+ if err != nil {
+ t.Fatalf("GetAlbumByID: %v", err)
+ }
+ return a
+}
+```
+
+Add to the imports at top of `media_test.go` as they're used:
+
+```go
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+```
+
+- [ ] **Step 4: Run the new tests; all must pass**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/ -run 'TestAudioContentType|TestImageContentType|TestFindSidecarCover|TestResolveAlbumCoverPath' -v
+```
+
+Expected: all 7 tests PASS.
+
+- [ ] **Step 5: Run the full api test suite to catch regressions in shared fixtures**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/... -count=1
+```
+
+Expected: all tests PASS (existing + new).
+
+- [ ] **Step 6: Commit**
+
+```
+git add internal/api/media.go internal/api/media_test.go internal/api/library_fixtures_test.go
+git commit -m "feat(api): scaffold media helpers for cover + stream endpoints"
+```
+
+---
+
+## Task 2: Implement `GET /api/albums/{id}/cover`
+
+**Files:**
+- Modify: `internal/api/media.go` (add handler)
+- Modify: `internal/api/media_test.go` (add integration tests)
+- Modify: `internal/api/library_test.go` (register route in `newLibraryRouter`)
+
+TDD order — tests first, then handler. Tests fail first because the handler doesn't exist yet; they pass after it's added.
+
+- [ ] **Step 1: Register `/cover` in `newLibraryRouter`**
+
+Edit `internal/api/library_test.go:16-24` and add the cover route:
+
+```go
+func newLibraryRouter(h *handlers) chi.Router {
+ r := chi.NewRouter()
+ r.Get("/api/artists", h.handleListArtists)
+ r.Get("/api/artists/{id}", h.handleGetArtist)
+ r.Get("/api/albums/{id}", h.handleGetAlbum)
+ r.Get("/api/albums/{id}/cover", h.handleGetCover)
+ r.Get("/api/tracks/{id}", h.handleGetTrack)
+ r.Get("/api/search", h.handleSearch)
+ return r
+}
+```
+
+At this point the package won't compile — `handleGetCover` is undefined. That's expected; the integration tests need the route registered so they can fail with "handler missing" in the same compile unit as the test router. (Implementer hint: if the compile error bothers you, add the method body as `func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {}` as a temporary stub for Step 2. Remove the stub before Step 4.)
+
+- [ ] **Step 2: Append integration tests to `internal/api/media_test.go`**
+
+```go
+func TestHandleGetCover_SidecarHappyPath(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+
+ _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
+ coverBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x01, 0x02, 0x03} // small JPEG-ish payload
+ if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), coverBytes, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
+ t.Errorf("Content-Type = %q", ct)
+ }
+ if !bytes.Equal(w.Body.Bytes(), coverBytes) {
+ t.Errorf("body = %x, want %x", w.Body.Bytes(), coverBytes)
+ }
+}
+
+func TestHandleGetCover_ExplicitPathHappyPath(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+
+ dir := t.TempDir()
+ explicit := filepath.Join(dir, "art.png")
+ pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic
+ if err := os.WriteFile(explicit, pngBytes, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(context.Background(),
+ `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil {
+ t.Fatal(err)
+ }
+ // No track needed — explicit path wins before we ever check the track dir.
+
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ if ct := w.Header().Get("Content-Type"); ct != "image/png" {
+ t.Errorf("Content-Type = %q", ct)
+ }
+ if !bytes.Equal(w.Body.Bytes(), pngBytes) {
+ t.Errorf("body mismatch")
+ }
+}
+
+func TestHandleGetCover_ExplicitMissingFallsThroughToSidecar(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+
+ _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
+ sidecarBytes := []byte("sidecar")
+ if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), sidecarBytes, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(context.Background(),
+ `UPDATE albums SET cover_art_path = $1 WHERE id = $2`,
+ filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d", w.Code)
+ }
+ if !bytes.Equal(w.Body.Bytes(), sidecarBytes) {
+ t.Errorf("body = %q, want %q", w.Body.Bytes(), sidecarBytes)
+ }
+}
+
+func TestHandleGetCover_NoArt(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+ seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic path, no sidecar
+
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var body errorBody
+ _ = json.Unmarshal(w.Body.Bytes(), &body)
+ if body.Error.Code != "not_found" || body.Error.Message != "cover not found" {
+ t.Errorf("error body = %+v", body)
+ }
+}
+
+func TestHandleGetCover_BadUUID(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid/cover", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleGetCover_UnknownAlbum(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ req := httptest.NewRequest(http.MethodGet,
+ "/api/albums/00000000-0000-0000-0000-000000000001/cover", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var body errorBody
+ _ = json.Unmarshal(w.Body.Bytes(), &body)
+ if body.Error.Message != "album not found" {
+ t.Errorf("message = %q, want \"album not found\"", body.Error.Message)
+ }
+}
+```
+
+Update the imports at the top of `media_test.go` to add `"bytes"`, `"encoding/json"`, `"net/http"`, `"net/http/httptest"`:
+
+```go
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+```
+
+- [ ] **Step 3: Run the cover tests — expect FAIL (handler not implemented)**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/ -run TestHandleGetCover -v
+```
+
+Expected: compile error OR 6 FAILS.
+
+- [ ] **Step 4: Append `handleGetCover` to `internal/api/media.go`**
+
+Add these imports to media.go (alongside existing):
+
+```go
+"errors"
+"net/http"
+
+"github.com/go-chi/chi/v5"
+"github.com/jackc/pgx/v5"
+```
+
+Append the handler:
+
+```go
+// handleGetCover implements GET /api/albums/{id}/cover. Resolves the cover
+// path (explicit column, else sidecar next to first track), then delegates
+// byte-serving to http.ServeContent so clients get Range / If-Modified-Since
+// / ETag for free.
+func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
+ return
+ }
+ q := dbq.New(h.pool)
+ album, err := q.GetAlbumByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "album not found")
+ return
+ }
+ h.logger.Error("api: get album for cover failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "server error")
+ return
+ }
+ path := resolveAlbumCoverPath(r.Context(), q, album)
+ if path == "" {
+ writeErr(w, http.StatusNotFound, "not_found", "cover not found")
+ return
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ // resolveAlbumCoverPath saw the file a moment ago; if it vanished
+ // between stat and open, treat it the same as no-art — the user
+ // experience (404) matches, and 500 would misleadingly imply a server
+ // bug rather than a filesystem race.
+ writeErr(w, http.StatusNotFound, "not_found", "cover not found")
+ return
+ }
+ defer func() { _ = f.Close() }()
+ info, err := f.Stat()
+ if err != nil {
+ h.logger.Error("api: stat cover failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "server error")
+ return
+ }
+ w.Header().Set("Content-Type", imageContentType(path))
+ http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
+}
+```
+
+- [ ] **Step 5: Run the cover tests — expect all PASS**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/ -run TestHandleGetCover -v
+```
+
+Expected: 6 PASS.
+
+- [ ] **Step 6: Run the full api suite**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/... -count=1
+```
+
+Expected: all PASS.
+
+- [ ] **Step 7: Commit**
+
+```
+git add internal/api/media.go internal/api/media_test.go internal/api/library_test.go
+git commit -m "feat(api): GET /api/albums/{id}/cover with sidecar fallback"
+```
+
+---
+
+## Task 3: Implement `GET /api/tracks/{id}/stream`
+
+**Files:**
+- Modify: `internal/api/media.go` (add handler)
+- Modify: `internal/api/media_test.go` (add tests)
+- Modify: `internal/api/library_test.go` (register route in `newLibraryRouter`)
+
+Same TDD shape as Task 2.
+
+- [ ] **Step 1: Register `/stream` in `newLibraryRouter`**
+
+Edit `internal/api/library_test.go` so the helper also knows the stream route:
+
+```go
+func newLibraryRouter(h *handlers) chi.Router {
+ r := chi.NewRouter()
+ r.Get("/api/artists", h.handleListArtists)
+ r.Get("/api/artists/{id}", h.handleGetArtist)
+ r.Get("/api/albums/{id}", h.handleGetAlbum)
+ r.Get("/api/albums/{id}/cover", h.handleGetCover)
+ r.Get("/api/tracks/{id}", h.handleGetTrack)
+ r.Get("/api/tracks/{id}/stream", h.handleGetStream)
+ r.Get("/api/search", h.handleSearch)
+ return r
+}
+```
+
+(Same compile-time hint as Task 2 — add a stub if that compile error is in your way while writing the tests.)
+
+- [ ] **Step 2: Append integration tests to `internal/api/media_test.go`**
+
+```go
+// deterministicBytes returns n bytes whose values cycle 0..255. Using a
+// fixed pattern (not crypto/rand) makes assertions easy and the test
+// reproducible.
+func deterministicBytes(n int) []byte {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = byte(i % 256)
+ }
+ return b
+}
+
+func TestHandleGetStream_HappyPath(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+ body := deterministicBytes(32 * 1024)
+ track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
+ }
+ if ct := w.Header().Get("Content-Type"); ct != "audio/mpeg" {
+ t.Errorf("Content-Type = %q", ct)
+ }
+ if ar := w.Header().Get("Accept-Ranges"); ar != "bytes" {
+ t.Errorf("Accept-Ranges = %q", ar)
+ }
+ if cl := w.Header().Get("Content-Length"); cl != "32768" {
+ t.Errorf("Content-Length = %q, want 32768", cl)
+ }
+ if !bytes.Equal(w.Body.Bytes(), body) {
+ t.Errorf("body len = %d, want %d", len(w.Body.Bytes()), len(body))
+ }
+}
+
+func TestHandleGetStream_RangeFromStart(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+ body := deterministicBytes(32 * 1024)
+ track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
+ req.Header.Set("Range", "bytes=0-99")
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusPartialContent {
+ t.Fatalf("status = %d, want 206", w.Code)
+ }
+ if cr := w.Header().Get("Content-Range"); cr != "bytes 0-99/32768" {
+ t.Errorf("Content-Range = %q", cr)
+ }
+ if got := w.Body.Bytes(); !bytes.Equal(got, body[:100]) {
+ t.Errorf("body mismatch: len=%d, want 100", len(got))
+ }
+}
+
+func TestHandleGetStream_RangeFromMiddleToEnd(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+ body := deterministicBytes(32 * 1024)
+ track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
+ req.Header.Set("Range", "bytes=32000-")
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusPartialContent {
+ t.Fatalf("status = %d", w.Code)
+ }
+ if cr := w.Header().Get("Content-Range"); cr != "bytes 32000-32767/32768" {
+ t.Errorf("Content-Range = %q", cr)
+ }
+ if got := w.Body.Bytes(); !bytes.Equal(got, body[32000:]) {
+ t.Errorf("body mismatch: len=%d, want %d", len(got), len(body)-32000)
+ }
+}
+
+func TestHandleGetStream_IfModifiedSinceReturns304(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+ body := deterministicBytes(1024)
+ track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
+
+ info, err := os.Stat(track.FilePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
+ req.Header.Set("If-Modified-Since", info.ModTime().UTC().Format(http.TimeFormat))
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotModified {
+ t.Fatalf("status = %d, want 304", w.Code)
+ }
+ if got := w.Body.Len(); got != 0 {
+ t.Errorf("body len = %d, want 0 for 304", got)
+ }
+}
+
+func TestHandleGetStream_BadUUID(t *testing.T) {
+ h, _ := testHandlers(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid/stream", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleGetStream_UnknownTrack(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ req := httptest.NewRequest(http.MethodGet,
+ "/api/tracks/00000000-0000-0000-0000-000000000001/stream", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var body errorBody
+ _ = json.Unmarshal(w.Body.Bytes(), &body)
+ if body.Error.Message != "track not found" {
+ t.Errorf("message = %q, want \"track not found\"", body.Error.Message)
+ }
+}
+
+func TestHandleGetStream_VanishedFile(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ artist := seedArtist(t, pool, "A")
+ album := seedAlbum(t, pool, artist.ID, "X", 0)
+ dir := t.TempDir()
+ // Insert a track whose file_path points somewhere we never create.
+ gone := filepath.Join(dir, "does-not-exist.mp3")
+ if _, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
+ Title: "gone", AlbumID: album.ID, ArtistID: artist.ID,
+ DurationMs: 1, FilePath: gone, FileSize: 1, FileFormat: "mp3",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // Look up the just-inserted track so we can address it by id.
+ tr, err := dbq.New(pool).GetTrackByPath(context.Background(), gone)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(tr.ID)+"/stream", nil)
+ w := httptest.NewRecorder()
+ newLibraryRouter(h).ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d", w.Code)
+ }
+ var body errorBody
+ _ = json.Unmarshal(w.Body.Bytes(), &body)
+ if body.Error.Message != "track file not found" {
+ t.Errorf("message = %q, want \"track file not found\"", body.Error.Message)
+ }
+}
+```
+
+- [ ] **Step 3: Run the stream tests — expect FAIL (handler not implemented)**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/ -run TestHandleGetStream -v
+```
+
+Expected: compile error OR 7 FAILS.
+
+- [ ] **Step 4: Append `handleGetStream` to `internal/api/media.go`**
+
+```go
+// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
+// disk and delegates byte-serving to http.ServeContent, which handles Range,
+// If-Modified-Since, and ETag based on the file's mod time.
+func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
+ id, ok := parseUUID(chi.URLParam(r, "id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
+ return
+ }
+ q := dbq.New(h.pool)
+ track, err := q.GetTrackByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, http.StatusNotFound, "not_found", "track not found")
+ return
+ }
+ h.logger.Error("api: get track for stream failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "server error")
+ return
+ }
+ f, err := os.Open(track.FilePath)
+ if err != nil {
+ // File vanished (scanner indexed it, filesystem lost it). Treat as
+ // 404 so clients can fall back to the next item in a queue.
+ writeErr(w, http.StatusNotFound, "not_found", "track file not found")
+ return
+ }
+ defer func() { _ = f.Close() }()
+ info, err := f.Stat()
+ if err != nil {
+ h.logger.Error("api: stat track failed", "err", err)
+ writeErr(w, http.StatusInternalServerError, "server_error", "server error")
+ return
+ }
+ w.Header().Set("Content-Type", audioContentType(track.FileFormat))
+ w.Header().Set("Accept-Ranges", "bytes")
+ http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
+}
+```
+
+- [ ] **Step 5: Run the stream tests — expect all PASS**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/ -run TestHandleGetStream -v
+```
+
+Expected: 7 PASS.
+
+- [ ] **Step 6: Run the full suite**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./... -count=1
+```
+
+Expected: all PASS.
+
+- [ ] **Step 7: Commit**
+
+```
+git add internal/api/media.go internal/api/media_test.go internal/api/library_test.go
+git commit -m "feat(api): GET /api/tracks/{id}/stream with Range support"
+```
+
+---
+
+## Task 4: Wire routes into production `Mount` + route-registration regression test
+
+**Files:**
+- Modify: `internal/api/api.go`
+- Modify: `internal/api/library_test.go`
+
+- [ ] **Step 1: Extend the existing `TestRoutesRegisteredInMount` paths list**
+
+Edit `internal/api/library_test.go:436-441` (the `paths := []string{...}` slice inside `TestRoutesRegisteredInMount`) so it becomes:
+
+```go
+paths := []string{
+ "/api/artists",
+ "/api/artists/00000000-0000-0000-0000-000000000001",
+ "/api/albums/00000000-0000-0000-0000-000000000001",
+ "/api/albums/00000000-0000-0000-0000-000000000001/cover",
+ "/api/tracks/00000000-0000-0000-0000-000000000001",
+ "/api/tracks/00000000-0000-0000-0000-000000000001/stream",
+ "/api/search?q=x",
+}
+```
+
+- [ ] **Step 2: Run the regression test — expect FAIL (routes not in Mount)**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/ -run TestRoutesRegisteredInMount -v
+```
+
+Expected: FAIL — the two new paths return 404 because `Mount` doesn't wire them yet.
+
+- [ ] **Step 3: Register both routes in production `Mount`**
+
+Edit `internal/api/api.go:23-34` (the authed chi.Group body) to add the two new routes. The full authed block becomes:
+
+```go
+api.Group(func(authed chi.Router) {
+ authed.Use(auth.RequireUser(pool))
+ authed.Post("/auth/logout", h.handleLogout)
+ authed.Get("/me", h.handleGetMe)
+
+ authed.Get("/artists", h.handleListArtists)
+ authed.Get("/artists/{id}", h.handleGetArtist)
+ authed.Get("/albums/{id}", h.handleGetAlbum)
+ authed.Get("/albums/{id}/cover", h.handleGetCover)
+ authed.Get("/tracks/{id}", h.handleGetTrack)
+ authed.Get("/tracks/{id}/stream", h.handleGetStream)
+ authed.Get("/search", h.handleSearch)
+})
+```
+
+- [ ] **Step 4: Run the regression test — expect PASS**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./internal/api/ -run TestRoutesRegisteredInMount -v
+```
+
+Expected: PASS — both new paths now return 401 (auth required), not 404.
+
+- [ ] **Step 5: Run the full suite**
+
+```
+MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable \
+ go test ./... -count=1
+```
+
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```
+git add internal/api/api.go internal/api/library_test.go
+git commit -m "feat(api): register cover + stream routes under RequireUser"
+```
+
+---
+
+## Post-merge smoke checklist
+
+After the PR merges, run this sanity pass against a live build to confirm end-to-end behavior — unit and integration tests cover correctness, but browsers reveal surprises.
+
+- Log in through the SPA and hit `GET /api/albums//cover` for an album with a sidecar — expect the image bytes and `Content-Type: image/jpeg`.
+- `GET /api/albums//cover` for an album with no art — expect a JSON 404 body.
+- `GET /api/tracks//stream` in a browser `