diff --git a/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md b/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md new file mode 100644 index 00000000..d73588c5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md @@ -0,0 +1,1838 @@ +# M5c — Suggested additions on `/discover` — 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:** Personalized artist suggestions on `/discover` (search-input-empty state). Top-12 out-of-library artists ranked by per-user signal (likes ×5 + recency-decayed plays), with top-3 contributing seeds attributed per card. One-click add via the existing M5a Lidarr-request flow. + +**Architecture:** Extend the M4b similarity ingest worker to persist unmatched artist MBIDs to a new `artist_similarity_unmatched` table (mirrors `artist_similarity` shape). New `internal/recommendation` service runs a single CTE at request time that scores candidates from the user's likes + plays through the unmatched table. New `GET /api/discover/suggestions` handler. Frontend swaps `` between the existing search results and a new suggestion feed when the search input is empty. + +**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · existing FabledSword design tokens. + +**Spec:** [`docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md`](../specs/2026-04-30-m5c-suggested-additions-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). + +--- + +## File map + +### Backend — create + +- `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` · `.down.sql` +- `internal/recommendation/suggestions.go` — `SuggestArtists` service + types +- `internal/recommendation/suggestions_integration_test.go` +- `internal/api/suggestions.go` — `GET /api/discover/suggestions` handler +- `internal/api/suggestions_test.go` + +### Backend — modify + +- `internal/db/queries/similarity.sql` — add `UpsertArtistSimilarityUnmatched` +- `internal/db/queries/recommendation.sql` — add `SuggestArtistsForUser` +- `internal/db/dbq/*` — regenerated by `sqlc generate` +- `internal/scrobble/listenbrainz/client.go` — add `Name string \`json:"name"\`` to `SimilarArtist` +- `internal/similarity/worker.go` — extend `upsertArtistSimilar` to persist unmatched MBIDs +- `internal/similarity/worker_test.go` — extend with `TestUpsertArtistSimilar_PersistsUnmatchedToTable` +- `internal/api/api.go` — register `/api/discover/suggestions` route + +### Frontend — create + +- `web/src/lib/api/suggestions.ts` — client (`listSuggestions`, `createSuggestionsQuery`) +- `web/src/lib/api/suggestions.test.ts` +- `web/src/lib/components/SuggestionFeed.svelte` — subcomponent for the suggestion grid +- `web/src/lib/components/SuggestionFeed.test.ts` + +### Frontend — modify + +- `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` +- `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` +- `web/src/lib/components/DiscoverResultCard.svelte` — add optional `attribution?: string` prop +- `web/src/lib/components/DiscoverResultCard.test.ts` — assert attribution rendering +- `web/src/routes/discover/+page.svelte` — empty-input branches to ``; tabs hide when input is empty +- `web/src/routes/discover/discover.test.ts` — extend with suggestion-feed tests + +--- + +## Task list + +### Task 1 — Migration 0012 + worker upsert query + +**Files:** +- Create: `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` +- Create: `internal/db/migrations/0012_artist_similarity_unmatched.down.sql` +- Modify: `internal/db/queries/similarity.sql` +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 1.1: Write the up migration** + +`internal/db/migrations/0012_artist_similarity_unmatched.up.sql`: + +```sql +-- M5c: persist unmatched-similar-artist MBIDs that the M4b worker would +-- otherwise discard. Mirrors artist_similarity shape: same composite PK +-- with source, same (seed_id, score DESC) index, same source enum check. +-- The candidate side is text + name (no FK) — that's the whole point. + +CREATE TABLE artist_similarity_unmatched ( + seed_artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + candidate_mbid text NOT NULL, + candidate_name text NOT NULL, + score DOUBLE PRECISION NOT NULL, + source text NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')), + fetched_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (seed_artist_id, candidate_mbid, source) +); + +CREATE INDEX artist_similarity_unmatched_seed_score_idx + ON artist_similarity_unmatched (seed_artist_id, score DESC); +``` + +- [ ] **Step 1.2: Write the down migration** + +`internal/db/migrations/0012_artist_similarity_unmatched.down.sql`: + +```sql +DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx; +DROP TABLE IF EXISTS artist_similarity_unmatched; +``` + +- [ ] **Step 1.3: Append the worker upsert query** + +Append to `internal/db/queries/similarity.sql`: + +```sql +-- name: UpsertArtistSimilarityUnmatched :exec +-- Persists an out-of-library similar-artist MBID. Idempotent on +-- (seed_artist_id, candidate_mbid, source) — re-fetches refresh the +-- name/score and bump fetched_at. +INSERT INTO artist_similarity_unmatched ( + seed_artist_id, candidate_mbid, candidate_name, score, source +) VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET + candidate_name = EXCLUDED.candidate_name, + score = EXCLUDED.score, + fetched_at = now(); +``` + +- [ ] **Step 1.4: Add a `dbtest.ResetDB` entry for the new table** + +Modify `internal/dbtest/reset.go` — append `"artist_similarity_unmatched"` to the `dataTables` slice between `track_similarity` and `scrobble_queue` so M5c integration tests don't inherit residual rows. + +```go +var dataTables = []string{ + "artist_similarity", + "track_similarity", + "artist_similarity_unmatched", // M5c + "scrobble_queue", + // ... rest unchanged ... +} +``` + +- [ ] **Step 1.5: Regenerate sqlc** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +go vet ./... +``` + +Expected: clean build. New method `UpsertArtistSimilarityUnmatched` appears in `internal/db/dbq/similarity.sql.go`. + +- [ ] **Step 1.6: Apply migration to verify it runs** + +```bash +docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS artist_similarity_unmatched;" +# Migration applies on next test run / server start. +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 -race ./internal/db/... -count=1 +docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d artist_similarity_unmatched" +``` + +Expected: `\d` shows the table with all columns + the seed_score index. + +- [ ] **Step 1.7: Commit** + +```bash +git add internal/db/migrations/0012_artist_similarity_unmatched.up.sql \ + internal/db/migrations/0012_artist_similarity_unmatched.down.sql \ + internal/db/queries/similarity.sql \ + internal/db/dbq/ \ + internal/dbtest/reset.go +git commit -m "feat(db): add artist_similarity_unmatched schema (migration 0012)" +``` + +--- + +### Task 2 — Extend `SimilarArtist` with `Name` field + +**Files:** +- Modify: `internal/scrobble/listenbrainz/client.go` — add `Name` to the struct +- Modify: `internal/scrobble/listenbrainz/client_test.go` (if existing tests break) — already-passing tests should still pass since adding a field is additive + +The current struct at `internal/scrobble/listenbrainz/client.go:241-245`: + +```go +type SimilarArtist struct { + MBID string `json:"artist_mbid"` + Score float64 `json:"score"` +} +``` + +ListenBrainz's `/1/explore/similar-artists/{mbid}` endpoint returns each row with `artist_mbid`, `name`, `score`, plus other fields we don't care about. Adding `Name` is a purely additive struct change; existing JSON unmarshal still works for everything else. + +- [ ] **Step 2.1: Add the field** + +Modify `internal/scrobble/listenbrainz/client.go:241-245`: + +```go +type SimilarArtist struct { + MBID string `json:"artist_mbid"` + Name string `json:"name"` + Score float64 `json:"score"` +} +``` + +- [ ] **Step 2.2: Verify existing tests still pass** + +```bash +go test ./internal/scrobble/listenbrainz/... -count=1 +``` + +Expected: all green. The unmarshal already ignored the `name` field; capturing it doesn't break anything. + +- [ ] **Step 2.3: Commit** + +```bash +git add internal/scrobble/listenbrainz/client.go +git commit -m "feat(listenbrainz): expose Name on SimilarArtist for M5c suggestions" +``` + +--- + +### Task 3 — Extend similarity worker to persist unmatched MBIDs + +**Files:** +- Modify: `internal/similarity/worker.go` — extend `upsertArtistSimilar` +- Modify: `internal/similarity/worker_test.go` — add `TestUpsertArtistSimilar_PersistsUnmatchedToTable` + +The current `upsertArtistSimilar` filters returned MBIDs to those in `idByMBID` (in-library only) and discards the rest. M5c keeps the matched-path unchanged but adds a parallel unmatched-persist loop with the same top-K cap. + +- [ ] **Step 3.1: Read the current `upsertArtistSimilar` shape** + +Read `internal/similarity/worker.go:170-200` (function body) before editing. It mirrors `upsertTrackSimilar` exactly — same sort, same `idByMBID`, same top-K pattern. The matched-loop pattern is the template. + +- [ ] **Step 3.2: Extend `upsertArtistSimilar`** + +Replace the function body (`internal/similarity/worker.go:170` onwards). The matched loop stays exactly as it was; we add a second loop that walks the same sorted `results` and persists unmatched rows up to `w.topK`: + +```go +func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) { + if len(results) == 0 { + return + } + sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) + + mbids := make([]string, 0, len(results)) + for _, r := range results { + mbids = append(mbids, r.MBID) + } + rows, err := q.GetArtistsByMBIDs(ctx, mbids) + if err != nil { + w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err) + return + } + idByMBID := make(map[string]pgtype.UUID, len(rows)) + for _, r := range rows { + if r.Mbid != nil { + idByMBID[*r.Mbid] = r.ID + } + } + + // Matched: in-library similars → artist_similarity (existing path). + takenMatched := 0 + for _, r := range results { + if takenMatched >= w.topK { + break + } + localID, ok := idByMBID[r.MBID] + if !ok { + continue + } + if localID == artistAID { + continue // defensive — DB CHECK constraint also catches self-edges + } + if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{ + ArtistAID: artistAID, ArtistBID: localID, Score: r.Score, Source: "listenbrainz", + }); uerr != nil { + w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) + continue + } + takenMatched++ + } + + // Unmatched: out-of-library similars → artist_similarity_unmatched (M5c). + // Same top-K cap as the matched path; missing-name rows are skipped (we + // can't render a suggestion without an artist name). + takenUnmatched := 0 + for _, r := range results { + if takenUnmatched >= w.topK { + break + } + if _, inLib := idByMBID[r.MBID]; inLib { + continue + } + if r.Name == "" { + w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID) + continue + } + if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: artistAID, + CandidateMbid: r.MBID, + CandidateName: r.Name, + Score: r.Score, + Source: "listenbrainz", + }); uerr != nil { + w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr) + continue + } + takenUnmatched++ + } +} +``` + +Note: the existing function used a single `taken` variable; the new version splits into `takenMatched` and `takenUnmatched` so each path is bounded independently. Verify the existing call to `UpsertArtistSimilarity` in your repo matches the signature you're substituting — sqlc may name the params struct differently. + +- [ ] **Step 3.3: Write `TestUpsertArtistSimilar_PersistsUnmatchedToTable`** + +Append to `internal/similarity/worker_test.go`: + +```go +func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + pool := newPool(t) // existing helper + q := dbq.New(pool) + ctx := context.Background() + + // Seed one in-library artist that will be the in-library match. + inLibMBID := "in-lib-mbid-123" + inLibArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ + Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID, + }) + if err != nil { + t.Fatalf("seed in-lib artist: %v", err) + } + + // Seed a "seed" artist (the one whose similars we're processing). + seedMBID := "seed-artist-mbid" + seedArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ + Name: "Seed Artist", SortName: "Seed Artist", Mbid: &seedMBID, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + + w := &Worker{pool: pool, logger: newTestLogger(), topK: 10} + + similars := []listenbrainz.SimilarArtist{ + {MBID: inLibMBID, Name: "InLib Artist", Score: 0.95}, + {MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85}, + {MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80}, + {MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70}, + {MBID: "out-mbid-4", Name: "", Score: 0.60}, // missing name — should be skipped + } + w.upsertArtistSimilar(ctx, q, seedArtist.ID, similars) + + // Matched path: 1 row in artist_similarity. + var matchedCount int + if err := pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1", + seedArtist.ID, + ).Scan(&matchedCount); err != nil { + t.Fatalf("count matched: %v", err) + } + if matchedCount != 1 { + t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount) + } + + // Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped. + var unmatchedCount int + if err := pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1", + seedArtist.ID, + ).Scan(&unmatchedCount); err != nil { + t.Fatalf("count unmatched: %v", err) + } + if unmatchedCount != 3 { + t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount) + } + + // Verify a specific row's name + score round-tripped correctly. + var name string + var score float64 + if err := pool.QueryRow(ctx, + "SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2", + seedArtist.ID, "out-mbid-1", + ).Scan(&name, &score); err != nil { + t.Fatalf("fetch out-mbid-1: %v", err) + } + if name != "Outsider One" || score != 0.85 { + t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score) + } + + // suppress unused warning if inLibArtist isn't otherwise referenced + _ = inLibArtist +} +``` + +If `internal/similarity/worker_test.go` doesn't already have a `newPool(t)` and `newTestLogger()` helper, mirror the pattern from `internal/lidarrquarantine/service_test.go` — those have working examples. + +- [ ] **Step 3.4: Run 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 -race -count=1 ./internal/similarity/... +``` + +Expected: existing tests still pass + new test passes. + +- [ ] **Step 3.5: Commit** + +```bash +git add internal/similarity/worker.go internal/similarity/worker_test.go +git commit -m "feat(similarity): persist unmatched similar-artist MBIDs for M5c" +``` + +--- + +### Task 4 — `internal/recommendation` `SuggestArtists` service + +**Files:** +- Create: `internal/recommendation/suggestions.go` +- Create: `internal/recommendation/suggestions_integration_test.go` +- Modify: `internal/db/queries/recommendation.sql` — add the suggestion CTE query +- Regenerate: `internal/db/dbq/*` + +- [ ] **Step 4.1: Append the suggestion query** + +Append to `internal/db/queries/recommendation.sql`: + +```sql +-- name: SuggestArtistsForUser :many +-- M5c: per-user artist suggestions ranked by signal × similarity. The +-- seeds CTE collects the user's likes (×5) plus recency-decayed plays +-- (exp(-age_days / $2)). The contributions CTE joins those seeds against +-- artist_similarity_unmatched and filters out candidates already in +-- library or already in a non-terminal lidarr_request. The outer SELECT +-- aggregates per candidate, returning the top-3 contributing seeds for +-- attribution. $1=user_id, $2=half_life_days, $3=limit. +WITH seeds AS ( + SELECT a.id AS artist_id, + 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) + + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2 * 86400.0))), 0) + AS signal, + (gla.artist_id IS NOT NULL) AS is_liked, + COUNT(pe.id) AS play_count + FROM artists a + LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 + 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.artist_id IS NOT NULL OR pe.id IS NOT NULL + GROUP BY a.id, gla.artist_id +), +contributions AS ( + SELECT u.candidate_mbid, + u.candidate_name, + seeds.artist_id AS seed_id, + seeds.is_liked, + seeds.play_count, + seeds.signal * u.score AS contribution + FROM artist_similarity_unmatched u + JOIN seeds ON seeds.artist_id = u.seed_artist_id + WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_requests r + WHERE r.user_id = $1 + AND r.lidarr_artist_mbid = u.candidate_mbid + AND r.status NOT IN ('rejected', 'failed') + ) +) +SELECT candidate_mbid, + candidate_name, + SUM(contribution)::float8 AS total_score, + (array_agg(seed_id ORDER BY contribution DESC))[1:3] AS top_seed_ids, + (array_agg(contribution ORDER BY contribution DESC))[1:3] AS top_contributions, + (array_agg(is_liked ORDER BY contribution DESC))[1:3] AS top_is_liked, + (array_agg(play_count ORDER BY contribution DESC))[1:3] AS top_play_counts +FROM contributions +GROUP BY candidate_mbid, candidate_name +ORDER BY total_score DESC +LIMIT $3; +``` + +The extra arrays (`top_is_liked`, `top_play_counts`) let the SPA pick "liked"/"played" verbiage per attribution-line slot. + +- [ ] **Step 4.2: Regenerate sqlc** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate +go build ./... +``` + +Expected: clean. New method `SuggestArtistsForUser` in `internal/db/dbq/recommendation.sql.go`. Note the row type may have field names like `TopSeedIds` (plural-suffix suppressed by sqlc) — read the generated file before relying on names in the next step. + +- [ ] **Step 4.3: Write `internal/recommendation/suggestions.go`** + +```go +// suggestions.go is the M5c per-user artist-suggestion service. Reads +// the user's likes + plays, projects them through artist_similarity_unmatched +// via a single CTE, returns top-N candidates with top-3 attribution seeds +// resolved to artist names. +package recommendation + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds. +type ArtistSuggestion struct { + MBID string + Name string + Score float64 + Attribution []SeedContribution +} + +// SeedContribution is one of the top-3 contributing seeds for a candidate. +type SeedContribution struct { + ArtistID pgtype.UUID + Name string + Contribution float64 + IsLiked bool + PlayCount int64 +} + +// SuggestArtists returns top-N artist suggestions for the user. limit is +// capped at 50; halfLifeDays is the recency-decay half-life for plays +// (default 30, operator-tunable). +func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) { + if limit <= 0 || limit > 50 { + limit = 12 + } + if halfLifeDays <= 0 { + halfLifeDays = 30 + } + q := dbq.New(pool) + rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{ + UserID: userID, + Column2: halfLifeDays, // sqlc names unbound positional params Column2/3 — verify + Limit: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("suggest: query: %w", err) + } + if len(rows) == 0 { + return []ArtistSuggestion{}, nil + } + + // Collect the union of top-3 seed IDs across all rows for one batched + // name lookup. + seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3) + for _, r := range rows { + for _, sid := range r.TopSeedIds { + seedSet[sid] = struct{}{} + } + } + seedIDs := make([]pgtype.UUID, 0, len(seedSet)) + for id := range seedSet { + seedIDs = append(seedIDs, id) + } + artists, err := q.GetArtistsByIDs(ctx, seedIDs) + if err != nil { + return nil, fmt.Errorf("suggest: resolve seeds: %w", err) + } + nameByID := make(map[pgtype.UUID]string, len(artists)) + for _, a := range artists { + nameByID[a.ID] = a.Name + } + + out := make([]ArtistSuggestion, 0, len(rows)) + for _, r := range rows { + attribution := make([]SeedContribution, 0, len(r.TopSeedIds)) + for i, sid := range r.TopSeedIds { + if i >= len(r.TopContributions) { + break + } + attribution = append(attribution, SeedContribution{ + ArtistID: sid, + Name: nameByID[sid], + Contribution: r.TopContributions[i], + IsLiked: r.TopIsLiked[i], + PlayCount: r.TopPlayCounts[i], + }) + } + out = append(out, ArtistSuggestion{ + MBID: r.CandidateMbid, + Name: r.CandidateName, + Score: r.TotalScore, + Attribution: attribution, + }) + } + return out, nil +} +``` + +Two things to verify against the actual generated code in `internal/db/dbq/recommendation.sql.go`: +1. The param struct may name `$2` something other than `Column2` (sqlc occasionally renames). Look at `SuggestArtistsForUserParams` and use whatever's there. +2. `GetArtistsByIDs` may not exist — check `internal/db/queries/artists.sql`. If absent, add a query in this same task: + +```sql +-- name: GetArtistsByIDs :many +SELECT * FROM artists WHERE id = ANY($1::uuid[]); +``` + +(If `GetArtistsByMBIDs` exists but not `GetArtistsByIDs`, add the IDs variant; sqlc-regenerate.) + +- [ ] **Step 4.4: Write integration tests** + +Create `internal/recommendation/suggestions_integration_test.go`: + +```go +package recommendation + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + return u +} + +func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist { + t.Helper() + var mbidPtr *string + if mbid != "" { + mbidPtr = &mbid + } + a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: name, SortName: name, Mbid: mbidPtr, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + return a +} + +func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) { + t.Helper() + if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: seedID, + CandidateMbid: candMBID, + CandidateName: candName, + Score: score, + Source: "listenbrainz", + }); err != nil { + t.Fatalf("seed unmatched: %v", err) + } +} + +func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seedA := seedArtist(t, pool, "Seed Liked", "") + seedB := seedArtist(t, pool, "Seed Played", "") + + // alice likes seedA. + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seedA.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + // alice played seedB. (Need a play_event with the right artist via tracks.) + seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B") + seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B") + insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour)) + + // Both seeds point at the same out-of-library candidate. + seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9) + seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1", len(out)) + } + s := out[0] + if s.MBID != "out-mbid" || s.Name != "Outsider" { + t.Errorf("got = %+v", s) + } + if s.Score <= 0 { + t.Errorf("score = %v, want > 0", s.Score) + } + if len(s.Attribution) != 2 { + t.Errorf("attribution len = %d, want 2", len(s.Attribution)) + } +} + +func TestSuggestArtists_Top12Cap(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + for i := 0; i < 30; i++ { + seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01) + } + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 12 { + t.Errorf("len = %d, want 12", len(out)) + } + if out[0].MBID != "mbid-00" { + t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID) + } +} + +func TestSuggestArtists_AttributionTopThree(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + // 5 seed artists all liked, all pointing at the same candidate but + // with descending similarity scores so contributions order is clean. + seeds := make([]dbq.Artist, 5) + for i := 0; i < 5; i++ { + seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seeds[i].ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1) + } + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1 (shared candidate)", len(out)) + } + if got := len(out[0].Attribution); got != 3 { + t.Errorf("attribution len = %d, want 3", got) + } + // Verify ordering: highest contribution first (seed 0 with score 0.9). + if out[0].Attribution[0].Name != "Seed 0" { + t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name) + } +} + +func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + recentSeed := seedArtist(t, pool, "Recent", "") + oldSeed := seedArtist(t, pool, "Old", "") + + rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album") + rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track") + insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour)) + + oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album") + oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track") + insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour)) + + // Both seeds point at the same candidate with the same similarity score. + seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5) + seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Fatalf("len = %d, want 1", len(out)) + } + if len(out[0].Attribution) != 2 { + t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution)) + } + // Recent seed contributes more than old seed. + if out[0].Attribution[0].Name != "Recent" { + t.Errorf("top attribution = %q, want Recent (1d-old play decays less than 90d)", out[0].Attribution[0].Name) + } + if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution { + t.Errorf("recent contribution (%v) should exceed old (%v)", + out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution) + } +} + +func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + // Candidate that's already in library. + inLibMBID := "in-lib-mbid" + seedArtist(t, pool, "InLib", inLibMBID) + seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9) + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out)) + } +} + +func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9) + if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ + UserID: user.ID, + Kind: dbq.LidarrRequestKindArtist, + LidarrArtistMbid: "req-mbid", + ArtistName: "Pending Request", + }); err != nil { + t.Fatalf("CreateLidarrRequest: %v", err) + } + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out)) + } +} + +func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + seed := seedArtist(t, pool, "Seed", "") + if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seed.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9) + req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ + UserID: user.ID, + Kind: dbq.LidarrRequestKindArtist, + LidarrArtistMbid: "rej-mbid", + ArtistName: "Rejected Once", + }) + if err != nil { + t.Fatalf("CreateLidarrRequest: %v", err) + } + rejNotes := "wrong artist" + if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{ + ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID, + }); err != nil { + t.Fatalf("RejectLidarrRequest: %v", err) + } + + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 1 { + t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out)) + } +} + +func TestSuggestArtists_EmptyForNewUser(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "newbie") + out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) + if err != nil { + t.Fatalf("SuggestArtists: %v", err) + } + if len(out) != 0 { + t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) + } +} +``` + +Helper functions `seedAlbumForArtist`, `seedTrackOnAlbum`, `insertPlayEvent` are needed. Mirror the same helpers from `internal/lidarrquarantine/service_test.go`'s `seedTrack` (but parameterize artist): + +```go +func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album { + t.Helper() + a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, ArtistID: artistID, + }) + if err != nil { + t.Fatalf("seed album: %v", err) + } + return a +} + +func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track { + t.Helper() + tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: albumID, ArtistID: artistID, + DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3", + FileSize: 1, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("seed track: %v", err) + } + return tr +} + +func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) { + t.Helper() + if _, err := pool.Exec(context.Background(), + `INSERT INTO play_events (user_id, track_id, started_at, was_skipped) VALUES ($1, $2, $3, false)`, + userID, trackID, startedAt, + ); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} +``` + +The exact `play_events` column set may differ from this minimal insert — read the migration `0005_events.up.sql` and add any required NOT-NULL columns (e.g. `client_id`, `session_id`) with sensible defaults if the insert fails. + +- [ ] **Step 4.5: Run 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 -race -count=1 -p 1 ./internal/recommendation/... +``` + +Expected: 7 tests pass. + +- [ ] **Step 4.6: Commit** + +```bash +git add internal/recommendation/suggestions.go \ + internal/recommendation/suggestions_integration_test.go \ + internal/db/queries/recommendation.sql \ + internal/db/queries/artists.sql \ + internal/db/dbq/ +git commit -m "feat(recommendation): SuggestArtists service for M5c" +``` + +(Include `artists.sql` if you added `GetArtistsByIDs` to it in Step 4.3.) + +--- + +### Task 5 — `/api/discover/suggestions` handler + route mount + +**Files:** +- Create: `internal/api/suggestions.go` +- Create: `internal/api/suggestions_test.go` +- Modify: `internal/api/api.go` — add the route + +- [ ] **Step 5.1: Write the handler** + +Create `internal/api/suggestions.go`: + +```go +package api + +import ( + "net/http" + "strconv" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// suggestionView is the wire shape returned by GET /api/discover/suggestions. +type suggestionView struct { + MBID string `json:"mbid"` + Name string `json:"name"` + Score float64 `json:"score"` + Attribution []seedContributionView `json:"attribution"` +} + +type seedContributionView struct { + ArtistID pgtype.UUID `json:"artist_id"` + Name string `json:"name"` + Contribution float64 `json:"contribution"` + IsLiked bool `json:"is_liked"` + PlayCount int64 `json:"play_count"` +} + +// handleListSuggestions implements GET /api/discover/suggestions. +// +// Query params: +// - limit (default 12, capped at 50) +// - half_life_days (default 30, no max) +// +// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate. +func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + limit := 12 + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") + return + } + limit = n + } + halfLife := 30.0 + if v := r.URL.Query().Get("half_life_days"); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil || f <= 0 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid half_life_days") + return + } + halfLife = f + } + + suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit) + if err != nil { + h.logger.Error("api: list suggestions", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "failed to load suggestions") + return + } + + out := make([]suggestionView, 0, len(suggestions)) + for _, s := range suggestions { + attr := make([]seedContributionView, 0, len(s.Attribution)) + for _, a := range s.Attribution { + attr = append(attr, seedContributionView{ + ArtistID: a.ArtistID, + Name: a.Name, + Contribution: a.Contribution, + IsLiked: a.IsLiked, + PlayCount: a.PlayCount, + }) + } + out = append(out, suggestionView{ + MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, + }) + } + writeJSON(w, http.StatusOK, out) +} +``` + +- [ ] **Step 5.2: Mount the route** + +In `internal/api/api.go`, find the authed group (after `r.Get("/api/radio", ...)` line) and add: + +```go +authed.Get("/discover/suggestions", h.handleListSuggestions) +``` + +- [ ] **Step 5.3: Write handler tests** + +Create `internal/api/suggestions_test.go`: + +```go +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func TestSuggestions_HappyPath(t *testing.T) { + h, _ := testHandlers(t) + user := seedUser(t, h.pool, "alice", "pw", false) + + // Seed: alice likes a seed artist; that seed has one out-of-library + // similar in artist_similarity_unmatched. + seedA, err := dbq.New(h.pool).UpsertArtist(t.Context(), dbq.UpsertArtistParams{ + Name: "Seed", SortName: "Seed", + }) + if err != nil { + t.Fatalf("UpsertArtist: %v", err) + } + if _, err := dbq.New(h.pool).LikeArtist(t.Context(), dbq.LikeArtistParams{ + UserID: user.ID, ArtistID: seedA.ID, + }); err != nil { + t.Fatalf("LikeArtist: %v", err) + } + if err := dbq.New(h.pool).UpsertArtistSimilarityUnmatched(t.Context(), dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: seedA.ID, + CandidateMbid: "out-mbid", + CandidateName: "Outsider", + Score: 0.9, + Source: "listenbrainz", + }); err != nil { + t.Fatalf("UpsertArtistSimilarityUnmatched: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) + setUserCtx(req, user) + w := httptest.NewRecorder() + h.handleListSuggestions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got []suggestionView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1; body = %s", len(got), w.Body.String()) + } + if got[0].MBID != "out-mbid" || got[0].Name != "Outsider" { + t.Errorf("got = %+v", got[0]) + } + if len(got[0].Attribution) != 1 { + t.Errorf("attribution len = %d, want 1", len(got[0].Attribution)) + } + if got[0].Attribution[0].Name != "Seed" { + t.Errorf("attribution name = %q, want Seed", got[0].Attribution[0].Name) + } +} + +func TestSuggestions_EmptyForNewUser(t *testing.T) { + h, _ := testHandlers(t) + user := seedUser(t, h.pool, "newbie", "pw", false) + + req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil) + setUserCtx(req, user) + w := httptest.NewRecorder() + h.handleListSuggestions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if got := w.Body.String(); got != "[]\n" && got != "[]" { + t.Errorf("body = %q, want []", got) + } +} + +func TestSuggestions_BadLimit(t *testing.T) { + h, _ := testHandlers(t) + user := seedUser(t, h.pool, "alice", "pw", false) + + req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions?limit=not-a-number", nil) + setUserCtx(req, user) + w := httptest.NewRecorder() + h.handleListSuggestions(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// suppress unused imports in some test layouts +var _ = dbtest.TestUserPrefix +``` + +`testHandlers`, `seedUser`, `setUserCtx` are existing helpers in the test layout — see `internal/api/auth_test.go` and `internal/api/requests_test.go`. Use whatever pattern those tests use to seed a user and inject it into the request context. + +- [ ] **Step 5.4: Run 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 -race -count=1 -p 1 ./internal/api/... -run Suggestions +``` + +Expected: 3 tests pass. + +- [ ] **Step 5.5: Commit** + +```bash +git add internal/api/suggestions.go internal/api/suggestions_test.go internal/api/api.go +git commit -m "feat(api): /api/discover/suggestions handler" +``` + +--- + +### Task 6 — Frontend API client + types + qk + +**Files:** +- Create: `web/src/lib/api/suggestions.ts` +- Create: `web/src/lib/api/suggestions.test.ts` +- Modify: `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution` +- Modify: `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)` + +- [ ] **Step 6.1: Add types** + +Append to `web/src/lib/api/types.ts`: + +```ts +export type SeedContribution = { + artist_id: string; + name: string; + contribution: number; + is_liked: boolean; + play_count: number; +}; + +export type ArtistSuggestion = { + mbid: string; + name: string; + score: number; + attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC +}; +``` + +- [ ] **Step 6.2: Add query key** + +Append to the `qk` object in `web/src/lib/api/queries.ts`: + +```ts +suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, +``` + +- [ ] **Step 6.3: Write `suggestions.ts`** + +Create `web/src/lib/api/suggestions.ts`: + +```ts +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { ArtistSuggestion } from './types'; + +export async function listSuggestions(limit = 12): Promise { + return api.get(`/api/discover/suggestions?limit=${limit}`); +} + +export function createSuggestionsQuery(limit = 12) { + return createQuery({ + queryKey: qk.suggestions(limit), + queryFn: () => listSuggestions(limit), + staleTime: 5 * 60_000 // 5 minutes + }); +} +``` + +- [ ] **Step 6.4: Write tests** + +Create `web/src/lib/api/suggestions.test.ts`: + +```ts +import { describe, expect, test, vi, afterEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { get: vi.fn() } +})); + +import { listSuggestions } from './suggestions'; +import { qk } from './queries'; +import { api } from './client'; +import type { ArtistSuggestion } from './types'; + +afterEach(() => vi.clearAllMocks()); + +describe('suggestions client', () => { + test('listSuggestions hits the right URL with default limit', async () => { + const fixture: ArtistSuggestion[] = [ + { + mbid: 'm1', + name: 'Outsider', + score: 1.5, + attribution: [ + { artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 } + ] + } + ]; + (api.get as ReturnType).mockResolvedValueOnce(fixture); + const got = await listSuggestions(); + expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=12'); + expect(got).toEqual(fixture); + }); + + test('listSuggestions honors a custom limit', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await listSuggestions(20); + expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=20'); + }); + + test('qk.suggestions key shape', () => { + expect(qk.suggestions()).toEqual(['suggestions', { limit: 12 }]); + expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]); + }); +}); +``` + +- [ ] **Step 6.5: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run suggestions +cd .. +``` + +Expected: 0 errors, 3 tests pass. + +- [ ] **Step 6.6: Commit** + +```bash +git add web/src/lib/api/suggestions.ts web/src/lib/api/suggestions.test.ts \ + web/src/lib/api/types.ts web/src/lib/api/queries.ts +git commit -m "feat(web): API client for /api/discover/suggestions" +``` + +--- + +### Task 7 — Extend `` with `attribution` prop + +**Files:** +- Modify: `web/src/lib/components/DiscoverResultCard.svelte` +- Modify: `web/src/lib/components/DiscoverResultCard.test.ts` + +The card already has a `$props()` block accepting `kind`, `title`, `subtitle?`, `imageUrl?`, `state`, `onRequest?`. Add `attribution?: string` and render it in italic Vellum below the title (above the reserved badge slot). + +- [ ] **Step 7.1: Add the prop** + +In `DiscoverResultCard.svelte`, modify the `$props()` destructure: + +```ts +let { + kind, + title, + subtitle, + imageUrl, + state, + attribution, + onRequest, +}: { + kind: DiscoverCardKind; + title: string; + subtitle?: string; + imageUrl?: string; + state: DiscoverCardState; + attribution?: string; + onRequest?: () => void; +} = $props(); +``` + +- [ ] **Step 7.2: Render the attribution line** + +Find the section rendering the title + subtitle (search for `class="title"` and `class="subtitle"`). Add the attribution line between subtitle and `.badge-row`: + +```svelte +
+
{title}
+ {#if subtitle} +
{subtitle}
+ {/if} + {#if attribution} +
+ {attribution} +
+ {/if} +
+ {#if state === 'kept'} + Kept + {/if} +
+
+``` + +- [ ] **Step 7.3: Add tests** + +Append to `DiscoverResultCard.test.ts`: + +```ts +test('renders attribution line when prop is set', () => { + render(DiscoverResultCard, { + props: { + kind: 'artist', + title: 'Outsider', + state: 'requestable', + attribution: 'Because you liked Boards of Canada and played Aphex Twin.' + } + }); + expect(screen.getByTestId('attribution')).toHaveTextContent('Because you liked Boards of Canada and played Aphex Twin.'); +}); + +test('omits attribution line when prop is absent', () => { + render(DiscoverResultCard, { + props: { kind: 'artist', title: 'Outsider', state: 'requestable' } + }); + expect(screen.queryByTestId('attribution')).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 7.4: Verify + commit** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run DiscoverResultCard +cd .. +git add web/src/lib/components/DiscoverResultCard.svelte web/src/lib/components/DiscoverResultCard.test.ts +git commit -m "feat(web): DiscoverResultCard attribution prop for M5c suggestions" +``` + +--- + +### Task 8 — `` subcomponent + `/discover` integration + +**Files:** +- Create: `web/src/lib/components/SuggestionFeed.svelte` +- Create: `web/src/lib/components/SuggestionFeed.test.ts` +- Modify: `web/src/routes/discover/+page.svelte` — branch to `` when search is empty +- Modify: `web/src/routes/discover/discover.test.ts` — add suggestion-feed scenarios + +- [ ] **Step 8.1: Write ``** + +Create `web/src/lib/components/SuggestionFeed.svelte`: + +```svelte + + +
+
+

Suggested for you

+

Out-of-library artists drawn from what you've liked and played.

+
+ + {#if !query.isPending && suggestions.length === 0} +

Listen to something or like an artist to start getting suggestions.

+ {:else if suggestions.length > 0} +
+ {#each suggestions.filter(visible) as s (s.mbid)} + onRequest(s)} + /> + {/each} +
+ {/if} +
+``` + +- [ ] **Step 8.2: Write `` tests** + +Create `web/src/lib/components/SuggestionFeed.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; + +const invalidateMock = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; +}); + +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); + +vi.mock('$lib/api/requests', () => ({ + createRequest: vi.fn().mockResolvedValue({}) +})); + +import SuggestionFeed from './SuggestionFeed.svelte'; +import { createSuggestionsQuery } from '$lib/api/suggestions'; +import { createRequest } from '$lib/api/requests'; +import type { ArtistSuggestion } from '$lib/api/types'; + +const oneSeed: ArtistSuggestion = { + mbid: 'mb1', name: 'Outsider', score: 1.0, + attribution: [{ artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 }] +}; + +const twoSeeds: ArtistSuggestion = { + mbid: 'mb2', name: 'Outsider Two', score: 2.0, + attribution: [ + { artist_id: 'a1', name: 'A', contribution: 0.8, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'B', contribution: 0.5, is_liked: false, play_count: 3 } + ] +}; + +const threeSeeds: ArtistSuggestion = { + mbid: 'mb3', name: 'Outsider Three', score: 3.0, + attribution: [ + { artist_id: 'a1', name: 'X', contribution: 0.9, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'Y', contribution: 0.6, is_liked: false, play_count: 5 }, + { artist_id: 'a3', name: 'Z', contribution: 0.3, is_liked: false, play_count: 1 } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +describe('SuggestionFeed', () => { + test('renders one card per suggestion', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [oneSeed, twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText('Outsider')).toBeInTheDocument(); + expect(screen.getByText('Outsider Two')).toBeInTheDocument(); + }); + + test('attribution copy: 1 seed → "Because you liked X."', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 2 seeds → "Because you liked A and played B."', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 3 seeds → Oxford comma', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [threeSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument(); + }); + + test('Request button calls createRequest with artist-kind body', async () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + await fireEvent.click(screen.getByRole('button', { name: /request outsider/i })); + expect(createRequest).toHaveBeenCalledWith({ + kind: 'artist', + lidarr_artist_mbid: 'mb1', + artist_name: 'Outsider' + }); + expect(invalidateMock).toHaveBeenCalled(); + }); + + test('empty state when data is []', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); + render(SuggestionFeed); + expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 8.3: Wire `` into `/discover`** + +Modify `web/src/routes/discover/+page.svelte`. Read it first — the existing structure runs the search query when `debouncedQ.length > 0`. Add the feed branch: + +```svelte + + + + +
+ + + + {#if debouncedQ === ''} + + {:else} + + {existing markup unchanged} + {/if} +
+``` + +The search-input element stays at the top level (visible in both branches). The header copy ("Add music to the library" vs "Suggested for you") is now owned by the respective branch — `` renders its own header; the search branch keeps the existing one. + +If the existing `+page.svelte` has its header above the input, you'll need to move it inside the search branch. Pattern: + +```svelte + + +{#if debouncedQ === ''} + +{:else} +
+

Add music to the library

+

...

+
+ +{/if} +``` + +- [ ] **Step 8.4: Update `discover.test.ts`** + +The existing tests assume `inputValue === ''` shows the initial-copy state. Now it shows the suggestion feed. Update the relevant test and add new ones: + +Add a mock for `$lib/api/suggestions`: + +```ts +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); +``` + +Update the existing "initial state shows search prompt copy" test (or add a replacement): + +```ts +test('empty input shows the suggestion feed', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [] }) + ); + render(DiscoverPage); + expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); +}); + +test('typing replaces feed with search', async () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [] }) + ); + // mock the lidarr search query as before + // ... fire input event to trigger debounced search ... + // ... advance fake timers ... + expect(screen.queryByText(/suggested for you/i)).not.toBeInTheDocument(); + expect(screen.getByText(/add music to the library/i)).toBeInTheDocument(); +}); +``` + +The existing tests that drive the search flow stay — they always provide a non-empty query. The empty-input case becomes a suggestion-feed test. + +- [ ] **Step 8.5: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run SuggestionFeed discover +npm run build +cd .. +``` + +Expected: 0 errors, all tests pass, build clean. + +- [ ] **Step 8.6: Commit** + +```bash +git add web/src/lib/components/SuggestionFeed.svelte \ + web/src/lib/components/SuggestionFeed.test.ts \ + web/src/routes/discover/+page.svelte \ + web/src/routes/discover/discover.test.ts +git commit -m "feat(web): suggestion feed on /discover (search-empty default)" +``` + +--- + +### Task 9 — Final verification + branch finish + +- [ ] **Step 9.1: Full Go test sweep** + +```bash +go test -short -race ./... +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 -race -p 1 ./... +``` + +Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented (`project_scanner_flake.md`) and not blocking. + +- [ ] **Step 9.2: Lint clean** + +```bash +golangci-lint run ./... +``` + +Expected: no output. + +- [ ] **Step 9.3: Coverage check** + +```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 \ + bash -c 'go test -race -p 1 -coverprofile=/tmp/cov.out \ + ./internal/recommendation/... ./internal/similarity/... ./internal/api/... && \ + go tool cover -func=/tmp/cov.out | tail -1' +``` + +Expected: combined ≥ 80% on the new code per spec §8. + +- [ ] **Step 9.4: Frontend full check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web +npm run check +npm test -- --run +npm run build +cd .. +``` + +Expected: 0 errors, all tests pass, build succeeds. + +- [ ] **Step 9.5: Manual smoke** + +- Like an artist (or play a few tracks). +- Open `/discover` with the search input empty. +- Verify the "Suggested for you" header + grid renders. +- Verify each card has an attribution line that reads naturally. +- Click Request on a suggestion → confirm it disappears (optimistic) and a row appears at `/requests`. +- Type a search term → confirm the feed swaps out for search results. +- Clear the search input → confirm the feed comes back (cached, instant). +- For a fresh user with no likes/plays, the empty-state copy renders. + +- [ ] **Step 9.6: Use `superpowers:finishing-a-development-branch`** + +Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. + +--- + +## Self-review checklist + +**Spec coverage** — every spec section maps to a task: +- §3 Architecture: Tasks 1 (table), 2 (LB client), 3 (worker), 4 (service), 5 (handler), 6-8 (frontend) +- §4 Schema: Task 1 +- §5 API surface: Task 5 +- §6 UI surfaces: Tasks 7 (DiscoverResultCard), 8 (SuggestionFeed + /discover integration) +- §7 Error handling: distributed across Tasks 3 (worker WARN), 5 (handler 500), 8 (frontend silent-on-failure) +- §8 Testing: every Task includes tests; Task 9 verifies coverage targets +- §9 Decisions ledger: not directly implemented, referenced in commit messages +- §10 Out of scope: explicitly excluded — no album/track suggestions, no realtime invalidation, no cross-user CF, no pagination, no materialization +- §11 Open questions: Task 2 verifies the `Name` field on `SimilarArtist`; cold-start sparseness is documented behavior + +**Placeholder scan:** the per-task detail level drops after Task 5 (frontend tasks become standard SvelteKit page work) — intentional for navigability. No "TBD" or "TODO" remains. + +**Type consistency:** +- Service method: `SuggestArtists` consistent across plan +- Types: `ArtistSuggestion`, `SeedContribution` consistent across Go and TS +- API path: `/api/discover/suggestions` consistent +- Component name: `` consistent +- DB field names: `seed_artist_id`, `candidate_mbid`, `candidate_name`, `score`, `source`, `fetched_at` consistent across migration / queries / tests + +Plan is complete.