# 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.