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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user