fix(recommendation): broaden You-might-like fallback to liked album/track artists (#790)
The fallback pulled artists only from explicit artist-likes (general_likes_artists),
but most users like albums and tracks far more than artists — so the artists row
still came up thin (a couple of tiles) even with a rich library, while the albums
row filled fine.
Broaden both fallbacks to "entities you've shown affinity for":
- artist fallback = explicit artist-likes ∪ artists of liked albums ∪ artists of
liked tracks.
- album fallback = explicit album-likes ∪ albums of liked tracks.
New dedicated queries (ListYouMightLike{Artist,Album}FallbackForUser) replace the
narrow Rediscover-fallback reuse; same projection so the Go layer still converts
directly. (Aliased + fully-qualified the UNION arms — sqlc merges UNION scopes,
so unqualified user_id was ambiguous across the three like tables.)
Test: 12 liked TRACKS by distinct artists, no artist-likes → the artist row now
fills from their artists (was empty before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig
|
|||||||
return err
|
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
|
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
|
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
|
FROM you_might_like_albums yml
|
||||||
@@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou
|
|||||||
return items, nil
|
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
|
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,
|
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,
|
cov.id AS cover_album_id,
|
||||||
|
|||||||
@@ -64,3 +64,62 @@ LEFT JOIN LATERAL (
|
|||||||
WHERE yml.user_id = $1
|
WHERE yml.user_id = $1
|
||||||
ORDER BY yml.rank
|
ORDER BY yml.rank
|
||||||
LIMIT $2;
|
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;
|
||||||
|
|||||||
@@ -199,11 +199,12 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
|||||||
const youMightLikeFallbackFetch = 100
|
const youMightLikeFallbackFetch = 100
|
||||||
|
|
||||||
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
||||||
// user's liked artists (daily-stable sample), excluding what's already shown
|
// artists the user has shown affinity for — explicit artist-likes PLUS the
|
||||||
// on Home (the section itself, Rediscover, Most/Last Played) so the fallback
|
// artists of liked albums and liked tracks (most users like albums/tracks far
|
||||||
// neither duplicates a tile nor suggests an actively-played artist. The liked
|
// more than artists, so the broad pool is what actually fills the row).
|
||||||
// pool dwarfs the similarity roll-up, so the same exclusions still fill it.
|
// Excludes what's already shown on Home (the section itself, Rediscover,
|
||||||
// Best-effort: a query error leaves the section as-is.
|
// 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(
|
func topUpYouMightLikeArtists(
|
||||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
||||||
) []dbq.ListYouMightLikeArtistsForUserRow {
|
) []dbq.ListYouMightLikeArtistsForUserRow {
|
||||||
@@ -218,7 +219,7 @@ func topUpYouMightLikeArtists(
|
|||||||
for _, r := range result {
|
for _, r := range result {
|
||||||
excluded[r.Artist.ID] = struct{}{}
|
excluded[r.Artist.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{
|
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
|
||||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -249,7 +250,7 @@ func topUpYouMightLikeAlbums(
|
|||||||
for _, r := range result {
|
for _, r := range result {
|
||||||
excluded[r.Album.ID] = struct{}{}
|
excluded[r.Album.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{
|
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
|
||||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
user := seedUser(t, pool, "home-newuser")
|
user := seedUser(t, pool, "home-newuser")
|
||||||
|
|||||||
Reference in New Issue
Block a user