diff --git a/internal/db/dbq/you_might_like.sql.go b/internal/db/dbq/you_might_like.sql.go index 4f2a98c0..ed4027f1 100644 --- a/internal/db/dbq/you_might_like.sql.go +++ b/internal/db/dbq/you_might_like.sql.go @@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig return err } +const listYouMightLikeAlbumFallbackForUser = `-- name: ListYouMightLikeAlbumFallbackForUser :many +WITH liked_album_ids AS ( + SELECT likb.album_id AS album_id + FROM general_likes_albums likb + WHERE likb.user_id = $1 + UNION + SELECT trk.album_id AS album_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +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, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +FROM liked_album_ids lai +JOIN albums ON albums.id = lai.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY md5(albums.id::text || current_date::text) +LIMIT $2 +` + +type ListYouMightLikeAlbumFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeAlbumFallbackForUserRow struct { + Album Album + ArtistName string +} + +// Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS +// the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser. +func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg ListYouMightLikeAlbumFallbackForUserParams) ([]ListYouMightLikeAlbumFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeAlbumFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeAlbumFallbackForUserRow + for rows.Next() { + var i ListYouMightLikeAlbumFallbackForUserRow + 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.Album.CoverArtSource, + &i.Album.CoverArtSourcesVersion, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :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, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name FROM you_might_like_albums yml @@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou return items, nil } +const listYouMightLikeArtistFallbackForUser = `-- name: ListYouMightLikeArtistFallbackForUser :many +WITH liked_artist_ids AS ( + SELECT lika.artist_id AS artist_id + FROM general_likes_artists lika + WHERE lika.user_id = $1 + UNION + SELECT alb.artist_id AS artist_id + FROM general_likes_albums likb + JOIN albums alb ON alb.id = likb.album_id + WHERE likb.user_id = $1 + UNION + SELECT trk.artist_id AS artist_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM liked_artist_ids lai +JOIN artists ON artists.id = lai.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 +` + +type ListYouMightLikeArtistFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeArtistFallbackForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// Broad "artists you've shown affinity for" pool to fill a thin +// You-might-like artists row: explicitly liked artists PLUS the artists of +// liked albums and liked tracks. Most users like albums/tracks far more than +// they like artists explicitly, so the union is much deeper than +// general_likes_artists alone. Daily-stable order; same projection as +// ListYouMightLikeArtistsForUser so the Go layer reuses it directly. +func (q *Queries) ListYouMightLikeArtistFallbackForUser(ctx context.Context, arg ListYouMightLikeArtistFallbackForUserParams) ([]ListYouMightLikeArtistFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeArtistFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeArtistFallbackForUserRow + for rows.Next() { + var i ListYouMightLikeArtistFallbackForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.Artist.ArtistThumbPath, + &i.Artist.ArtistFanartPath, + &i.Artist.ArtistArtSource, + &i.Artist.ArtistArtSourcesVersion, + &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 listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version, cov.id AS cover_album_id, diff --git a/internal/db/queries/you_might_like.sql b/internal/db/queries/you_might_like.sql index c6038d5a..5b4697ab 100644 --- a/internal/db/queries/you_might_like.sql +++ b/internal/db/queries/you_might_like.sql @@ -64,3 +64,62 @@ LEFT JOIN LATERAL ( WHERE yml.user_id = $1 ORDER BY yml.rank LIMIT $2; + +-- name: ListYouMightLikeArtistFallbackForUser :many +-- Broad "artists you've shown affinity for" pool to fill a thin +-- You-might-like artists row: explicitly liked artists PLUS the artists of +-- liked albums and liked tracks. Most users like albums/tracks far more than +-- they like artists explicitly, so the union is much deeper than +-- general_likes_artists alone. Daily-stable order; same projection as +-- ListYouMightLikeArtistsForUser so the Go layer reuses it directly. +WITH liked_artist_ids AS ( + SELECT lika.artist_id AS artist_id + FROM general_likes_artists lika + WHERE lika.user_id = $1 + UNION + SELECT alb.artist_id AS artist_id + FROM general_likes_albums likb + JOIN albums alb ON alb.id = likb.album_id + WHERE likb.user_id = $1 + UNION + SELECT trk.artist_id AS artist_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM liked_artist_ids lai +JOIN artists ON artists.id = lai.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: ListYouMightLikeAlbumFallbackForUser :many +-- Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS +-- the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser. +WITH liked_album_ids AS ( + SELECT likb.album_id AS album_id + FROM general_likes_albums likb + WHERE likb.user_id = $1 + UNION + SELECT trk.album_id AS album_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM liked_album_ids lai +JOIN albums ON albums.id = lai.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY md5(albums.id::text || current_date::text) +LIMIT $2; diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go index eab129db..c61c11c5 100644 --- a/internal/recommendation/home.go +++ b/internal/recommendation/home.go @@ -199,11 +199,12 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom const youMightLikeFallbackFetch = 100 // topUpYouMightLikeArtists fills a thin You-might-like artist row from the -// user's liked artists (daily-stable sample), excluding what's already shown -// on Home (the section itself, Rediscover, Most/Last Played) so the fallback -// neither duplicates a tile nor suggests an actively-played artist. The liked -// pool dwarfs the similarity roll-up, so the same exclusions still fill it. -// Best-effort: a query error leaves the section as-is. +// artists the user has shown affinity for — explicit artist-likes PLUS the +// artists of liked albums and liked tracks (most users like albums/tracks far +// more than artists, so the broad pool is what actually fills the row). +// Excludes what's already shown on Home (the section itself, Rediscover, +// Most/Last Played) so the fallback neither duplicates a tile nor suggests an +// actively-played artist. Best-effort: a query error leaves the section as-is. func topUpYouMightLikeArtists( ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload, ) []dbq.ListYouMightLikeArtistsForUserRow { @@ -218,7 +219,7 @@ func topUpYouMightLikeArtists( for _, r := range result { excluded[r.Artist.ID] = struct{}{} } - fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ + fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{ UserID: userID, Limit: youMightLikeFallbackFetch, }) if err != nil { @@ -249,7 +250,7 @@ func topUpYouMightLikeAlbums( for _, r := range result { excluded[r.Album.ID] = struct{}{} } - fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ + fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{ UserID: userID, Limit: youMightLikeFallbackFetch, }) if err != nil { diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go index 10cf19ae..c4353f28 100644 --- a/internal/recommendation/home_integration_test.go +++ b/internal/recommendation/home_integration_test.go @@ -48,6 +48,40 @@ func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) { } } +// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback +// must surface the artists of LIKED TRACKS even with no explicit artist-likes +// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct +// artists, no artist-likes, no plays → the artist row fills from them. +func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-yml-trackartists") + fromLiked := make(map[pgtype.UUID]bool, 12) + for i := 0; i < 12; i++ { + a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "") + al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i)) + tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i)) + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil { + t.Fatalf("like track: %v", err) + } + fromLiked[a.ID] = true + } + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit { + t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)", + len(got.YouMightLikeArtists), HomeYouMightLikeLimit) + } + for _, r := range got.YouMightLikeArtists { + if !fromLiked[r.Artist.ID] { + t.Errorf("artist %s not from the liked-track pool", r.Artist.Name) + } + } +} + func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-newuser")