Commit Graph

8 Commits

Author SHA1 Message Date
bvandeusen 042f9919fe fix(server/playlists): redistributeSlots double-counting + tieBreakHash avalanche
Two real algorithm bugs in F-T1's For-You composition + Discover
allocator. Both surfaced as failing unit tests under go test -race.

1. redistributeSlots was re-redistributing a bucket's full deficit
   on every pass instead of just the residual. The loop computed
   `deficit = b.want - final[i]` each iteration, but final[i] for
   a deficit bucket never increases (its supply is exhausted), so
   pass N saw the same deficit as pass N-1 and kept shoveling it
   to peers. For [want:40 avail:100, want:30 avail:0, want:30 avail:100],
   four passes pushed cross-user's deficit into dormant+random four
   times each, hitting the 100-slot clamp at the end and producing
   [50, 0, 50] instead of the spec'd [55, 0, 45].

   Fix: track per-source `redistributed[i]` and subtract it from the
   deficit each pass. Multi-pass behavior still works for the case
   where a peer's supply runs out mid-distribution.

2. tieBreakHash used FNV-1a 64-bit with trackID + dateStr appended.
   For dateStrs differing only in the last character ("2026-05-07"
   vs "2026-05-08"), the FNV state diverged only in low bits at the
   final byte; multiplication by FNV_prime propagates upward but the
   relative ordering of 60 small candidate UUIDs (which differ only
   in their last byte) ended up identical across the two dates. The
   For-You head/tail test asserted that the tail's first 5 should
   change across days; it didn't.

   Fix: switch to SHA-256 truncated to 8 bytes. SHA-256 has full
   avalanche, so any single-bit input change roughly half-flips the
   output bits and meaningfully reorders.

The hash isn't security-load-bearing; we just need strong avalanche
for tiny dateStr deltas. Determinism (same inputs → same output) is
preserved.
2026-05-07 18:33:06 -04:00
bvandeusen 0bfd51a149 feat(server/playlists): For-You head+tail + diversity caps
Two improvements to the system playlist builder:

1. Per-artist (<=3) and per-album (<=2) caps applied to the
   pickTopN truncation step, using the same numeric caps Discover
   already enforces. Both For-You and Songs-like-X benefit. Same
   skewed candidate pool no longer collapses to "10 tracks from
   the same artist" — the playlist always carries at least 9
   distinct artists in 25 slots.

2. New pickHeadAndTail function for For-You: 20 top-similarity
   tracks + 5 sampled from the tail (positions 2*headN onward of
   the score-sorted, cap-applied pool). Tail sampling uses
   tieBreakHash for daily determinism — same user same day still
   sees the same playlist, but the daily refresh feels less
   stuck-in-a-rut. Tail tracks are still similarity-related
   (they passed the similarity candidate filter) so the user
   should enjoy them, just from artists they wouldn't have surfaced
   via strict top-N ranking.

Songs-like-X keeps the simple pickTopN call — the seed-artist
context already provides the "you'll like this" framing without
needing a tail injection.

Refactors pickTopN internals: now sorts candidates first via
scoreAndSortCandidates, applies the cap on []Candidate via
capCandidatesByAlbumAndArtist, and truncates. Removes the now-
dead stableSortByScoreThenHash helper (only used in old pickTopN).
The cap helper mirrors capByAlbumAndArtist in discover.go but
operates on recommendation.Candidate so it sees Track.AlbumID /
Track.ArtistID directly.

Tests cover the cap helper truth table, head+tail split with
small/large pools, buffer-zone exclusion, daily determinism, and
cross-day tail variance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:34:39 -04:00
bvandeusen f77a0699ec feat(server/playlists): Discover system playlist
Adds the third system playlist variant: 'discover'. Surfaces 100
tracks the operator has not played and not liked, biased toward
artists they rarely play (< 10 plays) and complemented by tracks
liked by other users plus a random unheard sample. Cold start
collapses to all-random; single-user servers redistribute the
cross-user-likes allocation equally across the other two buckets.

The build runs as a fourth phase inside BuildSystemPlaylists
alongside For You and the seed-artist mixes. Same daily-deterministic
md5(track_id || dateStr) ordering as the other variants. Per-album
(<=2) and per-artist (<=3) caps prevent collapse onto a single
prolific artist. Buckets interleave round-robin so the playlist
order doesn't front-load one bucket's flavour.

Post-commit collage generation (already wired) gives Discover the
same 4-cell cover treatment as the other system playlists — no
new collage code needed.

Tests cover the slot-redistribution table (all-available,
cold-start, single-user, partial-dormant, fully-empty), the
per-album/per-artist caps, the round-robin interleave, and a
DB-backed cold-start integration test that asserts a Discover
playlist lands with non-zero tracks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 08:35:54 -04:00
bvandeusen 735383fb1d feat(server/playlists): generate cover collages for system playlists
System-generated playlists ("For You", "Songs like X") rendered with
empty placeholder boxes in the UI even when their contributing tracks
had album art. The build path inserted playlist rows directly without
ever invoking the existing playlists.GenerateCollage machinery —
that was only wired into user-edited playlist mutations.

After this change, BuildSystemPlaylists generates a 4-cell collage
for every system playlist it creates, post-commit. Same cover
mechanism user playlists use, same on-disk layout
(<DataDir>/playlist_covers/<id>.jpg). Cells whose source album lacks
art fall back to the FabledSword glyph, so a partially-covered
library still produces a sensible visual.

Threading: BuildSystemPlaylists, StartSystemPlaylistCron, and the
api lazy-build path all gain a dataDir string parameter; main.go
passes cfg.Storage.DataDir.

Drops the now-redundant PickTopAlbumCoverForArtistByUser lookup —
the collage is more visually informative than a single album cover
copy, and the seed-artist's top album is one of the contributing
tracks in the mix anyway, so it'll appear as one of the four cells.

Tests pass t.TempDir() for the dataDir parameter.
2026-05-07 08:14:13 -04:00
bvandeusen 0dbe326d8b fix(server,web): forward-fix CI lint + vitest failures
- gofmt -s on system.go, system_cron.go, api.go
- rename unused r → _ in 3 fetcher_test.go HTTP handlers
- TrackRow +queue test uses /add .* to queue/i (track-aware aria-label from #377)
- /library/artists page test mocks likes + tanstack-query (ArtistCard now embeds LikeButton)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:39:06 -04:00
bvandeusen 797a2c8a45 fix(server/m7-352): log Finish/Fail run errors; drop unused seed-limit constant 2026-05-04 09:14:40 -04:00
bvandeusen 46a9de8e9a feat(server/m7-352): BuildSystemPlaylists with atomic replace + concurrency guard
Implements T5 of #352 slice 2: adds CreateSystemPlaylist sqlc query,
BuildSystemPlaylists function (For-You + Songs-like mixes, tx atomic
replace, in_flight guard, tieBreakHash determinism), and 7 integration
tests covering activity, quarantine exclusion, atomic replace, concurrency,
daily nonce stability, ListActiveUsers, and stale-in-flight recovery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:35:02 -04:00
bvandeusen ef478778ef feat(server/m7-352): system mix helpers (tieBreakHash, pickSeedArtists) 2026-05-04 08:24:39 -04:00