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