8b586c2e50
User request 2026-06-01: the previous Rediscover only fired on explicit album/artist likes, so users who only liked at the track level got an empty Rediscover row. Also no daily rotation - the section never changed between data updates - and 25 items felt long for the Home carousel. SQL (internal/db/queries/recommendation.sql): - ListRediscoverAlbumsForUser UNIONs the existing explicit album-like signal with a new track-derived path: an album qualifies if it has >=2 liked tracks where the earliest like is >30 days old. - ListRediscoverArtistsForUser does the same with threshold 3 (artists span more releases, so the bar is higher to avoid one-hit-wonder affinities). - Both ordering switched from longest-since-last-play to a daily- stable random hash md5(entity_id || user_id || current_date) so the row randomizes but stays consistent within a day. - Fallback queries also switched to the daily-stable hash so the fallback rows don't reshuffle on every refresh. Go (internal/recommendation/home.go): - HomeRediscoverLimit dropped from 25 to 10 (carousel-sized). - rediscoverInnerLimit (30) is the per-query cap; gives headroom so the Go-layer filters don't undershoot. - applyRediscoverAlbumFilters does cross-section dedup vs Most Played (no point surfacing albums the user is actively spinning) plus a diversity cap of max 2 albums per artist (prevents one liked-but- forgotten artist dominating the row). - applyRediscoverArtistFilters does cross-section dedup vs Most Played's artist set; no diversity cap needed (one row per artist). Tests (internal/recommendation/home_integration_test.go): - TrackDerived path: 2 liked tracks >30d old + no recent plays = album appears in Rediscover. - Threshold guard: 1 liked track = album does NOT appear. - Diversity cap: 4 explicit album-likes from same artist = 2 in output. - Dedup vs MostPlayed: eligible album whose track is in MostPlayed gets filtered out. - Existing FallbackWhenSparse test preserved (semantics unchanged for recent likes that miss the >30d primary filter). Clients unchanged - they render whatever /api/home/index returns. No parity-map row needed (this is a server-internal recommendation change, not a layout divergence).
292 lines
11 KiB
Go
292 lines
11 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-newuser")
|
|
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("HomeData returned nil payload")
|
|
}
|
|
if len(got.RecentlyAddedAlbums) != 0 {
|
|
t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums))
|
|
}
|
|
if len(got.RediscoverAlbums) != 0 {
|
|
t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums))
|
|
}
|
|
if len(got.RediscoverArtists) != 0 {
|
|
t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists))
|
|
}
|
|
if len(got.MostPlayedTracks) != 0 {
|
|
t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks))
|
|
}
|
|
if len(got.LastPlayedArtists) != 0 {
|
|
t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists))
|
|
}
|
|
}
|
|
|
|
// Compile-time assert that pgtype.UUID is a valid map key — used by the
|
|
// service when de-duping rediscover fallback rows. If this changes, the
|
|
// service needs to switch to keying by string.
|
|
var _ map[pgtype.UUID]struct{}
|
|
|
|
func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-recents")
|
|
a1 := seedArtist(t, pool, "ArtistA", "")
|
|
// Three albums, oldest first — created_at increases naturally with each insert.
|
|
al1 := seedAlbumForArtist(t, pool, a1.ID, "Older")
|
|
al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle")
|
|
al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest")
|
|
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
if len(got.RecentlyAddedAlbums) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums))
|
|
}
|
|
if got.RecentlyAddedAlbums[0].Album.ID != al3.ID ||
|
|
got.RecentlyAddedAlbums[1].Album.ID != al2.ID ||
|
|
got.RecentlyAddedAlbums[2].Album.ID != al1.ID {
|
|
t.Errorf("order = [%s, %s, %s], want newest→oldest",
|
|
got.RecentlyAddedAlbums[0].Album.Title,
|
|
got.RecentlyAddedAlbums[1].Album.Title,
|
|
got.RecentlyAddedAlbums[2].Album.Title)
|
|
}
|
|
}
|
|
|
|
func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-mostplayed")
|
|
artist := seedArtist(t, pool, "ArtistB", "")
|
|
album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB")
|
|
tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High")
|
|
tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low")
|
|
tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined")
|
|
now := time.Now()
|
|
for i := 0; i < 3; i++ {
|
|
insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute))
|
|
}
|
|
insertPlayEvent(t, pool, user.ID, tLow.ID, now)
|
|
for i := 0; i < 5; i++ {
|
|
insertPlayEvent(t, pool, user.ID, tQuar.ID, now)
|
|
}
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
|
|
user.ID, tQuar.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert quarantine: %v", err)
|
|
}
|
|
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
if len(got.MostPlayedTracks) != 2 {
|
|
t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks))
|
|
}
|
|
if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID {
|
|
t.Errorf("ranking wrong: got [%s, %s], want [High, Low]",
|
|
got.MostPlayedTracks[0].Track.Title,
|
|
got.MostPlayedTracks[1].Track.Title)
|
|
}
|
|
}
|
|
|
|
func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-rediscover")
|
|
artist := seedArtist(t, pool, "ArtistC", "")
|
|
// Like one album NOW (won't satisfy >30d eligibility) — should still
|
|
// surface via fallback because primary returns 0 rows.
|
|
al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike")
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`,
|
|
user.ID, al.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert like: %v", err)
|
|
}
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
if len(got.RediscoverAlbums) != 1 {
|
|
t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums))
|
|
}
|
|
if got.RediscoverAlbums[0].Album.ID != al.ID {
|
|
t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title)
|
|
}
|
|
}
|
|
|
|
// TestHomeData_Rediscover_AlbumFromTrackLikes verifies the track-derived
|
|
// path: 2+ liked tracks from an album (with the earliest like >30 days
|
|
// ago) is enough for the album to appear in Rediscover, even with no
|
|
// explicit general_likes_albums entry.
|
|
func TestHomeData_Rediscover_AlbumFromTrackLikes(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-redis-tracklike")
|
|
artist := seedArtist(t, pool, "ArtistTL", "")
|
|
al := seedAlbumForArtist(t, pool, artist.ID, "TrackLiked")
|
|
t1 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "T1")
|
|
t2 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "T2")
|
|
// Two track-likes, both >30 days old; no explicit album-like.
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO general_likes (user_id, track_id, liked_at) VALUES
|
|
($1, $2, now() - interval '60 days'),
|
|
($1, $3, now() - interval '45 days')`,
|
|
user.ID, t1.ID, t2.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert track likes: %v", err)
|
|
}
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
if len(got.RediscoverAlbums) != 1 {
|
|
t.Fatalf("len = %d, want 1 (track-derived)", len(got.RediscoverAlbums))
|
|
}
|
|
if got.RediscoverAlbums[0].Album.ID != al.ID {
|
|
t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title)
|
|
}
|
|
}
|
|
|
|
// TestHomeData_Rediscover_AlbumTrackLikeThreshold verifies that ONE
|
|
// liked track from an album is not enough — threshold is 2 for the
|
|
// track-derived path. The fallback should also miss this since no
|
|
// album-like exists.
|
|
func TestHomeData_Rediscover_AlbumTrackLikeThreshold(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-redis-threshold")
|
|
artist := seedArtist(t, pool, "ArtistTh", "")
|
|
al := seedAlbumForArtist(t, pool, artist.ID, "OneTrackLiked")
|
|
t1 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "Solo")
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO general_likes (user_id, track_id, liked_at) VALUES
|
|
($1, $2, now() - interval '60 days')`,
|
|
user.ID, t1.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert track like: %v", err)
|
|
}
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
if len(got.RediscoverAlbums) != 0 {
|
|
t.Errorf("len = %d, want 0 (below 2-track threshold)", len(got.RediscoverAlbums))
|
|
}
|
|
}
|
|
|
|
// TestHomeData_Rediscover_DiversityCap verifies that no more than 2
|
|
// albums from the same artist appear in Rediscover even when more are
|
|
// eligible.
|
|
func TestHomeData_Rediscover_DiversityCap(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-redis-cap")
|
|
artist := seedArtist(t, pool, "ArtistDom", "")
|
|
albums := []dbq.Album{
|
|
seedAlbumForArtist(t, pool, artist.ID, "A1"),
|
|
seedAlbumForArtist(t, pool, artist.ID, "A2"),
|
|
seedAlbumForArtist(t, pool, artist.ID, "A3"),
|
|
seedAlbumForArtist(t, pool, artist.ID, "A4"),
|
|
}
|
|
// Explicit album-likes >30 days old on all four.
|
|
for _, al := range albums {
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO general_likes_albums (user_id, album_id, liked_at)
|
|
VALUES ($1, $2, now() - interval '40 days')`,
|
|
user.ID, al.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert like: %v", err)
|
|
}
|
|
}
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
if len(got.RediscoverAlbums) != maxAlbumsPerArtistInRediscover {
|
|
t.Errorf("len = %d, want %d (diversity cap)",
|
|
len(got.RediscoverAlbums), maxAlbumsPerArtistInRediscover)
|
|
}
|
|
}
|
|
|
|
// TestHomeData_Rediscover_DedupVsMostPlayed verifies that an otherwise-
|
|
// eligible album whose tracks appear in the Most Played section is
|
|
// dropped from Rediscover (the "you forgot about this" contract is
|
|
// broken if the user is actively spinning it).
|
|
func TestHomeData_Rediscover_DedupVsMostPlayed(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "home-redis-dedup")
|
|
artist := seedArtist(t, pool, "ArtistD", "")
|
|
// "Forgotten" album: liked >30d ago, no recent plays — would be
|
|
// eligible on its own.
|
|
forgotten := seedAlbumForArtist(t, pool, artist.ID, "Forgotten")
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO general_likes_albums (user_id, album_id, liked_at)
|
|
VALUES ($1, $2, now() - interval '40 days')`,
|
|
user.ID, forgotten.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert forgotten like: %v", err)
|
|
}
|
|
// "Active" album: liked >30d ago BUT not played in last 14 days at
|
|
// the album level (uses one >30-day-old track-like, no recent plays
|
|
// — qualifies for Rediscover). Its track ALSO appears in MostPlayed
|
|
// via many old plays >14 days ago... wait, we need the track to be
|
|
// in MostPlayed, which means it needs play_events that aren't
|
|
// skipped. MostPlayed has no recency filter; any plays count.
|
|
active := seedAlbumForArtist(t, pool, artist.ID, "Active")
|
|
activeT1 := seedTrackOnAlbum(t, pool, active.ID, artist.ID, "AT1")
|
|
activeT2 := seedTrackOnAlbum(t, pool, active.ID, artist.ID, "AT2")
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO general_likes (user_id, track_id, liked_at) VALUES
|
|
($1, $2, now() - interval '60 days'),
|
|
($1, $3, now() - interval '60 days')`,
|
|
user.ID, activeT1.ID, activeT2.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert active track likes: %v", err)
|
|
}
|
|
// Many plays >14 days ago (so still Rediscover-eligible by the
|
|
// "not played in last 14 days" rule) but enough to put it in
|
|
// MostPlayed.
|
|
for i := 0; i < 5; i++ {
|
|
insertPlayEvent(t, pool, user.ID, activeT1.ID,
|
|
time.Now().Add(-30*24*time.Hour))
|
|
}
|
|
|
|
got, err := HomeData(context.Background(), pool, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("HomeData: %v", err)
|
|
}
|
|
// MostPlayed must include the active track.
|
|
foundInMP := false
|
|
for _, mp := range got.MostPlayedTracks {
|
|
if mp.Track.ID == activeT1.ID {
|
|
foundInMP = true
|
|
}
|
|
}
|
|
if !foundInMP {
|
|
t.Fatalf("active track not in MostPlayed; test fixture broken")
|
|
}
|
|
// Rediscover must contain forgotten but NOT active.
|
|
if len(got.RediscoverAlbums) != 1 {
|
|
t.Fatalf("RediscoverAlbums len = %d, want 1 (active deduped)",
|
|
len(got.RediscoverAlbums))
|
|
}
|
|
if got.RediscoverAlbums[0].Album.ID != forgotten.ID {
|
|
t.Errorf("got %s, want %s (active should have been deduped)",
|
|
got.RediscoverAlbums[0].Album.Title, forgotten.Title)
|
|
}
|
|
}
|