From fd77217b7ca6d30d33f5cdab1615b7d91979c7c2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 08:32:00 -0400 Subject: [PATCH] docs: add M6a home page implementation plan 20 tasks covering backend SQL queries, recommendation service, three new API endpoints (/api/home, /api/library/albums, /api/artists/{id}/tracks), and frontend rewrite of / as the home page composing four horizontal-scroll sections, plus new /library/artists and /library/albums wrapping-grid pages. ArtistRow component retired in favor of circular ArtistCard (takes the click-target bug with it). AlbumCard gets the FabledSword polish pass with a play-button overlay. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-30-m6a-home-page.md | 3341 +++++++++++++++++ 1 file changed, 3341 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-30-m6a-home-page.md diff --git a/docs/superpowers/plans/2026-04-30-m6a-home-page.md b/docs/superpowers/plans/2026-04-30-m6a-home-page.md new file mode 100644 index 00000000..4aedcee1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-m6a-home-page.md @@ -0,0 +1,3341 @@ +# M6a — Home page redesign + library list relocation — 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:** Replace `/` with a personalized home page composed of independent horizontal-scroll rows (Recently added · Rediscover · Most played · Last played), relocate the library list to `/library/artists` and `/library/albums` as wrapping grids with alphabetical dividers, polish `` to the FabledSword bar, retire `` (and its click-target bug) in favor of a circular ``, and ship play affordances on album/artist cards. + +**Architecture:** No DB migration. Backend adds composite SQL queries for each home section + new alpha-with-cover variants for the library lists. New `internal/recommendation/home.go` runs the section queries in parallel and assembles a single `HomePayload`. New `GET /api/home`, `GET /api/library/albums`, `GET /api/artists/{id}/tracks` endpoints. Frontend introduces four reusable components (``, ``, ``, ``), polishes ``, and ships three new routes. + +**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres (no migration) · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · Lucide icons · FabledSword design tokens. + +**Spec:** [`docs/superpowers/specs/2026-04-30-m6a-home-page-design.md`](../specs/2026-04-30-m6a-home-page-design.md). Read it before starting — every decision is explained there. + +**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops), `project_git_workflow.md` (commit on `dev`; PR to `main` separately), `project_ui_quality.md` (this slice raises the bar — no half-built UI). + +--- + +## File map + +### Backend — create + +- `internal/recommendation/home.go` — `HomeData` composite service + `HomePayload` struct +- `internal/recommendation/home_integration_test.go` +- `internal/api/home.go` — `GET /api/home` handler +- `internal/api/home_test.go` +- `internal/api/library_albums.go` — `GET /api/library/albums` handler +- `internal/api/library_albums_test.go` + +### Backend — modify + +- `internal/db/queries/recommendation.sql` — append home-section + rediscover queries +- `internal/db/queries/albums.sql` — append `ListRecentlyAddedAlbumsWithArtist`, `ListAlbumsAlphaWithArtist`, `CountAlbums` +- `internal/db/queries/artists.sql` — append `ListArtistsAlphaWithCovers` +- `internal/db/queries/tracks.sql` — append `ListArtistTracksForUser` +- `internal/db/dbq/*` — regenerated by `sqlc generate` +- `internal/api/types.go` — extend `ArtistRef` + `AlbumRef` +- `internal/api/convert.go` — extend `artistRefFrom` + `albumRefFrom` to populate new fields +- `internal/api/library.go` — replace `ListArtistsAlpha` call with `ListArtistsAlphaWithCovers`; add `handleGetArtistTracks` +- `internal/api/library_test.go` — extend with artist-tracks tests +- `internal/api/api.go` — register `/api/home`, `/api/library/albums`, `/api/artists/{id}/tracks` routes + +### Frontend — create + +- `web/src/lib/api/home.ts` — `listHome()` + `createHomeQuery()` +- `web/src/lib/api/home.test.ts` +- `web/src/lib/api/albums.ts` — `listAlbumsAlpha()` + `createAlbumsAlphaInfiniteQuery()` + `listAlbumTracks()` if extracted later +- `web/src/lib/api/albums.test.ts` +- `web/src/lib/components/HorizontalScrollRow.svelte` +- `web/src/lib/components/HorizontalScrollRow.test.ts` +- `web/src/lib/components/ArtistCard.svelte` +- `web/src/lib/components/ArtistCard.test.ts` +- `web/src/lib/components/CompactTrackCard.svelte` +- `web/src/lib/components/CompactTrackCard.test.ts` +- `web/src/lib/components/AlphabeticalGrid.svelte` +- `web/src/lib/components/AlphabeticalGrid.test.ts` +- `web/src/routes/library/artists/+page.svelte` +- `web/src/routes/library/albums/+page.svelte` + +### Frontend — modify + +- `web/src/lib/api/types.ts` — extend `ArtistRef` + `AlbumRef`, add `HomePayload` +- `web/src/lib/api/queries.ts` — add `qk.home()`, `qk.albumsAlpha()`, `qk.artistTracks()` +- `web/src/lib/api/artists.ts` — create or extend with `listArtistTracks()` +- `web/src/lib/components/AlbumCard.svelte` — FabledSword polish + play-button overlay + `sort_title` exposure +- `web/src/lib/components/AlbumCard.test.ts` — extend with play-overlay test +- `web/src/lib/components/Shell.svelte` — nav order updated +- `web/src/lib/components/Shell.test.ts` — assert new nav items +- `web/src/routes/+page.svelte` — full rewrite as home page +- `web/src/routes/artists.test.ts` — adjust expectations now that `/` is the home + +### Frontend — delete + +- `web/src/lib/components/ArtistRow.svelte` (retired with the click-target bug) +- `web/src/lib/components/ArtistRow.test.ts` + +--- + +## Task list + +### Task 1 — SQL: Recently added + Most played + Last played queries + +**Files:** +- Modify: `internal/db/queries/albums.sql` — append `ListRecentlyAddedAlbumsWithArtist` +- Modify: `internal/db/queries/recommendation.sql` — append `ListMostPlayedTracksForUser`, `ListLastPlayedArtistsForUser` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 1.1: Append `ListRecentlyAddedAlbumsWithArtist` to `albums.sql`** + +Append at the bottom of `internal/db/queries/albums.sql`: + +```sql +-- name: ListRecentlyAddedAlbumsWithArtist :many +-- M6a: recently-added albums joined with artist_name + artist_id for the +-- home-page section. created_at is the row-insert timestamp from the +-- scanner — the closest proxy to "added to library". Stable secondary +-- ordering by id keeps pagination tie-break deterministic. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.created_at DESC, albums.id +LIMIT $1; +``` + +- [ ] **Step 1.2: Append `ListMostPlayedTracksForUser` to `recommendation.sql`** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListMostPlayedTracksForUser :many +-- M6a: top-N tracks by completed-play count for the user. was_skipped +-- excludes skips so a user spamming next doesn't fabricate a top track. +-- Quarantined tracks (per-user soft-hide from M5b) are filtered out. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, + t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, + t.mbid, t.genre, t.added_at, t.updated_at, + albums.title, artists.name +ORDER BY count(*) DESC, t.id +LIMIT $2; +``` + +- [ ] **Step 1.3: Append `ListLastPlayedArtistsForUser` to `recommendation.sql`** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListLastPlayedArtistsForUser :many +-- M6a: artists ranked by max(play_events.started_at) for the user, with +-- a derived cover_album_id via a representative-album lateral join (most +-- recent album that has cover_art_path set). album_count joined for the +-- ArtistRef wire shape. +SELECT sqlc.embed(a), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + max_started.started_at::timestamptz AS last_played_at +FROM artists a +JOIN LATERAL ( + SELECT max(pe.started_at) AS started_at + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND t.artist_id = a.id +) max_started ON max_started.started_at IS NOT NULL +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = a.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = a.id +) cnt ON true +ORDER BY max_started.started_at DESC, a.id +LIMIT $2; +``` + +- [ ] **Step 1.4: Regenerate sqlc** + +Run: + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. New methods `ListRecentlyAddedAlbumsWithArtist`, `ListMostPlayedTracksForUser`, `ListLastPlayedArtistsForUser` appear in `internal/db/dbq/`. + +- [ ] **Step 1.5: Commit** + +```bash +git add internal/db/queries/albums.sql \ + internal/db/queries/recommendation.sql \ + internal/db/dbq/ +git commit -m "feat(db): add M6a home-section queries (recently added, most played, last played)" +``` + +--- + +### Task 2 — SQL: Rediscover queries (albums + artists, with fallbacks) + +**Files:** +- Modify: `internal/db/queries/recommendation.sql` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 2.1: Append rediscover-album queries** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListRediscoverAlbumsForUser :many +-- M6a: albums liked >30 days ago AND not played in the last 14 days, +-- ordered by longest-since-last-play first. The HAVING clause uses an +-- epoch sentinel so albums with NO plays count as eligible (their +-- "last play" is treated as 1970, which is always >14 days ago). +WITH eligible AS ( + SELECT al.id AS album_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_albums gla + JOIN albums al ON al.id = gla.album_id + LEFT JOIN tracks t ON t.album_id = al.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY al.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM eligible e +JOIN albums ON albums.id = e.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +LIMIT $2; + +-- name: ListRediscoverAlbumsFallbackForUser :many +-- M6a: random sample of liked albums for the user. The Go service uses +-- this to top up the rediscover row when ListRediscoverAlbumsForUser +-- returns fewer than `limit` rows. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM general_likes_albums gla +JOIN albums ON albums.id = gla.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2; +``` + +- [ ] **Step 2.2: Append rediscover-artist queries** + +Append at the bottom of `internal/db/queries/recommendation.sql`: + +```sql +-- name: ListRediscoverArtistsForUser :many +-- M6a: artists liked >30 days ago AND none of their tracks played in the +-- last 14 days, ordered by longest-since-last-play. cover_album_id is +-- derived from a representative album (LEFT JOIN LATERAL). +WITH eligible AS ( + SELECT a.id AS artist_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_artists gla + JOIN artists a ON a.id = gla.artist_id + LEFT JOIN tracks t ON t.artist_id = a.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY a.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM eligible e +JOIN artists ON artists.id = e.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +LIMIT $2; + +-- name: ListRediscoverArtistsFallbackForUser :many +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM general_likes_artists gla +JOIN artists ON artists.id = gla.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE gla.user_id = $1 +ORDER BY random() +LIMIT $2; +``` + +- [ ] **Step 2.3: Regenerate sqlc** + +Run: + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. Four new methods (`ListRediscoverAlbumsForUser`, `ListRediscoverAlbumsFallbackForUser`, `ListRediscoverArtistsForUser`, `ListRediscoverArtistsFallbackForUser`) appear in `internal/db/dbq/recommendation.sql.go`. + +- [ ] **Step 2.4: Commit** + +```bash +git add internal/db/queries/recommendation.sql internal/db/dbq/ +git commit -m "feat(db): add M6a rediscover queries (albums + artists, with fallbacks)" +``` + +--- + +### Task 3 — SQL: Library list + artist tracks queries + +**Files:** +- Modify: `internal/db/queries/artists.sql` +- Modify: `internal/db/queries/albums.sql` +- Modify: `internal/db/queries/tracks.sql` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 3.1: Append `ListArtistsAlphaWithCovers` to `artists.sql`** + +Append at the bottom of `internal/db/queries/artists.sql`: + +```sql +-- name: ListArtistsAlphaWithCovers :many +-- M6a: alpha-sorted artist list with derived cover_album_id (most-recent +-- album whose cover_art_path is set). Replaces ListArtistsAlpha for the +-- /api/artists?sort=alpha branch — the new column is appended, so the +-- alpha branch is purely additive on the wire. album_count is computed +-- in the same query to drop the old N+1 in handleListArtists. +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM artists +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY artists.sort_name, artists.name, artists.id +LIMIT $1 OFFSET $2; +``` + +- [ ] **Step 3.2: Append `ListAlbumsAlphaWithArtist` + `CountAlbums` to `albums.sql`** + +Append at the bottom of `internal/db/queries/albums.sql`: + +```sql +-- name: ListAlbumsAlphaWithArtist :many +-- M6a: alpha-sorted album list joined with artist_name. Used by +-- /api/library/albums for the wrapping-grid page. Stable id-tiebreak. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.sort_title, albums.id +LIMIT $1 OFFSET $2; + +-- name: CountAlbums :one +-- M6a: total album count for the /api/library/albums envelope. +SELECT COUNT(*) FROM albums; +``` + +- [ ] **Step 3.3: Append `ListArtistTracksForUser` to `tracks.sql`** + +Append at the bottom of `internal/db/queries/tracks.sql`: + +```sql +-- name: ListArtistTracksForUser :many +-- M6a: every track for the artist across their albums, with album_title +-- and artist_name joined. Honors per-user lidarr_quarantine. Used by +-- /api/artists/{id}/tracks for the artist-card play affordance, which +-- shuffles client-side. Ordering matches album/track natural order so +-- the shuffle has a deterministic input. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.artist_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = t.id + ) +ORDER BY albums.release_date NULLS LAST, albums.sort_title, + t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id; +``` + +- [ ] **Step 3.4: Regenerate sqlc + build** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. New methods appear in `internal/db/dbq/`. + +- [ ] **Step 3.5: Commit** + +```bash +git add internal/db/queries/artists.sql \ + internal/db/queries/albums.sql \ + internal/db/queries/tracks.sql \ + internal/db/dbq/ +git commit -m "feat(db): add M6a library-list + artist-tracks queries" +``` + +--- + +### Task 4 — Recommendation service: `HomeData` + +**Files:** +- Create: `internal/recommendation/home.go` +- Create: `internal/recommendation/home_integration_test.go` + +- [ ] **Step 4.1: Write failing integration test (new-user empty payload)** + +Create `internal/recommendation/home_integration_test.go`: + +```go +package recommendation + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-newuser") + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if got == nil { + t.Fatal("HomeData returned nil payload") + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } + if len(got.RediscoverAlbums) != 0 { + t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums)) + } + if len(got.RediscoverArtists) != 0 { + t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists)) + } + if len(got.MostPlayedTracks) != 0 { + t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks)) + } + if len(got.LastPlayedArtists) != 0 { + t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists)) + } +} + +// Compile-time assert that pgtype.UUID is a valid map key — used by the +// service when de-duping rediscover fallback rows. If this changes, the +// service needs to switch to keying by string. +var _ = func() pgtype.UUID { return pgtype.UUID{} } +``` + +- [ ] **Step 4.2: Run test to verify it fails** + +Run: + +```bash +go test ./internal/recommendation/ -run TestHomeData_NewUser_AllSectionsEmpty -count=1 +``` + +Expected: FAIL with "undefined: HomeData" or similar. + +- [ ] **Step 4.3: Write the service** + +Create `internal/recommendation/home.go`: + +```go +// home.go is the M6a per-user composite home-page service. Reads five +// sections (recently added, rediscover albums, rediscover artists, most +// played tracks, last played artists) in parallel via goroutines and +// assembles them into a single payload for /api/home. +package recommendation + +import ( + "context" + "fmt" + "sync" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Section size caps. SPA splits these into rows on the page. +const ( + HomeRecentlyAddedLimit = 50 + HomeRediscoverLimit = 25 + HomeMostPlayedLimit = 75 + HomeLastPlayedLimit = 25 +) + +// HomePayload is the composite returned by HomeData. All slices are +// non-nil at return time so the API handler can encode `[]` rather than +// `null` for empty sections. +type HomePayload struct { + RecentlyAddedAlbums []dbq.ListRecentlyAddedAlbumsWithArtistRow + RediscoverAlbums []dbq.ListRediscoverAlbumsForUserRow + RediscoverArtists []dbq.ListRediscoverArtistsForUserRow + MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow + LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow +} + +// HomeData runs five queries in parallel and assembles the payload. +// Rediscover sections fall back to a random sample of liked entities +// when the eligibility query returns fewer than HomeRediscoverLimit rows. +// Any single query error fails the whole call (predictable empty-or-full +// UX for the SPA — partial payloads are not a feature). +func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*HomePayload, error) { + q := dbq.New(pool) + + var ( + wg sync.WaitGroup + mu sync.Mutex + firstErr error + ) + fail := func(stage string, err error) { + mu.Lock() + defer mu.Unlock() + if firstErr == nil { + firstErr = fmt.Errorf("home: %s: %w", stage, err) + } + } + + out := &HomePayload{ + RecentlyAddedAlbums: []dbq.ListRecentlyAddedAlbumsWithArtistRow{}, + RediscoverAlbums: []dbq.ListRediscoverAlbumsForUserRow{}, + RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{}, + MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{}, + LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{}, + } + + wg.Add(5) + go func() { + defer wg.Done() + rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit) + if err != nil { + fail("recently_added", err) + return + } + out.RecentlyAddedAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListMostPlayedTracksForUser(ctx, dbq.ListMostPlayedTracksForUserParams{ + UserID: userID, Limit: HomeMostPlayedLimit, + }) + if err != nil { + fail("most_played", err) + return + } + out.MostPlayedTracks = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListLastPlayedArtistsForUser(ctx, dbq.ListLastPlayedArtistsForUserParams{ + UserID: userID, Limit: HomeLastPlayedLimit, + }) + if err != nil { + fail("last_played", err) + return + } + out.LastPlayedArtists = rows + }() + go func() { + defer wg.Done() + rows, err := loadRediscoverAlbums(ctx, q, userID) + if err != nil { + fail("rediscover_albums", err) + return + } + out.RediscoverAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := loadRediscoverArtists(ctx, q, userID) + if err != nil { + fail("rediscover_artists", err) + return + } + out.RediscoverArtists = rows + }() + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + return out, nil +} + +func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) { + primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + if len(primary) >= HomeRediscoverLimit { + return primary, nil + } + fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + seen := make(map[pgtype.UUID]struct{}, len(primary)) + for _, r := range primary { + seen[r.Album.ID] = struct{}{} + } + for _, r := range fallback { + if _, dup := seen[r.Album.ID]; dup { + continue + } + // Fallback rows have the same shape after a manual projection + // (ListRediscoverAlbumsFallbackForUserRow → ListRediscoverAlbumsForUserRow). + primary = append(primary, dbq.ListRediscoverAlbumsForUserRow{ + Album: r.Album, + ArtistName: r.ArtistName, + }) + if len(primary) >= HomeRediscoverLimit { + break + } + seen[r.Album.ID] = struct{}{} + } + return primary, nil +} + +func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverArtistsForUserRow, error) { + primary, err := q.ListRediscoverArtistsForUser(ctx, dbq.ListRediscoverArtistsForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + if len(primary) >= HomeRediscoverLimit { + return primary, nil + } + fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ + UserID: userID, Limit: HomeRediscoverLimit, + }) + if err != nil { + return nil, err + } + seen := make(map[pgtype.UUID]struct{}, len(primary)) + for _, r := range primary { + seen[r.Artist.ID] = struct{}{} + } + for _, r := range fallback { + if _, dup := seen[r.Artist.ID]; dup { + continue + } + primary = append(primary, dbq.ListRediscoverArtistsForUserRow{ + Artist: r.Artist, + CoverAlbumID: r.CoverAlbumID, + AlbumCount: r.AlbumCount, + }) + if len(primary) >= HomeRediscoverLimit { + break + } + seen[r.Artist.ID] = struct{}{} + } + return primary, nil +} +``` + +- [ ] **Step 4.4: Run test to verify it passes** + +Run: + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/recommendation/ -run TestHomeData_NewUser_AllSectionsEmpty -count=1 +``` + +Expected: PASS. + +- [ ] **Step 4.5: Add deeper integration tests** + +Append to `internal/recommendation/home_integration_test.go`: + +```go +import ( + "time" +) + +func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-recents") + a1 := seedArtist(t, pool, "ArtistA", "") + // Three albums, oldest first — created_at increases naturally with each insert. + al1 := seedAlbumForArtist(t, pool, a1.ID, "Older") + al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle") + al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest") + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.RecentlyAddedAlbums) != 3 { + t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums)) + } + if got.RecentlyAddedAlbums[0].Album.ID != al3.ID || + got.RecentlyAddedAlbums[1].Album.ID != al2.ID || + got.RecentlyAddedAlbums[2].Album.ID != al1.ID { + t.Errorf("order = [%s, %s, %s], want newest→oldest", + got.RecentlyAddedAlbums[0].Album.Title, + got.RecentlyAddedAlbums[1].Album.Title, + got.RecentlyAddedAlbums[2].Album.Title) + } +} + +func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-mostplayed") + artist := seedArtist(t, pool, "ArtistB", "") + album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB") + tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High") + tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low") + tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined") + now := time.Now() + for i := 0; i < 3; i++ { + insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute)) + } + insertPlayEvent(t, pool, user.ID, tLow.ID, now) + for i := 0; i < 5; i++ { + insertPlayEvent(t, pool, user.ID, tQuar.ID, now) + } + if _, err := pool.Exec(context.Background(), + `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + user.ID, tQuar.ID, + ); err != nil { + t.Fatalf("insert quarantine: %v", err) + } + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.MostPlayedTracks) != 2 { + t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks)) + } + if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID { + t.Errorf("ranking wrong: got [%s, %s], want [High, Low]", + got.MostPlayedTracks[0].Track.Title, + got.MostPlayedTracks[1].Track.Title) + } +} + +func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-rediscover") + artist := seedArtist(t, pool, "ArtistC", "") + // Like one album NOW (won't satisfy >30d eligibility) — should still + // surface via fallback because primary returns 0 rows. + al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike") + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`, + user.ID, al.ID, + ); err != nil { + t.Fatalf("insert like: %v", err) + } + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.RediscoverAlbums) != 1 { + t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums)) + } + if got.RediscoverAlbums[0].Album.ID != al.ID { + t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title) + } +} +``` + +- [ ] **Step 4.6: Run all home tests** + +Run: + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/recommendation/ -run TestHomeData -count=1 -v +``` + +Expected: 3/3 PASS. + +- [ ] **Step 4.7: Commit** + +```bash +git add internal/recommendation/home.go \ + internal/recommendation/home_integration_test.go +git commit -m "feat(recommendation): add HomeData composite service for /api/home" +``` + +--- + +### Task 5 — API types + convert helpers + +**Files:** +- Modify: `internal/api/types.go` +- Modify: `internal/api/convert.go` +- Modify: `internal/api/library.go` (handler signature follow-up — covered separately in Task 9) + +- [ ] **Step 5.1: Extend `ArtistRef` and `AlbumRef` in `types.go`** + +Modify `internal/api/types.go` — replace the `ArtistRef` and `AlbumRef` blocks: + +```go +// ArtistRef is the lightweight artist shape used in lists and search results. +// SortName is exposed so the SPA's alphabetical-grid divider can group rows +// by the catalogue sort order ("The Beatles" → "B"). CoverURL is derived from +// a representative album (most-recent with cover_art_path); empty when the +// artist's discography has no cover art. +type ArtistRef struct { + ID string `json:"id"` + Name string `json:"name"` + SortName string `json:"sort_name"` + AlbumCount int `json:"album_count"` + CoverURL string `json:"cover_url"` +} + +// AlbumRef is the lightweight album shape used in lists, artist details, +// and search results. SortTitle is exposed so the SPA's alphabetical-grid +// divider can group rows by sort order ("The Wall" → "W"). CoverURL points +// to /api/albums/{id}/cover. +type AlbumRef struct { + ID string `json:"id"` + Title string `json:"title"` + SortTitle string `json:"sort_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"` +} +``` + +- [ ] **Step 5.2: Add `HomePayload` view type to `types.go`** + +Append at the bottom of `internal/api/types.go`: + +```go +// HomePayload is the response body of GET /api/home. Field counts: +// 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists) +// / 75 (most_played) / 25 (last_played). All slices are non-nil at JSON +// encode time so empty sections render as [] rather than null. +type HomePayload struct { + RecentlyAddedAlbums []AlbumRef `json:"recently_added_albums"` + RediscoverAlbums []AlbumRef `json:"rediscover_albums"` + RediscoverArtists []ArtistRef `json:"rediscover_artists"` + MostPlayedTracks []TrackRef `json:"most_played_tracks"` + LastPlayedArtists []ArtistRef `json:"last_played_artists"` +} +``` + +- [ ] **Step 5.3: Update `albumRefFrom` to populate `SortTitle`** + +Modify `internal/api/convert.go` — replace the `albumRefFrom` function: + +```go +// 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, + SortTitle: a.SortTitle, + ArtistID: uuidToString(a.ArtistID), + ArtistName: artistName, + Year: yearFromDate(a.ReleaseDate), + TrackCount: trackCount, + DurationSec: durationSec, + CoverURL: coverURL(a.ID), + } +} +``` + +- [ ] **Step 5.4: Update `artistRefFrom` to populate `SortName` (existing call sites use empty cover)** + +Modify `internal/api/convert.go` — replace the `artistRefFrom` function and add a covered variant: + +```go +// artistRefFrom projects a dbq.Artist into an ArtistRef without cover. +// albumCount must be pre-computed by the caller. Used by code paths that +// don't have a representative-album lookup at hand (artist detail, search, +// liked-artists list). +func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef { + return ArtistRef{ + ID: uuidToString(a.ID), + Name: a.Name, + SortName: a.SortName, + AlbumCount: albumCount, + } +} + +// artistRefFromCovered projects a dbq.Artist into an ArtistRef with the +// derived CoverURL populated from a representative album id (nullable). +// Used by /api/home and /api/artists?sort=alpha which carry the lookup +// in the same query. +func artistRefFromCovered(a dbq.Artist, albumCount int, coverAlbumID pgtype.UUID) ArtistRef { + ref := artistRefFrom(a, albumCount) + if coverAlbumID.Valid { + ref.CoverURL = coverURL(coverAlbumID) + } + return ref +} +``` + +- [ ] **Step 5.5: Run vet + build** + +```bash +go vet ./internal/api/... +go build ./... +``` + +Expected: clean build. Existing call sites continue to compile because the appended fields default to zero values — JSON marshal still includes them as `"sort_name": ""`, which is harmless. + +- [ ] **Step 5.6: Commit** + +```bash +git add internal/api/types.go internal/api/convert.go +git commit -m "feat(api): extend ArtistRef/AlbumRef + add HomePayload for M6a" +``` + +--- + +### Task 6 — `GET /api/home` handler + +**Files:** +- Create: `internal/api/home.go` +- Create: `internal/api/home_test.go` + +- [ ] **Step 6.1: Write failing test for the happy-path empty payload** + +Create `internal/api/home_test.go`: + +```go +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newHomeRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/home", h.handleGetHome) + return r +} + +func doGetHome(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/home", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newHomeRouter(h).ServeHTTP(w, req) + return w +} + +func TestHome_EmptyForNewUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + w := doGetHome(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got HomePayload + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + // All slices must be non-nil so the SPA branches consistently on + // length rather than dealing with null. + if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil || + got.RediscoverArtists == nil || got.MostPlayedTracks == nil || + got.LastPlayedArtists == nil { + t.Errorf("payload has nil slice: %+v", got) + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } +} + +func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(t.Context(), dbq.UpsertArtistParams{ + Name: "ArtistOne", SortName: "ArtistOne", + }) + _, _ = q.UpsertAlbum(t.Context(), dbq.UpsertAlbumParams{ + Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID, + }) + + w := doGetHome(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got HomePayload + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.RecentlyAddedAlbums) != 1 { + t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums)) + } + if got.RecentlyAddedAlbums[0].Title != "Newest" { + t.Errorf("title = %q, want Newest", got.RecentlyAddedAlbums[0].Title) + } + if got.RecentlyAddedAlbums[0].ArtistName != "ArtistOne" { + t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName) + } +} +``` + +NOTE: this test uses `t.Context()`. The repo standard is `context.Background()` because Go 1.23 doesn't have `t.Context()` — replace before saving: + +```go +import "context" +// then: context.Background() everywhere instead of t.Context() +``` + +- [ ] **Step 6.2: Run test to verify it fails** + +```bash +go test ./internal/api/ -run TestHome_ -count=1 +``` + +Expected: FAIL with "undefined: handleGetHome" or similar. + +- [ ] **Step 6.3: Write the handler** + +Create `internal/api/home.go`: + +```go +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// handleGetHome implements GET /api/home. Returns five sections in one +// payload; sections that have no rows render as []. 500 on any underlying +// DB failure (no partial payload — the SPA should render either empty +// states or full content, not a half-broken page). +func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + data, err := recommendation.HomeData(r.Context(), h.pool, user.ID) + if err != nil { + h.logger.Error("api: home data", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "failed to load home") + return + } + + out := HomePayload{ + RecentlyAddedAlbums: make([]AlbumRef, 0, len(data.RecentlyAddedAlbums)), + RediscoverAlbums: make([]AlbumRef, 0, len(data.RediscoverAlbums)), + RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)), + MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)), + LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)), + } + for _, row := range data.RecentlyAddedAlbums { + out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.RediscoverAlbums { + out.RediscoverAlbums = append(out.RediscoverAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.RediscoverArtists { + out.RediscoverArtists = append(out.RediscoverArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + for _, row := range data.MostPlayedTracks { + out.MostPlayedTracks = append(out.MostPlayedTracks, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + for _, row := range data.LastPlayedArtists { + out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + writeJSON(w, http.StatusOK, out) +} + +// Compile-time guard that dbq has the row types we need; if the SQL is +// regenerated with a different name, this fails fast at build time. +var _ = dbq.ListMostPlayedTracksForUserRow{}.Track +``` + +- [ ] **Step 6.4: Run tests to verify they pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -run TestHome_ -count=1 -v +``` + +Expected: 2/2 PASS. + +- [ ] **Step 6.5: Commit** + +```bash +git add internal/api/home.go internal/api/home_test.go +git commit -m "feat(api): add GET /api/home composite handler" +``` + +--- + +### Task 7 — `GET /api/library/albums` handler + +**Files:** +- Create: `internal/api/library_albums.go` +- Create: `internal/api/library_albums_test.go` + +- [ ] **Step 7.1: Write failing test** + +Create `internal/api/library_albums_test.go`: + +```go +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newLibraryAlbumsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/library/albums", h.handleListLibraryAlbums) + return r +} + +func doListLibraryAlbums(h *handlers, user dbq.User, query string) *httptest.ResponseRecorder { + url := "/api/library/albums" + if query != "" { + url += "?" + query + } + req := httptest.NewRequest(http.MethodGet, url, nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newLibraryAlbumsRouter(h).ServeHTTP(w, req) + return w +} + +func TestLibraryAlbums_AlphaSorted(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Zoo", SortTitle: "Zoo", ArtistID: artist.ID, + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Apple", SortTitle: "Apple", ArtistID: artist.ID, + }) + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Mango", SortTitle: "Mango", ArtistID: artist.ID, + }) + + w := doListLibraryAlbums(h, user, "limit=10") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got Page[AlbumRef] + 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) + } + titles := []string{got.Items[0].Title, got.Items[1].Title, got.Items[2].Title} + if titles[0] != "Apple" || titles[1] != "Mango" || titles[2] != "Zoo" { + t.Errorf("titles = %v, want [Apple Mango Zoo]", titles) + } + if got.Items[0].SortTitle != "Apple" { + t.Errorf("sort_title = %q, want Apple", got.Items[0].SortTitle) + } +} + +func TestLibraryAlbums_Paging(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + for _, title := range []string{"A", "B", "C", "D"} { + _, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, ArtistID: artist.ID, + }) + } + + w := doListLibraryAlbums(h, user, "limit=2&offset=2") + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got Page[AlbumRef] + _ = json.Unmarshal(w.Body.Bytes(), &got) + if got.Total != 4 || got.Limit != 2 || got.Offset != 2 { + t.Errorf("envelope = %+v, want total=4 limit=2 offset=2", got) + } + if len(got.Items) != 2 || got.Items[0].Title != "C" || got.Items[1].Title != "D" { + t.Errorf("items = %v, want [C D]", got.Items) + } +} +``` + +- [ ] **Step 7.2: Run test to verify it fails** + +```bash +go test ./internal/api/ -run TestLibraryAlbums_ -count=1 +``` + +Expected: FAIL with "undefined: handleListLibraryAlbums". + +- [ ] **Step 7.3: Write the handler** + +Create `internal/api/library_albums.go`: + +```go +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// handleListLibraryAlbums implements GET /api/library/albums. Mirrors +// /api/artists?sort=alpha but for albums. The new wrapping-grid page on +// the SPA infinite-scrolls against this endpoint via TanStack +// createInfiniteQuery. +func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) { + limit, offset, err := parsePaging(r.URL.Query()) + if err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + q := dbq.New(h.pool) + rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list library albums", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, err := q.CountAlbums(r.Context()) + if err != nil { + h.logger.Error("api: count albums", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]AlbumRef, 0, len(rows)) + for _, row := range rows { + items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + writeJSON(w, http.StatusOK, Page[AlbumRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) +} +``` + +- [ ] **Step 7.4: Run tests to verify they pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -run TestLibraryAlbums_ -count=1 -v +``` + +Expected: 2/2 PASS. + +- [ ] **Step 7.5: Commit** + +```bash +git add internal/api/library_albums.go internal/api/library_albums_test.go +git commit -m "feat(api): add GET /api/library/albums handler" +``` + +--- + +### Task 8 — `GET /api/artists/{id}/tracks` handler + +**Files:** +- Modify: `internal/api/library.go` (append `handleGetArtistTracks`) +- Modify: `internal/api/library_test.go` (append tests) + +- [ ] **Step 8.1: Write failing tests** + +Append to `internal/api/library_test.go` at the bottom (preserve all existing tests): + +```go +func TestGetArtistTracks_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistA", SortName: "ArtistA", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "AlbumA", SortTitle: "AlbumA", ArtistID: artist.ID, + }) + _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T1", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-T1.mp3", + FileSize: 1, FileFormat: "mp3", + }) + _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T2", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-T2.mp3", + FileSize: 1, FileFormat: "mp3", + }) + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.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", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ArtistName != "ArtistA" || got[0].AlbumTitle != "AlbumA" { + t.Errorf("got = %+v", got[0]) + } +} + +func TestGetArtistTracks_HonorsQuarantine(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistB", SortName: "ArtistB", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "AlbumB", SortTitle: "AlbumB", ArtistID: artist.ID, + }) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Q", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: "/tmp/m6a-Q.mp3", + FileSize: 1, FileFormat: "mp3", + }) + if _, err := pool.Exec(context.Background(), + `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + user.ID, track.ID, + ); err != nil { + t.Fatalf("quarantine insert: %v", err) + } + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got []TrackRef + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got) != 0 { + t.Errorf("len = %d, want 0 (quarantined)", len(got)) + } +} + +func TestGetArtistTracks_NotFound(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + r := chi.NewRouter() + r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) + // Random UUID — no such artist exists. + req := httptest.NewRequest(http.MethodGet, "/api/artists/00000000-0000-0000-0000-000000000000/tracks", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } + _ = pool +} +``` + +- [ ] **Step 8.2: Run tests to verify they fail** + +```bash +go test ./internal/api/ -run TestGetArtistTracks_ -count=1 +``` + +Expected: FAIL with "undefined: handleGetArtistTracks". + +- [ ] **Step 8.3: Append the handler to `library.go`** + +Append at the bottom of `internal/api/library.go`: + +```go +// handleGetArtistTracks implements GET /api/artists/{id}/tracks. Returns +// every track the artist has across albums (per-user-quarantine filtered) +// as a flat list. Used by ArtistCard's play affordance, which shuffles +// client-side. 404 when the artist doesn't exist. +func (h *handlers) handleGetArtistTracks(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 + } + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + q := dbq.New(h.pool) + if _, err := q.GetArtistByID(r.Context(), id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "artist not found") + return + } + h.logger.Error("api: get artist for tracks", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + rows, err := q.ListArtistTracksForUser(r.Context(), dbq.ListArtistTracksForUserParams{ + ArtistID: id, UserID: user.ID, + }) + if err != nil { + h.logger.Error("api: list artist tracks", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + out := make([]TrackRef, 0, len(rows)) + for _, row := range rows { + out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + writeJSON(w, http.StatusOK, out) +} +``` + +- [ ] **Step 8.4: Run tests to verify they pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -run TestGetArtistTracks_ -count=1 -v +``` + +Expected: 3/3 PASS. + +- [ ] **Step 8.5: Commit** + +```bash +git add internal/api/library.go internal/api/library_test.go +git commit -m "feat(api): add GET /api/artists/{id}/tracks handler" +``` + +--- + +### Task 9 — Update `/api/artists?sort=alpha` to use covers query + register routes + +**Files:** +- Modify: `internal/api/library.go` (`handleListArtists` alpha branch) +- Modify: `internal/api/library_test.go` (existing alpha tests still pass; add cover-url assertion) +- Modify: `internal/api/api.go` + +- [ ] **Step 9.1: Update `handleListArtists` alpha branch to use the new query** + +Modify `internal/api/library.go` — the `handleListArtists` function's switch block: + +```go + switch sort { + case "newest": + artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list artists newest", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, terr := q.CountArtists(r.Context()) + if terr != nil { + h.logger.Error("api: count artists", "err", terr) + 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", "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, + }) + return + + default: // alpha + rows, err := q.ListArtistsAlphaWithCovers(r.Context(), dbq.ListArtistsAlphaWithCoversParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + h.logger.Error("api: list artists alpha", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + total, terr := q.CountArtists(r.Context()) + if terr != nil { + h.logger.Error("api: count artists", "err", terr) + writeErr(w, http.StatusInternalServerError, "server_error", "count failed") + return + } + items := make([]ArtistRef, 0, len(rows)) + for _, row := range rows { + items = append(items, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + writeJSON(w, http.StatusOK, Page[ArtistRef]{ + Items: items, Total: int(total), Limit: limit, Offset: offset, + }) + return + } +``` + +Then delete the redundant `_ = artists; _ = err` and old shared `total` block at the bottom of the function — both branches now return inline. The function should end after the `switch` with no further statements. + +- [ ] **Step 9.2: Register routes in `api.go`** + +Modify `internal/api/api.go` — add three new routes inside the authenticated group, alongside the existing `authed.Get("/artists/...", ...)` lines: + +```go + authed.Get("/home", h.handleGetHome) + authed.Get("/library/albums", h.handleListLibraryAlbums) + authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) +``` + +Place these adjacent to the existing artist/album/track registrations for readability. The exact location: + +```go + authed.Get("/artists", h.handleListArtists) + authed.Get("/artists/{id}", h.handleGetArtist) + authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) // NEW + authed.Get("/albums/{id}", h.handleGetAlbum) + authed.Get("/albums/{id}/cover", h.handleGetCover) + authed.Get("/library/albums", h.handleListLibraryAlbums) // NEW + authed.Get("/tracks/{id}", h.handleGetTrack) + authed.Get("/tracks/{id}/stream", h.handleGetStream) + authed.Get("/search", h.handleSearch) + authed.Get("/radio", h.handleRadio) + authed.Get("/home", h.handleGetHome) // NEW +``` + +- [ ] **Step 9.3: Run all api tests** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./internal/api/ -count=1 +``` + +Expected: all PASS. Existing artist-list tests must continue to pass — the alpha branch now exposes `cover_url` (empty string when no cover) and `sort_name` (always populated). + +- [ ] **Step 9.4: Commit** + +```bash +git add internal/api/library.go internal/api/api.go +git commit -m "feat(api): wire M6a routes; alpha artist list uses covers query" +``` + +--- + +### Task 10 — Frontend: TS types + queries.ts + +**Files:** +- Modify: `web/src/lib/api/types.ts` +- Modify: `web/src/lib/api/queries.ts` + +- [ ] **Step 10.1: Extend `ArtistRef` and `AlbumRef`; add `HomePayload`** + +Modify `web/src/lib/api/types.ts` — replace the `ArtistRef` and `AlbumRef` blocks: + +```ts +export type ArtistRef = { + id: string; + name: string; + sort_name: string; + album_count: number; + cover_url: string; // "" when no representative album cover +}; + +export type AlbumRef = { + id: string; + title: string; + sort_title: string; + artist_id: string; + artist_name: string; + year?: number; + track_count: number; + duration_sec: number; + cover_url: string; +}; +``` + +Append at the bottom of `web/src/lib/api/types.ts`: + +```ts +// Mirrors internal/api/types.go HomePayload. All slices are non-null +// per the server contract — empty sections render as []. +export type HomePayload = { + recently_added_albums: AlbumRef[]; + rediscover_albums: AlbumRef[]; + rediscover_artists: ArtistRef[]; + most_played_tracks: TrackRef[]; + last_played_artists: ArtistRef[]; +}; +``` + +- [ ] **Step 10.2: Add new query keys** + +Modify `web/src/lib/api/queries.ts` — append entries to the `qk` object literal: + +```ts + home: () => ['home'] as const, + albumsAlpha: () => ['albumsAlpha'] as const, + artistTracks: (artistId: string) => ['artistTracks', artistId] as const, +``` + +- [ ] **Step 10.3: Update existing queries.test.ts** + +Append to `web/src/lib/api/queries.test.ts`: + +```ts +test('M6a query keys are stable', () => { + expect(qk.home()).toEqual(['home']); + expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); + expect(qk.artistTracks('a1')).toEqual(['artistTracks', 'a1']); +}); +``` + +- [ ] **Step 10.4: Update existing test fixtures for new required fields** + +Search for AlbumRef/ArtistRef literals in tests: + +```bash +cd web && grep -rln "album_count\|track_count" src/ --include="*.test.ts" +``` + +For each file with an AlbumRef literal, add `sort_title: ''`. For each ArtistRef literal, add `sort_name: ''` and `cover_url: ''`. Example for `web/src/lib/components/AlbumCard.test.ts`: + +```ts +const album: AlbumRef = { + id: 'xyz', + title: 'Kind of Blue', + sort_title: 'Kind of Blue', + artist_id: 'm-davis', + artist_name: 'Miles Davis', + year: 1959, + track_count: 5, + duration_sec: 2630, + cover_url: '/api/albums/xyz/cover', +}; +``` + +- [ ] **Step 10.5: Run all frontend tests** + +```bash +cd web && pnpm check && pnpm test +``` + +Expected: all PASS. + +- [ ] **Step 10.6: Commit** + +```bash +git add web/src/lib/api/types.ts web/src/lib/api/queries.ts web/src/lib/api/queries.test.ts \ + web/src/lib/components/*.test.ts +git commit -m "feat(web): extend ArtistRef/AlbumRef + add HomePayload + new query keys" +``` + +--- + +### Task 11 — Frontend API clients: home.ts + albums.ts + artists.ts + +**Files:** +- Create: `web/src/lib/api/home.ts` + `home.test.ts` +- Create: `web/src/lib/api/albums.ts` + `albums.test.ts` +- Create: `web/src/lib/api/artists.ts` + `artists.test.ts` + +- [ ] **Step 11.1: Write failing tests for `home.ts`** + +Create `web/src/lib/api/home.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listHome } from './home'; +import { qk } from './queries'; +import { api } from './client'; +import type { HomePayload } from './types'; + +afterEach(() => vi.clearAllMocks()); + +describe('home client', () => { + test('listHome hits /api/home', async () => { + const fixture: HomePayload = { + recently_added_albums: [], + rediscover_albums: [], + rediscover_artists: [], + most_played_tracks: [], + last_played_artists: [] + }; + (api.get as ReturnType).mockResolvedValueOnce(fixture); + const got = await listHome(); + expect(api.get).toHaveBeenCalledWith('/api/home'); + expect(got).toEqual(fixture); + }); + + test('qk.home key is stable', () => { + expect(qk.home()).toEqual(['home']); + }); +}); +``` + +- [ ] **Step 11.2: Create `home.ts`** + +Create `web/src/lib/api/home.ts`: + +```ts +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { HomePayload } from './types'; + +export async function listHome(): Promise { + return api.get('/api/home'); +} + +// staleTime = 60s — repeat home-page mounts within a minute reuse cached +// data. Like-state changes, new plays, freshly-added albums surface on +// the next refetch. +export function createHomeQuery() { + return createQuery({ + queryKey: qk.home(), + queryFn: listHome, + staleTime: 60_000 + }); +} +``` + +- [ ] **Step 11.3: Write failing tests for `albums.ts`** + +Create `web/src/lib/api/albums.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listAlbumsAlpha } from './albums'; +import { qk } from './queries'; +import { api } from './client'; + +afterEach(() => vi.clearAllMocks()); + +describe('albums client', () => { + test('listAlbumsAlpha hits /api/library/albums with paging', async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + items: [], total: 0, limit: 50, offset: 0 + }); + await listAlbumsAlpha(50, 0); + expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=50&offset=0'); + }); + + test('listAlbumsAlpha honors custom limit and offset', async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + items: [], total: 0, limit: 25, offset: 100 + }); + await listAlbumsAlpha(25, 100); + expect(api.get).toHaveBeenCalledWith('/api/library/albums?limit=25&offset=100'); + }); + + test('qk.albumsAlpha key is stable', () => { + expect(qk.albumsAlpha()).toEqual(['albumsAlpha']); + }); +}); +``` + +- [ ] **Step 11.4: Create `albums.ts`** + +Create `web/src/lib/api/albums.ts`: + +```ts +import { createInfiniteQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { AlbumRef, Page } from './types'; + +export const ALBUM_PAGE_SIZE = 50; + +export async function listAlbumsAlpha(limit: number, offset: number): Promise> { + return api.get>(`/api/library/albums?limit=${limit}&offset=${offset}`); +} + +export function createAlbumsAlphaInfiniteQuery() { + return createInfiniteQuery({ + queryKey: qk.albumsAlpha(), + queryFn: ({ pageParam = 0 }) => listAlbumsAlpha(ALBUM_PAGE_SIZE, pageParam), + initialPageParam: 0, + getNextPageParam: (last) => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + } + }); +} +``` + +- [ ] **Step 11.5: Write failing tests for `artists.ts`** + +Create `web/src/lib/api/artists.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listArtistTracks } from './artists'; +import { qk } from './queries'; +import { api } from './client'; + +afterEach(() => vi.clearAllMocks()); + +describe('artists client', () => { + test('listArtistTracks hits /api/artists/:id/tracks', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await listArtistTracks('art-1'); + expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); + }); + + test('qk.artistTracks key includes the artist id', () => { + expect(qk.artistTracks('art-1')).toEqual(['artistTracks', 'art-1']); + }); +}); +``` + +- [ ] **Step 11.6: Create `artists.ts`** + +Create `web/src/lib/api/artists.ts`: + +```ts +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { TrackRef } from './types'; + +export async function listArtistTracks(artistId: string): Promise { + return api.get(`/api/artists/${artistId}/tracks`); +} + +export function createArtistTracksQuery(artistId: string) { + return createQuery({ + queryKey: qk.artistTracks(artistId), + queryFn: () => listArtistTracks(artistId), + enabled: artistId.length > 0 + }); +} +``` + +- [ ] **Step 11.7: Run all client tests + commit** + +```bash +cd web && pnpm test src/lib/api/home.test.ts src/lib/api/albums.test.ts src/lib/api/artists.test.ts +``` + +Expected: all PASS. Commit: + +```bash +git add web/src/lib/api/home.ts web/src/lib/api/home.test.ts \ + web/src/lib/api/albums.ts web/src/lib/api/albums.test.ts \ + web/src/lib/api/artists.ts web/src/lib/api/artists.test.ts +git commit -m "feat(web): add home/albums/artists API clients for M6a" +``` + +--- + +### Task 12 — `` component + +**Files:** +- Create: `web/src/lib/components/HorizontalScrollRow.svelte` +- Create: `web/src/lib/components/HorizontalScrollRow.test.ts` + +- [ ] **Step 12.1: Write failing test** + +Create `web/src/lib/components/HorizontalScrollRow.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import HorizontalScrollRow from './HorizontalScrollRow.svelte'; + +afterEach(() => vi.clearAllMocks()); + +describe('HorizontalScrollRow', () => { + test('renders left and right scroll buttons', () => { + render(HorizontalScrollRow, { props: { items: ['a', 'b', 'c'] } }); + expect(screen.getByRole('button', { name: /scroll left/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /scroll right/i })).toBeInTheDocument(); + }); + + test('left button starts disabled (scrollLeft is 0)', () => { + render(HorizontalScrollRow, { props: { items: ['a'] } }); + expect(screen.getByRole('button', { name: /scroll left/i })).toBeDisabled(); + }); + + test('clicking right button calls scrollBy on the container', async () => { + const { container } = render(HorizontalScrollRow, { props: { items: ['a', 'b'] } }); + const scroller = container.querySelector('[data-testid="scroll-container"]') as HTMLElement; + const spy = vi.fn(); + scroller.scrollBy = spy as unknown as HTMLElement['scrollBy']; + Object.defineProperty(scroller, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(scroller, 'scrollWidth', { value: 2000, configurable: true }); + Object.defineProperty(scroller, 'scrollLeft', { value: 0, configurable: true, writable: true }); + + await fireEvent.click(screen.getByRole('button', { name: /scroll right/i })); + expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' }); + expect(spy.mock.calls[0][0].left).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 12.2: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/HorizontalScrollRow.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 12.3: Create the component** + +Create `web/src/lib/components/HorizontalScrollRow.svelte`: + +```svelte + + +
+ + +
+ {#each items as it, i (i)} + {@render item?.(it, i)} + {/each} +
+ + +
+ + +``` + +- [ ] **Step 12.4: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/HorizontalScrollRow.test.ts +``` + +Expected: 3/3 PASS. Commit: + +```bash +git add web/src/lib/components/HorizontalScrollRow.svelte \ + web/src/lib/components/HorizontalScrollRow.test.ts +git commit -m "feat(web): add HorizontalScrollRow component" +``` + +--- + +### Task 13 — `` polish + play overlay + +**Files:** +- Modify: `web/src/lib/components/AlbumCard.svelte` +- Modify: `web/src/lib/components/AlbumCard.test.ts` + +- [ ] **Step 13.1: Update existing test mock to include `playQueue`** + +Edit `web/src/lib/components/AlbumCard.test.ts` — change the player mock at the top of the file: + +```ts +vi.mock('$lib/player/store.svelte', () => ({ + enqueueTracks: vi.fn(), + playQueue: vi.fn() +})); +``` + +And the corresponding import: + +```ts +import { enqueueTracks, playQueue } from '$lib/player/store.svelte'; +``` + +- [ ] **Step 13.2: Append failing test for play overlay** + +Append inside the `describe('AlbumCard', ...)` block: + +```ts +test('play overlay click fetches album detail and calls playQueue at index 0', async () => { + const tracks: TrackRef[] = [ + { id: 't1', title: 'So What', + album_id: 'xyz', album_title: 'Kind of Blue', + artist_id: 'm-davis', artist_name: 'Miles Davis', + track_number: 1, disc_number: 1, duration_sec: 545, + stream_url: '/api/tracks/t1/stream' } + ]; + const detail: AlbumDetail = { ...album, tracks }; + (api.get as ReturnType).mockResolvedValueOnce(detail); + + render(AlbumCard, { props: { album } }); + await fireEvent.click(screen.getByRole('button', { name: /play kind of blue/i })); + expect(api.get).toHaveBeenCalledWith('/api/albums/xyz'); + await Promise.resolve(); + await Promise.resolve(); + expect(playQueue).toHaveBeenCalledWith(tracks, 0); +}); +``` + +- [ ] **Step 13.3: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/AlbumCard.test.ts +``` + +Expected: FAIL — no play button exists. + +- [ ] **Step 13.4: Rewrite `AlbumCard.svelte`** + +Replace the entire contents of `web/src/lib/components/AlbumCard.svelte`: + +```svelte + + + + + +``` + +- [ ] **Step 13.5: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/AlbumCard.test.ts +``` + +Expected: all PASS. Commit: + +```bash +git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts +git commit -m "feat(web): polish AlbumCard with FabledSword tokens + play overlay" +``` + +--- + +### Task 14 — `` (circular) + +**Files:** +- Create: `web/src/lib/components/ArtistCard.svelte` +- Create: `web/src/lib/components/ArtistCard.test.ts` + +- [ ] **Step 14.1: Write failing test** + +Create `web/src/lib/components/ArtistCard.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { ArtistRef, TrackRef } from '$lib/api/types'; + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn() } +})); +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn() +})); + +import ArtistCard from './ArtistCard.svelte'; +import { api } from '$lib/api/client'; +import { playQueue } from '$lib/player/store.svelte'; + +const artist: ArtistRef = { + id: 'art-1', + name: 'Boards of Canada', + sort_name: 'Boards of Canada', + album_count: 5, + cover_url: '/api/albums/cov-1/cover' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('ArtistCard', () => { + test('renders cover img inside link to /artists/:id', () => { + const { container } = render(ArtistCard, { props: { artist } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/artists/art-1'); + const img = container.querySelector('img') as HTMLImageElement; + expect(img.src).toContain('/api/albums/cov-1/cover'); + }); + + test('falls back to Disc3 glyph when cover_url is empty', () => { + const { container } = render(ArtistCard, { + props: { artist: { ...artist, cover_url: '' } } + }); + expect(container.querySelector('img')).not.toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + test('play overlay fetches tracks, shuffles, calls playQueue', async () => { + const tracks: TrackRef[] = [ + { id: 't1', title: 'A', album_id: 'al-1', album_title: 'X', + artist_id: 'art-1', artist_name: 'BoC', + track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/x' }, + { id: 't2', title: 'B', album_id: 'al-1', album_title: 'X', + artist_id: 'art-1', artist_name: 'BoC', + track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/x' } + ]; + (api.get as ReturnType).mockResolvedValueOnce(tracks); + + render(ArtistCard, { props: { artist } }); + await fireEvent.click(screen.getByRole('button', { name: /play boards of canada/i })); + expect(api.get).toHaveBeenCalledWith('/api/artists/art-1/tracks'); + await Promise.resolve(); + await Promise.resolve(); + expect(playQueue).toHaveBeenCalledOnce(); + const [calledTracks, idx] = (playQueue as ReturnType).mock.calls[0]; + expect(calledTracks).toHaveLength(2); + expect(idx).toBe(0); + expect(calledTracks.map((t: TrackRef) => t.id).sort()).toEqual(['t1', 't2']); + }); +}); +``` + +- [ ] **Step 14.2: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/ArtistCard.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 14.3: Create the component** + +Create `web/src/lib/components/ArtistCard.svelte`: + +```svelte + + + + + +``` + +- [ ] **Step 14.4: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/ArtistCard.test.ts +``` + +Expected: 3/3 PASS. Commit: + +```bash +git add web/src/lib/components/ArtistCard.svelte \ + web/src/lib/components/ArtistCard.test.ts +git commit -m "feat(web): add ArtistCard (circular) with play-shuffle overlay" +``` + +--- + +### Task 15 — `` + +**Files:** +- Create: `web/src/lib/components/CompactTrackCard.svelte` +- Create: `web/src/lib/components/CompactTrackCard.test.ts` + +- [ ] **Step 15.1: Write failing test** + +Create `web/src/lib/components/CompactTrackCard.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { TrackRef } from '$lib/api/types'; + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn() +})); + +import CompactTrackCard from './CompactTrackCard.svelte'; +import { playQueue } from '$lib/player/store.svelte'; + +const tracks: TrackRef[] = [ + { id: 't1', title: 'First', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 1, disc_number: 1, duration_sec: 1, stream_url: '/a' }, + { id: 't2', title: 'Second', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 2, disc_number: 1, duration_sec: 1, stream_url: '/b' }, + { id: 't3', title: 'Third', album_id: 'a1', album_title: 'A', + artist_id: 'art-1', artist_name: 'Artist', + track_number: 3, disc_number: 1, duration_sec: 1, stream_url: '/c' } +]; + +afterEach(() => vi.clearAllMocks()); + +describe('CompactTrackCard', () => { + test('clicking the card calls playQueue with sectionTracks at the right index', async () => { + render(CompactTrackCard, { + props: { track: tracks[1], sectionTracks: tracks, index: 1 } + }); + await fireEvent.click(screen.getByRole('button', { name: /play second/i })); + expect(playQueue).toHaveBeenCalledWith(tracks, 1); + }); + + test('renders title and artist name', () => { + render(CompactTrackCard, { + props: { track: tracks[0], sectionTracks: tracks, index: 0 } + }); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Artist')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 15.2: Create the component** + +Create `web/src/lib/components/CompactTrackCard.svelte`: + +```svelte + + + +``` + +- [ ] **Step 15.3: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/CompactTrackCard.test.ts +``` + +Expected: 2/2 PASS. Commit: + +```bash +git add web/src/lib/components/CompactTrackCard.svelte \ + web/src/lib/components/CompactTrackCard.test.ts +git commit -m "feat(web): add CompactTrackCard for Most played rows" +``` + +--- + +### Task 16 — `` + +**Files:** +- Create: `web/src/lib/components/AlphabeticalGrid.svelte` +- Create: `web/src/lib/components/AlphabeticalGrid.test.ts` + +- [ ] **Step 16.1: Write failing test** + +Create `web/src/lib/components/AlphabeticalGrid.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import AlphabeticalGrid from './AlphabeticalGrid.svelte'; + +type Item = { id: string; key: string; label: string }; + +const items: Item[] = [ + { id: '1', key: 'A', label: 'Apple' }, + { id: '2', key: 'A', label: 'Avocado' }, + { id: '3', key: 'B', label: 'Banana' }, + { id: '4', key: 'D', label: 'Date' } // skips C — divider must jump to D directly +]; + +describe('AlphabeticalGrid', () => { + test('inserts a divider for each new key letter', () => { + render(AlphabeticalGrid, { + props: { + items, + getKey: (it: Item) => it.key, + item: createRawSnippet((it: Item) => ({ + render: () => `${it.label}` + })) + } + }); + // Three dividers (A, B, D); C is absent because no items use C. + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); + expect(screen.queryByText('C')).not.toBeInTheDocument(); + }); + + test('renders items in given order', () => { + const { container } = render(AlphabeticalGrid, { + props: { + items, + getKey: (it: Item) => it.key, + item: createRawSnippet((it: Item) => ({ + render: () => `${it.label}` + })) + } + }); + expect(container.textContent).toMatch(/A.*Apple.*Avocado.*B.*Banana.*D.*Date/s); + }); +}); +``` + +NOTE: `createRawSnippet` is the Svelte 5 utility for synthesising snippets in tests; if it doesn't suit your renderer, swap to a wrapper component that exposes the snippet via ``. + +- [ ] **Step 16.2: Run test to verify failure** + +```bash +cd web && pnpm test src/lib/components/AlphabeticalGrid.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 16.3: Create the component** + +Create `web/src/lib/components/AlphabeticalGrid.svelte`: + +```svelte + + +
+ {#each segments as seg, i (i)} + {#if seg.divider} +
+ {seg.divider} + +
+ {/if} +
+ {@render item(seg.item)} +
+ {/each} +
+ + +``` + +- [ ] **Step 16.4: Run tests + commit** + +```bash +cd web && pnpm test src/lib/components/AlphabeticalGrid.test.ts +``` + +Expected: 2/2 PASS. Commit: + +```bash +git add web/src/lib/components/AlphabeticalGrid.svelte \ + web/src/lib/components/AlphabeticalGrid.test.ts +git commit -m "feat(web): add AlphabeticalGrid wrapper with letter dividers" +``` + +--- + +### Task 17 — Home page rewrite (`/+page.svelte`) + +**Files:** +- Modify: `web/src/routes/+page.svelte` (full rewrite) +- Modify: `web/src/routes/artists.test.ts` (assertions migrate) + +- [ ] **Step 17.1: Rewrite `+page.svelte`** + +Replace the entire contents of `web/src/routes/+page.svelte`: + +```svelte + + +
+ {#if query.isError} + + {:else if showSkeleton.value && !data} +

Loading…

+ {:else if data} + +
+
+

Recently added

+
+ {#if data.recently_added_albums.length === 0} +

Nothing added yet. Scan a folder via the server's config.

+ {:else} + {#each chunk(data.recently_added_albums, 25) as row, i (i)} + + {#snippet item(album)} +
+ {/snippet} +
+ {/each} + {/if} +
+ + +
+
+

Rediscover

+
+ {#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} +

No forgotten favourites yet. Like some albums or artists to fill this in.

+ {:else} + {#if data.rediscover_albums.length > 0} + + {#snippet item(album)} +
+ {/snippet} +
+ {/if} + {#if data.rediscover_artists.length > 0} + + {#snippet item(artist)} +
+ {/snippet} +
+ {/if} + {/if} +
+ + +
+
+

Most played

+
+ {#if data.most_played_tracks.length === 0} +

No plays to draw from. Listen to something.

+ {:else} + {#each chunk(data.most_played_tracks, 25) as row, i (i)} + + {#snippet item(track, idx)} + + {/snippet} + + {/each} + {/if} +
+ + +
+
+

Last played

+
+ {#if data.last_played_artists.length === 0} +

No recent plays.

+ {:else} + + {#snippet item(artist)} +
+ {/snippet} +
+ {/if} +
+ {/if} +
+``` + +- [ ] **Step 17.2: Update `artists.test.ts` to point at the new home behavior** + +Open `web/src/routes/artists.test.ts`. The existing tests assert the old library-list behavior of `/`. Either move them to a new `library/artists.test.ts` (Task 18 covers that page) or delete them outright in this step. Decision: delete this file in this task because the replacement tests live with `/library/artists/+page.svelte`. + +```bash +git rm web/src/routes/artists.test.ts +``` + +- [ ] **Step 17.3: Run frontend tests + check** + +```bash +cd web && pnpm check && pnpm test +``` + +Expected: all PASS. The new home page has no dedicated component test (see Task 22 for an integration smoke test); `pnpm check` confirms type correctness against the new query/types. + +- [ ] **Step 17.4: Commit** + +```bash +git add web/src/routes/+page.svelte +git rm web/src/routes/artists.test.ts +git commit -m "feat(web): rewrite / as home page composing 4 horizontal-scroll sections" +``` + +--- + +### Task 18 — `/library/artists` page + +**Files:** +- Create: `web/src/routes/library/artists/+page.svelte` +- Create: `web/src/routes/library/artists/page.test.ts` + +- [ ] **Step 18.1: Write the page** + +Create `web/src/routes/library/artists/+page.svelte`: + +```svelte + + +
+
+

Artists

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'artist' : 'artists'} +

+ {/if} +
+ + {#if query.isError} + + {:else if showSkeleton.value && artists.length === 0} +

Loading…

+ {:else if !query.isPending && total === 0} +

No artists yet — scan a library folder via the server's config.

+ {:else} + a.sort_name || a.name} + > + {#snippet item(a)} + + {/snippet} + + + {#if query.hasNextPage} + + {:else if artists.length > 0} +

End of library

+ {/if} + {/if} +
+``` + +- [ ] **Step 18.2: Add a smoke test** + +Create `web/src/routes/library/artists/page.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/queries', () => ({ + createArtistsQuery: () => readable({ + data: { pages: [{ + items: [ + { id: 'a1', name: 'Alpha', sort_name: 'Alpha', album_count: 1, cover_url: '' }, + { id: 'b1', name: 'Beta', sort_name: 'Beta', album_count: 2, cover_url: '' } + ], + total: 2, limit: 50, offset: 0 + }] }, + isPending: false, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + refetch: () => {}, + fetchNextPage: () => {} + }) +})); +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn() })); + +import Page from './+page.svelte'; + +describe('/library/artists', () => { + test('renders title + count + alphabetical dividers', () => { + render(Page); + expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); + expect(screen.getByText(/2 artists/)).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 18.3: Run tests + commit** + +```bash +cd web && pnpm test src/routes/library/artists/page.test.ts +``` + +Expected: PASS. Commit: + +```bash +git add web/src/routes/library/artists/ +git commit -m "feat(web): add /library/artists wrapping-grid page" +``` + +--- + +### Task 19 — `/library/albums` page + +**Files:** +- Create: `web/src/routes/library/albums/+page.svelte` +- Create: `web/src/routes/library/albums/page.test.ts` + +- [ ] **Step 19.1: Write the page** + +Create `web/src/routes/library/albums/+page.svelte`: + +```svelte + + +
+
+

Albums

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'album' : 'albums'} +

+ {/if} +
+ + {#if query.isError} + + {:else if showSkeleton.value && albums.length === 0} +

Loading…

+ {:else if !query.isPending && total === 0} +

No albums yet — scan a library folder via the server's config.

+ {:else} + a.sort_title || a.title} + > + {#snippet item(album)} + + {/snippet} + + + {#if query.hasNextPage} + + {:else if albums.length > 0} +

End of library

+ {/if} + {/if} +
+``` + +- [ ] **Step 19.2: Add a smoke test** + +Create `web/src/routes/library/albums/page.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/albums', () => ({ + createAlbumsAlphaInfiniteQuery: () => readable({ + data: { pages: [{ + items: [ + { id: 'a1', title: 'Apple', sort_title: 'Apple', + artist_id: 'art-1', artist_name: 'X', + track_count: 5, duration_sec: 100, cover_url: '/api/albums/a1/cover' }, + { id: 'b1', title: 'Banana', sort_title: 'Banana', + artist_id: 'art-1', artist_name: 'X', + track_count: 3, duration_sec: 50, cover_url: '/api/albums/b1/cover' } + ], + total: 2, limit: 50, offset: 0 + }] }, + isPending: false, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + refetch: () => {}, + fetchNextPage: () => {} + }), + ALBUM_PAGE_SIZE: 50 +})); +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ + enqueueTracks: vi.fn(), + playQueue: vi.fn() +})); +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, isError: false + }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +import Page from './+page.svelte'; + +describe('/library/albums', () => { + test('renders title + count + alphabetical dividers', () => { + render(Page); + expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); + expect(screen.getByText(/2 albums/)).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 19.3: Run tests + commit** + +```bash +cd web && pnpm test src/routes/library/albums/page.test.ts +``` + +Expected: PASS. Commit: + +```bash +git add web/src/routes/library/albums/ +git commit -m "feat(web): add /library/albums wrapping-grid page" +``` + +--- + +### Task 20 — Shell nav update + retire `` + +**Files:** +- Modify: `web/src/lib/components/Shell.svelte` +- Modify: `web/src/lib/components/Shell.test.ts` +- Delete: `web/src/lib/components/ArtistRow.svelte` +- Delete: `web/src/lib/components/ArtistRow.test.ts` +- Modify: `web/src/lib/components/LibrarySkeleton.svelte` (optional cleanup — see Step 20.5) + +- [ ] **Step 20.1: Update `Shell.svelte` navItems** + +Modify `web/src/lib/components/Shell.svelte` — replace the `navItems` array: + +```ts + const navItems = [ + { href: '/', label: 'Home' }, + { href: '/library/artists', label: 'Artists' }, + { href: '/library/albums', label: 'Albums' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/library/hidden', label: 'Hidden' }, + { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } + ]; +``` + +- [ ] **Step 20.2: Update `Shell.test.ts` assertions** + +Open `web/src/lib/components/Shell.test.ts`. Locate the assertion that lists nav items (search for `Library`) and replace with the new order: + +```ts +test('renders nav items in expected order', () => { + render(Shell, { props: { children } }); + const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Hidden', 'Search', + 'Discover', 'Requests', 'Playlists', 'Settings']; + for (const label of labels) { + expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); + } +}); +``` + +If the existing test does not assert the exact label list, add this test — preserving any existing tests in the file. + +- [ ] **Step 20.3: Audit `` usages and remove** + +Run: + +```bash +cd web && grep -rln "ArtistRow" src/ --include="*.svelte" --include="*.ts" +``` + +Expected (after Task 17): only `ArtistRow.svelte` and `ArtistRow.test.ts`. If any other file imports `ArtistRow`, replace those imports with `ArtistCard` and adjust the call site (likely a `` becomes ``). The migration is mechanical. + +Then delete: + +```bash +git rm web/src/lib/components/ArtistRow.svelte \ + web/src/lib/components/ArtistRow.test.ts +``` + +- [ ] **Step 20.4: Run all frontend tests** + +```bash +cd web && pnpm check && pnpm test +``` + +Expected: all PASS. Type errors at this step usually mean a forgotten ArtistRow import; fix per Step 20.3. + +- [ ] **Step 20.5: Inspect `LibrarySkeleton` usage** + +Run: + +```bash +cd web && grep -rln "LibrarySkeleton" src/ --include="*.svelte" --include="*.ts" +``` + +If the only remaining importers (after Tasks 17/18/19) use only the existing `variant="grid"` or `variant="list"` props, no change is needed — the skeleton was always extensible. Leave it. If a call site references the old `variant="list"` for a now-deleted page, delete that import. + +- [ ] **Step 20.6: Commit** + +```bash +git add web/src/lib/components/Shell.svelte \ + web/src/lib/components/Shell.test.ts +git rm web/src/lib/components/ArtistRow.svelte \ + web/src/lib/components/ArtistRow.test.ts +git commit -m "feat(web): update nav (Home/Artists/Albums); retire ArtistRow" +``` + +--- + +## Verification (post-task-20) + +After all tasks land, the following all-green checks confirm M6a is ready for PR: + +- [ ] **Backend full test pass** + +```bash +docker run --rm --network minstrel_minstrel \ + -v "$(pwd):/src" -w /src \ + -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \ + golang:1.23-bookworm \ + go test ./... -count=1 +``` + +Expected: all PASS except the pre-existing `internal/library/TestScanner_Integration` flake (per `project_scanner_flake.md` memory). + +- [ ] **Frontend full test pass + typecheck** + +```bash +cd web && pnpm check && pnpm test && pnpm build +``` + +Expected: typecheck clean, all unit tests PASS, production build succeeds. + +- [ ] **Manual smoke** + +Start dev server (`docker compose up -d` + `cd web && pnpm dev`); log in; verify: + + 1. `/` renders all four sections; rows scroll independently with arrow buttons. + 2. Hovering an album card reveals the play overlay; clicking it queues the album from track 1. + 3. Hovering an artist card reveals the play overlay; clicking it shuffle-plays artist tracks. + 4. Compact track cards click-play in section context. + 5. Nav: clicking "Artists" goes to `/library/artists` (wrapping grid + dividers). + 6. Nav: clicking "Albums" goes to `/library/albums` (wrapping grid + dividers). + 7. Quarantined tracks for the test user are absent from "Most played" and from the artist-shuffle queue. + +- [ ] **Commit + push to dev branch** + +```bash +git push -u origin dev +``` + +- [ ] **Open Forgejo PR via MCP** (per `project_no_github.md`) + +Use `mcp__forgejo__pull_request_write` with action=create, base=main, head=dev. Title: +`feat(m6a): home page redesign + library list relocation`. Body summarises the slice and lists the verification steps above. + +--- + +## Notes for the implementer + +- **No DB migration.** All sections derive from existing tables. +- **Tests gated on `MINSTREL_TEST_DATABASE_URL`.** Without it, integration tests skip cleanly. +- **`t.Context()` is Go 1.24+; this repo runs Go 1.23.** Always use `context.Background()` in tests. +- **Forgejo only.** No GitHub. Use the forgejo MCP for PR ops. +- **TanStack v5 patterns.** `createInfiniteQuery` for paged lists; `createQuery` for one-shots; `staleTime: 60_000` on the home query. +- **FabledSword tokens always.** No raw hex in component styles. Use `bg-surface`, `text-text-primary`, `border-border`, `text-accent`, etc. +- **Lucide icons.** 16px/1px stroke for chrome buttons, 32px/1.5px for fallbacks/overlays. +- **Sentence case copy.** "Recently added", "Most played", not "Recently Added". +- **No half-finished UI.** Per the spec's "no in-between steps" rule, every surface added here ships finished. + +