The Discover request surface was the one recommendation surface still on its M5c implementation from early May. #796's taste profile, #1488's taste_unheard bucket and #1490's folksonomy enrichment all modernized in-library surfaces; this one was never in scope for any of them, so it still projected raw likes + plays through artist_similarity_unmatched. Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed over every play of the artist. It is unbounded, and contribution is signal × similarity — so a handful of heavily-played artists monopolize all twelve slots, and their share GROWS the more the user listens. The surface entrenched harder the better it knew you, which is exactly backwards and matches the reported "goes stale once it has a strong signal of your taste". It also counted every play_event with no was_skipped filter, so skipping an artist repeatedly INCREASED its signal and pushed more of its neighbours at the user. ListMostPlayedTracksForUser and the taste engine both filter skips; this query was the odd one out. Seeds now come from taste_profile_artists.weight, which the taste engine has already engagement-graded, time-decayed and signed — an artist the user drifted away from stops contributing instead of accumulating forever, and can even contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is the profile, tier 2 is likes + completed plays for a user who has no profile rows yet (new account, or before the first daily recompute), so the surface never empties. The old unfiltered-play signal is gone, not kept behind a toggle. The signal is also log-damped, so one artist cannot take every slot even when its weight dwarfs the rest. $2 stays wired to the tier-2 decay: it is genuinely still used there, and dropping the parameter would have changed the generated signature. sqlc's image is not on this workstation and the change preserves the query signature exactly — same three params, same seven columns — so only the embedded SQL const moves. Both copies are edited and verified byte-identical rather than pulling a container onto the operator's machine; a malformed query fails the integration lane loudly, which is the real check either way. Four integration tests cover what changed: a taste weight alone seeds with no like or play; tier 2 does not run alongside tier 1; a non-positive weight never seeds (guarded by a second positive row, so an empty tier 1 can't make it pass for the wrong reason); and skip-only history seeds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
583 lines
23 KiB
SQL
583 lines
23 KiB
SQL
-- name: LoadRadioCandidates :many
|
||
-- Returns all tracks except the seed and any played by the user within
|
||
-- the last $3 hours, joined with the stats needed for scoring:
|
||
-- is_liked — boolean from general_likes for this user
|
||
-- last_played_at — max(play_events.started_at) for this user/track
|
||
-- play_count — count of play_events for this user/track
|
||
-- skip_count — play_events where was_skipped=true
|
||
SELECT
|
||
sqlc.embed(t),
|
||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||
pe.last_played_at::timestamptz AS last_played_at,
|
||
pe.play_count,
|
||
pe.skip_count,
|
||
al.release_date AS release_date
|
||
FROM tracks t
|
||
JOIN albums al ON al.id = t.album_id
|
||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||
LEFT JOIN LATERAL (
|
||
SELECT
|
||
max(started_at) AS last_played_at,
|
||
count(*) AS play_count,
|
||
count(*) FILTER (WHERE was_skipped) AS skip_count
|
||
FROM play_events
|
||
WHERE user_id = $1 AND track_id = t.id
|
||
) pe ON true
|
||
WHERE t.id <> $2
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM play_events
|
||
WHERE user_id = $1 AND track_id = t.id
|
||
AND started_at > now() - $3 * interval '1 hour'
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||
);
|
||
|
||
-- name: LoadRadioCandidatesV2 :many
|
||
-- M4c: similarity-driven candidate pool. 6-way UNION:
|
||
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||
-- enter the pool even when the similarity/random arms miss them; scored
|
||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||
-- $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||
-- instance with the seed's artist; source='user_cooccurrence').
|
||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||
|
||
WITH
|
||
seed_artist AS (
|
||
SELECT artist_id
|
||
FROM tracks
|
||
WHERE tracks.id = $2
|
||
),
|
||
seed_tags AS (
|
||
SELECT trim(g) AS tag
|
||
FROM tracks t
|
||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(tag) ON true
|
||
WHERE t.id = $2 AND trim(g) <> ''
|
||
),
|
||
excluded_ids AS (
|
||
SELECT unnest($4::uuid[]) AS id
|
||
UNION ALL
|
||
SELECT pe.track_id AS id
|
||
FROM play_events pe
|
||
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
|
||
UNION ALL
|
||
SELECT q.track_id AS id
|
||
FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1
|
||
),
|
||
lb_similar AS (
|
||
SELECT ts.track_b_id AS track_id, ts.score AS sim_score
|
||
FROM track_similarity ts
|
||
WHERE ts.track_a_id = $2
|
||
AND ts.source = 'listenbrainz'
|
||
AND ts.track_b_id NOT IN (SELECT id FROM excluded_ids)
|
||
ORDER BY ts.score DESC
|
||
LIMIT $5
|
||
),
|
||
similar_artists AS (
|
||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||
FROM artist_similarity asim
|
||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||
WHERE asim.source = 'listenbrainz'
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
ORDER BY asim.score DESC, random()
|
||
LIMIT $6
|
||
),
|
||
tag_overlap AS (
|
||
SELECT t.id AS track_id,
|
||
(count(DISTINCT trim(g))::float8
|
||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||
FROM tracks t
|
||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
GROUP BY t.id
|
||
HAVING count(DISTINCT trim(g_split.g)) > 0
|
||
ORDER BY sim_score DESC
|
||
LIMIT $7
|
||
),
|
||
likes_overlap AS (
|
||
SELECT gl.track_id, 0.6::float8 AS sim_score
|
||
FROM general_likes gl
|
||
WHERE gl.user_id = $1
|
||
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
||
AND EXISTS (
|
||
SELECT 1 FROM tracks t
|
||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_overlap(g) ON true
|
||
WHERE t.id = gl.track_id
|
||
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
|
||
)
|
||
ORDER BY random()
|
||
LIMIT $8
|
||
),
|
||
taste_overlap AS (
|
||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||
FROM taste_profile_artists tpa
|
||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||
WHERE tpa.user_id = $1
|
||
AND tpa.weight > 0
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
ORDER BY tpa.weight DESC, t.id
|
||
LIMIT $10
|
||
),
|
||
coplay_artists AS (
|
||
-- Household co-play (#1533): tracks by artists co-played across the
|
||
-- instance with the seed's artist (source='user_cooccurrence', built by
|
||
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||
-- similar_artists since it's artist-level.
|
||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||
FROM artist_similarity asim
|
||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||
WHERE asim.source = 'user_cooccurrence'
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
ORDER BY asim.score DESC, random()
|
||
LIMIT $11
|
||
),
|
||
random_fill AS (
|
||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||
FROM tracks t
|
||
WHERE t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
AND t.id NOT IN (
|
||
SELECT track_id FROM lb_similar
|
||
UNION SELECT track_id FROM similar_artists
|
||
UNION SELECT track_id FROM tag_overlap
|
||
UNION SELECT track_id FROM likes_overlap
|
||
UNION SELECT track_id FROM taste_overlap
|
||
UNION SELECT track_id FROM coplay_artists
|
||
)
|
||
ORDER BY random()
|
||
LIMIT $9
|
||
)
|
||
SELECT
|
||
sqlc.embed(t),
|
||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||
pe.last_played_at::timestamptz AS last_played_at,
|
||
pe.play_count,
|
||
pe.skip_count,
|
||
al.release_date AS release_date,
|
||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||
FROM (
|
||
SELECT track_id, sim_score FROM lb_similar
|
||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||
) u
|
||
JOIN tracks t ON t.id = u.track_id
|
||
JOIN albums al ON al.id = t.album_id
|
||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||
LEFT JOIN LATERAL (
|
||
SELECT max(started_at) AS last_played_at,
|
||
count(*) AS play_count,
|
||
count(*) FILTER (WHERE was_skipped) AS skip_count
|
||
FROM play_events
|
||
WHERE user_id = $1 AND track_id = t.id
|
||
) pe ON true
|
||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||
al.release_date;
|
||
|
||
-- name: ListArtistContextPlayCountsForUser :many
|
||
-- Per-artist completed-play counts split by whether each play falls in the
|
||
-- CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||
-- daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||
-- $2 (the current device) is '', the device dimension is ignored (identical to
|
||
-- the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||
-- when $2 is set (radio), a play only counts in the cell if its device_class
|
||
-- matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||
-- window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||
-- afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||
WITH tz AS (
|
||
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||
FROM users u WHERE u.id = $1
|
||
),
|
||
now_cell AS (
|
||
SELECT
|
||
CASE
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
|
||
ELSE 3
|
||
END AS daypart,
|
||
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||
FROM tz
|
||
),
|
||
plays AS (
|
||
SELECT t.artist_id,
|
||
pe.device_class,
|
||
CASE
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
|
||
ELSE 3
|
||
END AS daypart,
|
||
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||
FROM play_events pe
|
||
JOIN tracks t ON t.id = pe.track_id
|
||
CROSS JOIN tz
|
||
WHERE pe.user_id = $1
|
||
AND pe.was_skipped = false
|
||
AND pe.started_at > now() - interval '365 days'
|
||
)
|
||
SELECT p.artist_id,
|
||
count(*) AS total_plays,
|
||
count(*) FILTER (
|
||
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||
AND ($2::text = '' OR p.device_class = $2::text)
|
||
) AS cell_plays
|
||
FROM plays p
|
||
GROUP BY p.artist_id;
|
||
|
||
-- name: GetLatestPlayDeviceClassForUser :one
|
||
-- The device_class of the user's most recent play (#1551), used as the
|
||
-- "current device" for radio context conditioning. NULL when the latest play
|
||
-- predates device capture or came from a client that didn't send one.
|
||
SELECT device_class
|
||
FROM play_events
|
||
WHERE user_id = $1
|
||
ORDER BY started_at DESC
|
||
LIMIT 1;
|
||
|
||
-- name: SuggestArtistsForUser :many
|
||
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||
--
|
||
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||
-- from stops contributing instead of accumulating forever.
|
||
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||
-- (new account, or before the first daily recompute).
|
||
--
|
||
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||
--
|
||
-- Candidates already in the library, or already requested and not terminal,
|
||
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||
WITH artist_plays AS (
|
||
-- Completed plays only. The previous seed query counted every play_event,
|
||
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||
-- its neighbours at the user (issue #2367 mechanism 3).
|
||
SELECT t.artist_id, count(*)::bigint AS play_count
|
||
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.artist_id
|
||
),
|
||
profile_seeds AS (
|
||
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||
FROM taste_profile_artists tpa
|
||
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||
),
|
||
fallback_seeds AS (
|
||
SELECT a.id AS artist_id,
|
||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||
AS raw_signal
|
||
FROM artists a
|
||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||
LEFT JOIN play_events pe
|
||
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||
GROUP BY a.id, gla.artist_id
|
||
),
|
||
seeds AS (
|
||
SELECT s.artist_id,
|
||
ln(1.0 + s.raw_signal) AS signal,
|
||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||
FROM (
|
||
SELECT artist_id, raw_signal FROM profile_seeds
|
||
UNION ALL
|
||
SELECT artist_id, raw_signal FROM fallback_seeds
|
||
) s
|
||
LEFT JOIN general_likes_artists gla
|
||
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||
WHERE s.raw_signal > 0
|
||
),
|
||
contributions AS (
|
||
SELECT u.candidate_mbid,
|
||
u.candidate_name,
|
||
seeds.artist_id AS seed_id,
|
||
seeds.is_liked,
|
||
seeds.play_count,
|
||
seeds.signal * u.score AS contribution
|
||
FROM artist_similarity_unmatched u
|
||
JOIN seeds ON seeds.artist_id = u.seed_artist_id
|
||
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_requests r
|
||
WHERE r.user_id = $1
|
||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||
AND r.status NOT IN ('rejected', 'failed')
|
||
)
|
||
)
|
||
SELECT candidate_mbid,
|
||
candidate_name,
|
||
SUM(contribution)::float8 AS total_score,
|
||
((array_agg(seed_id ORDER BY contribution DESC))[1:3])::uuid[] AS top_seed_ids,
|
||
((array_agg(contribution ORDER BY contribution DESC))[1:3])::float8[] AS top_contributions,
|
||
((array_agg(is_liked ORDER BY contribution DESC))[1:3])::boolean[] AS top_is_liked,
|
||
((array_agg(play_count ORDER BY contribution DESC))[1:3])::bigint[] AS top_play_counts
|
||
FROM contributions
|
||
GROUP BY candidate_mbid, candidate_name
|
||
ORDER BY total_score DESC
|
||
LIMIT $3;
|
||
|
||
-- name: ListMostPlayedTracksForUser :many
|
||
-- M6a: top-N tracks by completed-play count for the user. was_skipped
|
||
-- excludes skips so a user spamming next doesn't fabricate a top track.
|
||
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||
--
|
||
-- Aggregate play_events first (uses play_events_user_track_idx) and
|
||
-- join through tracks/albums/artists only for the survivors. Earlier
|
||
-- shape did the join across every play_event row before grouping —
|
||
-- O(plays) instead of O(distinct tracks).
|
||
WITH plays AS (
|
||
SELECT track_id, count(*) AS cnt
|
||
FROM play_events
|
||
WHERE user_id = $1 AND was_skipped = false
|
||
GROUP BY track_id
|
||
)
|
||
SELECT sqlc.embed(t),
|
||
albums.title AS album_title,
|
||
artists.name AS artist_name
|
||
FROM plays p
|
||
JOIN tracks t ON t.id = p.track_id
|
||
JOIN albums ON albums.id = t.album_id
|
||
JOIN artists ON artists.id = t.artist_id
|
||
WHERE NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||
)
|
||
ORDER BY p.cnt DESC, t.id
|
||
LIMIT $2;
|
||
|
||
-- name: ListMostPlayedTracksForArtist :many
|
||
-- Top tracks for one artist by this user's completed-play count (skips
|
||
-- excluded, quarantine filtered). Same projection as
|
||
-- ListMostPlayedTracksForUser plus an artist_id filter, so the handler
|
||
-- reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
|
||
WITH plays AS (
|
||
SELECT track_id, count(*) AS cnt
|
||
FROM play_events
|
||
WHERE user_id = sqlc.arg(user_id) AND was_skipped = false
|
||
GROUP BY track_id
|
||
)
|
||
SELECT sqlc.embed(t),
|
||
albums.title AS album_title,
|
||
artists.name AS artist_name
|
||
FROM plays p
|
||
JOIN tracks t ON t.id = p.track_id
|
||
JOIN albums ON albums.id = t.album_id
|
||
JOIN artists ON artists.id = t.artist_id
|
||
WHERE t.artist_id = sqlc.arg(artist_id)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||
)
|
||
ORDER BY p.cnt DESC, t.id
|
||
LIMIT sqlc.arg(result_limit);
|
||
|
||
-- name: ListLastPlayedArtistsForUser :many
|
||
-- M6a: artists ranked by max(play_events.started_at) for the user, with
|
||
-- a derived cover_album_id via a representative-album lateral join (most
|
||
-- recent album that has cover_art_path set). album_count joined for the
|
||
-- ArtistRef wire shape.
|
||
--
|
||
-- Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||
-- subquery per artist — O(total_artists) plan even for users with a
|
||
-- handful of plays. New shape aggregates the user's plays by artist
|
||
-- via the play_events → tracks join up front (uses
|
||
-- play_events_user_track_idx + tracks pkey lookups), then attaches
|
||
-- the artist row and lateral cover/count subqueries only for the
|
||
-- artists that actually appear. Distinct-artists set is small for a
|
||
-- typical user, so cost is bounded by play history not library size.
|
||
WITH user_plays AS (
|
||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||
FROM play_events pe
|
||
JOIN tracks t ON t.id = pe.track_id
|
||
WHERE pe.user_id = $1
|
||
GROUP BY t.artist_id
|
||
)
|
||
SELECT sqlc.embed(a),
|
||
cov.id AS cover_album_id,
|
||
cnt.album_count::bigint AS album_count,
|
||
up.last_started::timestamptz AS last_played_at
|
||
FROM user_plays up
|
||
JOIN artists a ON a.id = up.artist_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT id FROM albums
|
||
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
||
ORDER BY created_at DESC LIMIT 1
|
||
) cov ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT count(*) AS album_count
|
||
FROM albums WHERE artist_id = a.id
|
||
) cnt ON true
|
||
ORDER BY up.last_started DESC, a.id
|
||
LIMIT $2;
|
||
|
||
-- name: ListRediscoverAlbumsForUser :many
|
||
-- Albums eligible for Rediscover via either signal:
|
||
-- (a) explicit album-like (general_likes_albums) liked >30 days ago, OR
|
||
-- (b) >=2 liked tracks from the album (general_likes), where the
|
||
-- earliest such track-like is >30 days ago.
|
||
-- AND not played in the last 14 days. The HAVING epoch sentinel keeps
|
||
-- albums with NO play history eligible (treated as last-played in 1970).
|
||
-- Ordering is daily-stable random (md5 of album+user+date hashes),
|
||
-- so the row rotates at midnight local without re-running per request.
|
||
-- Final cap + diversity + cross-section dedup land in the Go layer
|
||
-- (internal/recommendation/home.go).
|
||
WITH liked_signal AS (
|
||
-- Path (a): explicit album-likes >30 days old.
|
||
SELECT al.id AS album_id, gla.liked_at AS like_at
|
||
FROM general_likes_albums gla
|
||
JOIN albums al ON al.id = gla.album_id
|
||
WHERE gla.user_id = $1
|
||
AND gla.liked_at < now() - interval '30 days'
|
||
UNION ALL
|
||
-- Path (b): >=2 liked tracks per album, earliest like >30 days old.
|
||
SELECT t.album_id, MIN(gl.liked_at) AS like_at
|
||
FROM general_likes gl
|
||
JOIN tracks t ON t.id = gl.track_id
|
||
WHERE gl.user_id = $1
|
||
GROUP BY t.album_id
|
||
HAVING COUNT(*) >= 2
|
||
AND MIN(gl.liked_at) < now() - interval '30 days'
|
||
),
|
||
eligible AS (
|
||
SELECT ls.album_id,
|
||
MIN(ls.like_at) AS like_at,
|
||
max(pe.started_at) AS last_played
|
||
FROM liked_signal ls
|
||
LEFT JOIN tracks t ON t.album_id = ls.album_id
|
||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||
GROUP BY ls.album_id
|
||
HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz)
|
||
< now() - interval '14 days'
|
||
)
|
||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||
FROM eligible e
|
||
JOIN albums ON albums.id = e.album_id
|
||
JOIN artists ON artists.id = albums.artist_id
|
||
ORDER BY md5(albums.id::text || current_date::text)
|
||
LIMIT $2;
|
||
|
||
-- name: ListRediscoverAlbumsFallbackForUser :many
|
||
-- Daily-stable random sample of liked albums for the user. The Go
|
||
-- service uses this to top up the rediscover row when the primary
|
||
-- eligibility query returns fewer than the inner limit. Same daily
|
||
-- hash ordering as the primary so the fallback rows stay stable
|
||
-- through the day instead of reshuffling on every refresh. Hash
|
||
-- omits user_id so sqlc can keep the user_id parameter UUID-typed
|
||
-- (a $1::text cast would force the param to string); eligibility
|
||
-- already differs per user, so output sets differ regardless.
|
||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||
FROM general_likes_albums gla
|
||
JOIN albums ON albums.id = gla.album_id
|
||
JOIN artists ON artists.id = albums.artist_id
|
||
WHERE gla.user_id = $1
|
||
ORDER BY md5(albums.id::text || current_date::text)
|
||
LIMIT $2;
|
||
|
||
-- name: ListRediscoverArtistsForUser :many
|
||
-- Artists eligible for Rediscover via either signal:
|
||
-- (a) explicit artist-like (general_likes_artists) liked >30 days ago, OR
|
||
-- (b) >=3 liked tracks from the artist (general_likes), where the
|
||
-- earliest such track-like is >30 days ago.
|
||
-- AND none of their tracks played in the last 14 days. Higher track
|
||
-- threshold than the album path (3 vs 2) because artists span many
|
||
-- releases, so the bar is higher to avoid surfacing one-hit-wonder
|
||
-- affinities. Ordering is daily-stable random (md5 of artist+user+date
|
||
-- hashes). cover_album_id derived from a representative album (most
|
||
-- recently created album with cover_art_path set).
|
||
WITH liked_signal AS (
|
||
SELECT a.id AS artist_id, gla.liked_at AS like_at
|
||
FROM general_likes_artists gla
|
||
JOIN artists a ON a.id = gla.artist_id
|
||
WHERE gla.user_id = $1
|
||
AND gla.liked_at < now() - interval '30 days'
|
||
UNION ALL
|
||
SELECT t.artist_id, MIN(gl.liked_at) AS like_at
|
||
FROM general_likes gl
|
||
JOIN tracks t ON t.id = gl.track_id
|
||
WHERE gl.user_id = $1
|
||
GROUP BY t.artist_id
|
||
HAVING COUNT(*) >= 3
|
||
AND MIN(gl.liked_at) < now() - interval '30 days'
|
||
),
|
||
eligible AS (
|
||
SELECT ls.artist_id,
|
||
MIN(ls.like_at) AS like_at,
|
||
max(pe.started_at) AS last_played
|
||
FROM liked_signal ls
|
||
LEFT JOIN tracks t ON t.artist_id = ls.artist_id
|
||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||
GROUP BY ls.artist_id
|
||
HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz)
|
||
< now() - interval '14 days'
|
||
)
|
||
SELECT sqlc.embed(artists),
|
||
cov.id AS cover_album_id,
|
||
cnt.album_count::bigint AS album_count
|
||
FROM eligible e
|
||
JOIN artists ON artists.id = e.artist_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT id FROM albums
|
||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||
ORDER BY created_at DESC LIMIT 1
|
||
) cov ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT count(*) AS album_count
|
||
FROM albums WHERE artist_id = artists.id
|
||
) cnt ON true
|
||
ORDER BY md5(artists.id::text || current_date::text)
|
||
LIMIT $2;
|
||
|
||
-- name: ListRediscoverArtistsFallbackForUser :many
|
||
-- Daily-stable random sample of liked artists, paired with the Go
|
||
-- top-up logic in HomeData when the primary eligibility query returns
|
||
-- fewer than the inner limit. Daily hash ordering matches the primary
|
||
-- (omitting user_id from the hash for the same sqlc-typing reason).
|
||
SELECT sqlc.embed(artists),
|
||
cov.id AS cover_album_id,
|
||
cnt.album_count::bigint AS album_count
|
||
FROM general_likes_artists gla
|
||
JOIN artists ON artists.id = gla.artist_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT id FROM albums
|
||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||
ORDER BY created_at DESC LIMIT 1
|
||
) cov ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT count(*) AS album_count
|
||
FROM albums WHERE artist_id = artists.id
|
||
) cnt ON true
|
||
WHERE gla.user_id = $1
|
||
ORDER BY md5(artists.id::text || current_date::text)
|
||
LIMIT $2;
|