feat(recommendation): rework Rediscover - track-derived eligibility + daily rotation + caps
test-go / test (push) Failing after 15s
test-go / integration (push) Failing after 2m16s

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:
2026-06-01 00:57:40 -04:00
parent bf61d6cfa3
commit 8b586c2e50
4 changed files with 431 additions and 77 deletions
+72 -28
View File
@@ -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;