cf1b75ca12
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.
1839 lines
64 KiB
Markdown
1839 lines
64 KiB
Markdown
# M5c — Suggested additions on `/discover` — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Personalized artist suggestions on `/discover` (search-input-empty state). Top-12 out-of-library artists ranked by per-user signal (likes ×5 + recency-decayed plays), with top-3 contributing seeds attributed per card. One-click add via the existing M5a Lidarr-request flow.
|
||
|
||
**Architecture:** Extend the M4b similarity ingest worker to persist unmatched artist MBIDs to a new `artist_similarity_unmatched` table (mirrors `artist_similarity` shape). New `internal/recommendation` service runs a single CTE at request time that scores candidates from the user's likes + plays through the unmatched table. New `GET /api/discover/suggestions` handler. Frontend swaps `<DiscoverResultCard>` between the existing search results and a new suggestion feed when the search input is empty.
|
||
|
||
**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · existing FabledSword design tokens.
|
||
|
||
**Spec:** [`docs/superpowers/specs/2026-04-30-m5c-suggested-additions-design.md`](../specs/2026-04-30-m5c-suggested-additions-design.md). Read it before starting — every decision is explained there.
|
||
|
||
**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops), `project_git_workflow.md` (commit on `dev`; PR to `main` separately).
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
### Backend — create
|
||
|
||
- `internal/db/migrations/0012_artist_similarity_unmatched.up.sql` · `.down.sql`
|
||
- `internal/recommendation/suggestions.go` — `SuggestArtists` service + types
|
||
- `internal/recommendation/suggestions_integration_test.go`
|
||
- `internal/api/suggestions.go` — `GET /api/discover/suggestions` handler
|
||
- `internal/api/suggestions_test.go`
|
||
|
||
### Backend — modify
|
||
|
||
- `internal/db/queries/similarity.sql` — add `UpsertArtistSimilarityUnmatched`
|
||
- `internal/db/queries/recommendation.sql` — add `SuggestArtistsForUser`
|
||
- `internal/db/dbq/*` — regenerated by `sqlc generate`
|
||
- `internal/scrobble/listenbrainz/client.go` — add `Name string \`json:"name"\`` to `SimilarArtist`
|
||
- `internal/similarity/worker.go` — extend `upsertArtistSimilar` to persist unmatched MBIDs
|
||
- `internal/similarity/worker_test.go` — extend with `TestUpsertArtistSimilar_PersistsUnmatchedToTable`
|
||
- `internal/api/api.go` — register `/api/discover/suggestions` route
|
||
|
||
### Frontend — create
|
||
|
||
- `web/src/lib/api/suggestions.ts` — client (`listSuggestions`, `createSuggestionsQuery`)
|
||
- `web/src/lib/api/suggestions.test.ts`
|
||
- `web/src/lib/components/SuggestionFeed.svelte` — subcomponent for the suggestion grid
|
||
- `web/src/lib/components/SuggestionFeed.test.ts`
|
||
|
||
### Frontend — modify
|
||
|
||
- `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution`
|
||
- `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)`
|
||
- `web/src/lib/components/DiscoverResultCard.svelte` — add optional `attribution?: string` prop
|
||
- `web/src/lib/components/DiscoverResultCard.test.ts` — assert attribution rendering
|
||
- `web/src/routes/discover/+page.svelte` — empty-input branches to `<SuggestionFeed>`; tabs hide when input is empty
|
||
- `web/src/routes/discover/discover.test.ts` — extend with suggestion-feed tests
|
||
|
||
---
|
||
|
||
## Task list
|
||
|
||
### Task 1 — Migration 0012 + worker upsert query
|
||
|
||
**Files:**
|
||
- Create: `internal/db/migrations/0012_artist_similarity_unmatched.up.sql`
|
||
- Create: `internal/db/migrations/0012_artist_similarity_unmatched.down.sql`
|
||
- Modify: `internal/db/queries/similarity.sql`
|
||
- Regenerate: `internal/db/dbq/*`
|
||
|
||
- [ ] **Step 1.1: Write the up migration**
|
||
|
||
`internal/db/migrations/0012_artist_similarity_unmatched.up.sql`:
|
||
|
||
```sql
|
||
-- M5c: persist unmatched-similar-artist MBIDs that the M4b worker would
|
||
-- otherwise discard. Mirrors artist_similarity shape: same composite PK
|
||
-- with source, same (seed_id, score DESC) index, same source enum check.
|
||
-- The candidate side is text + name (no FK) — that's the whole point.
|
||
|
||
CREATE TABLE artist_similarity_unmatched (
|
||
seed_artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||
candidate_mbid text NOT NULL,
|
||
candidate_name text NOT NULL,
|
||
score DOUBLE PRECISION NOT NULL,
|
||
source text NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
|
||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (seed_artist_id, candidate_mbid, source)
|
||
);
|
||
|
||
CREATE INDEX artist_similarity_unmatched_seed_score_idx
|
||
ON artist_similarity_unmatched (seed_artist_id, score DESC);
|
||
```
|
||
|
||
- [ ] **Step 1.2: Write the down migration**
|
||
|
||
`internal/db/migrations/0012_artist_similarity_unmatched.down.sql`:
|
||
|
||
```sql
|
||
DROP INDEX IF EXISTS artist_similarity_unmatched_seed_score_idx;
|
||
DROP TABLE IF EXISTS artist_similarity_unmatched;
|
||
```
|
||
|
||
- [ ] **Step 1.3: Append the worker upsert query**
|
||
|
||
Append to `internal/db/queries/similarity.sql`:
|
||
|
||
```sql
|
||
-- name: UpsertArtistSimilarityUnmatched :exec
|
||
-- Persists an out-of-library similar-artist MBID. Idempotent on
|
||
-- (seed_artist_id, candidate_mbid, source) — re-fetches refresh the
|
||
-- name/score and bump fetched_at.
|
||
INSERT INTO artist_similarity_unmatched (
|
||
seed_artist_id, candidate_mbid, candidate_name, score, source
|
||
) VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET
|
||
candidate_name = EXCLUDED.candidate_name,
|
||
score = EXCLUDED.score,
|
||
fetched_at = now();
|
||
```
|
||
|
||
- [ ] **Step 1.4: Add a `dbtest.ResetDB` entry for the new table**
|
||
|
||
Modify `internal/dbtest/reset.go` — append `"artist_similarity_unmatched"` to the `dataTables` slice between `track_similarity` and `scrobble_queue` so M5c integration tests don't inherit residual rows.
|
||
|
||
```go
|
||
var dataTables = []string{
|
||
"artist_similarity",
|
||
"track_similarity",
|
||
"artist_similarity_unmatched", // M5c
|
||
"scrobble_queue",
|
||
// ... rest unchanged ...
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.5: Regenerate sqlc**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate
|
||
go build ./...
|
||
go vet ./...
|
||
```
|
||
|
||
Expected: clean build. New method `UpsertArtistSimilarityUnmatched` appears in `internal/db/dbq/similarity.sql.go`.
|
||
|
||
- [ ] **Step 1.6: Apply migration to verify it runs**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS artist_similarity_unmatched;"
|
||
# Migration applies on next test run / server start.
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race ./internal/db/... -count=1
|
||
docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d artist_similarity_unmatched"
|
||
```
|
||
|
||
Expected: `\d` shows the table with all columns + the seed_score index.
|
||
|
||
- [ ] **Step 1.7: Commit**
|
||
|
||
```bash
|
||
git add internal/db/migrations/0012_artist_similarity_unmatched.up.sql \
|
||
internal/db/migrations/0012_artist_similarity_unmatched.down.sql \
|
||
internal/db/queries/similarity.sql \
|
||
internal/db/dbq/ \
|
||
internal/dbtest/reset.go
|
||
git commit -m "feat(db): add artist_similarity_unmatched schema (migration 0012)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2 — Extend `SimilarArtist` with `Name` field
|
||
|
||
**Files:**
|
||
- Modify: `internal/scrobble/listenbrainz/client.go` — add `Name` to the struct
|
||
- Modify: `internal/scrobble/listenbrainz/client_test.go` (if existing tests break) — already-passing tests should still pass since adding a field is additive
|
||
|
||
The current struct at `internal/scrobble/listenbrainz/client.go:241-245`:
|
||
|
||
```go
|
||
type SimilarArtist struct {
|
||
MBID string `json:"artist_mbid"`
|
||
Score float64 `json:"score"`
|
||
}
|
||
```
|
||
|
||
ListenBrainz's `/1/explore/similar-artists/{mbid}` endpoint returns each row with `artist_mbid`, `name`, `score`, plus other fields we don't care about. Adding `Name` is a purely additive struct change; existing JSON unmarshal still works for everything else.
|
||
|
||
- [ ] **Step 2.1: Add the field**
|
||
|
||
Modify `internal/scrobble/listenbrainz/client.go:241-245`:
|
||
|
||
```go
|
||
type SimilarArtist struct {
|
||
MBID string `json:"artist_mbid"`
|
||
Name string `json:"name"`
|
||
Score float64 `json:"score"`
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.2: Verify existing tests still pass**
|
||
|
||
```bash
|
||
go test ./internal/scrobble/listenbrainz/... -count=1
|
||
```
|
||
|
||
Expected: all green. The unmarshal already ignored the `name` field; capturing it doesn't break anything.
|
||
|
||
- [ ] **Step 2.3: Commit**
|
||
|
||
```bash
|
||
git add internal/scrobble/listenbrainz/client.go
|
||
git commit -m "feat(listenbrainz): expose Name on SimilarArtist for M5c suggestions"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3 — Extend similarity worker to persist unmatched MBIDs
|
||
|
||
**Files:**
|
||
- Modify: `internal/similarity/worker.go` — extend `upsertArtistSimilar`
|
||
- Modify: `internal/similarity/worker_test.go` — add `TestUpsertArtistSimilar_PersistsUnmatchedToTable`
|
||
|
||
The current `upsertArtistSimilar` filters returned MBIDs to those in `idByMBID` (in-library only) and discards the rest. M5c keeps the matched-path unchanged but adds a parallel unmatched-persist loop with the same top-K cap.
|
||
|
||
- [ ] **Step 3.1: Read the current `upsertArtistSimilar` shape**
|
||
|
||
Read `internal/similarity/worker.go:170-200` (function body) before editing. It mirrors `upsertTrackSimilar` exactly — same sort, same `idByMBID`, same top-K pattern. The matched-loop pattern is the template.
|
||
|
||
- [ ] **Step 3.2: Extend `upsertArtistSimilar`**
|
||
|
||
Replace the function body (`internal/similarity/worker.go:170` onwards). The matched loop stays exactly as it was; we add a second loop that walks the same sorted `results` and persists unmatched rows up to `w.topK`:
|
||
|
||
```go
|
||
func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) {
|
||
if len(results) == 0 {
|
||
return
|
||
}
|
||
sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score })
|
||
|
||
mbids := make([]string, 0, len(results))
|
||
for _, r := range results {
|
||
mbids = append(mbids, r.MBID)
|
||
}
|
||
rows, err := q.GetArtistsByMBIDs(ctx, mbids)
|
||
if err != nil {
|
||
w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err)
|
||
return
|
||
}
|
||
idByMBID := make(map[string]pgtype.UUID, len(rows))
|
||
for _, r := range rows {
|
||
if r.Mbid != nil {
|
||
idByMBID[*r.Mbid] = r.ID
|
||
}
|
||
}
|
||
|
||
// Matched: in-library similars → artist_similarity (existing path).
|
||
takenMatched := 0
|
||
for _, r := range results {
|
||
if takenMatched >= w.topK {
|
||
break
|
||
}
|
||
localID, ok := idByMBID[r.MBID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if localID == artistAID {
|
||
continue // defensive — DB CHECK constraint also catches self-edges
|
||
}
|
||
if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{
|
||
ArtistAID: artistAID, ArtistBID: localID, Score: r.Score, Source: "listenbrainz",
|
||
}); uerr != nil {
|
||
w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr)
|
||
continue
|
||
}
|
||
takenMatched++
|
||
}
|
||
|
||
// Unmatched: out-of-library similars → artist_similarity_unmatched (M5c).
|
||
// Same top-K cap as the matched path; missing-name rows are skipped (we
|
||
// can't render a suggestion without an artist name).
|
||
takenUnmatched := 0
|
||
for _, r := range results {
|
||
if takenUnmatched >= w.topK {
|
||
break
|
||
}
|
||
if _, inLib := idByMBID[r.MBID]; inLib {
|
||
continue
|
||
}
|
||
if r.Name == "" {
|
||
w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID)
|
||
continue
|
||
}
|
||
if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{
|
||
SeedArtistID: artistAID,
|
||
CandidateMbid: r.MBID,
|
||
CandidateName: r.Name,
|
||
Score: r.Score,
|
||
Source: "listenbrainz",
|
||
}); uerr != nil {
|
||
w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr)
|
||
continue
|
||
}
|
||
takenUnmatched++
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: the existing function used a single `taken` variable; the new version splits into `takenMatched` and `takenUnmatched` so each path is bounded independently. Verify the existing call to `UpsertArtistSimilarity` in your repo matches the signature you're substituting — sqlc may name the params struct differently.
|
||
|
||
- [ ] **Step 3.3: Write `TestUpsertArtistSimilar_PersistsUnmatchedToTable`**
|
||
|
||
Append to `internal/similarity/worker_test.go`:
|
||
|
||
```go
|
||
func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping integration test in -short mode")
|
||
}
|
||
pool := newPool(t) // existing helper
|
||
q := dbq.New(pool)
|
||
ctx := context.Background()
|
||
|
||
// Seed one in-library artist that will be the in-library match.
|
||
inLibMBID := "in-lib-mbid-123"
|
||
inLibArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{
|
||
Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed in-lib artist: %v", err)
|
||
}
|
||
|
||
// Seed a "seed" artist (the one whose similars we're processing).
|
||
seedMBID := "seed-artist-mbid"
|
||
seedArtist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{
|
||
Name: "Seed Artist", SortName: "Seed Artist", Mbid: &seedMBID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed artist: %v", err)
|
||
}
|
||
|
||
w := &Worker{pool: pool, logger: newTestLogger(), topK: 10}
|
||
|
||
similars := []listenbrainz.SimilarArtist{
|
||
{MBID: inLibMBID, Name: "InLib Artist", Score: 0.95},
|
||
{MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85},
|
||
{MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80},
|
||
{MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70},
|
||
{MBID: "out-mbid-4", Name: "", Score: 0.60}, // missing name — should be skipped
|
||
}
|
||
w.upsertArtistSimilar(ctx, q, seedArtist.ID, similars)
|
||
|
||
// Matched path: 1 row in artist_similarity.
|
||
var matchedCount int
|
||
if err := pool.QueryRow(ctx,
|
||
"SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1",
|
||
seedArtist.ID,
|
||
).Scan(&matchedCount); err != nil {
|
||
t.Fatalf("count matched: %v", err)
|
||
}
|
||
if matchedCount != 1 {
|
||
t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount)
|
||
}
|
||
|
||
// Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped.
|
||
var unmatchedCount int
|
||
if err := pool.QueryRow(ctx,
|
||
"SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1",
|
||
seedArtist.ID,
|
||
).Scan(&unmatchedCount); err != nil {
|
||
t.Fatalf("count unmatched: %v", err)
|
||
}
|
||
if unmatchedCount != 3 {
|
||
t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount)
|
||
}
|
||
|
||
// Verify a specific row's name + score round-tripped correctly.
|
||
var name string
|
||
var score float64
|
||
if err := pool.QueryRow(ctx,
|
||
"SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2",
|
||
seedArtist.ID, "out-mbid-1",
|
||
).Scan(&name, &score); err != nil {
|
||
t.Fatalf("fetch out-mbid-1: %v", err)
|
||
}
|
||
if name != "Outsider One" || score != 0.85 {
|
||
t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score)
|
||
}
|
||
|
||
// suppress unused warning if inLibArtist isn't otherwise referenced
|
||
_ = inLibArtist
|
||
}
|
||
```
|
||
|
||
If `internal/similarity/worker_test.go` doesn't already have a `newPool(t)` and `newTestLogger()` helper, mirror the pattern from `internal/lidarrquarantine/service_test.go` — those have working examples.
|
||
|
||
- [ ] **Step 3.4: Run tests**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race -count=1 ./internal/similarity/...
|
||
```
|
||
|
||
Expected: existing tests still pass + new test passes.
|
||
|
||
- [ ] **Step 3.5: Commit**
|
||
|
||
```bash
|
||
git add internal/similarity/worker.go internal/similarity/worker_test.go
|
||
git commit -m "feat(similarity): persist unmatched similar-artist MBIDs for M5c"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4 — `internal/recommendation` `SuggestArtists` service
|
||
|
||
**Files:**
|
||
- Create: `internal/recommendation/suggestions.go`
|
||
- Create: `internal/recommendation/suggestions_integration_test.go`
|
||
- Modify: `internal/db/queries/recommendation.sql` — add the suggestion CTE query
|
||
- Regenerate: `internal/db/dbq/*`
|
||
|
||
- [ ] **Step 4.1: Append the suggestion query**
|
||
|
||
Append to `internal/db/queries/recommendation.sql`:
|
||
|
||
```sql
|
||
-- name: SuggestArtistsForUser :many
|
||
-- M5c: per-user artist suggestions ranked by signal × similarity. The
|
||
-- seeds CTE collects the user's likes (×5) plus recency-decayed plays
|
||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
||
-- artist_similarity_unmatched and filters out candidates already in
|
||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
||
WITH seeds AS (
|
||
SELECT a.id AS artist_id,
|
||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2 * 86400.0))), 0)
|
||
AS signal,
|
||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||
COUNT(pe.id) AS play_count
|
||
FROM artists a
|
||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||
GROUP BY a.id, gla.artist_id
|
||
),
|
||
contributions AS (
|
||
SELECT u.candidate_mbid,
|
||
u.candidate_name,
|
||
seeds.artist_id AS seed_id,
|
||
seeds.is_liked,
|
||
seeds.play_count,
|
||
seeds.signal * u.score AS contribution
|
||
FROM artist_similarity_unmatched u
|
||
JOIN seeds ON seeds.artist_id = u.seed_artist_id
|
||
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_requests r
|
||
WHERE r.user_id = $1
|
||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||
AND r.status NOT IN ('rejected', 'failed')
|
||
)
|
||
)
|
||
SELECT candidate_mbid,
|
||
candidate_name,
|
||
SUM(contribution)::float8 AS total_score,
|
||
(array_agg(seed_id ORDER BY contribution DESC))[1:3] AS top_seed_ids,
|
||
(array_agg(contribution ORDER BY contribution DESC))[1:3] AS top_contributions,
|
||
(array_agg(is_liked ORDER BY contribution DESC))[1:3] AS top_is_liked,
|
||
(array_agg(play_count ORDER BY contribution DESC))[1:3] AS top_play_counts
|
||
FROM contributions
|
||
GROUP BY candidate_mbid, candidate_name
|
||
ORDER BY total_score DESC
|
||
LIMIT $3;
|
||
```
|
||
|
||
The extra arrays (`top_is_liked`, `top_play_counts`) let the SPA pick "liked"/"played" verbiage per attribution-line slot.
|
||
|
||
- [ ] **Step 4.2: Regenerate sqlc**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && sqlc generate
|
||
go build ./...
|
||
```
|
||
|
||
Expected: clean. New method `SuggestArtistsForUser` in `internal/db/dbq/recommendation.sql.go`. Note the row type may have field names like `TopSeedIds` (plural-suffix suppressed by sqlc) — read the generated file before relying on names in the next step.
|
||
|
||
- [ ] **Step 4.3: Write `internal/recommendation/suggestions.go`**
|
||
|
||
```go
|
||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
||
// resolved to artist names.
|
||
package recommendation
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds.
|
||
type ArtistSuggestion struct {
|
||
MBID string
|
||
Name string
|
||
Score float64
|
||
Attribution []SeedContribution
|
||
}
|
||
|
||
// SeedContribution is one of the top-3 contributing seeds for a candidate.
|
||
type SeedContribution struct {
|
||
ArtistID pgtype.UUID
|
||
Name string
|
||
Contribution float64
|
||
IsLiked bool
|
||
PlayCount int64
|
||
}
|
||
|
||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||
// capped at 50; halfLifeDays is the recency-decay half-life for plays
|
||
// (default 30, operator-tunable).
|
||
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
||
if limit <= 0 || limit > 50 {
|
||
limit = 12
|
||
}
|
||
if halfLifeDays <= 0 {
|
||
halfLifeDays = 30
|
||
}
|
||
q := dbq.New(pool)
|
||
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
||
UserID: userID,
|
||
Column2: halfLifeDays, // sqlc names unbound positional params Column2/3 — verify
|
||
Limit: int32(limit),
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("suggest: query: %w", err)
|
||
}
|
||
if len(rows) == 0 {
|
||
return []ArtistSuggestion{}, nil
|
||
}
|
||
|
||
// Collect the union of top-3 seed IDs across all rows for one batched
|
||
// name lookup.
|
||
seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3)
|
||
for _, r := range rows {
|
||
for _, sid := range r.TopSeedIds {
|
||
seedSet[sid] = struct{}{}
|
||
}
|
||
}
|
||
seedIDs := make([]pgtype.UUID, 0, len(seedSet))
|
||
for id := range seedSet {
|
||
seedIDs = append(seedIDs, id)
|
||
}
|
||
artists, err := q.GetArtistsByIDs(ctx, seedIDs)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("suggest: resolve seeds: %w", err)
|
||
}
|
||
nameByID := make(map[pgtype.UUID]string, len(artists))
|
||
for _, a := range artists {
|
||
nameByID[a.ID] = a.Name
|
||
}
|
||
|
||
out := make([]ArtistSuggestion, 0, len(rows))
|
||
for _, r := range rows {
|
||
attribution := make([]SeedContribution, 0, len(r.TopSeedIds))
|
||
for i, sid := range r.TopSeedIds {
|
||
if i >= len(r.TopContributions) {
|
||
break
|
||
}
|
||
attribution = append(attribution, SeedContribution{
|
||
ArtistID: sid,
|
||
Name: nameByID[sid],
|
||
Contribution: r.TopContributions[i],
|
||
IsLiked: r.TopIsLiked[i],
|
||
PlayCount: r.TopPlayCounts[i],
|
||
})
|
||
}
|
||
out = append(out, ArtistSuggestion{
|
||
MBID: r.CandidateMbid,
|
||
Name: r.CandidateName,
|
||
Score: r.TotalScore,
|
||
Attribution: attribution,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
```
|
||
|
||
Two things to verify against the actual generated code in `internal/db/dbq/recommendation.sql.go`:
|
||
1. The param struct may name `$2` something other than `Column2` (sqlc occasionally renames). Look at `SuggestArtistsForUserParams` and use whatever's there.
|
||
2. `GetArtistsByIDs` may not exist — check `internal/db/queries/artists.sql`. If absent, add a query in this same task:
|
||
|
||
```sql
|
||
-- name: GetArtistsByIDs :many
|
||
SELECT * FROM artists WHERE id = ANY($1::uuid[]);
|
||
```
|
||
|
||
(If `GetArtistsByMBIDs` exists but not `GetArtistsByIDs`, add the IDs variant; sqlc-regenerate.)
|
||
|
||
- [ ] **Step 4.4: Write integration tests**
|
||
|
||
Create `internal/recommendation/suggestions_integration_test.go`:
|
||
|
||
```go
|
||
package recommendation
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||
)
|
||
|
||
func newPool(t *testing.T) *pgxpool.Pool {
|
||
t.Helper()
|
||
if testing.Short() {
|
||
t.Skip("skipping integration test in -short mode")
|
||
}
|
||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||
if dsn == "" {
|
||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||
}
|
||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
pool, err := pgxpool.New(context.Background(), dsn)
|
||
if err != nil {
|
||
t.Fatalf("pool: %v", err)
|
||
}
|
||
t.Cleanup(pool.Close)
|
||
dbtest.ResetDB(t, pool)
|
||
return pool
|
||
}
|
||
|
||
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||
t.Helper()
|
||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||
ApiToken: name + "-token", IsAdmin: false,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed user: %v", err)
|
||
}
|
||
return u
|
||
}
|
||
|
||
func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist {
|
||
t.Helper()
|
||
var mbidPtr *string
|
||
if mbid != "" {
|
||
mbidPtr = &mbid
|
||
}
|
||
a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||
Name: name, SortName: name, Mbid: mbidPtr,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed artist: %v", err)
|
||
}
|
||
return a
|
||
}
|
||
|
||
func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) {
|
||
t.Helper()
|
||
if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{
|
||
SeedArtistID: seedID,
|
||
CandidateMbid: candMBID,
|
||
CandidateName: candName,
|
||
Score: score,
|
||
Source: "listenbrainz",
|
||
}); err != nil {
|
||
t.Fatalf("seed unmatched: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
seedA := seedArtist(t, pool, "Seed Liked", "")
|
||
seedB := seedArtist(t, pool, "Seed Played", "")
|
||
|
||
// alice likes seedA.
|
||
if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{
|
||
UserID: user.ID, ArtistID: seedA.ID,
|
||
}); err != nil {
|
||
t.Fatalf("LikeArtist: %v", err)
|
||
}
|
||
// alice played seedB. (Need a play_event with the right artist via tracks.)
|
||
seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B")
|
||
seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B")
|
||
insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour))
|
||
|
||
// Both seeds point at the same out-of-library candidate.
|
||
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
|
||
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
|
||
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 1 {
|
||
t.Fatalf("len = %d, want 1", len(out))
|
||
}
|
||
s := out[0]
|
||
if s.MBID != "out-mbid" || s.Name != "Outsider" {
|
||
t.Errorf("got = %+v", s)
|
||
}
|
||
if s.Score <= 0 {
|
||
t.Errorf("score = %v, want > 0", s.Score)
|
||
}
|
||
if len(s.Attribution) != 2 {
|
||
t.Errorf("attribution len = %d, want 2", len(s.Attribution))
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_Top12Cap(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
seed := seedArtist(t, pool, "Seed", "")
|
||
if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{
|
||
UserID: user.ID, ArtistID: seed.ID,
|
||
}); err != nil {
|
||
t.Fatalf("LikeArtist: %v", err)
|
||
}
|
||
for i := 0; i < 30; i++ {
|
||
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
|
||
}
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 12 {
|
||
t.Errorf("len = %d, want 12", len(out))
|
||
}
|
||
if out[0].MBID != "mbid-00" {
|
||
t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID)
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_AttributionTopThree(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
// 5 seed artists all liked, all pointing at the same candidate but
|
||
// with descending similarity scores so contributions order is clean.
|
||
seeds := make([]dbq.Artist, 5)
|
||
for i := 0; i < 5; i++ {
|
||
seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "")
|
||
if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{
|
||
UserID: user.ID, ArtistID: seeds[i].ID,
|
||
}); err != nil {
|
||
t.Fatalf("LikeArtist: %v", err)
|
||
}
|
||
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
|
||
}
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 1 {
|
||
t.Fatalf("len = %d, want 1 (shared candidate)", len(out))
|
||
}
|
||
if got := len(out[0].Attribution); got != 3 {
|
||
t.Errorf("attribution len = %d, want 3", got)
|
||
}
|
||
// Verify ordering: highest contribution first (seed 0 with score 0.9).
|
||
if out[0].Attribution[0].Name != "Seed 0" {
|
||
t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name)
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
recentSeed := seedArtist(t, pool, "Recent", "")
|
||
oldSeed := seedArtist(t, pool, "Old", "")
|
||
|
||
rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album")
|
||
rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track")
|
||
insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour))
|
||
|
||
oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album")
|
||
oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track")
|
||
insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour))
|
||
|
||
// Both seeds point at the same candidate with the same similarity score.
|
||
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
|
||
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
|
||
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 1 {
|
||
t.Fatalf("len = %d, want 1", len(out))
|
||
}
|
||
if len(out[0].Attribution) != 2 {
|
||
t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution))
|
||
}
|
||
// Recent seed contributes more than old seed.
|
||
if out[0].Attribution[0].Name != "Recent" {
|
||
t.Errorf("top attribution = %q, want Recent (1d-old play decays less than 90d)", out[0].Attribution[0].Name)
|
||
}
|
||
if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution {
|
||
t.Errorf("recent contribution (%v) should exceed old (%v)",
|
||
out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution)
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
seed := seedArtist(t, pool, "Seed", "")
|
||
if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{
|
||
UserID: user.ID, ArtistID: seed.ID,
|
||
}); err != nil {
|
||
t.Fatalf("LikeArtist: %v", err)
|
||
}
|
||
// Candidate that's already in library.
|
||
inLibMBID := "in-lib-mbid"
|
||
seedArtist(t, pool, "InLib", inLibMBID)
|
||
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
|
||
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 0 {
|
||
t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out))
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
seed := seedArtist(t, pool, "Seed", "")
|
||
if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{
|
||
UserID: user.ID, ArtistID: seed.ID,
|
||
}); err != nil {
|
||
t.Fatalf("LikeArtist: %v", err)
|
||
}
|
||
seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9)
|
||
if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
|
||
UserID: user.ID,
|
||
Kind: dbq.LidarrRequestKindArtist,
|
||
LidarrArtistMbid: "req-mbid",
|
||
ArtistName: "Pending Request",
|
||
}); err != nil {
|
||
t.Fatalf("CreateLidarrRequest: %v", err)
|
||
}
|
||
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 0 {
|
||
t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out))
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
seed := seedArtist(t, pool, "Seed", "")
|
||
if _, err := dbq.New(pool).LikeArtist(context.Background(), dbq.LikeArtistParams{
|
||
UserID: user.ID, ArtistID: seed.ID,
|
||
}); err != nil {
|
||
t.Fatalf("LikeArtist: %v", err)
|
||
}
|
||
seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9)
|
||
req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
|
||
UserID: user.ID,
|
||
Kind: dbq.LidarrRequestKindArtist,
|
||
LidarrArtistMbid: "rej-mbid",
|
||
ArtistName: "Rejected Once",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateLidarrRequest: %v", err)
|
||
}
|
||
rejNotes := "wrong artist"
|
||
if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{
|
||
ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID,
|
||
}); err != nil {
|
||
t.Fatalf("RejectLidarrRequest: %v", err)
|
||
}
|
||
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 1 {
|
||
t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out))
|
||
}
|
||
}
|
||
|
||
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "newbie")
|
||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||
if err != nil {
|
||
t.Fatalf("SuggestArtists: %v", err)
|
||
}
|
||
if len(out) != 0 {
|
||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||
}
|
||
}
|
||
```
|
||
|
||
Helper functions `seedAlbumForArtist`, `seedTrackOnAlbum`, `insertPlayEvent` are needed. Mirror the same helpers from `internal/lidarrquarantine/service_test.go`'s `seedTrack` (but parameterize artist):
|
||
|
||
```go
|
||
func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album {
|
||
t.Helper()
|
||
a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||
Title: title, SortTitle: title, ArtistID: artistID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed album: %v", err)
|
||
}
|
||
return a
|
||
}
|
||
|
||
func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track {
|
||
t.Helper()
|
||
tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: title, AlbumID: albumID, ArtistID: artistID,
|
||
DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3",
|
||
FileSize: 1, FileFormat: "mp3",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed track: %v", err)
|
||
}
|
||
return tr
|
||
}
|
||
|
||
func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||
t.Helper()
|
||
if _, err := pool.Exec(context.Background(),
|
||
`INSERT INTO play_events (user_id, track_id, started_at, was_skipped) VALUES ($1, $2, $3, false)`,
|
||
userID, trackID, startedAt,
|
||
); err != nil {
|
||
t.Fatalf("insert play_event: %v", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
The exact `play_events` column set may differ from this minimal insert — read the migration `0005_events.up.sql` and add any required NOT-NULL columns (e.g. `client_id`, `session_id`) with sensible defaults if the insert fails.
|
||
|
||
- [ ] **Step 4.5: Run tests**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race -count=1 -p 1 ./internal/recommendation/...
|
||
```
|
||
|
||
Expected: 7 tests pass.
|
||
|
||
- [ ] **Step 4.6: Commit**
|
||
|
||
```bash
|
||
git add internal/recommendation/suggestions.go \
|
||
internal/recommendation/suggestions_integration_test.go \
|
||
internal/db/queries/recommendation.sql \
|
||
internal/db/queries/artists.sql \
|
||
internal/db/dbq/
|
||
git commit -m "feat(recommendation): SuggestArtists service for M5c"
|
||
```
|
||
|
||
(Include `artists.sql` if you added `GetArtistsByIDs` to it in Step 4.3.)
|
||
|
||
---
|
||
|
||
### Task 5 — `/api/discover/suggestions` handler + route mount
|
||
|
||
**Files:**
|
||
- Create: `internal/api/suggestions.go`
|
||
- Create: `internal/api/suggestions_test.go`
|
||
- Modify: `internal/api/api.go` — add the route
|
||
|
||
- [ ] **Step 5.1: Write the handler**
|
||
|
||
Create `internal/api/suggestions.go`:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||
)
|
||
|
||
// suggestionView is the wire shape returned by GET /api/discover/suggestions.
|
||
type suggestionView struct {
|
||
MBID string `json:"mbid"`
|
||
Name string `json:"name"`
|
||
Score float64 `json:"score"`
|
||
Attribution []seedContributionView `json:"attribution"`
|
||
}
|
||
|
||
type seedContributionView struct {
|
||
ArtistID pgtype.UUID `json:"artist_id"`
|
||
Name string `json:"name"`
|
||
Contribution float64 `json:"contribution"`
|
||
IsLiked bool `json:"is_liked"`
|
||
PlayCount int64 `json:"play_count"`
|
||
}
|
||
|
||
// handleListSuggestions implements GET /api/discover/suggestions.
|
||
//
|
||
// Query params:
|
||
// - limit (default 12, capped at 50)
|
||
// - half_life_days (default 30, no max)
|
||
//
|
||
// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate.
|
||
func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
limit := 12
|
||
if v := r.URL.Query().Get("limit"); v != "" {
|
||
n, err := strconv.Atoi(v)
|
||
if err != nil || n < 1 {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit")
|
||
return
|
||
}
|
||
limit = n
|
||
}
|
||
halfLife := 30.0
|
||
if v := r.URL.Query().Get("half_life_days"); v != "" {
|
||
f, err := strconv.ParseFloat(v, 64)
|
||
if err != nil || f <= 0 {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid half_life_days")
|
||
return
|
||
}
|
||
halfLife = f
|
||
}
|
||
|
||
suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit)
|
||
if err != nil {
|
||
h.logger.Error("api: list suggestions", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "failed to load suggestions")
|
||
return
|
||
}
|
||
|
||
out := make([]suggestionView, 0, len(suggestions))
|
||
for _, s := range suggestions {
|
||
attr := make([]seedContributionView, 0, len(s.Attribution))
|
||
for _, a := range s.Attribution {
|
||
attr = append(attr, seedContributionView{
|
||
ArtistID: a.ArtistID,
|
||
Name: a.Name,
|
||
Contribution: a.Contribution,
|
||
IsLiked: a.IsLiked,
|
||
PlayCount: a.PlayCount,
|
||
})
|
||
}
|
||
out = append(out, suggestionView{
|
||
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.2: Mount the route**
|
||
|
||
In `internal/api/api.go`, find the authed group (after `r.Get("/api/radio", ...)` line) and add:
|
||
|
||
```go
|
||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||
```
|
||
|
||
- [ ] **Step 5.3: Write handler tests**
|
||
|
||
Create `internal/api/suggestions_test.go`:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||
)
|
||
|
||
func TestSuggestions_HappyPath(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
user := seedUser(t, h.pool, "alice", "pw", false)
|
||
|
||
// Seed: alice likes a seed artist; that seed has one out-of-library
|
||
// similar in artist_similarity_unmatched.
|
||
seedA, err := dbq.New(h.pool).UpsertArtist(t.Context(), dbq.UpsertArtistParams{
|
||
Name: "Seed", SortName: "Seed",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("UpsertArtist: %v", err)
|
||
}
|
||
if _, err := dbq.New(h.pool).LikeArtist(t.Context(), dbq.LikeArtistParams{
|
||
UserID: user.ID, ArtistID: seedA.ID,
|
||
}); err != nil {
|
||
t.Fatalf("LikeArtist: %v", err)
|
||
}
|
||
if err := dbq.New(h.pool).UpsertArtistSimilarityUnmatched(t.Context(), dbq.UpsertArtistSimilarityUnmatchedParams{
|
||
SeedArtistID: seedA.ID,
|
||
CandidateMbid: "out-mbid",
|
||
CandidateName: "Outsider",
|
||
Score: 0.9,
|
||
Source: "listenbrainz",
|
||
}); err != nil {
|
||
t.Fatalf("UpsertArtistSimilarityUnmatched: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil)
|
||
setUserCtx(req, user)
|
||
w := httptest.NewRecorder()
|
||
h.handleListSuggestions(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||
}
|
||
var got []suggestionView
|
||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(got) != 1 {
|
||
t.Fatalf("len = %d, want 1; body = %s", len(got), w.Body.String())
|
||
}
|
||
if got[0].MBID != "out-mbid" || got[0].Name != "Outsider" {
|
||
t.Errorf("got = %+v", got[0])
|
||
}
|
||
if len(got[0].Attribution) != 1 {
|
||
t.Errorf("attribution len = %d, want 1", len(got[0].Attribution))
|
||
}
|
||
if got[0].Attribution[0].Name != "Seed" {
|
||
t.Errorf("attribution name = %q, want Seed", got[0].Attribution[0].Name)
|
||
}
|
||
}
|
||
|
||
func TestSuggestions_EmptyForNewUser(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
user := seedUser(t, h.pool, "newbie", "pw", false)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions", nil)
|
||
setUserCtx(req, user)
|
||
w := httptest.NewRecorder()
|
||
h.handleListSuggestions(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
if got := w.Body.String(); got != "[]\n" && got != "[]" {
|
||
t.Errorf("body = %q, want []", got)
|
||
}
|
||
}
|
||
|
||
func TestSuggestions_BadLimit(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
user := seedUser(t, h.pool, "alice", "pw", false)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/discover/suggestions?limit=not-a-number", nil)
|
||
setUserCtx(req, user)
|
||
w := httptest.NewRecorder()
|
||
h.handleListSuggestions(w, req)
|
||
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
// suppress unused imports in some test layouts
|
||
var _ = dbtest.TestUserPrefix
|
||
```
|
||
|
||
`testHandlers`, `seedUser`, `setUserCtx` are existing helpers in the test layout — see `internal/api/auth_test.go` and `internal/api/requests_test.go`. Use whatever pattern those tests use to seed a user and inject it into the request context.
|
||
|
||
- [ ] **Step 5.4: Run tests**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race -count=1 -p 1 ./internal/api/... -run Suggestions
|
||
```
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 5.5: Commit**
|
||
|
||
```bash
|
||
git add internal/api/suggestions.go internal/api/suggestions_test.go internal/api/api.go
|
||
git commit -m "feat(api): /api/discover/suggestions handler"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6 — Frontend API client + types + qk
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/api/suggestions.ts`
|
||
- Create: `web/src/lib/api/suggestions.test.ts`
|
||
- Modify: `web/src/lib/api/types.ts` — add `ArtistSuggestion`, `SeedContribution`
|
||
- Modify: `web/src/lib/api/queries.ts` — add `qk.suggestions(limit)`
|
||
|
||
- [ ] **Step 6.1: Add types**
|
||
|
||
Append to `web/src/lib/api/types.ts`:
|
||
|
||
```ts
|
||
export type SeedContribution = {
|
||
artist_id: string;
|
||
name: string;
|
||
contribution: number;
|
||
is_liked: boolean;
|
||
play_count: number;
|
||
};
|
||
|
||
export type ArtistSuggestion = {
|
||
mbid: string;
|
||
name: string;
|
||
score: number;
|
||
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6.2: Add query key**
|
||
|
||
Append to the `qk` object in `web/src/lib/api/queries.ts`:
|
||
|
||
```ts
|
||
suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const,
|
||
```
|
||
|
||
- [ ] **Step 6.3: Write `suggestions.ts`**
|
||
|
||
Create `web/src/lib/api/suggestions.ts`:
|
||
|
||
```ts
|
||
import { createQuery } from '@tanstack/svelte-query';
|
||
import { api } from './client';
|
||
import { qk } from './queries';
|
||
import type { ArtistSuggestion } from './types';
|
||
|
||
export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> {
|
||
return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`);
|
||
}
|
||
|
||
export function createSuggestionsQuery(limit = 12) {
|
||
return createQuery({
|
||
queryKey: qk.suggestions(limit),
|
||
queryFn: () => listSuggestions(limit),
|
||
staleTime: 5 * 60_000 // 5 minutes
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6.4: Write tests**
|
||
|
||
Create `web/src/lib/api/suggestions.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, test, vi, afterEach } from 'vitest';
|
||
|
||
vi.mock('./client', () => ({
|
||
api: { get: vi.fn() }
|
||
}));
|
||
|
||
import { listSuggestions } from './suggestions';
|
||
import { qk } from './queries';
|
||
import { api } from './client';
|
||
import type { ArtistSuggestion } from './types';
|
||
|
||
afterEach(() => vi.clearAllMocks());
|
||
|
||
describe('suggestions client', () => {
|
||
test('listSuggestions hits the right URL with default limit', async () => {
|
||
const fixture: ArtistSuggestion[] = [
|
||
{
|
||
mbid: 'm1',
|
||
name: 'Outsider',
|
||
score: 1.5,
|
||
attribution: [
|
||
{ artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 }
|
||
]
|
||
}
|
||
];
|
||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
|
||
const got = await listSuggestions();
|
||
expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=12');
|
||
expect(got).toEqual(fixture);
|
||
});
|
||
|
||
test('listSuggestions honors a custom limit', async () => {
|
||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||
await listSuggestions(20);
|
||
expect(api.get).toHaveBeenCalledWith('/api/discover/suggestions?limit=20');
|
||
});
|
||
|
||
test('qk.suggestions key shape', () => {
|
||
expect(qk.suggestions()).toEqual(['suggestions', { limit: 12 }]);
|
||
expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6.5: Verify**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web
|
||
npm run check
|
||
npm test -- --run suggestions
|
||
cd ..
|
||
```
|
||
|
||
Expected: 0 errors, 3 tests pass.
|
||
|
||
- [ ] **Step 6.6: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/api/suggestions.ts web/src/lib/api/suggestions.test.ts \
|
||
web/src/lib/api/types.ts web/src/lib/api/queries.ts
|
||
git commit -m "feat(web): API client for /api/discover/suggestions"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7 — Extend `<DiscoverResultCard>` with `attribution` prop
|
||
|
||
**Files:**
|
||
- Modify: `web/src/lib/components/DiscoverResultCard.svelte`
|
||
- Modify: `web/src/lib/components/DiscoverResultCard.test.ts`
|
||
|
||
The card already has a `$props()` block accepting `kind`, `title`, `subtitle?`, `imageUrl?`, `state`, `onRequest?`. Add `attribution?: string` and render it in italic Vellum below the title (above the reserved badge slot).
|
||
|
||
- [ ] **Step 7.1: Add the prop**
|
||
|
||
In `DiscoverResultCard.svelte`, modify the `$props()` destructure:
|
||
|
||
```ts
|
||
let {
|
||
kind,
|
||
title,
|
||
subtitle,
|
||
imageUrl,
|
||
state,
|
||
attribution,
|
||
onRequest,
|
||
}: {
|
||
kind: DiscoverCardKind;
|
||
title: string;
|
||
subtitle?: string;
|
||
imageUrl?: string;
|
||
state: DiscoverCardState;
|
||
attribution?: string;
|
||
onRequest?: () => void;
|
||
} = $props();
|
||
```
|
||
|
||
- [ ] **Step 7.2: Render the attribution line**
|
||
|
||
Find the section rendering the title + subtitle (search for `class="title"` and `class="subtitle"`). Add the attribution line between subtitle and `.badge-row`:
|
||
|
||
```svelte
|
||
<div class="text mt-3">
|
||
<div class="title text-base font-medium text-text-primary">{title}</div>
|
||
{#if subtitle}
|
||
<div class="subtitle text-sm text-text-secondary">{subtitle}</div>
|
||
{/if}
|
||
{#if attribution}
|
||
<div class="attribution text-xs italic text-text-secondary" data-testid="attribution">
|
||
{attribution}
|
||
</div>
|
||
{/if}
|
||
<div class="badge-row" data-testid="badge-row">
|
||
{#if state === 'kept'}
|
||
<span class="kept-pill" role="status">Kept</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 7.3: Add tests**
|
||
|
||
Append to `DiscoverResultCard.test.ts`:
|
||
|
||
```ts
|
||
test('renders attribution line when prop is set', () => {
|
||
render(DiscoverResultCard, {
|
||
props: {
|
||
kind: 'artist',
|
||
title: 'Outsider',
|
||
state: 'requestable',
|
||
attribution: 'Because you liked Boards of Canada and played Aphex Twin.'
|
||
}
|
||
});
|
||
expect(screen.getByTestId('attribution')).toHaveTextContent('Because you liked Boards of Canada and played Aphex Twin.');
|
||
});
|
||
|
||
test('omits attribution line when prop is absent', () => {
|
||
render(DiscoverResultCard, {
|
||
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
|
||
});
|
||
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7.4: Verify + commit**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web
|
||
npm run check
|
||
npm test -- --run DiscoverResultCard
|
||
cd ..
|
||
git add web/src/lib/components/DiscoverResultCard.svelte web/src/lib/components/DiscoverResultCard.test.ts
|
||
git commit -m "feat(web): DiscoverResultCard attribution prop for M5c suggestions"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8 — `<SuggestionFeed>` subcomponent + `/discover` integration
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/components/SuggestionFeed.svelte`
|
||
- Create: `web/src/lib/components/SuggestionFeed.test.ts`
|
||
- Modify: `web/src/routes/discover/+page.svelte` — branch to `<SuggestionFeed>` when search is empty
|
||
- Modify: `web/src/routes/discover/discover.test.ts` — add suggestion-feed scenarios
|
||
|
||
- [ ] **Step 8.1: Write `<SuggestionFeed>`**
|
||
|
||
Create `web/src/lib/components/SuggestionFeed.svelte`:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
||
import { createRequest } from '$lib/api/requests';
|
||
import { qk } from '$lib/api/queries';
|
||
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
||
import type { ArtistSuggestion, SeedContribution } from '$lib/api/types';
|
||
|
||
const client = useQueryClient();
|
||
const queryStore = createSuggestionsQuery();
|
||
const query = $derived($queryStore);
|
||
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
|
||
|
||
// Track MBIDs the user just requested so the card flips immediately.
|
||
let optimisticRequested = $state(new Set<string>());
|
||
|
||
function visible(s: ArtistSuggestion): boolean {
|
||
return !optimisticRequested.has(s.mbid);
|
||
}
|
||
|
||
function attributionText(attribution: SeedContribution[]): string {
|
||
if (attribution.length === 0) return '';
|
||
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
|
||
const phrases = attribution.map((s) => `${verb(s)} ${s.name}`);
|
||
if (phrases.length === 1) {
|
||
return `Because you ${phrases[0]}.`;
|
||
}
|
||
if (phrases.length === 2) {
|
||
return `Because you ${phrases[0]} and ${phrases[1]}.`;
|
||
}
|
||
// 3 with Oxford comma
|
||
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
|
||
}
|
||
|
||
async function onRequest(s: ArtistSuggestion) {
|
||
try {
|
||
await createRequest({
|
||
kind: 'artist',
|
||
lidarr_artist_mbid: s.mbid,
|
||
artist_name: s.name
|
||
});
|
||
const next = new Set(optimisticRequested);
|
||
next.add(s.mbid);
|
||
optimisticRequested = next;
|
||
// Suggestions filter requested MBIDs server-side; refetch to drop the row.
|
||
await client.invalidateQueries({ queryKey: qk.suggestions() });
|
||
} catch {
|
||
// Swallow for v1; the SPA will refetch on next mount and the card stays
|
||
// requestable so the user can retry.
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div>
|
||
<header class="mb-4 space-y-1">
|
||
<h2 class="font-display text-2xl font-medium text-text-primary">Suggested for you</h2>
|
||
<p class="text-text-secondary">Out-of-library artists drawn from what you've liked and played.</p>
|
||
</header>
|
||
|
||
{#if !query.isPending && suggestions.length === 0}
|
||
<p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p>
|
||
{:else if suggestions.length > 0}
|
||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||
{#each suggestions.filter(visible) as s (s.mbid)}
|
||
<DiscoverResultCard
|
||
kind="artist"
|
||
title={s.name}
|
||
state="requestable"
|
||
attribution={attributionText(s.attribution)}
|
||
onRequest={() => onRequest(s)}
|
||
/>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 8.2: Write `<SuggestionFeed>` tests**
|
||
|
||
Create `web/src/lib/components/SuggestionFeed.test.ts`:
|
||
|
||
```ts
|
||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||
import { mockQuery } from '../../test-utils/query';
|
||
|
||
const invalidateMock = vi.fn();
|
||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||
const actual = (await orig()) as Record<string, unknown>;
|
||
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
|
||
});
|
||
|
||
vi.mock('$lib/api/suggestions', () => ({
|
||
createSuggestionsQuery: vi.fn()
|
||
}));
|
||
|
||
vi.mock('$lib/api/requests', () => ({
|
||
createRequest: vi.fn().mockResolvedValue({})
|
||
}));
|
||
|
||
import SuggestionFeed from './SuggestionFeed.svelte';
|
||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
||
import { createRequest } from '$lib/api/requests';
|
||
import type { ArtistSuggestion } from '$lib/api/types';
|
||
|
||
const oneSeed: ArtistSuggestion = {
|
||
mbid: 'mb1', name: 'Outsider', score: 1.0,
|
||
attribution: [{ artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 }]
|
||
};
|
||
|
||
const twoSeeds: ArtistSuggestion = {
|
||
mbid: 'mb2', name: 'Outsider Two', score: 2.0,
|
||
attribution: [
|
||
{ artist_id: 'a1', name: 'A', contribution: 0.8, is_liked: true, play_count: 0 },
|
||
{ artist_id: 'a2', name: 'B', contribution: 0.5, is_liked: false, play_count: 3 }
|
||
]
|
||
};
|
||
|
||
const threeSeeds: ArtistSuggestion = {
|
||
mbid: 'mb3', name: 'Outsider Three', score: 3.0,
|
||
attribution: [
|
||
{ artist_id: 'a1', name: 'X', contribution: 0.9, is_liked: true, play_count: 0 },
|
||
{ artist_id: 'a2', name: 'Y', contribution: 0.6, is_liked: false, play_count: 5 },
|
||
{ artist_id: 'a3', name: 'Z', contribution: 0.3, is_liked: false, play_count: 1 }
|
||
]
|
||
};
|
||
|
||
afterEach(() => vi.clearAllMocks());
|
||
|
||
describe('SuggestionFeed', () => {
|
||
test('renders one card per suggestion', () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockQuery({ data: [oneSeed, twoSeeds] })
|
||
);
|
||
render(SuggestionFeed);
|
||
expect(screen.getByText('Outsider')).toBeInTheDocument();
|
||
expect(screen.getByText('Outsider Two')).toBeInTheDocument();
|
||
});
|
||
|
||
test('attribution copy: 1 seed → "Because you liked X."', () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockQuery({ data: [oneSeed] })
|
||
);
|
||
render(SuggestionFeed);
|
||
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('attribution copy: 2 seeds → "Because you liked A and played B."', () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockQuery({ data: [twoSeeds] })
|
||
);
|
||
render(SuggestionFeed);
|
||
expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('attribution copy: 3 seeds → Oxford comma', () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockQuery({ data: [threeSeeds] })
|
||
);
|
||
render(SuggestionFeed);
|
||
expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('Request button calls createRequest with artist-kind body', async () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockQuery({ data: [oneSeed] })
|
||
);
|
||
render(SuggestionFeed);
|
||
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
|
||
expect(createRequest).toHaveBeenCalledWith({
|
||
kind: 'artist',
|
||
lidarr_artist_mbid: 'mb1',
|
||
artist_name: 'Outsider'
|
||
});
|
||
expect(invalidateMock).toHaveBeenCalled();
|
||
});
|
||
|
||
test('empty state when data is []', () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||
render(SuggestionFeed);
|
||
expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 8.3: Wire `<SuggestionFeed>` into `/discover`**
|
||
|
||
Modify `web/src/routes/discover/+page.svelte`. Read it first — the existing structure runs the search query when `debouncedQ.length > 0`. Add the feed branch:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
// ... existing imports ...
|
||
import SuggestionFeed from '$lib/components/SuggestionFeed.svelte';
|
||
</script>
|
||
|
||
<!-- existing markup ... before the kind tabs / search results: -->
|
||
|
||
<div class="space-y-6">
|
||
<!-- search input always visible -->
|
||
<input ... />
|
||
|
||
{#if debouncedQ === ''}
|
||
<SuggestionFeed />
|
||
{:else}
|
||
<!-- existing: kind tabs + search-results grid + track-kind modal -->
|
||
{existing markup unchanged}
|
||
{/if}
|
||
</div>
|
||
```
|
||
|
||
The search-input element stays at the top level (visible in both branches). The header copy ("Add music to the library" vs "Suggested for you") is now owned by the respective branch — `<SuggestionFeed>` renders its own header; the search branch keeps the existing one.
|
||
|
||
If the existing `+page.svelte` has its header above the input, you'll need to move it inside the search branch. Pattern:
|
||
|
||
```svelte
|
||
<input bind:value={inputValue} ... />
|
||
|
||
{#if debouncedQ === ''}
|
||
<SuggestionFeed />
|
||
{:else}
|
||
<header>
|
||
<h2>Add music to the library</h2>
|
||
<p>...</p>
|
||
</header>
|
||
<!-- kind tabs + grid + modal as before -->
|
||
{/if}
|
||
```
|
||
|
||
- [ ] **Step 8.4: Update `discover.test.ts`**
|
||
|
||
The existing tests assume `inputValue === ''` shows the initial-copy state. Now it shows the suggestion feed. Update the relevant test and add new ones:
|
||
|
||
Add a mock for `$lib/api/suggestions`:
|
||
|
||
```ts
|
||
vi.mock('$lib/api/suggestions', () => ({
|
||
createSuggestionsQuery: vi.fn()
|
||
}));
|
||
```
|
||
|
||
Update the existing "initial state shows search prompt copy" test (or add a replacement):
|
||
|
||
```ts
|
||
test('empty input shows the suggestion feed', () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockQuery({ data: [] })
|
||
);
|
||
render(DiscoverPage);
|
||
expect(screen.getByText(/suggested for you/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('typing replaces feed with search', async () => {
|
||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockQuery({ data: [] })
|
||
);
|
||
// mock the lidarr search query as before
|
||
// ... fire input event to trigger debounced search ...
|
||
// ... advance fake timers ...
|
||
expect(screen.queryByText(/suggested for you/i)).not.toBeInTheDocument();
|
||
expect(screen.getByText(/add music to the library/i)).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
The existing tests that drive the search flow stay — they always provide a non-empty query. The empty-input case becomes a suggestion-feed test.
|
||
|
||
- [ ] **Step 8.5: Verify**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web
|
||
npm run check
|
||
npm test -- --run SuggestionFeed discover
|
||
npm run build
|
||
cd ..
|
||
```
|
||
|
||
Expected: 0 errors, all tests pass, build clean.
|
||
|
||
- [ ] **Step 8.6: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/components/SuggestionFeed.svelte \
|
||
web/src/lib/components/SuggestionFeed.test.ts \
|
||
web/src/routes/discover/+page.svelte \
|
||
web/src/routes/discover/discover.test.ts
|
||
git commit -m "feat(web): suggestion feed on /discover (search-empty default)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9 — Final verification + branch finish
|
||
|
||
- [ ] **Step 9.1: Full Go test sweep**
|
||
|
||
```bash
|
||
go test -short -race ./...
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race -p 1 ./...
|
||
```
|
||
|
||
Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented (`project_scanner_flake.md`) and not blocking.
|
||
|
||
- [ ] **Step 9.2: Lint clean**
|
||
|
||
```bash
|
||
golangci-lint run ./...
|
||
```
|
||
|
||
Expected: no output.
|
||
|
||
- [ ] **Step 9.3: Coverage check**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
bash -c 'go test -race -p 1 -coverprofile=/tmp/cov.out \
|
||
./internal/recommendation/... ./internal/similarity/... ./internal/api/... && \
|
||
go tool cover -func=/tmp/cov.out | tail -1'
|
||
```
|
||
|
||
Expected: combined ≥ 80% on the new code per spec §8.
|
||
|
||
- [ ] **Step 9.4: Frontend full check**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web
|
||
npm run check
|
||
npm test -- --run
|
||
npm run build
|
||
cd ..
|
||
```
|
||
|
||
Expected: 0 errors, all tests pass, build succeeds.
|
||
|
||
- [ ] **Step 9.5: Manual smoke**
|
||
|
||
- Like an artist (or play a few tracks).
|
||
- Open `/discover` with the search input empty.
|
||
- Verify the "Suggested for you" header + grid renders.
|
||
- Verify each card has an attribution line that reads naturally.
|
||
- Click Request on a suggestion → confirm it disappears (optimistic) and a row appears at `/requests`.
|
||
- Type a search term → confirm the feed swaps out for search results.
|
||
- Clear the search input → confirm the feed comes back (cached, instant).
|
||
- For a fresh user with no likes/plays, the empty-state copy renders.
|
||
|
||
- [ ] **Step 9.6: Use `superpowers:finishing-a-development-branch`**
|
||
|
||
Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence.
|
||
|
||
---
|
||
|
||
## Self-review checklist
|
||
|
||
**Spec coverage** — every spec section maps to a task:
|
||
- §3 Architecture: Tasks 1 (table), 2 (LB client), 3 (worker), 4 (service), 5 (handler), 6-8 (frontend)
|
||
- §4 Schema: Task 1
|
||
- §5 API surface: Task 5
|
||
- §6 UI surfaces: Tasks 7 (DiscoverResultCard), 8 (SuggestionFeed + /discover integration)
|
||
- §7 Error handling: distributed across Tasks 3 (worker WARN), 5 (handler 500), 8 (frontend silent-on-failure)
|
||
- §8 Testing: every Task includes tests; Task 9 verifies coverage targets
|
||
- §9 Decisions ledger: not directly implemented, referenced in commit messages
|
||
- §10 Out of scope: explicitly excluded — no album/track suggestions, no realtime invalidation, no cross-user CF, no pagination, no materialization
|
||
- §11 Open questions: Task 2 verifies the `Name` field on `SimilarArtist`; cold-start sparseness is documented behavior
|
||
|
||
**Placeholder scan:** the per-task detail level drops after Task 5 (frontend tasks become standard SvelteKit page work) — intentional for navigability. No "TBD" or "TODO" remains.
|
||
|
||
**Type consistency:**
|
||
- Service method: `SuggestArtists` consistent across plan
|
||
- Types: `ArtistSuggestion`, `SeedContribution` consistent across Go and TS
|
||
- API path: `/api/discover/suggestions` consistent
|
||
- Component name: `<SuggestionFeed>` consistent
|
||
- DB field names: `seed_artist_id`, `candidate_mbid`, `candidate_name`, `score`, `source`, `fetched_at` consistent across migration / queries / tests
|
||
|
||
Plan is complete.
|