From 362cb2c9ce4817a62d5403dffe6aa61cec34dfc0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 07:56:57 -0400 Subject: [PATCH] feat(db): add LoadRadioCandidatesV2 sqlc query (5-way UNION) --- internal/db/dbq/recommendation.sql.go | 186 +++++++++++++++++++++++++ internal/db/queries/recommendation.sql | 111 +++++++++++++++ 2 files changed, 297 insertions(+) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 8bfbcf62..146136a1 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -96,3 +96,189 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat } return items, nil } + +const loadRadioCandidatesV2 = `-- name: LoadRadioCandidatesV2 :many + +WITH +seed_info AS ( + SELECT t.artist_id, + trim(g)::text 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' +), +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 (SELECT DISTINCT artist_id FROM seed_info) 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(DISTINCT tag) FROM seed_info), 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_info) + 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_info) + ) + ORDER BY random() + LIMIT $8 +), +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 + ) + ORDER BY random() + LIMIT $9 +) +SELECT + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + (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, + 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 random_fill +) u +JOIN tracks t ON t.id = u.track_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 +` + +type LoadRadioCandidatesV2Params struct { + UserID pgtype.UUID + ID pgtype.UUID + Column3 interface{} + Column4 []pgtype.UUID + Limit int32 + Limit_2 int32 + Limit_3 int32 + Limit_4 int32 + Limit_5 int32 +} + +type LoadRadioCandidatesV2Row struct { + Track Track + IsLiked bool + LastPlayedAt pgtype.Timestamptz + PlayCount int64 + SkipCount int64 + SimilarityScore interface{} +} + +// M4c: similarity-driven candidate pool. 5-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. +// +// Returns same shape as LoadRadioCandidates plus similarity_score column. +func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { + rows, err := q.db.Query(ctx, loadRadioCandidatesV2, + arg.UserID, + arg.ID, + arg.Column3, + arg.Column4, + arg.Limit, + arg.Limit_2, + arg.Limit_3, + arg.Limit_4, + arg.Limit_5, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LoadRadioCandidatesV2Row + for rows.Next() { + var i LoadRadioCandidatesV2Row + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.IsLiked, + &i.LastPlayedAt, + &i.PlayCount, + &i.SkipCount, + &i.SimilarityScore, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 6eea7a75..204b83cb 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -27,3 +27,114 @@ WHERE t.id <> $2 WHERE user_id = $1 AND track_id = t.id AND started_at > now() - $3 * interval '1 hour' ); + +-- name: LoadRadioCandidatesV2 :many +-- M4c: similarity-driven candidate pool. 5-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. +-- Returns same shape as LoadRadioCandidates plus similarity_score column. + +WITH +seed_info AS ( + SELECT t.artist_id, + trim(g)::text 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' +), +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 (SELECT DISTINCT artist_id FROM seed_info) 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(DISTINCT tag) FROM seed_info), 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_info) + 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_info) + ) + ORDER BY random() + LIMIT $8 +), +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 + ) + 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, + 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 random_fill +) u +JOIN tracks t ON t.id = u.track_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;