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.
64 KiB
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. 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.sqlinternal/recommendation/suggestions.go—SuggestArtistsservice + typesinternal/recommendation/suggestions_integration_test.gointernal/api/suggestions.go—GET /api/discover/suggestionshandlerinternal/api/suggestions_test.go
Backend — modify
internal/db/queries/similarity.sql— addUpsertArtistSimilarityUnmatchedinternal/db/queries/recommendation.sql— addSuggestArtistsForUserinternal/db/dbq/*— regenerated bysqlc generateinternal/scrobble/listenbrainz/client.go— addName string \json:"name"`toSimilarArtist`internal/similarity/worker.go— extendupsertArtistSimilarto persist unmatched MBIDsinternal/similarity/worker_test.go— extend withTestUpsertArtistSimilar_PersistsUnmatchedToTableinternal/api/api.go— register/api/discover/suggestionsroute
Frontend — create
web/src/lib/api/suggestions.ts— client (listSuggestions,createSuggestionsQuery)web/src/lib/api/suggestions.test.tsweb/src/lib/components/SuggestionFeed.svelte— subcomponent for the suggestion gridweb/src/lib/components/SuggestionFeed.test.ts
Frontend — modify
web/src/lib/api/types.ts— addArtistSuggestion,SeedContributionweb/src/lib/api/queries.ts— addqk.suggestions(limit)web/src/lib/components/DiscoverResultCard.svelte— add optionalattribution?: stringpropweb/src/lib/components/DiscoverResultCard.test.ts— assert attribution renderingweb/src/routes/discover/+page.svelte— empty-input branches to<SuggestionFeed>; tabs hide when input is emptyweb/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:
-- 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:
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:
-- 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.ResetDBentry 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.
var dataTables = []string{
"artist_similarity",
"track_similarity",
"artist_similarity_unmatched", // M5c
"scrobble_queue",
// ... rest unchanged ...
}
- Step 1.5: Regenerate sqlc
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
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
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— addNameto 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:
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:
type SimilarArtist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
Score float64 `json:"score"`
}
- Step 2.2: Verify existing tests still pass
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
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— extendupsertArtistSimilar - Modify:
internal/similarity/worker_test.go— addTestUpsertArtistSimilar_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
upsertArtistSimilarshape
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:
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:
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
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
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:
-- 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
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
// 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:
- The param struct may name
$2something other thanColumn2(sqlc occasionally renames). Look atSuggestArtistsForUserParamsand use whatever's there. GetArtistsByIDsmay not exist — checkinternal/db/queries/artists.sql. If absent, add a query in this same task:
-- 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:
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):
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
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
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:
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:
authed.Get("/discover/suggestions", h.handleListSuggestions)
- Step 5.3: Write handler tests
Create internal/api/suggestions_test.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
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
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— addArtistSuggestion,SeedContribution -
Modify:
web/src/lib/api/queries.ts— addqk.suggestions(limit) -
Step 6.1: Add types
Append to web/src/lib/api/types.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:
suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const,
- Step 6.3: Write
suggestions.ts
Create web/src/lib/api/suggestions.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:
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
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
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:
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:
<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:
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
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:
<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:
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:
<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:
<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:
vi.mock('$lib/api/suggestions', () => ({
createSuggestionsQuery: vi.fn()
}));
Update the existing "initial state shows search prompt copy" test (or add a replacement):
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
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
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
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
golangci-lint run ./...
Expected: no output.
- Step 9.3: Coverage check
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
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
/discoverwith 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
Namefield onSimilarArtist; 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:
SuggestArtistsconsistent across plan - Types:
ArtistSuggestion,SeedContributionconsistent across Go and TS - API path:
/api/discover/suggestionsconsistent - Component name:
<SuggestionFeed>consistent - DB field names:
seed_artist_id,candidate_mbid,candidate_name,score,source,fetched_atconsistent across migration / queries / tests
Plan is complete.