diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index cafeb6b5..60f059e8 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -69,6 +69,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err return i, err } +const getArtistsByIDs = `-- name: GetArtistsByIDs :many +SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = ANY($1::uuid[]) +` + +// Batched lookup used by M5c suggestion attribution to resolve top-3 +// contributing seed UUIDs back to artist names in one round-trip. +func (q *Queries) GetArtistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Artist, error) { + rows, err := q.db.Query(ctx, getArtistsByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Artist + for rows.Next() { + var i Artist + if err := rows.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listArtists = `-- name: ListArtists :many SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists ORDER BY sort_name ` diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index dd1dea47..0ccf9a66 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -294,3 +294,99 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid } return items, nil } + +const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many +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::float8 * 86400.0))), 0) + AS signal, + (gla.artist_id IS NOT NULL) AS is_liked, + COUNT(pe.id)::bigint 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])::uuid[] AS top_seed_ids, + ((array_agg(contribution ORDER BY contribution DESC))[1:3])::float8[] AS top_contributions, + ((array_agg(is_liked ORDER BY contribution DESC))[1:3])::boolean[] AS top_is_liked, + ((array_agg(play_count ORDER BY contribution DESC))[1:3])::bigint[] AS top_play_counts +FROM contributions +GROUP BY candidate_mbid, candidate_name +ORDER BY total_score DESC +LIMIT $3 +` + +type SuggestArtistsForUserParams struct { + UserID pgtype.UUID + Column2 float64 + Limit int32 +} + +type SuggestArtistsForUserRow struct { + CandidateMbid string + CandidateName string + TotalScore float64 + TopSeedIds []pgtype.UUID + TopContributions []float64 + TopIsLiked []bool + TopPlayCounts []int64 +} + +// M5c: per-user artist suggestions ranked by signal x similarity. The +// seeds CTE collects the user's likes (x5) 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. +func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SuggestArtistsForUserRow + for rows.Next() { + var i SuggestArtistsForUserRow + if err := rows.Scan( + &i.CandidateMbid, + &i.CandidateName, + &i.TotalScore, + &i.TopSeedIds, + &i.TopContributions, + &i.TopIsLiked, + &i.TopPlayCounts, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/artists.sql b/internal/db/queries/artists.sql index f089d46e..50ab2985 100644 --- a/internal/db/queries/artists.sql +++ b/internal/db/queries/artists.sql @@ -37,3 +37,8 @@ SELECT COUNT(*) FROM artists; -- name: CountArtistsMatching :one SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%'; + +-- name: GetArtistsByIDs :many +-- Batched lookup used by M5c suggestion attribution to resolve top-3 +-- contributing seed UUIDs back to artist names in one round-trip. +SELECT * FROM artists WHERE id = ANY($1::uuid[]); diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index f3dc081c..af4f525c 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -150,3 +150,54 @@ GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, t.mbid, t.genre, t.added_at, t.updated_at, l.user_id, pe.last_played_at, pe.play_count, pe.skip_count; + +-- name: SuggestArtistsForUser :many +-- M5c: per-user artist suggestions ranked by signal x similarity. The +-- seeds CTE collects the user's likes (x5) 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::float8 * 86400.0))), 0) + AS signal, + (gla.artist_id IS NOT NULL) AS is_liked, + COUNT(pe.id)::bigint 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])::uuid[] AS top_seed_ids, + ((array_agg(contribution ORDER BY contribution DESC))[1:3])::float8[] AS top_contributions, + ((array_agg(is_liked ORDER BY contribution DESC))[1:3])::boolean[] AS top_is_liked, + ((array_agg(play_count ORDER BY contribution DESC))[1:3])::bigint[] AS top_play_counts +FROM contributions +GROUP BY candidate_mbid, candidate_name +ORDER BY total_score DESC +LIMIT $3; diff --git a/internal/recommendation/suggestions.go b/internal/recommendation/suggestions.go new file mode 100644 index 00000000..dfe970e3 --- /dev/null +++ b/internal/recommendation/suggestions.go @@ -0,0 +1,102 @@ +// 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 (default 12 when out of range); 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, + 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. pgtype.UUID is a comparable struct so it works as a map + // key directly. + 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) || i >= len(r.TopIsLiked) || i >= len(r.TopPlayCounts) { + 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 +} diff --git a/internal/recommendation/suggestions_integration_test.go b/internal/recommendation/suggestions_integration_test.go new file mode 100644 index 00000000..9b5cf0c4 --- /dev/null +++ b/internal/recommendation/suggestions_integration_test.go @@ -0,0 +1,332 @@ +package recommendation + +import ( + "context" + "fmt" + "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 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() + ctx := context.Background() + var sessionID pgtype.UUID + if err := pool.QueryRow(ctx, + `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) + VALUES ($1, $2, $2, 'm5c-test') RETURNING id`, + userID, startedAt, + ).Scan(&sessionID); err != nil { + t.Fatalf("insert play_session: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped) + VALUES ($1, $2, $3, $4, false)`, + userID, trackID, sessionID, startedAt, + ); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} + +func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) { + t.Helper() + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + userID, artistID, + ); err != nil { + t.Fatalf("like artist: %v", err) + } +} + +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", "") + + likeArtist(t, pool, user.ID, seedA.ID) + + 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)) + + 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", "") + likeArtist(t, pool, user.ID, seed.ID) + 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") + seeds := make([]dbq.Artist, 5) + for i := 0; i < 5; i++ { + seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "") + likeArtist(t, pool, user.ID, seeds[i].ID) + 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) + } + 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)) + + 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)) + } + if out[0].Attribution[0].Name != "Recent" { + t.Errorf("top attribution = %q, want Recent", 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", "") + likeArtist(t, pool, user.ID, seed.ID) + 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", "") + likeArtist(t, pool, user.ID, seed.ID) + 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", "") + likeArtist(t, pool, user.ID, seed.ID) + 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)) + } +}