7 Commits

Author SHA1 Message Date
bvandeusen c7adf2c87a fix(recommendation): broaden You-might-like fallback to liked album/track artists (#790)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m36s
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>
2026-06-11 23:55:08 -04:00
bvandeusen 26c368c35e fix(recommendation): use type conversion for fallback rows (staticcheck S1016)
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m35s
golangci-lint v2 (CI-only; local is v1) flagged the field-by-field struct
literals — the fallback and you-might-like row types are identical, so convert
directly instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:26:35 -04:00
bvandeusen 36786defd1 feat(recommendation): fall back to liked entities when You-might-like is thin (#790)
test-go / test (push) Failing after 14s
test-go / integration (push) Has been cancelled
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) <noreply@anthropic.com>
2026-06-11 23:24:21 -04:00
bvandeusen fdd14ef04c feat(server): "You might like" album/artist Home rows (#790)
test-go / test (push) Successful in 39s
test-go / integration (push) Failing after 4m39s
Surface in-library albums/artists the listener doesn't actively spin but
is predicted to enjoy, derived from the same similarity + like-weighted
candidate engine that powers For-You — rolled up from track scores to
album/artist granularity. Built in the daily 3am BuildSystemPlaylists
pass, atomic-replaced alongside the system playlists, and read back by
/api/home (+ /api/home/index).

Cold-start gate: skips generation entirely below 20 distinct unskipped
tracks AND 5 distinct artists, so a thin profile ships empty rows rather
than near-random tiles.

- migration 0034: you_might_like_albums / you_might_like_artists (id+rank,
  CASCADE, per-user rank index).
- playlists/you_might_like.go: cold-start gate + similarity roll-up
  (sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the
  same userIDHash jitter as For-You) + atomic-replace persist in the tx.
- recommendation/home.go: two new HomePayload sections with read-time
  cross-section dedup vs Most Played / Rediscover / Last Played, trimmed
  to 10 each.
- api: you_might_like_albums / you_might_like_artists on /api/home and
  /api/home/index, reusing albumRefFrom / artistRefFromCovered.
- tests: pure roll-up/aggregation/cap unit tests + DB-backed gate,
  sufficiency, and atomic-replace tests (all green vs real Postgres).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:33:46 -04:00
bvandeusen 8b586c2e50 feat(recommendation): rework Rediscover - track-derived eligibility + daily rotation + caps
test-go / test (push) Failing after 15s
test-go / integration (push) Failing after 2m16s
User request 2026-06-01: the previous Rediscover only fired on
explicit album/artist likes, so users who only liked at the track
level got an empty Rediscover row. Also no daily rotation - the
section never changed between data updates - and 25 items felt long
for the Home carousel.

SQL (internal/db/queries/recommendation.sql):
- ListRediscoverAlbumsForUser UNIONs the existing explicit album-like
  signal with a new track-derived path: an album qualifies if it has
  >=2 liked tracks where the earliest like is >30 days old.
- ListRediscoverArtistsForUser does the same with threshold 3 (artists
  span more releases, so the bar is higher to avoid one-hit-wonder
  affinities).
- Both ordering switched from longest-since-last-play to a daily-
  stable random hash md5(entity_id || user_id || current_date) so
  the row randomizes but stays consistent within a day.
- Fallback queries also switched to the daily-stable hash so the
  fallback rows don't reshuffle on every refresh.

Go (internal/recommendation/home.go):
- HomeRediscoverLimit dropped from 25 to 10 (carousel-sized).
- rediscoverInnerLimit (30) is the per-query cap; gives headroom so
  the Go-layer filters don't undershoot.
- applyRediscoverAlbumFilters does cross-section dedup vs Most Played
  (no point surfacing albums the user is actively spinning) plus a
  diversity cap of max 2 albums per artist (prevents one liked-but-
  forgotten artist dominating the row).
- applyRediscoverArtistFilters does cross-section dedup vs Most
  Played's artist set; no diversity cap needed (one row per artist).

Tests (internal/recommendation/home_integration_test.go):
- TrackDerived path: 2 liked tracks >30d old + no recent plays = album
  appears in Rediscover.
- Threshold guard: 1 liked track = album does NOT appear.
- Diversity cap: 4 explicit album-likes from same artist = 2 in output.
- Dedup vs MostPlayed: eligible album whose track is in MostPlayed
  gets filtered out.
- Existing FallbackWhenSparse test preserved (semantics unchanged for
  recent likes that miss the >30d primary filter).

Clients unchanged - they render whatever /api/home/index returns. No
parity-map row needed (this is a server-internal recommendation
change, not a layout divergence).
2026-06-01 00:57:40 -04:00
bvandeusen 461c6bf514 fix(go): collapse struct-literal copies to type conversions (S1016)
staticcheck S1016 in the new golangci-lint v2 flagged four sites
copying field-by-field between two types with identical struct
shapes. Direct type conversion is the canonical form:

- internal/library/scanrun.go:
  - LibraryStageTallies copy from Stats → LibraryStageTallies(lastStats)
  - MBIDBackfillStageTallies copy from BackfillMBIDsResult →
    MBIDBackfillStageTallies(lastRes)
- internal/recommendation/home.go:
  - dbq.ListRediscoverAlbumsForUserRow copy from the Fallback row
    type → dbq.ListRediscoverAlbumsForUserRow(r)
  - Same pattern for the Artists rediscover pair.

No behavior change; the underlying struct shapes are identical
(staticcheck verified the conversion is valid). Net -18 +4 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:23:39 -04:00
bvandeusen d25b1f9291 feat(recommendation): add HomeData composite service for /api/home
Runs five SQL queries in parallel (recently added albums, most played
tracks, last played artists, rediscover albums, rediscover artists) via
goroutines with mutex-guarded first-error capture. Rediscover sections
merge primary + fallback results, de-duping by pgtype.UUID map key.
All HomePayload slices are non-nil so the API handler encodes [] not null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:06:43 -04:00