fd77217b7c
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) <noreply@anthropic.com>
3342 lines
104 KiB
Markdown
3342 lines
104 KiB
Markdown
# 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 `<AlbumCard>` to the FabledSword bar, retire `<ArtistRow>` (and its click-target bug) in favor of a circular `<ArtistCard>`, 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 (`<HorizontalScrollRow>`, `<ArtistCard>`, `<CompactTrackCard>`, `<AlphabeticalGrid>`), polishes `<AlbumCard>`, 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: '<value>'`. For each ArtistRef literal, add `sort_name: '<value>'` 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<typeof vi.fn>).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<HomePayload> {
|
|
return api.get<HomePayload>('/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<typeof vi.fn>).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<typeof vi.fn>).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<Page<AlbumRef>> {
|
|
return api.get<Page<AlbumRef>>(`/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<typeof vi.fn>).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<TrackRef[]> {
|
|
return api.get<TrackRef[]>(`/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 — `<HorizontalScrollRow>` 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
|
|
<script lang="ts" generics="T">
|
|
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
|
import type { Snippet } from 'svelte';
|
|
|
|
let {
|
|
items,
|
|
item,
|
|
ariaLabel
|
|
}: {
|
|
items: T[];
|
|
item?: Snippet<[T, number]>;
|
|
ariaLabel?: string;
|
|
} = $props();
|
|
|
|
let scroller: HTMLElement | undefined = $state();
|
|
let scrollLeft = $state(0);
|
|
let scrollWidth = $state(0);
|
|
let clientWidth = $state(0);
|
|
|
|
const atStart = $derived(scrollLeft <= 1);
|
|
const atEnd = $derived(scrollLeft + clientWidth >= scrollWidth - 1);
|
|
|
|
function refreshMetrics() {
|
|
if (!scroller) return;
|
|
scrollLeft = scroller.scrollLeft;
|
|
scrollWidth = scroller.scrollWidth;
|
|
clientWidth = scroller.clientWidth;
|
|
}
|
|
|
|
function page(direction: -1 | 1) {
|
|
if (!scroller) return;
|
|
const delta = direction * scroller.clientWidth * 0.85;
|
|
scroller.scrollBy({ left: delta, behavior: 'smooth' });
|
|
setTimeout(refreshMetrics, 350);
|
|
}
|
|
|
|
$effect(() => { refreshMetrics(); });
|
|
</script>
|
|
|
|
<div class="row relative" aria-label={ariaLabel}>
|
|
<button
|
|
type="button"
|
|
class="arrow arrow-left"
|
|
aria-label="Scroll left"
|
|
onclick={() => page(-1)}
|
|
disabled={atStart}
|
|
>
|
|
<ChevronLeft size={16} strokeWidth={1} />
|
|
</button>
|
|
|
|
<div
|
|
bind:this={scroller}
|
|
onscroll={refreshMetrics}
|
|
data-testid="scroll-container"
|
|
class="scroller flex gap-4 overflow-x-auto"
|
|
>
|
|
{#each items as it, i (i)}
|
|
{@render item?.(it, i)}
|
|
{/each}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="arrow arrow-right"
|
|
aria-label="Scroll right"
|
|
onclick={() => page(1)}
|
|
disabled={atEnd}
|
|
>
|
|
<ChevronRight size={16} strokeWidth={1} />
|
|
</button>
|
|
</div>
|
|
|
|
<style>
|
|
.row { padding: 0 36px; }
|
|
.scroller {
|
|
scroll-snap-type: x mandatory;
|
|
scrollbar-width: thin;
|
|
}
|
|
.scroller > :global(*) {
|
|
flex-shrink: 0;
|
|
scroll-snap-align: start;
|
|
}
|
|
.arrow {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 9999px;
|
|
background: var(--fs-iron);
|
|
color: var(--fs-vellum);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid var(--fs-pewter);
|
|
z-index: 1;
|
|
}
|
|
.arrow:hover:not(:disabled) { color: var(--fs-parchment); }
|
|
.arrow:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
.arrow-left { left: 0; }
|
|
.arrow-right { right: 0; }
|
|
@media (hover: hover) {
|
|
.arrow { opacity: 0; transition: opacity 150ms ease; }
|
|
.row:hover .arrow { opacity: 1; }
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **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 — `<AlbumCard>` 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<typeof vi.fn>).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
|
|
<script lang="ts">
|
|
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
|
import { api } from '$lib/api/client';
|
|
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
|
import { Plus, Play } from 'lucide-svelte';
|
|
import LikeButton from './LikeButton.svelte';
|
|
|
|
let { album }: { album: AlbumRef } = $props();
|
|
|
|
function onImgError(e: Event) {
|
|
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
}
|
|
|
|
async function onAddClick(e: MouseEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const detail = await api.get<AlbumDetail>(`/api/albums/${album.id}`);
|
|
enqueueTracks(detail.tracks);
|
|
}
|
|
|
|
async function onPlayClick(e: MouseEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const detail = await api.get<AlbumDetail>(`/api/albums/${album.id}`);
|
|
playQueue(detail.tracks, 0);
|
|
}
|
|
</script>
|
|
|
|
<div class="card relative">
|
|
<a
|
|
href={`/albums/${album.id}`}
|
|
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
<div class="art-wrap relative aspect-square w-full overflow-hidden rounded-md">
|
|
<img
|
|
src={album.cover_url}
|
|
alt=""
|
|
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
|
loading="lazy"
|
|
onerror={onImgError}
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={`Play ${album.title}`}
|
|
onclick={onPlayClick}
|
|
class="play-overlay absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full"
|
|
>
|
|
<Play size={24} strokeWidth={1.5} fill="currentColor" />
|
|
</button>
|
|
</div>
|
|
<div class="mt-2 truncate text-sm font-medium text-text-primary">{album.title}</div>
|
|
{#if album.artist_name}
|
|
<div class="truncate text-xs text-text-secondary">{album.artist_name}</div>
|
|
{/if}
|
|
{#if album.year}
|
|
<div class="text-xs text-text-secondary">{album.year}</div>
|
|
{/if}
|
|
</a>
|
|
<div class="absolute right-2 top-2 z-10 flex gap-1">
|
|
<LikeButton entityType="album" entityId={album.id} />
|
|
<button
|
|
type="button"
|
|
aria-label="Add to queue"
|
|
onclick={onAddClick}
|
|
class="rounded-full bg-surface px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
<Plus size={16} strokeWidth={1} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.play-overlay {
|
|
background: var(--fs-accent);
|
|
color: var(--fs-parchment);
|
|
opacity: 0;
|
|
transition: opacity 150ms ease;
|
|
}
|
|
@media (hover: hover) {
|
|
.card:hover .play-overlay { opacity: 1; }
|
|
}
|
|
@media (hover: none) {
|
|
.play-overlay { opacity: 1; }
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **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 — `<ArtistCard>` (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<typeof vi.fn>).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<typeof vi.fn>).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
|
|
<script lang="ts">
|
|
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
|
import { api } from '$lib/api/client';
|
|
import { playQueue } from '$lib/player/store.svelte';
|
|
import { Disc3, Play } from 'lucide-svelte';
|
|
|
|
let { artist }: { artist: ArtistRef } = $props();
|
|
|
|
function shuffle<T>(items: T[]): T[] {
|
|
const arr = items.slice();
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
async function onPlayClick(e: MouseEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
|
|
if (tracks.length === 0) return;
|
|
playQueue(shuffle(tracks), 0);
|
|
}
|
|
</script>
|
|
|
|
<div class="card relative">
|
|
<a
|
|
href={`/artists/${artist.id}`}
|
|
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
|
{#if artist.cover_url}
|
|
<img
|
|
src={artist.cover_url}
|
|
alt=""
|
|
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
|
loading="lazy"
|
|
/>
|
|
{:else}
|
|
<div class="flex h-full w-full items-center justify-center">
|
|
<Disc3 size={32} strokeWidth={1.5} class="text-text-muted" />
|
|
</div>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
aria-label={`Play ${artist.name}`}
|
|
onclick={onPlayClick}
|
|
class="play-overlay absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full"
|
|
>
|
|
<Play size={24} strokeWidth={1.5} fill="currentColor" />
|
|
</button>
|
|
</div>
|
|
<div class="mt-2 truncate text-center text-sm font-medium text-text-primary">{artist.name}</div>
|
|
</a>
|
|
</div>
|
|
|
|
<style>
|
|
.play-overlay {
|
|
background: var(--fs-accent);
|
|
color: var(--fs-parchment);
|
|
opacity: 0;
|
|
transition: opacity 150ms ease;
|
|
}
|
|
@media (hover: hover) {
|
|
.card:hover .play-overlay { opacity: 1; }
|
|
}
|
|
@media (hover: none) {
|
|
.play-overlay { opacity: 1; }
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **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 — `<CompactTrackCard>`
|
|
|
|
**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
|
|
<script lang="ts">
|
|
import type { TrackRef } from '$lib/api/types';
|
|
import { playQueue } from '$lib/player/store.svelte';
|
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
|
|
|
let {
|
|
track,
|
|
sectionTracks,
|
|
index
|
|
}: {
|
|
track: TrackRef;
|
|
sectionTracks: TrackRef[];
|
|
index: number;
|
|
} = $props();
|
|
|
|
const coverUrl = $derived(`/api/albums/${track.album_id}/cover`);
|
|
|
|
function onImgError(e: Event) {
|
|
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
}
|
|
|
|
function onClick() {
|
|
playQueue(sectionTracks, index);
|
|
}
|
|
</script>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label={`Play ${track.title}`}
|
|
onclick={onClick}
|
|
class="card group block w-36 rounded text-left focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
<div class="aspect-square w-full overflow-hidden rounded-md bg-surface-hover">
|
|
<img
|
|
src={coverUrl}
|
|
alt=""
|
|
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
|
loading="lazy"
|
|
onerror={onImgError}
|
|
/>
|
|
</div>
|
|
<div class="mt-2 truncate text-xs font-medium text-text-primary">{track.title}</div>
|
|
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
|
</button>
|
|
```
|
|
|
|
- [ ] **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 — `<AlphabeticalGrid>`
|
|
|
|
**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: () => `<span>${it.label}</span>`
|
|
}))
|
|
}
|
|
});
|
|
// 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: () => `<span>${it.label}</span>`
|
|
}))
|
|
}
|
|
});
|
|
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 `<svelte:fragment>`.
|
|
|
|
- [ ] **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
|
|
<script lang="ts" generics="T">
|
|
import type { Snippet } from 'svelte';
|
|
|
|
let {
|
|
items,
|
|
getKey,
|
|
item
|
|
}: {
|
|
items: T[];
|
|
getKey: (it: T) => string;
|
|
item: Snippet<[T]>;
|
|
} = $props();
|
|
|
|
// Compute the divider letter for each item; if it differs from the
|
|
// previous item's letter, that index gets a divider above it.
|
|
const segments = $derived.by(() => {
|
|
const out: { divider: string | null; item: T }[] = [];
|
|
let last: string | null = null;
|
|
for (const it of items) {
|
|
const k = (getKey(it) || '').charAt(0).toUpperCase();
|
|
if (k !== last) {
|
|
out.push({ divider: k, item: it });
|
|
last = k;
|
|
} else {
|
|
out.push({ divider: null, item: it });
|
|
}
|
|
}
|
|
return out;
|
|
});
|
|
</script>
|
|
|
|
<div class="alpha-grid">
|
|
{#each segments as seg, i (i)}
|
|
{#if seg.divider}
|
|
<div class="divider">
|
|
<span class="letter">{seg.divider}</span>
|
|
<span class="rule"></span>
|
|
</div>
|
|
{/if}
|
|
<div class="cell">
|
|
{@render item(seg.item)}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<style>
|
|
.alpha-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
.divider {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding-top: 8px;
|
|
}
|
|
.letter {
|
|
font-family: var(--fs-font-display);
|
|
font-size: 24px;
|
|
font-weight: 500;
|
|
color: var(--fs-parchment);
|
|
}
|
|
.rule {
|
|
flex: 1;
|
|
height: 1px;
|
|
background: var(--fs-pewter);
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **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
|
|
<script lang="ts">
|
|
import { createHomeQuery } from '$lib/api/home';
|
|
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
|
|
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
|
import ArtistCard from '$lib/components/ArtistCard.svelte';
|
|
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
|
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
|
import { useDelayed } from '$lib/utils/useDelayed.svelte';
|
|
|
|
const queryStore = createHomeQuery();
|
|
const query = $derived($queryStore);
|
|
const data = $derived(query.data);
|
|
|
|
const showSkeleton = useDelayed(() => query.isPending);
|
|
|
|
// Each "Most played" row gets 25 of the 75 tracks (server returns up
|
|
// to 75; rows 0/1/2 slice [0..25), [25..50), [50..75)).
|
|
function chunk<T>(items: T[], size: number): T[][] {
|
|
const out: T[][] = [];
|
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
return out;
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-8">
|
|
{#if query.isError}
|
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
|
{:else if showSkeleton.value && !data}
|
|
<p class="text-text-secondary">Loading…</p>
|
|
{:else if data}
|
|
<!-- Recently added: 50 albums in 2 rows of 25 -->
|
|
<section class="space-y-3">
|
|
<header>
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Recently added</h2>
|
|
</header>
|
|
{#if data.recently_added_albums.length === 0}
|
|
<p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p>
|
|
{:else}
|
|
{#each chunk(data.recently_added_albums, 25) as row, i (i)}
|
|
<HorizontalScrollRow items={row} ariaLabel="Recently added row {i + 1}">
|
|
{#snippet item(album)}
|
|
<div class="w-44"><AlbumCard {album} /></div>
|
|
{/snippet}
|
|
</HorizontalScrollRow>
|
|
{/each}
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Rediscover: albums + artists -->
|
|
<section class="space-y-3">
|
|
<header>
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Rediscover</h2>
|
|
</header>
|
|
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0}
|
|
<p class="text-text-secondary">No forgotten favourites yet. Like some albums or artists to fill this in.</p>
|
|
{:else}
|
|
{#if data.rediscover_albums.length > 0}
|
|
<HorizontalScrollRow items={data.rediscover_albums} ariaLabel="Rediscover albums">
|
|
{#snippet item(album)}
|
|
<div class="w-44"><AlbumCard {album} /></div>
|
|
{/snippet}
|
|
</HorizontalScrollRow>
|
|
{/if}
|
|
{#if data.rediscover_artists.length > 0}
|
|
<HorizontalScrollRow items={data.rediscover_artists} ariaLabel="Rediscover artists">
|
|
{#snippet item(artist)}
|
|
<div class="w-36"><ArtistCard {artist} /></div>
|
|
{/snippet}
|
|
</HorizontalScrollRow>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Most played: 75 tracks in 3 rows of 25 -->
|
|
<section class="space-y-3">
|
|
<header>
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Most played</h2>
|
|
</header>
|
|
{#if data.most_played_tracks.length === 0}
|
|
<p class="text-text-secondary">No plays to draw from. Listen to something.</p>
|
|
{:else}
|
|
{#each chunk(data.most_played_tracks, 25) as row, i (i)}
|
|
<HorizontalScrollRow items={row} ariaLabel="Most played row {i + 1}">
|
|
{#snippet item(track, idx)}
|
|
<CompactTrackCard
|
|
{track}
|
|
sectionTracks={row}
|
|
index={idx}
|
|
/>
|
|
{/snippet}
|
|
</HorizontalScrollRow>
|
|
{/each}
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Last played artists -->
|
|
<section class="space-y-3">
|
|
<header>
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Last played</h2>
|
|
</header>
|
|
{#if data.last_played_artists.length === 0}
|
|
<p class="text-text-secondary">No recent plays.</p>
|
|
{:else}
|
|
<HorizontalScrollRow items={data.last_played_artists} ariaLabel="Last played artists">
|
|
{#snippet item(artist)}
|
|
<div class="w-36"><ArtistCard {artist} /></div>
|
|
{/snippet}
|
|
</HorizontalScrollRow>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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
|
|
<script lang="ts">
|
|
import { createArtistsQuery } from '$lib/api/queries';
|
|
import ArtistCard from '$lib/components/ArtistCard.svelte';
|
|
import AlphabeticalGrid from '$lib/components/AlphabeticalGrid.svelte';
|
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
|
import { useDelayed } from '$lib/utils/useDelayed.svelte';
|
|
|
|
const queryStore = $derived(createArtistsQuery('alpha'));
|
|
const query = $derived($queryStore);
|
|
const artists = $derived(query.data?.pages?.flatMap((p) => p.items) ?? []);
|
|
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
|
|
const showSkeleton = useDelayed(() => query.isPending);
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<header>
|
|
<h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1>
|
|
{#if !query.isPending && !query.isError}
|
|
<p class="text-sm text-text-secondary">
|
|
{total} {total === 1 ? 'artist' : 'artists'}
|
|
</p>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if query.isError}
|
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
|
{:else if showSkeleton.value && artists.length === 0}
|
|
<p class="text-text-secondary">Loading…</p>
|
|
{:else if !query.isPending && total === 0}
|
|
<p class="text-text-secondary">No artists yet — scan a library folder via the server's config.</p>
|
|
{:else}
|
|
<AlphabeticalGrid
|
|
items={artists}
|
|
getKey={(a) => a.sort_name || a.name}
|
|
>
|
|
{#snippet item(a)}
|
|
<ArtistCard artist={a} />
|
|
{/snippet}
|
|
</AlphabeticalGrid>
|
|
|
|
{#if query.hasNextPage}
|
|
<button
|
|
type="button"
|
|
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
|
disabled={query.isFetchingNextPage}
|
|
onclick={() => query.fetchNextPage()}
|
|
>
|
|
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
|
</button>
|
|
{:else if artists.length > 0}
|
|
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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
|
|
<script lang="ts">
|
|
import { createAlbumsAlphaInfiniteQuery } from '$lib/api/albums';
|
|
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
|
import AlphabeticalGrid from '$lib/components/AlphabeticalGrid.svelte';
|
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
|
import { useDelayed } from '$lib/utils/useDelayed.svelte';
|
|
|
|
const queryStore = createAlbumsAlphaInfiniteQuery();
|
|
const query = $derived($queryStore);
|
|
const albums = $derived(query.data?.pages?.flatMap((p) => p.items) ?? []);
|
|
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
|
|
const showSkeleton = useDelayed(() => query.isPending);
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<header>
|
|
<h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1>
|
|
{#if !query.isPending && !query.isError}
|
|
<p class="text-sm text-text-secondary">
|
|
{total} {total === 1 ? 'album' : 'albums'}
|
|
</p>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if query.isError}
|
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
|
{:else if showSkeleton.value && albums.length === 0}
|
|
<p class="text-text-secondary">Loading…</p>
|
|
{:else if !query.isPending && total === 0}
|
|
<p class="text-text-secondary">No albums yet — scan a library folder via the server's config.</p>
|
|
{:else}
|
|
<AlphabeticalGrid
|
|
items={albums}
|
|
getKey={(a) => a.sort_title || a.title}
|
|
>
|
|
{#snippet item(album)}
|
|
<AlbumCard {album} />
|
|
{/snippet}
|
|
</AlphabeticalGrid>
|
|
|
|
{#if query.hasNextPage}
|
|
<button
|
|
type="button"
|
|
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
|
disabled={query.isFetchingNextPage}
|
|
onclick={() => query.fetchNextPage()}
|
|
>
|
|
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
|
</button>
|
|
{:else if albums.length > 0}
|
|
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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 `<ArtistRow>`
|
|
|
|
**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 `<ArtistRow>` 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 `<ArtistRow {artist} />` becomes `<ArtistCard {artist} />`). 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.
|
|
|
|
|