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

64 KiB
Raw Blame History

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.sql
  • internal/recommendation/suggestions.goSuggestArtists service + types
  • internal/recommendation/suggestions_integration_test.go
  • internal/api/suggestions.goGET /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"`toSimilarArtist`
  • 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:

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

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 — 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:

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 — 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:

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:

  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:
-- 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 — 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:

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