Files
minstrel/docs/superpowers/plans/2026-04-20-web-ui-library-reads.md
T
bvandeusen 62e48642e2 docs(plan): add implementation plan for library reads + search
12 TDD-style tasks covering SQL additions, DTOs, conversion helpers,
per-endpoint handlers (tracks, albums, artist detail, artist list, search),
route wiring, and an end-to-end smoke + PR. Follows the approved design at
docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 23:42:47 -04:00

2125 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
// <img src> 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":"<your admin bootstrap 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/<artist-id> | jq .
# Grab a real album id from the artist detail output:
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/albums/<album-id> | jq .
# Grab a real track id from the album detail output:
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tracks/<track-id> | 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 410. `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 711.