From 2e7b81fdfe001c551d7725f5ed40451e9221c9cf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 16 May 2026 12:48:20 -0400 Subject: [PATCH] fix(playlists): robust For-You seed + deep fill; young-library mix fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/db/dbq/system_mixes.sql.go | 67 +++++++++++++++++------- internal/db/dbq/system_playlists.sql.go | 60 ++++++++++++++++----- internal/db/queries/system_mixes.sql | 65 ++++++++++++++++------- internal/db/queries/system_playlists.sql | 58 +++++++++++++++----- internal/playlists/system.go | 19 ++++++- 5 files changed, 200 insertions(+), 69 deletions(-) diff --git a/internal/db/dbq/system_mixes.sql.go b/internal/db/dbq/system_mixes.sql.go index 5557b28b..3ea0e5b9 100644 --- a/internal/db/dbq/system_mixes.sql.go +++ b/internal/db/dbq/system_mixes.sql.go @@ -202,8 +202,8 @@ WITH windowed AS ( SELECT track_id, COUNT(*) AS c FROM play_events WHERE user_id = $1 AND was_skipped = false - AND started_at < now() - interval '60 days' - AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7 + AND started_at < now() - interval '30 days' + AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10 GROUP BY track_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 -// (±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 -// 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. func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) { 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 WITH stats AS ( - SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at - FROM play_events - WHERE user_id = $1 AND was_skipped = false - GROUP BY track_id + SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at + FROM play_events pe + WHERE pe.user_id = $1 AND pe.was_skipped = false + 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 - 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 - ) - ORDER BY s.c DESC, t.id +SELECT id, album_id, artist_id + FROM ( + SELECT id, album_id, artist_id, c, tier FROM deep + UNION ALL + SELECT id, album_id, artist_id, c, tier FROM shallow + WHERE NOT EXISTS (SELECT 1 FROM deep) + ) u + ORDER BY tier, c DESC, id LIMIT 200 ` @@ -280,8 +303,12 @@ type ListRediscoverTracksRow struct { } // #420 Rediscover: tracks the user played a lot (>=5 non-skip) but -// not in the last 6 months. Ordered by historical affection. -// $1 user_id. +// has drifted away from. Tiered so a young library still gets a mix: +// +// 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) { rows, err := q.db.Query(ctx, listRediscoverTracks, userID) if err != nil { diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go index 1c2a3d4f..aa1c41cb 100644 --- a/internal/db/dbq/system_playlists.sql.go +++ b/internal/db/dbq/system_playlists.sql.go @@ -412,23 +412,55 @@ func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg Pic } const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many -SELECT t.id - FROM play_events pe - JOIN tracks t ON t.id = pe.track_id - WHERE pe.user_id = $1 - AND pe.started_at > now() - INTERVAL '7 days' - AND pe.was_skipped = false - GROUP BY t.id - ORDER BY COUNT(*) DESC, t.id +WITH recent AS ( + SELECT t.id, COUNT(*) AS c, 0 AS tier + FROM play_events pe + JOIN tracks t ON t.id = pe.track_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 ` -// For-You candidate seeds. Returns the user's top-5 most-played -// non-skipped tracks in the last 7 days; tie-break by track_id for -// determinism. The Go-side picker (pickForYouSeedForDay) chooses one -// of the returned rows as today's seed via userIDHash so the -// candidate pool rotates day-to-day while staying stable within a -// day. +// For-You candidate seeds, tiered so For-You never silently vanishes: +// +// tier 0 top non-skip plays in the last 30 days +// tier 1 (only if tier 0 empty) all-time top non-skip plays +// tier 2 (only if tiers 0+1 empty) liked tracks +// +// 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) { rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID) if err != nil { diff --git a/internal/db/queries/system_mixes.sql b/internal/db/queries/system_mixes.sql index 62074620..b483fad0 100644 --- a/internal/db/queries/system_mixes.sql +++ b/internal/db/queries/system_mixes.sql @@ -38,24 +38,46 @@ SELECT t.id, t.album_id, t.artist_id -- name: ListRediscoverTracks :many -- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but --- not in the last 6 months. Ordered by historical affection. --- $1 user_id. +-- has drifted away from. Tiered so a young library still gets a mix: +-- 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 ( - SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at - FROM play_events - WHERE user_id = $1 AND was_skipped = false - GROUP BY track_id + SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at + FROM play_events pe + WHERE pe.user_id = $1 AND pe.was_skipped = false + 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 - 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 - ) - ORDER BY s.c DESC, t.id +SELECT id, album_id, artist_id + FROM ( + SELECT id, album_id, artist_id, c, tier FROM deep + UNION ALL + SELECT id, album_id, artist_id, c, tier FROM shallow + WHERE NOT EXISTS (SELECT 1 FROM deep) + ) u + ORDER BY tier, c DESC, id LIMIT 200; -- name: ListNewForYouTracks :many @@ -87,16 +109,19 @@ SELECT t.id, t.album_id, t.artist_id -- name: ListOnThisDayTracks :many -- #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 --- 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. WITH windowed AS ( SELECT track_id, COUNT(*) AS c FROM play_events WHERE user_id = $1 AND was_skipped = false - AND started_at < now() - interval '60 days' - AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7 + AND started_at < now() - interval '30 days' + AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10 GROUP BY track_id ) SELECT t.id, t.album_id, t.artist_id diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql index 8064dcf9..305c8b75 100644 --- a/internal/db/queries/system_playlists.sql +++ b/internal/db/queries/system_playlists.sql @@ -73,20 +73,50 @@ SELECT p.artist_id, LIMIT 5; -- name: PickTopPlayedTracksForUser :many --- For-You candidate seeds. Returns the user's top-5 most-played --- non-skipped tracks in the last 7 days; tie-break by track_id for --- determinism. The Go-side picker (pickForYouSeedForDay) chooses one --- of the returned rows as today's seed via userIDHash so the --- candidate pool rotates day-to-day while staying stable within a --- day. -SELECT t.id - FROM play_events pe - JOIN tracks t ON t.id = pe.track_id - WHERE pe.user_id = $1 - AND pe.started_at > now() - INTERVAL '7 days' - AND pe.was_skipped = false - GROUP BY t.id - ORDER BY COUNT(*) DESC, t.id +-- For-You candidate seeds, tiered so For-You never silently vanishes: +-- tier 0 top non-skip plays in the last 30 days +-- tier 1 (only if tier 0 empty) all-time top non-skip plays +-- tier 2 (only if tiers 0+1 empty) liked tracks +-- 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. +WITH recent AS ( + SELECT t.id, COUNT(*) AS c, 0 AS tier + FROM play_events pe + JOIN tracks t ON t.id = pe.track_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; -- name: PickTopPlayedTrackForArtistByUser :one diff --git a/internal/playlists/system.go b/internal/playlists/system.go index c0610dad..fc55eed6 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -289,6 +289,23 @@ var systemPlaylistRegistry = []systemPlaylistKind{ {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 // (rotates daily via userIDHash), similarity candidate pool, head+ // 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 zeroVec, []pgtype.UUID{forYouSeed}, - recommendation.DefaultCandidateSourceLimits(), + systemForYouSourceLimits(), ) if cerr != nil { logger.Warn("system playlist: for-you candidates load failed; skipping",