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