feat(recommendation): rework Rediscover - track-derived eligibility + daily rotation + caps
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).
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user