# 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.