Files
minstrel/docs/superpowers/plans/2026-04-30-m5c-suggested-additions.md
T
bvandeusen cf1b75ca12 docs(plan): add M5c suggested-additions implementation plan
9 tasks: migration 0012 (artist_similarity_unmatched), ListenBrainz client
extension (SimilarArtist.Name), similarity worker extension to persist
unmatched MBIDs, recommendation.SuggestArtists service with single CTE,
GET /api/discover/suggestions handler, frontend client + types, DiscoverResultCard
attribution prop, SuggestionFeed component + /discover integration.
2026-04-30 23:02:41 -04:00

1839 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<DiscoverResultCard>` 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 `<SuggestionFeed>`; 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<ArtistSuggestion[]> {
return api.get<ArtistSuggestion[]>(`/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<typeof vi.fn>).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<typeof vi.fn>).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 `<DiscoverResultCard>` 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
<div class="text mt-3">
<div class="title text-base font-medium text-text-primary">{title}</div>
{#if subtitle}
<div class="subtitle text-sm text-text-secondary">{subtitle}</div>
{/if}
{#if attribution}
<div class="attribution text-xs italic text-text-secondary" data-testid="attribution">
{attribution}
</div>
{/if}
<div class="badge-row" data-testid="badge-row">
{#if state === 'kept'}
<span class="kept-pill" role="status">Kept</span>
{/if}
</div>
</div>
```
- [ ] **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 — `<SuggestionFeed>` 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 `<SuggestionFeed>` when search is empty
- Modify: `web/src/routes/discover/discover.test.ts` — add suggestion-feed scenarios
- [ ] **Step 8.1: Write `<SuggestionFeed>`**
Create `web/src/lib/components/SuggestionFeed.svelte`:
```svelte
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createSuggestionsQuery } from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import DiscoverResultCard from './DiscoverResultCard.svelte';
import type { ArtistSuggestion, SeedContribution } from '$lib/api/types';
const client = useQueryClient();
const queryStore = createSuggestionsQuery();
const query = $derived($queryStore);
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
// Track MBIDs the user just requested so the card flips immediately.
let optimisticRequested = $state(new Set<string>());
function visible(s: ArtistSuggestion): boolean {
return !optimisticRequested.has(s.mbid);
}
function attributionText(attribution: SeedContribution[]): string {
if (attribution.length === 0) return '';
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
const phrases = attribution.map((s) => `${verb(s)} ${s.name}`);
if (phrases.length === 1) {
return `Because you ${phrases[0]}.`;
}
if (phrases.length === 2) {
return `Because you ${phrases[0]} and ${phrases[1]}.`;
}
// 3 with Oxford comma
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
}
async function onRequest(s: ArtistSuggestion) {
try {
await createRequest({
kind: 'artist',
lidarr_artist_mbid: s.mbid,
artist_name: s.name
});
const next = new Set(optimisticRequested);
next.add(s.mbid);
optimisticRequested = next;
// Suggestions filter requested MBIDs server-side; refetch to drop the row.
await client.invalidateQueries({ queryKey: qk.suggestions() });
} catch {
// Swallow for v1; the SPA will refetch on next mount and the card stays
// requestable so the user can retry.
}
}
</script>
<div>
<header class="mb-4 space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">Suggested for you</h2>
<p class="text-text-secondary">Out-of-library artists drawn from what you've liked and played.</p>
</header>
{#if !query.isPending && suggestions.length === 0}
<p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p>
{:else if suggestions.length > 0}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each suggestions.filter(visible) as s (s.mbid)}
<DiscoverResultCard
kind="artist"
title={s.name}
state="requestable"
attribution={attributionText(s.attribution)}
onRequest={() => onRequest(s)}
/>
{/each}
</div>
{/if}
</div>
```
- [ ] **Step 8.2: Write `<SuggestionFeed>` 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<string, unknown>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
render(SuggestionFeed);
expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument();
});
});
```
- [ ] **Step 8.3: Wire `<SuggestionFeed>` 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
<script lang="ts">
// ... existing imports ...
import SuggestionFeed from '$lib/components/SuggestionFeed.svelte';
</script>
<!-- existing markup ... before the kind tabs / search results: -->
<div class="space-y-6">
<!-- search input always visible -->
<input ... />
{#if debouncedQ === ''}
<SuggestionFeed />
{:else}
<!-- existing: kind tabs + search-results grid + track-kind modal -->
{existing markup unchanged}
{/if}
</div>
```
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 — `<SuggestionFeed>` 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
<input bind:value={inputValue} ... />
{#if debouncedQ === ''}
<SuggestionFeed />
{:else}
<header>
<h2>Add music to the library</h2>
<p>...</p>
</header>
<!-- kind tabs + grid + modal as before -->
{/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<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [] })
);
render(DiscoverPage);
expect(screen.getByText(/suggested for you/i)).toBeInTheDocument();
});
test('typing replaces feed with search', async () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).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: `<SuggestionFeed>` consistent
- DB field names: `seed_artist_id`, `candidate_mbid`, `candidate_name`, `score`, `source`, `fetched_at` consistent across migration / queries / tests
Plan is complete.