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)) } } // --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix --- func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) { t.Helper() if _, err := pool.Exec(context.Background(), `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3) ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`, userID, artistID, weight, ); err != nil { t.Fatalf("set taste weight: %v", err) } } func insertSkippedPlayEvent(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, 'skip-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, true)`, userID, trackID, sessionID, startedAt, ); err != nil { t.Fatalf("insert skipped play_event: %v", err) } } // Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no // play on the seed artist. Before #2367 the profile was ignored entirely here. func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") seed := seedArtist(t, pool, "Profile Seed", "") setTasteWeight(t, pool, user.ID, seed.ID, 4.0) seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9) 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 (taste weight alone should seed)", len(out)) } if out[0].MBID != "out-mbid" { t.Errorf("mbid = %q, want out-mbid", out[0].MBID) } if out[0].Score <= 0 { t.Errorf("score = %v, want > 0", out[0].Score) } } // Once the profile has any positive row, tier 2 is not consulted — so an artist // the user played but that the taste engine did not keep does NOT seed. That is // the point of the rewrite: the taste engine decides what counts as affinity. func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") kept := seedArtist(t, pool, "Kept By Taste", "") dropped := seedArtist(t, pool, "Dropped By Taste", "") setTasteWeight(t, pool, user.ID, kept.ID, 3.0) // `dropped` has real completed plays but no profile row. album := seedAlbumForArtist(t, pool, dropped.ID, "Album") track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track") insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour)) seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9) seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9) 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 (tier 2 must not run alongside tier 1)", len(out)) } if out[0].MBID != "kept-cand" { t.Errorf("mbid = %q, want kept-cand", out[0].MBID) } } // A non-positive taste weight is not affinity. Guarded by a second, positive // row so tier 1 stays active — otherwise an empty tier 1 would fall through to // tier 2 and the assertion would pass for the wrong reason. func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") positive := seedArtist(t, pool, "Still Liked", "") abandoned := seedArtist(t, pool, "Abandoned", "") setTasteWeight(t, pool, user.ID, positive.ID, 2.0) setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5) seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9) seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } for _, s := range out { if s.MBID == "neg-cand" { t.Fatalf("negative-weight artist seeded a suggestion: %+v", s) } } if len(out) != 1 || out[0].MBID != "pos-cand" { t.Errorf("out = %+v, want only pos-cand", out) } } // Tier 2 counts COMPLETED plays only. Previously every play_event counted, so // skipping an artist repeatedly increased its signal and pushed more of its // neighbours at the user (#2367 mechanism 3). func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") skipped := seedArtist(t, pool, "Only Skipped", "") album := seedAlbumForArtist(t, pool, skipped.ID, "Album") track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track") for i := 0; i < 5; i++ { insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour)) } seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 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 (skips are not affinity): %+v", len(out), out) } }