From 5ed3c20b74622a9982b400a1b140d28b4b3740dd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 09:00:20 -0400 Subject: [PATCH] feat(db): add M6a rediscover queries (albums + artists, with fallbacks) Co-Authored-By: Claude Sonnet 4.6 --- internal/db/dbq/recommendation.sql.go | 258 +++++++++++++++++++++++++ internal/db/queries/recommendation.sql | 92 +++++++++ 2 files changed, 350 insertions(+) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index ee0e45dc..a1ca1056 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -156,6 +156,264 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP return items, nil } +const listRediscoverAlbumsFallbackForUser = `-- name: ListRediscoverAlbumsFallbackForUser :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, 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 random() +LIMIT $2 +` + +type ListRediscoverAlbumsFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverAlbumsFallbackForUserRow struct { + Album Album + ArtistName string +} + +// M6a: random sample of liked albums for the user. The Go service uses +// this to top up the rediscover row when ListRediscoverAlbumsForUser +// returns fewer than `limit` rows. +func (q *Queries) ListRediscoverAlbumsFallbackForUser(ctx context.Context, arg ListRediscoverAlbumsFallbackForUserParams) ([]ListRediscoverAlbumsFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverAlbumsFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverAlbumsFallbackForUserRow + for rows.Next() { + var i ListRediscoverAlbumsFallbackForUserRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRediscoverAlbumsForUser = `-- name: ListRediscoverAlbumsForUser :many +WITH eligible AS ( + SELECT al.id AS album_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_albums gla + JOIN albums al ON al.id = gla.album_id + LEFT JOIN tracks t ON t.album_id = al.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY al.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, 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 (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +LIMIT $2 +` + +type ListRediscoverAlbumsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverAlbumsForUserRow struct { + Album Album + ArtistName string +} + +// M6a: albums liked >30 days ago AND not played in the last 14 days, +// ordered by longest-since-last-play first. The HAVING clause uses an +// epoch sentinel so albums with NO plays count as eligible (their +// "last play" is treated as 1970, which is always >14 days ago). +func (q *Queries) ListRediscoverAlbumsForUser(ctx context.Context, arg ListRediscoverAlbumsForUserParams) ([]ListRediscoverAlbumsForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverAlbumsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverAlbumsForUserRow + for rows.Next() { + var i ListRediscoverAlbumsForUserRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRediscoverArtistsFallbackForUser = `-- name: ListRediscoverArtistsFallbackForUser :many +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, + 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 random() +LIMIT $2 +` + +type ListRediscoverArtistsFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverArtistsFallbackForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +func (q *Queries) ListRediscoverArtistsFallbackForUser(ctx context.Context, arg ListRediscoverArtistsFallbackForUserParams) ([]ListRediscoverArtistsFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverArtistsFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverArtistsFallbackForUserRow + for rows.Next() { + var i ListRediscoverArtistsFallbackForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRediscoverArtistsForUser = `-- name: ListRediscoverArtistsForUser :many +WITH eligible AS ( + SELECT a.id AS artist_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_artists gla + JOIN artists a ON a.id = gla.artist_id + 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 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY a.id, gla.liked_at + HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) + < now() - interval '14 days' +) +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, + 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 (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +LIMIT $2 +` + +type ListRediscoverArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRediscoverArtistsForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// M6a: artists liked >30 days ago AND none of their tracks played in the +// last 14 days, ordered by longest-since-last-play. cover_album_id is +// derived from a representative album (LEFT JOIN LATERAL). +func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRediscoverArtistsForUserParams) ([]ListRediscoverArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listRediscoverArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverArtistsForUserRow + for rows.Next() { + var i ListRediscoverArtistsForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const loadRadioCandidates = `-- name: LoadRadioCandidates :many 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, diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index a236bf42..34e3cd4e 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -253,3 +253,95 @@ LEFT JOIN LATERAL ( ) cnt ON true ORDER BY max_started.started_at DESC, a.id LIMIT $2; + +-- name: ListRediscoverAlbumsForUser :many +-- M6a: albums liked >30 days ago AND not played in the last 14 days, +-- ordered by longest-since-last-play first. The HAVING clause uses an +-- epoch sentinel so albums with NO plays count as eligible (their +-- "last play" is treated as 1970, which is always >14 days ago). +WITH eligible AS ( + SELECT al.id AS album_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_albums gla + JOIN albums al ON al.id = gla.album_id + LEFT JOIN tracks t ON t.album_id = al.id + LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY al.id, gla.liked_at + 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 (now() - COALESCE(e.last_played, e.liked_at)) DESC, albums.id +LIMIT $2; + +-- name: ListRediscoverAlbumsFallbackForUser :many +-- M6a: random sample of liked albums for the user. The Go service uses +-- this to top up the rediscover row when ListRediscoverAlbumsForUser +-- returns fewer than `limit` rows. +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 random() +LIMIT $2; + +-- name: ListRediscoverArtistsForUser :many +-- M6a: artists liked >30 days ago AND none of their tracks played in the +-- last 14 days, ordered by longest-since-last-play. cover_album_id is +-- derived from a representative album (LEFT JOIN LATERAL). +WITH eligible AS ( + SELECT a.id AS artist_id, + gla.liked_at, + max(pe.started_at) AS last_played + FROM general_likes_artists gla + JOIN artists a ON a.id = gla.artist_id + 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 + WHERE gla.user_id = $1 + AND gla.liked_at < now() - interval '30 days' + GROUP BY a.id, gla.liked_at + 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 (now() - COALESCE(e.last_played, e.liked_at)) DESC, artists.id +LIMIT $2; + +-- name: ListRediscoverArtistsFallbackForUser :many +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 random() +LIMIT $2;