diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index fc653a78..a8af907e 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -182,7 +182,7 @@ FROM general_likes_albums gla JOIN albums ON albums.id = gla.album_id JOIN artists ON artists.id = albums.artist_id WHERE gla.user_id = $1 -ORDER BY random() +ORDER BY md5(albums.id::text || $1::text || current_date::text) LIMIT $2 ` @@ -196,9 +196,11 @@ type ListRediscoverAlbumsFallbackForUserRow struct { ArtistName string } -// M6a: random sample of liked albums for the user. The Go service uses -// this to top up the rediscover row when ListRediscoverAlbumsForUser -// returns fewer than `limit` rows. +// Daily-stable random sample of liked albums for the user. The Go +// service uses this to top up the rediscover row when the primary +// eligibility query returns fewer than the inner limit. Same daily +// hash ordering as the primary so the fallback rows stay stable +// through the day instead of reshuffling on every refresh. func (q *Queries) ListRediscoverAlbumsFallbackForUser(ctx context.Context, arg ListRediscoverAlbumsFallbackForUserParams) ([]ListRediscoverAlbumsFallbackForUserRow, error) { rows, err := q.db.Query(ctx, listRediscoverAlbumsFallbackForUser, arg.UserID, arg.Limit) if err != nil { @@ -233,17 +235,31 @@ func (q *Queries) ListRediscoverAlbumsFallbackForUser(ctx context.Context, arg L } const listRediscoverAlbumsForUser = `-- name: ListRediscoverAlbumsForUser :many -WITH eligible AS ( - SELECT al.id AS album_id, - gla.liked_at, - max(pe.started_at) AS last_played +WITH liked_signal AS ( + -- Path (a): explicit album-likes >30 days old. + SELECT al.id AS album_id, gla.liked_at AS like_at FROM general_likes_albums gla JOIN albums al ON al.id = gla.album_id - LEFT JOIN tracks t ON t.album_id = al.id - LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 WHERE gla.user_id = $1 AND gla.liked_at < now() - interval '30 days' - GROUP BY al.id, gla.liked_at + UNION ALL + -- Path (b): >=2 liked tracks per album, earliest like >30 days old. + SELECT t.album_id, MIN(gl.liked_at) AS like_at + FROM general_likes gl + JOIN tracks t ON t.id = gl.track_id + WHERE gl.user_id = $1 + GROUP BY t.album_id + HAVING COUNT(*) >= 2 + AND MIN(gl.liked_at) < now() - interval '30 days' +), +eligible AS ( + SELECT ls.album_id, + MIN(ls.like_at) AS like_at, + max(pe.started_at) AS last_played + FROM liked_signal ls + LEFT JOIN tracks t ON t.album_id = ls.album_id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + GROUP BY ls.album_id HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) < now() - interval '14 days' ) @@ -251,13 +267,13 @@ SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.rele FROM eligible e JOIN albums ON albums.id = e.album_id JOIN artists ON artists.id = albums.artist_id -ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +ORDER BY md5(albums.id::text || $1::text || current_date::text) LIMIT $2 ` type ListRediscoverAlbumsForUserParams struct { - UserID pgtype.UUID - Limit int32 + Column1 string + Limit int32 } type ListRediscoverAlbumsForUserRow struct { @@ -265,12 +281,20 @@ type ListRediscoverAlbumsForUserRow struct { ArtistName string } -// M6a: albums liked >30 days ago AND not played in the last 14 days, -// ordered by longest-since-last-play first. The HAVING clause uses an -// epoch sentinel so albums with NO plays count as eligible (their -// "last play" is treated as 1970, which is always >14 days ago). +// Albums eligible for Rediscover via either signal: +// +// (a) explicit album-like (general_likes_albums) liked >30 days ago, OR +// (b) >=2 liked tracks from the album (general_likes), where the +// earliest such track-like is >30 days ago. +// +// AND not played in the last 14 days. The HAVING epoch sentinel keeps +// albums with NO play history eligible (treated as last-played in 1970). +// Ordering is daily-stable random (md5 of album+user+date hashes), +// so the row rotates at midnight local without re-running per request. +// Final cap + diversity + cross-section dedup land in the Go layer +// (internal/recommendation/home.go). func (q *Queries) ListRediscoverAlbumsForUser(ctx context.Context, arg ListRediscoverAlbumsForUserParams) ([]ListRediscoverAlbumsForUserRow, error) { - rows, err := q.db.Query(ctx, listRediscoverAlbumsForUser, arg.UserID, arg.Limit) + rows, err := q.db.Query(ctx, listRediscoverAlbumsForUser, arg.Column1, arg.Limit) if err != nil { return nil, err } @@ -318,7 +342,7 @@ LEFT JOIN LATERAL ( FROM albums WHERE artist_id = artists.id ) cnt ON true WHERE gla.user_id = $1 -ORDER BY random() +ORDER BY md5(artists.id::text || $1::text || current_date::text) LIMIT $2 ` @@ -333,6 +357,9 @@ type ListRediscoverArtistsFallbackForUserRow struct { AlbumCount int64 } +// Daily-stable random sample of liked artists, paired with the Go +// top-up logic in HomeData when the primary eligibility query returns +// fewer than the inner limit. Daily hash ordering matches the primary. func (q *Queries) ListRediscoverArtistsFallbackForUser(ctx context.Context, arg ListRediscoverArtistsFallbackForUserParams) ([]ListRediscoverArtistsFallbackForUserRow, error) { rows, err := q.db.Query(ctx, listRediscoverArtistsFallbackForUser, arg.UserID, arg.Limit) if err != nil { @@ -367,17 +394,29 @@ func (q *Queries) ListRediscoverArtistsFallbackForUser(ctx context.Context, arg } const listRediscoverArtistsForUser = `-- name: ListRediscoverArtistsForUser :many -WITH eligible AS ( - SELECT a.id AS artist_id, - gla.liked_at, - max(pe.started_at) AS last_played +WITH liked_signal AS ( + SELECT a.id AS artist_id, gla.liked_at AS like_at FROM general_likes_artists gla JOIN artists a ON a.id = gla.artist_id - 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.user_id = $1 AND gla.liked_at < now() - interval '30 days' - GROUP BY a.id, gla.liked_at + UNION ALL + SELECT t.artist_id, MIN(gl.liked_at) AS like_at + FROM general_likes gl + JOIN tracks t ON t.id = gl.track_id + WHERE gl.user_id = $1 + GROUP BY t.artist_id + HAVING COUNT(*) >= 3 + AND MIN(gl.liked_at) < now() - interval '30 days' +), +eligible AS ( + SELECT ls.artist_id, + MIN(ls.like_at) AS like_at, + max(pe.started_at) AS last_played + FROM liked_signal ls + LEFT JOIN tracks t ON t.artist_id = ls.artist_id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + GROUP BY ls.artist_id HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) < now() - interval '14 days' ) @@ -395,13 +434,13 @@ LEFT JOIN LATERAL ( SELECT count(*) AS album_count FROM albums WHERE artist_id = artists.id ) cnt ON true -ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +ORDER BY md5(artists.id::text || $1::text || current_date::text) LIMIT $2 ` type ListRediscoverArtistsForUserParams struct { - UserID pgtype.UUID - Limit int32 + Column1 string + Limit int32 } type ListRediscoverArtistsForUserRow struct { @@ -410,11 +449,20 @@ type ListRediscoverArtistsForUserRow struct { AlbumCount int64 } -// M6a: artists liked >30 days ago AND none of their tracks played in the -// last 14 days, ordered by longest-since-last-play. cover_album_id is -// derived from a representative album (LEFT JOIN LATERAL). +// Artists eligible for Rediscover via either signal: +// +// (a) explicit artist-like (general_likes_artists) liked >30 days ago, OR +// (b) >=3 liked tracks from the artist (general_likes), where the +// earliest such track-like is >30 days ago. +// +// AND none of their tracks played in the last 14 days. Higher track +// threshold than the album path (3 vs 2) because artists span many +// releases, so the bar is higher to avoid surfacing one-hit-wonder +// affinities. Ordering is daily-stable random (md5 of artist+user+date +// hashes). cover_album_id derived from a representative album (most +// recently created album with cover_art_path set). func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRediscoverArtistsForUserParams) ([]ListRediscoverArtistsForUserRow, error) { - rows, err := q.db.Query(ctx, listRediscoverArtistsForUser, arg.UserID, arg.Limit) + rows, err := q.db.Query(ctx, listRediscoverArtistsForUser, arg.Column1, arg.Limit) if err != nil { return nil, err } diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index b4cec84b..af259b0c 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -271,21 +271,41 @@ ORDER BY up.last_started DESC, a.id LIMIT $2; -- name: ListRediscoverAlbumsForUser :many --- M6a: albums liked >30 days ago AND not played in the last 14 days, --- ordered by longest-since-last-play first. The HAVING clause uses an --- epoch sentinel so albums with NO plays count as eligible (their --- "last play" is treated as 1970, which is always >14 days ago). -WITH eligible AS ( - SELECT al.id AS album_id, - gla.liked_at, - max(pe.started_at) AS last_played +-- Albums eligible for Rediscover via either signal: +-- (a) explicit album-like (general_likes_albums) liked >30 days ago, OR +-- (b) >=2 liked tracks from the album (general_likes), where the +-- earliest such track-like is >30 days ago. +-- AND not played in the last 14 days. The HAVING epoch sentinel keeps +-- albums with NO play history eligible (treated as last-played in 1970). +-- Ordering is daily-stable random (md5 of album+user+date hashes), +-- so the row rotates at midnight local without re-running per request. +-- Final cap + diversity + cross-section dedup land in the Go layer +-- (internal/recommendation/home.go). +WITH liked_signal AS ( + -- Path (a): explicit album-likes >30 days old. + SELECT al.id AS album_id, gla.liked_at AS like_at FROM general_likes_albums gla JOIN albums al ON al.id = gla.album_id - LEFT JOIN tracks t ON t.album_id = al.id - LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 WHERE gla.user_id = $1 AND gla.liked_at < now() - interval '30 days' - GROUP BY al.id, gla.liked_at + UNION ALL + -- Path (b): >=2 liked tracks per album, earliest like >30 days old. + SELECT t.album_id, MIN(gl.liked_at) AS like_at + FROM general_likes gl + JOIN tracks t ON t.id = gl.track_id + WHERE gl.user_id = $1 + GROUP BY t.album_id + HAVING COUNT(*) >= 2 + AND MIN(gl.liked_at) < now() - interval '30 days' +), +eligible AS ( + SELECT ls.album_id, + MIN(ls.like_at) AS like_at, + max(pe.started_at) AS last_played + FROM liked_signal ls + LEFT JOIN tracks t ON t.album_id = ls.album_id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + GROUP BY ls.album_id HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) < now() - interval '14 days' ) @@ -293,36 +313,57 @@ SELECT sqlc.embed(albums), artists.name AS artist_name FROM eligible e JOIN albums ON albums.id = e.album_id JOIN artists ON artists.id = albums.artist_id -ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +ORDER BY md5(albums.id::text || $1::text || current_date::text) LIMIT $2; -- name: ListRediscoverAlbumsFallbackForUser :many --- M6a: random sample of liked albums for the user. The Go service uses --- this to top up the rediscover row when ListRediscoverAlbumsForUser --- returns fewer than `limit` rows. +-- Daily-stable random sample of liked albums for the user. The Go +-- service uses this to top up the rediscover row when the primary +-- eligibility query returns fewer than the inner limit. Same daily +-- hash ordering as the primary so the fallback rows stay stable +-- through the day instead of reshuffling on every refresh. SELECT sqlc.embed(albums), artists.name AS artist_name FROM general_likes_albums gla JOIN albums ON albums.id = gla.album_id JOIN artists ON artists.id = albums.artist_id WHERE gla.user_id = $1 -ORDER BY random() +ORDER BY md5(albums.id::text || $1::text || current_date::text) LIMIT $2; -- name: ListRediscoverArtistsForUser :many --- M6a: artists liked >30 days ago AND none of their tracks played in the --- last 14 days, ordered by longest-since-last-play. cover_album_id is --- derived from a representative album (LEFT JOIN LATERAL). -WITH eligible AS ( - SELECT a.id AS artist_id, - gla.liked_at, - max(pe.started_at) AS last_played +-- Artists eligible for Rediscover via either signal: +-- (a) explicit artist-like (general_likes_artists) liked >30 days ago, OR +-- (b) >=3 liked tracks from the artist (general_likes), where the +-- earliest such track-like is >30 days ago. +-- AND none of their tracks played in the last 14 days. Higher track +-- threshold than the album path (3 vs 2) because artists span many +-- releases, so the bar is higher to avoid surfacing one-hit-wonder +-- affinities. Ordering is daily-stable random (md5 of artist+user+date +-- hashes). cover_album_id derived from a representative album (most +-- recently created album with cover_art_path set). +WITH liked_signal AS ( + SELECT a.id AS artist_id, gla.liked_at AS like_at FROM general_likes_artists gla JOIN artists a ON a.id = gla.artist_id - 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.user_id = $1 AND gla.liked_at < now() - interval '30 days' - GROUP BY a.id, gla.liked_at + UNION ALL + SELECT t.artist_id, MIN(gl.liked_at) AS like_at + FROM general_likes gl + JOIN tracks t ON t.id = gl.track_id + WHERE gl.user_id = $1 + GROUP BY t.artist_id + HAVING COUNT(*) >= 3 + AND MIN(gl.liked_at) < now() - interval '30 days' +), +eligible AS ( + SELECT ls.artist_id, + MIN(ls.like_at) AS like_at, + max(pe.started_at) AS last_played + FROM liked_signal ls + LEFT JOIN tracks t ON t.artist_id = ls.artist_id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + GROUP BY ls.artist_id HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) < now() - interval '14 days' ) @@ -340,10 +381,13 @@ LEFT JOIN LATERAL ( SELECT count(*) AS album_count FROM albums WHERE artist_id = artists.id ) cnt ON true -ORDER BY (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +ORDER BY md5(artists.id::text || $1::text || current_date::text) LIMIT $2; -- name: ListRediscoverArtistsFallbackForUser :many +-- Daily-stable random sample of liked artists, paired with the Go +-- top-up logic in HomeData when the primary eligibility query returns +-- fewer than the inner limit. Daily hash ordering matches the primary. SELECT sqlc.embed(artists), cov.id AS cover_album_id, cnt.album_count::bigint AS album_count @@ -359,5 +403,5 @@ LEFT JOIN LATERAL ( FROM albums WHERE artist_id = artists.id ) cnt ON true WHERE gla.user_id = $1 -ORDER BY random() +ORDER BY md5(artists.id::text || $1::text || current_date::text) LIMIT $2; diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go index 28180427..a731f833 100644 --- a/internal/recommendation/home.go +++ b/internal/recommendation/home.go @@ -18,9 +18,22 @@ import ( // Section size caps. SPA splits these into rows on the page. const ( HomeRecentlyAddedLimit = 50 - HomeRediscoverLimit = 25 - HomeMostPlayedLimit = 75 - HomeLastPlayedLimit = 25 + // HomeRediscoverLimit is the OUTPUT count clients render. The SQL + // query asks for rediscoverInnerLimit so the Go layer has headroom + // to apply cross-section dedup (vs Most Played) and the diversity + // cap (max 2 albums per artist) without undershooting. + HomeRediscoverLimit = 10 + HomeMostPlayedLimit = 75 + HomeLastPlayedLimit = 25 + + // rediscoverInnerLimit is the per-query cap; 3x HomeRediscoverLimit + // gives enough slack that filtering rarely undershoots. + rediscoverInnerLimit = 30 + + // maxAlbumsPerArtistInRediscover prevents one liked-but-forgotten + // artist from dominating the row. Two is enough variety; higher + // reads as "this artist's discography" instead of "rediscover". + maxAlbumsPerArtistInRediscover = 2 ) // HomePayload is the composite returned by HomeData. All slices are @@ -35,10 +48,13 @@ type HomePayload struct { } // HomeData runs five queries in parallel and assembles the payload. -// Rediscover sections fall back to a random sample of liked entities -// when the eligibility query returns fewer than HomeRediscoverLimit rows. -// Any single query error fails the whole call (predictable empty-or-full -// UX for the SPA — partial payloads are not a feature). +// Rediscover sections fan out to inner-limit results (track-derived + +// explicit-like eligibility, plus a daily-stable random fallback when +// the eligibility query is sparse), then the Go layer trims to +// HomeRediscoverLimit after applying cross-section dedup (vs Most +// Played) and the per-artist diversity cap. Any single query error +// fails the whole call (predictable empty-or-full UX for the SPA — +// partial payloads are not a feature). func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*HomePayload, error) { q := dbq.New(pool) @@ -118,21 +134,106 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom if firstErr != nil { return nil, firstErr } + + // Cross-section dedup + diversity cap, applied after the parallel + // fan-out so the SQL stays simple and reusable. Both filters trim + // the rediscover lists down toward HomeRediscoverLimit. + out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks) + out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, out.MostPlayedTracks) return out, nil } +// mostPlayedAlbumIDs returns the set of album IDs whose tracks appear +// in the Most Played row. Used to dedup Rediscover so it doesn't list +// albums the user is actively spinning. +func mostPlayedAlbumIDs(rows []dbq.ListMostPlayedTracksForUserRow) map[pgtype.UUID]struct{} { + out := make(map[pgtype.UUID]struct{}, len(rows)) + for _, r := range rows { + out[r.Track.AlbumID] = struct{}{} + } + return out +} + +// mostPlayedArtistIDs returns the set of artist IDs whose tracks appear +// in the Most Played row. +func mostPlayedArtistIDs(rows []dbq.ListMostPlayedTracksForUserRow) map[pgtype.UUID]struct{} { + out := make(map[pgtype.UUID]struct{}, len(rows)) + for _, r := range rows { + out[r.Track.ArtistID] = struct{}{} + } + return out +} + +// applyRediscoverAlbumFilters trims the inner-limit query result down +// to HomeRediscoverLimit via: +// 1. Cross-section dedup vs Most Played (drop albums actively played). +// 2. Diversity cap: at most maxAlbumsPerArtistInRediscover per artist. +// 3. Final trim to HomeRediscoverLimit. +// +// Input order is preserved (daily-stable hash from the SQL), so the +// row stays stable for the day even after filtering. +func applyRediscoverAlbumFilters( + rows []dbq.ListRediscoverAlbumsForUserRow, + mostPlayed []dbq.ListMostPlayedTracksForUserRow, +) []dbq.ListRediscoverAlbumsForUserRow { + excludedAlbums := mostPlayedAlbumIDs(mostPlayed) + perArtist := make(map[pgtype.UUID]int, len(rows)) + out := make([]dbq.ListRediscoverAlbumsForUserRow, 0, HomeRediscoverLimit) + for _, r := range rows { + if _, dup := excludedAlbums[r.Album.ID]; dup { + continue + } + if perArtist[r.Album.ArtistID] >= maxAlbumsPerArtistInRediscover { + continue + } + perArtist[r.Album.ArtistID]++ + out = append(out, r) + if len(out) >= HomeRediscoverLimit { + break + } + } + return out +} + +// applyRediscoverArtistFilters trims the artist list via cross-section +// dedup vs Most Played, then truncates to HomeRediscoverLimit. +// No diversity cap needed (one row per artist by construction). +func applyRediscoverArtistFilters( + rows []dbq.ListRediscoverArtistsForUserRow, + mostPlayed []dbq.ListMostPlayedTracksForUserRow, +) []dbq.ListRediscoverArtistsForUserRow { + excludedArtists := mostPlayedArtistIDs(mostPlayed) + out := make([]dbq.ListRediscoverArtistsForUserRow, 0, HomeRediscoverLimit) + for _, r := range rows { + if _, dup := excludedArtists[r.Artist.ID]; dup { + continue + } + out = append(out, r) + if len(out) >= HomeRediscoverLimit { + break + } + } + return out +} + +// loadRediscoverAlbums runs the primary eligibility query (track- +// derived + explicit album-likes UNION'd) and tops up with the +// fallback (random sample of any liked albums) if the primary returns +// fewer than rediscoverInnerLimit. Both use the same daily-stable +// hash ordering. The Go layer (applyRediscoverAlbumFilters) trims to +// HomeRediscoverLimit after cross-section dedup + diversity cap. func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) { primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, + UserID: userID, Limit: rediscoverInnerLimit, }) if err != nil { return nil, err } - if len(primary) >= HomeRediscoverLimit { + if len(primary) >= rediscoverInnerLimit { return primary, nil } fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, + UserID: userID, Limit: rediscoverInnerLimit, }) if err != nil { return nil, err @@ -146,7 +247,7 @@ func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUI continue } primary = append(primary, dbq.ListRediscoverAlbumsForUserRow(r)) - if len(primary) >= HomeRediscoverLimit { + if len(primary) >= rediscoverInnerLimit { break } seen[r.Album.ID] = struct{}{} @@ -156,16 +257,16 @@ func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUI func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverArtistsForUserRow, error) { primary, err := q.ListRediscoverArtistsForUser(ctx, dbq.ListRediscoverArtistsForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, + UserID: userID, Limit: rediscoverInnerLimit, }) if err != nil { return nil, err } - if len(primary) >= HomeRediscoverLimit { + if len(primary) >= rediscoverInnerLimit { return primary, nil } fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ - UserID: userID, Limit: HomeRediscoverLimit, + UserID: userID, Limit: rediscoverInnerLimit, }) if err != nil { return nil, err @@ -179,7 +280,7 @@ func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UU continue } primary = append(primary, dbq.ListRediscoverArtistsForUserRow(r)) - if len(primary) >= HomeRediscoverLimit { + if len(primary) >= rediscoverInnerLimit { break } seen[r.Artist.ID] = struct{}{} diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go index 219dadfe..639e16b9 100644 --- a/internal/recommendation/home_integration_test.go +++ b/internal/recommendation/home_integration_test.go @@ -6,6 +6,8 @@ import ( "time" "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { @@ -128,3 +130,162 @@ func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) { 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) + } +}