fix(playlists): robust For-You seed + deep fill; young-library mix fallbacks

For-You silently vanished after ~7 days of not listening (seed query
required a non-skip play in the last 7 days) and capped at ~40 on
self-hosted libraries with no ListenBrainz similarity data.

- PickTopPlayedTracksForUser: tiered seed — last 30d top plays, else
  all-time top plays, else liked tracks. For-You only disappears now
  if the account has zero plays AND zero likes.
- produceForYou uses deeper candidate source limits (raised random/
  tag/similar K) so it reaches ~100 even with empty lb_similar /
  similar_artists; richer when LB enrichment is present.
- Rediscover: tiered — 6-month-dormant, else ≥5-play 30-day-dormant.
- On This Day: floor 60→30 days, window ±7→±10 doy; still skips
  cleanly (no rows → no playlist) on insufficient history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 12:48:20 -04:00
parent 1e2c486356
commit 2e7b81fdfe
5 changed files with 200 additions and 69 deletions
+47 -20
View File
@@ -202,8 +202,8 @@ WITH windowed AS (
SELECT track_id, COUNT(*) AS c SELECT track_id, COUNT(*) AS c
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '60 days' AND started_at < now() - interval '30 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7 AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
@@ -229,9 +229,12 @@ type ListOnThisDayTracksRow struct {
} }
// #422 On This Day: tracks the user played around this calendar date // #422 On This Day: tracks the user played around this calendar date
// (±7 day-of-year) in the past, excluding the very recent (>60 days // (±10 day-of-year) in the past, excluding the very recent (>30 days
// ago) so it's nostalgic, not just "last week". Weighted by how much // ago) so it's nostalgic, not just "last week". Weighted by how much
// they were played in those windows. // they were played in those windows. Floor relaxed from 60→30 days
// and window ±7→±10 so it surfaces on a months-old library instead
// of needing a full year of history; still skips cleanly (no rows →
// no playlist) when there's no qualifying history yet.
// $1 user_id, $2 date string. // $1 user_id, $2 date string.
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) { func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
@@ -255,21 +258,41 @@ func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTrac
const listRediscoverTracks = `-- name: ListRediscoverTracks :many const listRediscoverTracks = `-- name: ListRediscoverTracks :many
WITH stats AS ( WITH stats AS (
SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
FROM play_events FROM play_events pe
WHERE user_id = $1 AND was_skipped = false WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY track_id GROUP BY pe.track_id
),
deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
) )
SELECT t.id, t.album_id, t.artist_id SELECT id, album_id, artist_id
FROM tracks t FROM (
JOIN stats s ON s.track_id = t.id SELECT id, album_id, artist_id, c, tier FROM deep
WHERE s.c >= 5 UNION ALL
AND s.last_at <= now() - interval '6 months' SELECT id, album_id, artist_id, c, tier FROM shallow
AND NOT EXISTS ( WHERE NOT EXISTS (SELECT 1 FROM deep)
SELECT 1 FROM lidarr_quarantine q ) u
WHERE q.user_id = $1 AND q.track_id = t.id ORDER BY tier, c DESC, id
)
ORDER BY s.c DESC, t.id
LIMIT 200 LIMIT 200
` `
@@ -280,8 +303,12 @@ type ListRediscoverTracksRow struct {
} }
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but // #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
// not in the last 6 months. Ordered by historical affection. // has drifted away from. Tiered so a young library still gets a mix:
// $1 user_id. //
// tier 0 not played in the last 6 months (true rediscovery)
// tier 1 (only if tier 0 empty) not played in the last 30 days
//
// Ordered by historical affection. $1 user_id.
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) { func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
rows, err := q.db.Query(ctx, listRediscoverTracks, userID) rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
if err != nil { if err != nil {
+46 -14
View File
@@ -412,23 +412,55 @@ func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg Pic
} }
const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
SELECT t.id WITH recent AS (
FROM play_events pe SELECT t.id, COUNT(*) AS c, 0 AS tier
JOIN tracks t ON t.id = pe.track_id FROM play_events pe
WHERE pe.user_id = $1 JOIN tracks t ON t.id = pe.track_id
AND pe.started_at > now() - INTERVAL '7 days' WHERE pe.user_id = $1
AND pe.was_skipped = false AND pe.started_at > now() - INTERVAL '30 days'
GROUP BY t.id AND pe.was_skipped = false
ORDER BY COUNT(*) DESC, t.id GROUP BY t.id
),
alltime AS (
SELECT t.id, COUNT(*) AS c, 1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.was_skipped = false
GROUP BY t.id
),
liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
FROM general_likes gl
WHERE gl.user_id = $1
),
chosen AS (
SELECT id, c, tier FROM recent
UNION ALL
SELECT id, c, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent)
UNION ALL
SELECT id, c, tier FROM liked
WHERE NOT EXISTS (SELECT 1 FROM recent)
AND NOT EXISTS (SELECT 1 FROM alltime)
)
SELECT id
FROM chosen
ORDER BY tier, c DESC, id
LIMIT 5 LIMIT 5
` `
// For-You candidate seeds. Returns the user's top-5 most-played // For-You candidate seeds, tiered so For-You never silently vanishes:
// non-skipped tracks in the last 7 days; tie-break by track_id for //
// determinism. The Go-side picker (pickForYouSeedForDay) chooses one // tier 0 top non-skip plays in the last 30 days
// of the returned rows as today's seed via userIDHash so the // tier 1 (only if tier 0 empty) all-time top non-skip plays
// candidate pool rotates day-to-day while staying stable within a // tier 2 (only if tiers 0+1 empty) liked tracks
// day. //
// Returns up to 5 ids; tie-break by track_id for determinism. The
// Go-side picker (pickForYouSeedForDay) rotates one per day via
// userIDHash. Widened from a hard 7-day window, which made For-You
// disappear after a week of not listening and never recover on a
// self-hosted library with sparse history.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID) rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil { if err != nil {
+45 -20
View File
@@ -38,24 +38,46 @@ SELECT t.id, t.album_id, t.artist_id
-- name: ListRediscoverTracks :many -- name: ListRediscoverTracks :many
-- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but -- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
-- not in the last 6 months. Ordered by historical affection. -- has drifted away from. Tiered so a young library still gets a mix:
-- $1 user_id. -- tier 0 not played in the last 6 months (true rediscovery)
-- tier 1 (only if tier 0 empty) not played in the last 30 days
-- Ordered by historical affection. $1 user_id.
WITH stats AS ( WITH stats AS (
SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
FROM play_events FROM play_events pe
WHERE user_id = $1 AND was_skipped = false WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY track_id GROUP BY pe.track_id
),
deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
) )
SELECT t.id, t.album_id, t.artist_id SELECT id, album_id, artist_id
FROM tracks t FROM (
JOIN stats s ON s.track_id = t.id SELECT id, album_id, artist_id, c, tier FROM deep
WHERE s.c >= 5 UNION ALL
AND s.last_at <= now() - interval '6 months' SELECT id, album_id, artist_id, c, tier FROM shallow
AND NOT EXISTS ( WHERE NOT EXISTS (SELECT 1 FROM deep)
SELECT 1 FROM lidarr_quarantine q ) u
WHERE q.user_id = $1 AND q.track_id = t.id ORDER BY tier, c DESC, id
)
ORDER BY s.c DESC, t.id
LIMIT 200; LIMIT 200;
-- name: ListNewForYouTracks :many -- name: ListNewForYouTracks :many
@@ -87,16 +109,19 @@ SELECT t.id, t.album_id, t.artist_id
-- name: ListOnThisDayTracks :many -- name: ListOnThisDayTracks :many
-- #422 On This Day: tracks the user played around this calendar date -- #422 On This Day: tracks the user played around this calendar date
-- (±7 day-of-year) in the past, excluding the very recent (>60 days -- (±10 day-of-year) in the past, excluding the very recent (>30 days
-- ago) so it's nostalgic, not just "last week". Weighted by how much -- ago) so it's nostalgic, not just "last week". Weighted by how much
-- they were played in those windows. -- they were played in those windows. Floor relaxed from 60→30 days
-- and window ±7→±10 so it surfaces on a months-old library instead
-- of needing a full year of history; still skips cleanly (no rows →
-- no playlist) when there's no qualifying history yet.
-- $1 user_id, $2 date string. -- $1 user_id, $2 date string.
WITH windowed AS ( WITH windowed AS (
SELECT track_id, COUNT(*) AS c SELECT track_id, COUNT(*) AS c
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '60 days' AND started_at < now() - interval '30 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7 AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
+44 -14
View File
@@ -73,20 +73,50 @@ SELECT p.artist_id,
LIMIT 5; LIMIT 5;
-- name: PickTopPlayedTracksForUser :many -- name: PickTopPlayedTracksForUser :many
-- For-You candidate seeds. Returns the user's top-5 most-played -- For-You candidate seeds, tiered so For-You never silently vanishes:
-- non-skipped tracks in the last 7 days; tie-break by track_id for -- tier 0 top non-skip plays in the last 30 days
-- determinism. The Go-side picker (pickForYouSeedForDay) chooses one -- tier 1 (only if tier 0 empty) all-time top non-skip plays
-- of the returned rows as today's seed via userIDHash so the -- tier 2 (only if tiers 0+1 empty) liked tracks
-- candidate pool rotates day-to-day while staying stable within a -- Returns up to 5 ids; tie-break by track_id for determinism. The
-- day. -- Go-side picker (pickForYouSeedForDay) rotates one per day via
SELECT t.id -- userIDHash. Widened from a hard 7-day window, which made For-You
FROM play_events pe -- disappear after a week of not listening and never recover on a
JOIN tracks t ON t.id = pe.track_id -- self-hosted library with sparse history.
WHERE pe.user_id = $1 WITH recent AS (
AND pe.started_at > now() - INTERVAL '7 days' SELECT t.id, COUNT(*) AS c, 0 AS tier
AND pe.was_skipped = false FROM play_events pe
GROUP BY t.id JOIN tracks t ON t.id = pe.track_id
ORDER BY COUNT(*) DESC, t.id WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND pe.was_skipped = false
GROUP BY t.id
),
alltime AS (
SELECT t.id, COUNT(*) AS c, 1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.was_skipped = false
GROUP BY t.id
),
liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
FROM general_likes gl
WHERE gl.user_id = $1
),
chosen AS (
SELECT id, c, tier FROM recent
UNION ALL
SELECT id, c, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent)
UNION ALL
SELECT id, c, tier FROM liked
WHERE NOT EXISTS (SELECT 1 FROM recent)
AND NOT EXISTS (SELECT 1 FROM alltime)
)
SELECT id
FROM chosen
ORDER BY tier, c DESC, id
LIMIT 5; LIMIT 5;
-- name: PickTopPlayedTrackForArtistByUser :one -- name: PickTopPlayedTrackForArtistByUser :one
+18 -1
View File
@@ -289,6 +289,23 @@ var systemPlaylistRegistry = []systemPlaylistKind{
{Key: "first_listens", Singleton: true, Produce: produceFirstListens}, {Key: "first_listens", Singleton: true, Produce: produceFirstListens},
} }
// systemForYouSourceLimits is a deeper candidate pool than the radio
// default. On a self-hosted library without ListenBrainz similarity
// data the lb_similar / similar_artists sources contribute nothing,
// so the default (~130 raw, ~40 after dedup + diversity caps) can
// never fill For-You's 100-track head/tail. The raised random/tag
// fill keeps For-You ~100 deep regardless of LB enrichment; when LB
// data IS present the larger lb/similar K just makes it richer.
func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
return recommendation.CandidateSourceLimits{
LBSimilar: 80,
SimilarArtist: 80,
TagOverlap: 60,
LikesOverlap: 40,
RandomFill: 150,
}
}
// produceForYou: today's seed from the user's top-5 played tracks // produceForYou: today's seed from the user's top-5 played tracks
// (rotates daily via userIDHash), similarity candidate pool, head+ // (rotates daily via userIDHash), similarity candidate pool, head+
// tail composition. The base seed query failing is fatal; a // tail composition. The base seed query failing is fatal; a
@@ -311,7 +328,7 @@ func produceForYou(
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood 1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
zeroVec, zeroVec,
[]pgtype.UUID{forYouSeed}, []pgtype.UUID{forYouSeed},
recommendation.DefaultCandidateSourceLimits(), systemForYouSourceLimits(),
) )
if cerr != nil { if cerr != nil {
logger.Warn("system playlist: for-you candidates load failed; skipping", logger.Warn("system playlist: for-you candidates load failed; skipping",