From 36786defd16c933ee61686e31b8e3da4376da3f7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 11 Jun 2026 23:24:21 -0400 Subject: [PATCH] feat(recommendation): fall back to liked entities when You-might-like is thin (#790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The taste roll-up surfaces top-similar albums/artists, which for a heavy listener are mostly ones they already play — so the read-time dedup (vs Most Played + Rediscover + Last Played) can strip the section down to a single tile (reported on the artists row). The code was sound; the section was just starved. Adds a read-time fallback: when a You-might-like row comes up short after dedup, top it up from the user's LIKED artists/albums — a far larger pool than the 12-entity similarity roll-up, so the same exclusions still leave plenty. Reuses the existing Rediscover-fallback queries (no new SQL), applies the same exclusions (already-shown + Rediscover + Most/Last Played) so it never duplicates a tile or suggests an actively-played entity, and is best-effort (a query error leaves the section as-is). Takes effect immediately — no rebuild. A cold-start user with no likes gets nothing from the fallback, so the new-user-empty behaviour is preserved (test still passes). Test: 20 liked artists, none played → Rediscover fills 10, You-might-like fallback fills the other 10, disjoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/recommendation/home.go | 88 +++++++++++++++++++ .../recommendation/home_integration_test.go | 38 ++++++++ 2 files changed, 126 insertions(+) diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go index 7f380139..a5833aa0 100644 --- a/internal/recommendation/home.go +++ b/internal/recommendation/home.go @@ -180,9 +180,97 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom out.YouMightLikeAlbums, out.MostPlayedTracks, out.RediscoverAlbums) out.YouMightLikeArtists = applyYouMightLikeArtistFilters( out.YouMightLikeArtists, out.MostPlayedTracks, out.RediscoverArtists, out.LastPlayedArtists) + // The taste roll-up surfaces top-similar entities, which for a heavy + // listener are mostly ones they already play — so the dedup above can + // leave the section thin (often a single artist). Top up from the much + // larger pool of liked entities, applying the same exclusions so the + // fallback neither duplicates a tile nor suggests an actively-played one. + if len(out.YouMightLikeArtists) < HomeYouMightLikeLimit { + out.YouMightLikeArtists = topUpYouMightLikeArtists(ctx, q, userID, out) + } + if len(out.YouMightLikeAlbums) < HomeYouMightLikeLimit { + out.YouMightLikeAlbums = topUpYouMightLikeAlbums(ctx, q, userID, out) + } return out, nil } +// youMightLikeFallbackFetch is the liked-entity pool the fallback draws from. +// Generous so the dedup still leaves enough to fill the row. +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. +func topUpYouMightLikeArtists( + ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload, +) []dbq.ListYouMightLikeArtistsForUserRow { + excluded := mostPlayedArtistIDs(out.MostPlayedTracks) + for _, r := range out.RediscoverArtists { + excluded[r.Artist.ID] = struct{}{} + } + for _, r := range out.LastPlayedArtists { + excluded[r.Artist.ID] = struct{}{} + } + result := out.YouMightLikeArtists + for _, r := range result { + excluded[r.Artist.ID] = struct{}{} + } + fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ + UserID: userID, Limit: youMightLikeFallbackFetch, + }) + if err != nil { + return result + } + for _, r := range fb { + if _, dup := excluded[r.Artist.ID]; dup { + continue + } + result = append(result, dbq.ListYouMightLikeArtistsForUserRow{ + Artist: r.Artist, CoverAlbumID: r.CoverAlbumID, AlbumCount: r.AlbumCount, + }) + if len(result) >= HomeYouMightLikeLimit { + break + } + } + return result +} + +// topUpYouMightLikeAlbums is the album-row counterpart to +// topUpYouMightLikeArtists, drawing from the user's liked albums. +func topUpYouMightLikeAlbums( + ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload, +) []dbq.ListYouMightLikeAlbumsForUserRow { + excluded := mostPlayedAlbumIDs(out.MostPlayedTracks) + for _, r := range out.RediscoverAlbums { + excluded[r.Album.ID] = struct{}{} + } + result := out.YouMightLikeAlbums + for _, r := range result { + excluded[r.Album.ID] = struct{}{} + } + fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ + UserID: userID, Limit: youMightLikeFallbackFetch, + }) + if err != nil { + return result + } + for _, r := range fb { + if _, dup := excluded[r.Album.ID]; dup { + continue + } + result = append(result, dbq.ListYouMightLikeAlbumsForUserRow{ + Album: r.Album, ArtistName: r.ArtistName, + }) + if len(result) >= HomeYouMightLikeLimit { + break + } + } + return result +} + // applyYouMightLikeAlbumFilters drops albums the user actively plays // (Most Played) or that already appear in Rediscover, then trims to // HomeYouMightLikeLimit. Build-time rank order is preserved. diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go index 639e16b9..10cf19ae 100644 --- a/internal/recommendation/home_integration_test.go +++ b/internal/recommendation/home_integration_test.go @@ -2,6 +2,7 @@ package recommendation import ( "context" + "fmt" "testing" "time" @@ -10,6 +11,43 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) +// TestHomeData_YouMightLike_FallbackFillsFromLikedArtists: when the taste +// roll-up is empty/thin (no you_might_like_artists rows here), the section +// fills from the user's liked artists. Rediscover takes its share first; the +// You-might-like fallback fills the rest disjointly so no artist is shown +// twice. 20 liked artists, none played → Rediscover 10 + You-might-like 10. +func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-yml-fallback") + liked := make(map[pgtype.UUID]bool, 20) + for i := 0; i < 20; i++ { + a := seedArtist(t, pool, fmt.Sprintf("YmlArtist%02d", i), "") + likeArtist(t, pool, user.ID, a.ID) + liked[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 (filled by fallback)", + len(got.YouMightLikeArtists), HomeYouMightLikeLimit) + } + inRediscover := map[pgtype.UUID]bool{} + for _, r := range got.RediscoverArtists { + inRediscover[r.Artist.ID] = true + } + for _, r := range got.YouMightLikeArtists { + if !liked[r.Artist.ID] { + t.Errorf("you-might-like artist %s not from the liked pool", r.Artist.Name) + } + if inRediscover[r.Artist.ID] { + t.Errorf("artist %s shown in both Rediscover and You-might-like", r.Artist.Name) + } + } +} + func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-newuser")