diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go index aa1c41cb..b7ba85eb 100644 --- a/internal/db/dbq/system_playlists.sql.go +++ b/internal/db/dbq/system_playlists.sql.go @@ -290,9 +290,13 @@ func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlayli } const pickSeedArtists = `-- name: PickSeedArtists :many -WITH plays AS ( +WITH liked AS ( + SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1 +), +recent7 AS ( SELECT t.artist_id, - COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, + 0 AS tier FROM play_events pe JOIN tracks t ON t.id = pe.track_id WHERE pe.user_id = $1 @@ -300,27 +304,79 @@ WITH plays AS ( AND t.artist_id IS NOT NULL GROUP BY t.artist_id ), -liked AS ( - SELECT artist_id FROM general_likes_artists WHERE user_id = $1 +recent30 AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, + 1 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 t.artist_id IS NOT NULL + GROUP BY t.artist_id +), +alltime AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, + 2 AS tier + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 + AND t.artist_id IS NOT NULL + GROUP BY t.artist_id +), +likedonly AS ( + SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier + FROM liked l +), +chosen AS ( + SELECT artist_id, play_count, tier FROM recent7 + UNION ALL + SELECT artist_id, play_count, tier FROM recent30 + WHERE NOT EXISTS (SELECT 1 FROM recent7) + UNION ALL + SELECT artist_id, play_count, tier FROM alltime + WHERE NOT EXISTS (SELECT 1 FROM recent7) + AND NOT EXISTS (SELECT 1 FROM recent30) + UNION ALL + SELECT artist_id, play_count, tier FROM likedonly + WHERE NOT EXISTS (SELECT 1 FROM recent7) + AND NOT EXISTS (SELECT 1 FROM recent30) + AND NOT EXISTS (SELECT 1 FROM alltime) ) -SELECT p.artist_id, - (p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score - FROM plays p - LEFT JOIN liked l ON l.artist_id = p.artist_id - ORDER BY score DESC, p.artist_id +SELECT c.artist_id, + (c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score, + c.tier::int AS tier + FROM chosen c + LEFT JOIN liked l ON l.artist_id = c.artist_id + ORDER BY score DESC, c.artist_id LIMIT 5 ` type PickSeedArtistsRow struct { ArtistID pgtype.UUID Score int64 + Tier int32 } -// Top-5 most-engaged distinct artist candidates in the user's last 7 -// days. The Go-side picker (pickSeedArtistsForDay) shuffles these -// daily-deterministically and takes the first 3 so the set of -// "Songs like X" mixes rotates day-to-day. -// Score = unskipped-play count + 5 if user has liked the artist. +// Top-5 most-engaged distinct artist candidates, tiered so the +// "Songs like X" mixes never silently vanish (#1255): the old hard +// 7-day window emptied the seed pool after a quiet week, and the +// daily atomic-replace build then deleted every existing mix until +// the user played something again. Same fallback shape as +// PickTopPlayedTracksForUser (For You's seeds): +// +// tier 0 engagement in the last 7 days +// tier 1 (only if tier 0 empty) last 30 days +// tier 2 (only if tiers 0+1 empty) all-time +// tier 3 (only if 0-2 empty) liked artists with no play history +// +// All returned rows share one tier; produceSeedMixes maps it onto the +// rule-#131 pick-kind ladder and stamps the built tracks, so metrics +// can compare mixes seeded from fresh vs stale engagement. +// Score = unskipped-play count + 5 if user has liked the artist. The +// Go-side picker (pickSeedArtistsForDay) shuffles the 5 +// daily-deterministically and takes 3 so the mix set rotates. func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) { rows, err := q.db.Query(ctx, pickSeedArtists, userID) if err != nil { @@ -330,7 +386,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi var items []PickSeedArtistsRow for rows.Next() { var i PickSeedArtistsRow - if err := rows.Scan(&i.ArtistID, &i.Score); err != nil { + if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil { return nil, err } items = append(items, i) diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql index 305c8b75..63245abb 100644 --- a/internal/db/queries/system_playlists.sql +++ b/internal/db/queries/system_playlists.sql @@ -47,14 +47,29 @@ UPDATE system_playlist_runs UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true; -- name: PickSeedArtists :many --- Top-5 most-engaged distinct artist candidates in the user's last 7 --- days. The Go-side picker (pickSeedArtistsForDay) shuffles these --- daily-deterministically and takes the first 3 so the set of --- "Songs like X" mixes rotates day-to-day. --- Score = unskipped-play count + 5 if user has liked the artist. -WITH plays AS ( +-- Top-5 most-engaged distinct artist candidates, tiered so the +-- "Songs like X" mixes never silently vanish (#1255): the old hard +-- 7-day window emptied the seed pool after a quiet week, and the +-- daily atomic-replace build then deleted every existing mix until +-- the user played something again. Same fallback shape as +-- PickTopPlayedTracksForUser (For You's seeds): +-- tier 0 engagement in the last 7 days +-- tier 1 (only if tier 0 empty) last 30 days +-- tier 2 (only if tiers 0+1 empty) all-time +-- tier 3 (only if 0-2 empty) liked artists with no play history +-- All returned rows share one tier; produceSeedMixes maps it onto the +-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics +-- can compare mixes seeded from fresh vs stale engagement. +-- Score = unskipped-play count + 5 if user has liked the artist. The +-- Go-side picker (pickSeedArtistsForDay) shuffles the 5 +-- daily-deterministically and takes 3 so the mix set rotates. +WITH liked AS ( + SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1 +), +recent7 AS ( SELECT t.artist_id, - COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, + 0 AS tier FROM play_events pe JOIN tracks t ON t.id = pe.track_id WHERE pe.user_id = $1 @@ -62,14 +77,52 @@ WITH plays AS ( AND t.artist_id IS NOT NULL GROUP BY t.artist_id ), -liked AS ( - SELECT artist_id FROM general_likes_artists WHERE user_id = $1 +recent30 AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, + 1 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 t.artist_id IS NOT NULL + GROUP BY t.artist_id +), +alltime AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, + 2 AS tier + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 + AND t.artist_id IS NOT NULL + GROUP BY t.artist_id +), +likedonly AS ( + SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier + FROM liked l +), +chosen AS ( + SELECT artist_id, play_count, tier FROM recent7 + UNION ALL + SELECT artist_id, play_count, tier FROM recent30 + WHERE NOT EXISTS (SELECT 1 FROM recent7) + UNION ALL + SELECT artist_id, play_count, tier FROM alltime + WHERE NOT EXISTS (SELECT 1 FROM recent7) + AND NOT EXISTS (SELECT 1 FROM recent30) + UNION ALL + SELECT artist_id, play_count, tier FROM likedonly + WHERE NOT EXISTS (SELECT 1 FROM recent7) + AND NOT EXISTS (SELECT 1 FROM recent30) + AND NOT EXISTS (SELECT 1 FROM alltime) ) -SELECT p.artist_id, - (p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score - FROM plays p - LEFT JOIN liked l ON l.artist_id = p.artist_id - ORDER BY score DESC, p.artist_id +SELECT c.artist_id, + (c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score, + c.tier::int AS tier + FROM chosen c + LEFT JOIN liked l ON l.artist_id = c.artist_id + ORDER BY score DESC, c.artist_id LIMIT 5; -- name: PickTopPlayedTracksForUser :many diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go index c3e5ec43..90aa35b9 100644 --- a/internal/playlists/seed_selection_test.go +++ b/internal/playlists/seed_selection_test.go @@ -46,6 +46,26 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) { } } +func TestPickKindForSeedTier(t *testing.T) { + // The seed query's fallback tiers map onto the rule-#131 ladder: + // fresh 7-day engagement is the exact desire, everything past the + // 30-day step-back collapses into the far tier (#1255). + cases := []struct { + tier int32 + want string + }{ + {0, pickKindTier1}, + {1, pickKindTier2}, + {2, pickKindTier3}, + {3, pickKindTier3}, + } + for _, c := range cases { + if got := pickKindForSeedTier(c.tier); got != c.want { + t.Errorf("pickKindForSeedTier(%d) = %q, want %q", c.tier, got, c.want) + } + } +} + // userIDHash is the per-user, per-day hash that drives the daily- // determinism RNGs. Same family as tieBreakHash, just keyed on user // ID instead of track ID. diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 969f0584..437498c0 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -147,8 +147,28 @@ const ( pickKindDormant = "dormant" pickKindCrossUser = "cross_user" pickKindRandom = "random" + pickKindTier1 = "tier1" + pickKindTier2 = "tier2" + pickKindTier3 = "tier3" ) +// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier +// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the +// mix's exact desire (tier1), the 30-day window steps back a little +// (tier2), and all-time / liked-only seeds are the far fallback +// (tier3). Stamped mix-wide — seed staleness is a property of the +// whole build, and it's what the metrics breakdown attributes skips to. +func pickKindForSeedTier(tier int32) string { + switch tier { + case 0: + return pickKindTier1 + case 1: + return pickKindTier2 + default: + return pickKindTier3 + } +} + const systemMixLength = 25 // systemMixWeights are the fixed scoring weights used by the cron worker. @@ -389,8 +409,11 @@ func produceForYou( } // produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed -// artists rotate daily-deterministically. The base seed-artist -// query failing is fatal; per-artist failures are logged + skipped. +// artists rotate daily-deterministically; the seed query falls back +// through widening engagement windows (#1255) and every returned row +// shares the winning tier, stamped onto the built tracks as their +// pick_kind. The base seed-artist query failing is fatal; per-artist +// failures are logged + skipped. func produceSeedMixes( ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string, now time.Time, @@ -399,6 +422,10 @@ func produceSeedMixes( if err != nil { return nil, fmt.Errorf("pick seed artists: %w", err) } + seedTierKind := "" + if len(seedRows) > 0 { + seedTierKind = pickKindForSeedTier(seedRows[0].Tier) + } seedRowsLocal := make([]seedArtistRow, 0, len(seedRows)) for _, r := range seedRows { seedRowsLocal = append(seedRowsLocal, seedArtistRow{ @@ -443,6 +470,9 @@ func produceSeedMixes( if len(tracks) == 0 { continue } + for i := range tracks { + tracks[i].PickKind = seedTierKind + } out = append(out, builtPlaylist{ Name: fmt.Sprintf("Songs like %s", artistRow.Name), Variant: "songs_like_artist", diff --git a/internal/playlists/system_test.go b/internal/playlists/system_test.go index 2e5fb5cf..e0757758 100644 --- a/internal/playlists/system_test.go +++ b/internal/playlists/system_test.go @@ -135,6 +135,59 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) { } } +func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) { + // #1255: the seed-artist query's old hard 7-day window emptied the + // pool after a quiet week, and the atomic-replace build then deleted + // every "Songs like X" mix. With the tiered fallback, plays that are + // ~20 days old (outside 7d, inside 30d) must still seed the mixes — + // and the built tracks carry the tier2 stamp so metrics can compare + // stale-seeded mixes against fresh ones. + pool := newPool(t) + logger := discardLogger() + u := seedUser(t, pool, "stale1") + old := time.Now().UTC().Add(-20 * 24 * time.Hour) + for a := 0; a < 4; a++ { + for k := 0; k < 3; k++ { + tk := seedTrack(t, pool, + "stale1-track-"+string(rune('A'+a))+string(rune('0'+k)), + "stale1-artist-"+string(rune('A'+a))) + for p := 0; p < 3; p++ { + seedPlayEvent(t, pool, u.ID, tk.ID, + old.Add(-time.Duration(a*10+k+p)*time.Hour), false) + } + } + } + + if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil { + t.Fatalf("build: %v", err) + } + + rows, err := pool.Query(context.Background(), ` + SELECT DISTINCT COALESCE(pt.pick_kind, '') + FROM playlist_tracks pt + JOIN playlists p ON p.id = pt.playlist_id + WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist' + `, u.ID) + if err != nil { + t.Fatalf("query pick_kinds: %v", err) + } + defer rows.Close() + var kinds []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + t.Fatalf("scan: %v", err) + } + kinds = append(kinds, k) + } + if len(kinds) == 0 { + t.Fatal("expected songs_like_artist tracks from the 30-day fallback tier; got none (the vanish bug)") + } + if len(kinds) != 1 || kinds[0] != "tier2" { + t.Errorf("pick_kinds = %v, want exactly [tier2] (30-day fallback seeds)", kinds) + } +} + func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) { pool := newPool(t) logger := discardLogger()