Compare commits

..
77 Commits
Author SHA1 Message Date
bvandeusen d145fee35d Merge pull request 'fix(android/resilience): auto-recover failed loads on reconnect' (#106) from dev into main
android / Build + lint + test (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 16s
2026-07-02 16:45:13 -04:00
bvandeusen 611715154b Merge pull request 'M9 diagnostics follow-ups: playback relabel, sort, connected fix, per-skip + track-identity' (#105) from dev into main
test-go / test (push) Successful in 38s
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m18s
test-go / integration (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 3m46s
release / Build + push container image (push) Successful in 16s
2026-06-30 19:19:15 -04:00
bvandeusen 23a82fb38d Merge pull request 'M9 — Device diagnostics & debug reporting (connectivity + UPnP desync)' (#104) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 1m38s
test-go / test (push) Successful in 35s
android / Build + lint + test (push) Successful in 4m6s
test-go / integration (push) Successful in 4m37s
test-web / test (push) Successful in 47s
2026-06-29 19:24:16 -04:00
bvandeusen 0de2437689 Merge pull request 'Image rendering + player resilience (#968, #980)' (#103) from dev into main
android / Build + lint + test (push) Successful in 4m11s
test-go / test (push) Successful in 46s
test-web / test (push) Successful in 55s
test-go / integration (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 3m53s
release / Build + push container image (push) Successful in 1m41s
2026-06-20 20:57:39 -04:00
bvandeusen a251dce7e3 Merge pull request 'feat: scan on startup by default + README first-run walkthrough & screenshots' (#102) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 37s
release / Build + push container image (push) Successful in 1m44s
test-go / integration (push) Successful in 4m35s
2026-06-20 11:40:56 -04:00
bvandeusen 938dae7163 Merge pull request 'docs: correct README setup/OOBE + fix data-volume mount' (#101) from dev into main 2026-06-20 10:58:07 -04:00
bvandeusen 93365cb555 Merge pull request 'ci(release): bundle the latest release APK into non-tag :latest builds' (#100) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 17s
2026-06-14 22:55:23 -04:00
bvandeusen eeabdf1f2c Merge pull request 'Web UI: Most Played hover fix, narrower seek bar, Android-parity track kebab' (#99) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 35s
release / Build + push container image (push) Successful in 1m50s
2026-06-14 22:26:52 -04:00
bvandeusen 3e258507bb Merge pull request 'fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening' (#98) from dev into main
android / Build + lint + test (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 15s
2026-06-12 21:25:25 -04:00
bvandeusen 9550d8daaf Merge pull request 'fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation)' (#97) from dev into main
android / Build + lint + test (push) Successful in 4m5s
release / Build signed APK (tag releases only) (push) Successful in 4m4s
release / Build + push container image (push) Successful in 13s
2026-06-12 19:45:44 -04:00
bvandeusen db393bbe65 Merge PR #96: recommendation batch (You-might-like fallback + taste 2b + observability)
test-go / test (push) Successful in 41s
test-web / test (push) Successful in 43s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-12 01:10:09 -04:00
bvandeusen 7b7bd0c3e8 Merge PR #95: You-might-like liked-entity fallback (#790)
test-go / test (push) Successful in 30s
release / Build signed APK (tag releases only) (push) Successful in 3m55s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-11 23:33:25 -04:00
bvandeusen 8019537b02 Merge PR #94: "You might like" web client row (#790)
test-web / test (push) Successful in 35s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 1m56s
2026-06-11 23:01:38 -04:00
bvandeusen e41d603c12 Merge PR #93: move "You might like" under system-playlists row
android / Build + lint + test (push) Successful in 4m24s
release / Build signed APK (tag releases only) (push) Successful in 4m29s
release / Build + push container image (push) Successful in 14s
2026-06-11 22:55:52 -04:00
bvandeusen a62f07bd3a Merge PR #92: "You might like" Android client row (#790)
release / Build signed APK (tag releases only) (push) Successful in 4m12s
release / Build + push container image (push) Successful in 15s
android / Build + lint + test (push) Successful in 4m19s
2026-06-11 22:36:22 -04:00
bvandeusen 3c646c6974 Merge PR #91: "You might like" rows + taste profile (learn + apply)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m34s
release / Build signed APK (tag releases only) (push) Successful in 4m5s
release / Build + push container image (push) Successful in 15s
2026-06-11 21:41:17 -04:00
bvandeusen 9a57dc4bec Merge pull request 'Offline/playback recording robustness (contract-audit follow-ups)' (#90) from dev into main
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 4m25s
release / Build signed APK (tag releases only) (push) Successful in 4m20s
release / Build + push container image (push) Successful in 1m53s
2026-06-11 14:19:27 -04:00
bvandeusen f167ddfbfb Merge pull request 'fix: restore native Android play-event recording (History was empty)' (#89) from dev into main
test-go / test (push) Successful in 38s
test-go / integration (push) Successful in 4m29s
android / Build + lint + test (push) Successful in 4m42s
release / Build signed APK (tag releases only) (push) Successful in 3m59s
release / Build + push container image (push) Successful in 14s
2026-06-11 09:08:20 -04:00
bvandeusen 962b4dbc8c Merge pull request 'v2026.06.07 — Sonos: one SOAP failure no longer drops to local' (#88) from dev into main
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 15s
2026-06-07 19:14:45 -04:00
bvandeusen aa4089118e chore: configure Renovate (tuned)
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 1m34s
Activate Renovate with a tuned config: target dev, ignore retired
flutter_client/**, auto-merge GREEN patch/minor bumps, hold majors behind
dependency-dashboard approval, and group go/CI/docker/gradle/npm updates.
Throttled to a weekend schedule with prHourlyLimit 2.
2026-06-07 12:00:44 -04:00
bvandeusen 7c791dc8e4 v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 14s
2026-06-06 23:31:27 -04:00
bvandeusen 11466e1525 Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
release / Build + push container image (push) Successful in 13s
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
2026-06-05 13:27:36 -04:00
bvandeusen 301c3bfb86 Merge pull request 'fix(android): don't flip offline on WAN-validation flicker — trust /healthz' (#85) from dev into main
android / Build + lint + test (push) Successful in 5m6s
release / Build signed APK (tag releases only) (push) Successful in 4m42s
release / Build + push container image (push) Successful in 14s
2026-06-04 23:02:28 -04:00
bvandeusen 4d8c7d6566 Merge pull request 'fix(android): notification art uses album cover, not embedded stream tags' (#84) from dev into main
android / Build + lint + test (push) Successful in 4m1s
release / Build signed APK (tag releases only) (push) Successful in 6m12s
release / Build + push container image (push) Successful in 15s
2026-06-04 22:35:21 -04:00
bvandeusen d6e6caa223 Merge pull request 'Sonos queue resync + cold-start prefetcher gate' (#83) from dev into main
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 13s
2026-06-04 17:36:31 -04:00
bvandeusen 8b08482d13 Merge pull request 'fix(android): hysteresis on /healthz reachable signal' (#82) from dev into main
android / Build + lint + test (push) Successful in 4m46s
release / Build signed APK (tag releases only) (push) Successful in 4m26s
release / Build + push container image (push) Successful in 13s
2026-06-04 14:23:49 -04:00
bvandeusen 7cf04fe24b Merge pull request 'Android #618 offline-mode UX + Sonos polish + server DRY' (#81) from dev into main
android / Build + lint + test (push) Successful in 4m35s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 13s
2026-06-04 12:53:59 -04:00
bvandeusen 222a0ff636 Merge pull request 'dev → main: collage center-crop, server DRY, CI durability-off' (#80) from dev into main
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m27s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 12s
2026-06-04 08:42:36 -04:00
bvandeusen d75c1ae37f Merge pull request 'dev → main: Android UPnP/Sonos transport parity + server stream URL extension' (#79) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 30s
release / Build + push container image (push) Successful in 1m24s
android / Build + lint + test (push) Successful in 4m12s
test-go / integration (push) Successful in 9m16s
2026-06-04 08:15:15 -04:00
bvandeusen 3c4c27fb08 Merge pull request 'v2026.06.03 hotfix — Sonos cast URL + UPnP picker polish' (#78) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m49s
test-go / integration (push) Successful in 10m56s
release / Build signed APK (tag releases only) (push) Successful in 4m15s
release / Build + push container image (push) Successful in 17s
2026-06-03 15:30:09 -04:00
bvandeusen a62a20b599 Merge pull request 'v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation' (#77) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 4m22s
test-go / integration (push) Successful in 11m14s
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m59s
release / Build + push container image (push) Successful in 2m6s
2026-06-03 14:09:23 -04:00
bvandeusen d9b2dd957c Merge pull request 'fix(android): interceptor order — auth before baseUrl (hotfix for v2026.06.02)' (#76) from dev into main
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m28s
release / Build + push container image (push) Successful in 2m1s
2026-06-02 22:07:32 -04:00
bvandeusen 46dcd38fd8 Merge pull request 'Drift audit 2026-06-02 — 26 findings shipped' (#75) from dev into main
test-go / test (push) Successful in 43s
test-web / test (push) Successful in 56s
release / Build signed APK (tag releases only) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 24s
android / Build + lint + test (push) Successful in 5m47s
test-go / integration (push) Failing after 12m37s
2026-06-02 19:21:53 -04:00
bvandeusen 7838038047 Merge pull request 'Playback errors slice + scrubber polish + various polish' (#74) from dev into main
test-go / test (push) Successful in 39s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 4m45s
release / Build signed APK (tag releases only) (push) Successful in 5m12s
release / Build + push container image (push) Successful in 22s
test-go / integration (push) Successful in 12m7s
2026-06-02 14:14:11 -04:00
bvandeusen b64965b38d Merge pull request 'Discover artwork + Library icon + notification tap routing' (#73) from dev into main
android / Build + lint + test (push) Successful in 5m55s
release / Build signed APK (tag releases only) (push) Successful in 5m26s
release / Build + push container image (push) Successful in 16s
2026-06-02 09:58:10 -04:00
bvandeusen bf2f9f3811 Merge pull request 'Lock Android MainActivity to portrait' (#72) from dev into main
android / Build + lint + test (push) Successful in 4m2s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 15s
2026-06-02 08:19:07 -04:00
bvandeusen deb726a285 Merge pull request 'Keep onPostScroll under detekt ReturnCount limit' (#71) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 10s
android / Build + lint + test (push) Successful in 4m9s
2026-06-02 08:10:12 -04:00
bvandeusen 7ede83a586 Merge pull request 'Alphabet rail page-chasing + Songs Like fix + scrubber polish' (#70) from dev into main
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Failing after 1m29s
release / Build signed APK (tag releases only) (push) Successful in 3m51s
release / Build + push container image (push) Successful in 12s
2026-06-01 23:36:07 -04:00
bvandeusen 1d7b91333f Merge pull request 'Web Library: alphabet rail always shows #/A-Z/&' (#69) from dev into main
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 19s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
2026-06-01 21:55:49 -04:00
bvandeusen 883d416d26 Merge pull request 'Web Library: continuous grid + sticky alphabet rail' (#68) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 28s
test-web / test (push) Successful in 35s
2026-06-01 21:36:55 -04:00
bvandeusen 09471a8f5c Merge pull request 'Web: Most Played horizontal tiles + nav centering + Library link + Search refinements' (#67) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 25s
test-web / test (push) Successful in 34s
2026-06-01 21:06:03 -04:00
bvandeusen 7de238e91e Merge pull request 'Web Home: drop hero row + compact Rediscover tiles' (#66) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 26s
test-web / test (push) Successful in 35s
2026-06-01 20:43:53 -04:00
bvandeusen 9440c5860b Merge pull request 'Android v1 polish + Web UI flavor pass' (#65) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 40s
release / Build + push container image (push) Successful in 41s
android / Build + lint + test (push) Successful in 4m29s
2026-06-01 20:17:49 -04:00
bvandeusen 082d31bfa9 Merge pull request 'ci: chain APK build → image build via needs (fix race, kill polling)' (#64) from dev into main
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m47s
release / Build + push container image (push) Successful in 14s
2026-06-01 18:31:12 -04:00
bvandeusen 8847b43d9e Merge pull request 'ci(android): make APK attach failures visible (debug v2026.06.01 missing asset)' (#63) from dev into main
release / release (push) Successful in 11s
android / Build + lint + test (push) Successful in 4m16s
android / Build signed release APK (push) Successful in 3m9s
2026-06-01 18:13:43 -04:00
bvandeusen 09cc810e5a Merge pull request 'ci: pin upload-artifact to v3 (Gitea GHES-mode incompatible with v4)' (#62) from dev into main
release / release (push) Successful in 11s
android / Build signed release APK (push) Has been skipped
android / Build + lint + test (push) Successful in 4m34s
2026-06-01 17:38:24 -04:00
bvandeusen 2534384ed1 Merge pull request 'fix(android): remove WorkManager auto-initializer (lintVitalRelease)' (#61) from dev into main
android / Build + lint + test (push) Successful in 3m42s
android / Build signed release APK (push) Successful in 3m22s
release / release (push) Successful in 7m46s
2026-06-01 16:55:52 -04:00
bvandeusen 9ff1b30e3f Merge pull request 'fix(docker): bump builder to go 1.25 to match go.mod' (#60) from dev into main
android / Build + lint + test (push) Successful in 3m36s
android / Build signed release APK (push) Failing after 3m21s
release / release (push) Successful in 15m17s
2026-06-01 16:13:43 -04:00
bvandeusen c01853577b Merge pull request 'Release: web UX overhaul + Android native port + server polish' (#59) from dev into main
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 44s
release / release (push) Failing after 15m9s
android / Build + lint + test (push) Successful in 4m49s
android / Build signed release APK (push) Failing after 3m32s
test-go / integration (push) Successful in 9m33s
2026-06-01 16:11:21 -04:00
bvandeusen 747ed4134b Merge pull request 'v2026.05.21.0 — Wear OS dispatch fix, playlist-load feedback, drift CI' (#58) from dev into main 2026-05-21 15:42:06 -04:00
bvandeusen ccbd3b62a0 Merge pull request 'v2026.05.19.3 — playback stall resilience + legacy home cleanup' (#56) from dev into main 2026-05-19 15:48:08 -04:00
bvandeusen 19de0c2874 Merge pull request 'v2026.05.19.2 — hotfix: restore media notification (remove broken custom favorite)' (#55) from dev into main
Merge v2026.05.19.2 hotfix — restore media notification (PR #55)
2026-05-19 07:48:10 -04:00
bvandeusen 22a4649bfc Merge pull request 'v2026.05.19.1 — hotfix: notification permission + full-player auto-minimize' (#54) from dev into main
Merge v2026.05.19.1 hotfix — notification permission + full-player auto-minimize (PR #54)
2026-05-18 23:08:33 -04:00
bvandeusen 8e7660c05e Merge pull request 'fix(ci): scope integration Postgres discovery to this job's network' (#53) from dev into main
Merge CI fix: scope integration Postgres discovery to this job's network (PR #53)
2026-05-18 22:50:36 -04:00
bvandeusen 53be834e89 Merge pull request 'v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration' (#52) from dev into main
Merge v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration (PR #52)
2026-05-18 21:42:48 -04:00
bvandeusen 0d410630a2 Merge pull request 'Release v2026.05.18.0 — integration-tests-in-CI + recommendations/cover-art/discover batch' (#51) from dev into main 2026-05-17 22:41:20 -04:00
bvandeusen 62db8edcdb Merge pull request 'Release v2026.05.16.0 — recommendations working end-to-end + cover-art uncap' (#50) from dev into main 2026-05-16 18:58:55 -04:00
bvandeusen ec0cc37bc9 Merge pull request 'Hotfix v2026.05.15.1 — allow discovery-mix variants in playlists CHECK constraints' (#49) from dev into main 2026-05-16 00:32:25 -04:00
bvandeusen e772938a3b Merge pull request 'Release v2026.05.15.0 — system playlists v2, offline cache rework, CI speedup' (#48) from dev into main 2026-05-15 23:15:48 -04:00
bvandeusen 29fee5aa37 Merge pull request 'Release v2026.05.14.0 — player polish, CacheFiller, offline mutation queue' (#47) from dev into main 2026-05-15 01:17:44 +00:00
bvandeusen e5ab471ce1 Merge pull request 'release v2026.05.13.3: full-player seed + MediaSession expansion (Wear)' (#46) from dev into main 2026-05-14 18:19:39 +00:00
bvandeusen 573aa4226d Merge pull request 'release v2026.05.13.2: artist covers + load-then-swap player transitions' (#45) from dev into main 2026-05-14 16:31:43 +00:00
bvandeusen 7339815ea9 Merge pull request 'release v2026.05.13.1: player + Discover hotfix' (#44) from dev into main 2026-05-14 15:17:45 +00:00
bvandeusen baa601765e Merge pull request 'release v2026.05.13.0: SSE live updates + offline cache + per-item rendering' (#43) from dev into main 2026-05-14 02:36:47 +00:00
bvandeusen 0d009b34e2 Merge pull request 'release v2026.05.12.1: Discover surface + nav fixes + cache hygiene' (#42) from dev into main 2026-05-12 03:58:43 +00:00
bvandeusen f1b4652c77 Merge pull request 'release v2026.05.11.3: caching, perf, playlist polish' (#41) from dev into main 2026-05-12 00:35:48 +00:00
bvandeusen e610948307 Merge pull request 'release v2026.05.11.2: signing key + library infinite scroll' (#40) from dev into main 2026-05-11 19:51:41 +00:00
bvandeusen fb811804d2 Merge pull request 'release v2026.05.11.1: Flutter caching, navigation, and player polish' (#39) from dev into main 2026-05-11 17:47:41 +00:00
bvandeusen 37134950a5 Merge pull request 'release: M5-M7 server + web + Flutter, plus Flutter v1 polish' (#38) from dev into main 2026-05-11 15:21:58 +00:00
bvandeusen fcded9294c Merge pull request 'fix(web): download button + server version display' (#37) from dev into main 2026-05-11 03:20:39 +00:00
bvandeusen 42abb7adff Merge pull request 'ci: unify REGISTRY_TOKEN + RELEASE_TOKEN into CI_TOKEN' (#36) from dev into main 2026-05-11 01:45:01 +00:00
bvandeusen 04933f2d9f Merge pull request 'M5–M7 sweep + offline cache + in-app updates + DRY pass' (#35) from dev into main 2026-05-11 01:09:36 +00:00
bvandeusen 626fc7502c Merge pull request 'refactor(server): remove bootstrap admin path' (#34) from dev into main 2026-05-09 02:19:40 +00:00
bvandeusen 9bf3b8a2f2 Merge pull request 'feat(flutter): admin parity slice — requests, quarantine, users, invites' (#33) from dev into main 2026-05-09 02:07:59 +00:00
bvandeusen 492460cf4a Merge pull request 'fix(web): /register reachable for bootstrap admin (closes #376)' (#32) from dev into main 2026-05-08 22:01:37 +00:00
bvandeusen 1c775905d7 Release v2026.05.08.1 — DRY pass + cover-art HTTP base (#31)
Merges ~50 commits from dev: full DRY pass round 1 + round 2, plus PR3 cover-art HTTP base.
2026-05-08 17:45:41 +00:00
bvandeusen cd2ff648d0 Merge pull request 'M5a frontend + M5b quarantine + M5c suggestions' (#30) from dev into main 2026-05-01 11:14:18 +00:00
103 changed files with 770 additions and 9342 deletions
@@ -2,19 +2,11 @@
package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -35,7 +27,6 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -97,7 +88,6 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -120,16 +110,6 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil (automatic refresh). Held through the pull
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_SETTLE_MS = 500L
private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f
private const val VEIL_SPINNER_DP = 22
private const val VEIL_SPINNER_STROKE_DP = 2
private const val VEIL_LABEL_GAP_DP = 12
// ─── State ───────────────────────────────────────────────────────────
data class HomeSections(
@@ -197,36 +177,22 @@ class HomeViewModel @Inject constructor(
*/
private val refreshError = MutableStateFlow<String?>(null)
private val updatingInternal = MutableStateFlow(false)
/**
* True while an automatic background refresh (the 03:00 daily rebuild
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
* your mixes…" veil so the section churn — delete-then-insert in
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
* hidden behind the veil instead of on screen. Manual pull-to-refresh
* and cold start are NOT veiled (they own the pull spinner / skeleton).
*/
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery (issue #1245): a Home that failed to
// load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up.
// Veiled: content is already on screen and would otherwise churn.
viewModelScope.launch {
networkStatus.recoveries().collect { refreshBehindVeil() }
networkStatus.recoveries().collect { refresh() }
}
// #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
// rebuild churn hides behind "Updating your mixes…".
// reload. Mirrors the web SSE consumer.
viewModelScope.launch {
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" }
.collect { refreshBehindVeil() }
.collect { refresh() }
}
}
@@ -357,26 +323,6 @@ class HomeViewModel @Inject constructor(
status.join()
}
/**
* Automatic background refresh with the "Updating your mixes…" veil
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
* paths where Home is already on screen. Holds the veil through the
* pull plus a short settle so per-tile hydration lands behind it, then
* lets it wipe off. Overlapping automatic refreshes are rare enough
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
*/
private fun refreshBehindVeil() {
viewModelScope.launch {
updatingInternal.value = true
try {
refresh().join()
delay(VEIL_SETTLE_MS)
} finally {
updatingInternal.value = false
}
}
}
val uiState: StateFlow<UiState<HomeSections>> =
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
@@ -452,108 +398,47 @@ fun HomeScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Box(Modifier.fillMaxSize()) {
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
// Automatic-refresh veil: the daily rebuild / reconnect
// churn hides behind an "Updating your mixes…" wipe. Manual
// pull owns the PullToRefreshBox spinner instead.
UpdatingVeil(visible = updating)
}
}
}
}
/**
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
*
* Key the Crossfade on the state CLASS, not the instance. Each section
* emission produces a new UiState.Success(data); if we keyed on `state`
* directly, every per-section hydration tick would re-run the 300ms
* crossfade, and first-sign-in (six sections cascading in) reads as
* continuous flicker. Keying on the class restricts the animation to
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
* recompositions update the LazyColumn without a fade.
*/
@Composable
private fun HomeStateCrossfade(
state: UiState<HomeSections>,
systemStatus: SystemPlaylistsStatus,
offline: Boolean,
navController: NavHostController,
viewModel: HomeViewModel,
) {
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.refresh() },
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
}
}
}
/**
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
* holds while an automatic refresh repopulates Home, then wipes off.
* Near-opaque so the section churn underneath never shows; swallows taps
* while raised so a mid-hydration tile can't be hit.
*/
@Composable
private fun BoxScope.UpdatingVeil(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {},
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
Text(
text = "Updating your mixes…",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// Key Crossfade on the state CLASS, not the instance. Each
// section emission produces a new UiState.Success(data); if
// we keyed on `state` directly, every per-section
// hydration tick would re-run the 300ms crossfade, and
// first-sign-in (six sections cascading in) reads as
// continuous flicker. Keying on the class restricts the
// animation to Loading↔Success↔Empty↔Error transitions and
// lets normal Success→Success recompositions update the
// LazyColumn without a fade.
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.refresh() },
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
}
}
}
}
@@ -623,12 +508,10 @@ private fun HomeSuccessContent(
) {
item {
// Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover slots
// that haven't generated yet. When offline, the cache-backed
// pool cards lead the row. Songs-like now lives in its own
// dedicated row below (#1491), no longer inside this carousel.
// placeholder cards filling the For You / Discover /
// 3× Songs-like slots that haven't generated yet. When
// offline, the cache-backed pool cards lead the row.
PlaylistsRow(
title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
offline = offline,
onPlaylistClick = onPlaylistClick,
@@ -636,18 +519,6 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist,
)
}
// Songs-like is the best-performing surface (#1491) — promoted out
// of the Playlists carousel into its own row so it shows a wider
// spread of "Songs like {artist}" mixes. Hidden when there's
// nothing to show (offline with none cached).
songsLikeSection(
playlists = sections.playlists,
status = systemStatus,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
youMightLikeSection(
albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists,
@@ -671,35 +542,6 @@ private fun HomeSuccessContent(
}
}
/**
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
* rendering with a distinct title; hidden entirely when there's nothing
* to show (offline with no cached mixes). Online with none generated yet
* still shows a few placeholders so the building / seed-needed state is
* visible, matching the pre-promotion carousel behavior.
*/
private fun LazyListScope.songsLikeSection(
playlists: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
val rowItems = buildSongsLikeRow(playlists, status, offline)
if (rowItems.isEmpty()) return
item {
PlaylistsRow(
title = "Songs like…",
rowItems = rowItems,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
}
}
private fun LazyListScope.recentlyAddedSection(
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
@@ -969,14 +811,13 @@ private fun AlbumsRow(
@Composable
private fun PlaylistsRow(
title: String,
rowItems: List<PlaylistRowItem>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
HorizontalScrollRow(title = title) {
HorizontalScrollRow(title = "Playlists") {
itemsIndexed(items = rowItems) { _, item ->
when (item) {
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
@@ -1027,12 +868,11 @@ enum class OfflinePoolKind(val label: String) {
/**
* Builds the Home Playlists row.
*
* Online: For You + Discover fixed slots (real card when generated,
* placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they exist —
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
* / rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
* [buildSongsLikeRow] and no longer appears in this carousel.
* user-owned playlists.
*
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached
@@ -1059,7 +899,7 @@ internal fun buildPlaylistsRow(
return out
}
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
/** The online layout: fixed system slots (with placeholders), secondary, user. */
private fun buildOnlineRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
@@ -1071,7 +911,12 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
// Songs-like is no longer here — it has its own dedicated row (#1491).
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
}
@@ -1079,44 +924,16 @@ private fun buildOnlineRow(
return out
}
/**
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
* mixes the server generated, no longer capped to the 3 carousel slots.
*
* Online: every generated mix as a real card; when none exist yet, a few
* placeholders so the building / seed-needed state stays visible.
* Offline: the cached mixes only (fully-cached first, greyed after), and
* an empty list — hiding the whole section — when nothing is cached.
*/
internal fun buildSongsLikeRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
): List<PlaylistRowItem> {
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
return when {
offline -> {
val (available, greyed) = mixes.partition { !it.unavailableOffline }
(available + greyed).map { PlaylistRowItem.Real(it) }
}
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
}
}
/**
* Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first.
* Songs-like is excluded here too — it renders in its own row via
* [buildSongsLikeRow] in both online and offline modes (#1491).
*/
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>()
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
}
@@ -1131,10 +948,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
else -> "pending"
}
// How many "Songs like…" placeholder cards the dedicated row shows while
// the mixes haven't generated yet (building / seed-needed). Real mixes,
// once generated, are shown in full and no longer capped by this (#1491).
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
private const val SONGS_LIKE_SLOTS = 3
/**
* The 5 system playlist kinds the server generates that aren't pinned
@@ -23,12 +23,8 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* ServerImage + fallback structure was identical at all three sites.
*
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
* (e.g. the `VariantPill` system-playlist label, the play button) —
* callers can use `Modifier.align(...)` inside it. The overlay sits on
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
* the square's corner, outside the circle, and would otherwise be clipped
* away. Only the artwork + background are clipped to [shape].
* (e.g. the `VariantPill` system-playlist label) — callers can use
* `Modifier.align(...)` inside it.
*/
@Composable
fun CoverTile(
@@ -42,23 +38,18 @@ fun CoverTile(
overlay: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier.size(size),
modifier = modifier
.size(size)
.clip(shape)
.background(background),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(shape)
.background(background),
contentAlignment = Alignment.Center,
ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
fallback()
}
fallback()
}
overlay()
}
@@ -56,51 +56,4 @@ class BuildPlaylistsRowTest {
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
}
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
id = id,
userId = "u",
name = "Songs like $id",
systemVariant = "songs_like_artist",
trackCount = 25,
fullyCached = cached,
)
@Test
fun `songs-like no longer appears in the main playlists carousel`() {
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
assertTrue("u1" in reals)
}
@Test
fun `online songs-like row shows every generated mix uncapped`() {
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
val owned = (1..6).map { songsLike("a$it", cached = true) }
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals((1..6).map { "a$it" }, reals)
}
@Test
fun `online songs-like row shows placeholders when none generated`() {
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
assertTrue(row.isNotEmpty())
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
}
@Test
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
// Fully-cached songs-like mix (not refreshable) is available; the
// un-cached one greys and sorts after. No placeholders offline.
assertEquals(listOf("full", "partial"), reals)
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
}
}
-33
View File
@@ -25,14 +25,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
func main() {
@@ -148,16 +146,6 @@ func run() error {
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
coverEnricher.DataDir = cfg.Storage.DataDir
// Recommendation tuning lab (#1250): seeds shipped defaults on first
// boot and pushes the daily_mix weights + taste config into package
// playlists — must precede the scheduler so the first builds score
// with the operator's tuned values, not the pre-push literals.
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
if err != nil {
logger.Error("recommendation settings service init failed", "err", err)
os.Exit(1)
}
// One unified scan chain: library walk → MBID backfill → cover enrich.
// Boot-time scan and manual-trigger scans share this path; results land
// in scan_runs for the admin overview.
@@ -206,25 +194,6 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if
// the provider set changed (re-opening settled rows), then drains tracks
// needing folksonomy tags on a periodic tick. Standalone (not in the file
// scan chain) because tag lookups need only DB fields — MBID / artist /
// title — that a scan has already imported.
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
if err != nil {
logger.Error("tag settings service init failed", "err", err)
os.Exit(1)
}
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
logger.Warn("tags: provider-hash boot check failed", "err", berr)
} else if bumped {
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
}
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
// that have no writer-side close path or retention policy:
// orphan play_events, stale play_sessions, expired
@@ -325,8 +294,6 @@ func run() error {
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
srv.RecSettings = recSettings
srv.TagSettings = tagSettings
srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{
Addr: cfg.Server.Address,
-202
View File
@@ -1,202 +0,0 @@
// Admin recommendation-trends endpoint (#1251): weekly per-surface
// outcome series with tuning-audit markers — the verify half of the
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
// all users because the knobs are global; rows carry rates only.
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
trendsDefaultWeeks = 12
trendsMaxWeeks = 52
// trendsAuditFetchCap bounds the audit fetch; markers older than
// the window are dropped in Go. Far above any real knob-turn count
// inside a year.
trendsAuditFetchCap = 500
)
// trendPoint is one week of one surface family's outcomes.
type trendPoint struct {
WeekStart string `json:"week_start"` // ISO date (Monday)
Plays int64 `json:"plays"`
Skips int64 `json:"skips"`
SkipRate float64 `json:"skip_rate"`
AvgCompletion float64 `json:"avg_completion"`
// TasteHitRate is the share of plays whose track's artist carries a
// positive weight in the player's current taste profile — a drifted
// but retroactive read on whether the surface feeds taste-fitting
// tracks.
TasteHitRate float64 `json:"taste_hit_rate"`
// completionN carries the completion sample count through same-week
// merges so avg_completion stays sample-weighted; not serialized.
completionN int64
}
// trendSeries is one surface family's weekly series.
type trendSeries struct {
Key string `json:"key"`
Label string `json:"label"`
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
Points []trendPoint `json:"points"`
}
// trendMarker is one tuning-audit event annotated on the timeline.
type trendMarker struct {
ChangedAt string `json:"changed_at"`
Scope string `json:"scope"`
Action string `json:"action"`
Changes json.RawMessage `json:"changes"`
}
type trendsResp struct {
Weeks int `json:"weeks"`
Series []trendSeries `json:"series"`
Markers []trendMarker `json:"markers"`
}
// handleGetRecommendationTrends implements
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
weeks := trendsDefaultWeeks
if v := r.URL.Query().Get("weeks"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
return
}
if n > trendsMaxWeeks {
n = trendsMaxWeeks
}
weeks = n
}
q := dbq.New(h.pool)
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
if err != nil {
h.logger.Error("api: recommendation trends", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
if err != nil {
h.logger.Error("api: recommendation trends audit", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
}
// buildTrendsResponse folds weekly rows into per-family series and
// windows the audit markers. Split from the handler for pure-unit
// testability.
func buildTrendsResponse(
weeks int, now time.Time,
rows []dbq.RecommendationWeeklyTrendsRow,
audits []dbq.RecommendationTuningAudit,
) trendsResp {
type accum struct {
fam recFamily
plays int64
points []trendPoint
}
families := map[string]*accum{}
for _, row := range rows {
fam := recFamily{key: "manual", label: "Manual library plays"}
if row.Source != nil && *row.Source != "" {
fam = bucketRecSource(*row.Source)
}
acc, ok := families[fam.key]
if !ok {
acc = &accum{fam: fam}
families[fam.key] = acc
}
acc.plays += row.Plays
p := trendPoint{
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
Plays: row.Plays,
Skips: row.Skips,
AvgCompletion: row.AvgCompletion,
completionN: row.CompletionN,
}
if row.Plays > 0 {
p.SkipRate = float64(row.Skips) / float64(row.Plays)
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
}
// Same family can arrive as several raw sources (radio:<uuid>);
// merge same-week points play-weighted.
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
} else {
acc.points = append(acc.points, p)
}
}
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
for _, acc := range families {
resp.Series = append(resp.Series, trendSeries{
Key: acc.fam.key,
Label: acc.fam.label,
Intent: acc.fam.intent,
Plays: acc.plays,
Points: acc.points,
})
}
sort.Slice(resp.Series, func(i, j int) bool {
if resp.Series[i].Plays != resp.Series[j].Plays {
return resp.Series[i].Plays > resp.Series[j].Plays
}
return resp.Series[i].Key < resp.Series[j].Key
})
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
for _, a := range audits {
if a.ChangedAt.Time.Before(cutoff) {
continue
}
resp.Markers = append(resp.Markers, trendMarker{
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
Scope: a.Scope,
Action: a.Action,
Changes: json.RawMessage(a.Changes),
})
}
// ListTuningAudit returns newest-first; the timeline reads better
// oldest-first.
sort.Slice(resp.Markers, func(i, j int) bool {
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
})
return resp
}
// mergeTrendPoints combines two same-week points of one family:
// counts add, skip/taste rates re-derive from the merged counts, and
// avg_completion is weighted by each side's completion sample count.
func mergeTrendPoints(a, b trendPoint) trendPoint {
out := trendPoint{
WeekStart: a.WeekStart,
Plays: a.Plays + b.Plays,
Skips: a.Skips + b.Skips,
completionN: a.completionN + b.completionN,
}
if out.Plays > 0 {
out.SkipRate = float64(out.Skips) / float64(out.Plays)
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
float64(out.Plays)
}
if out.completionN > 0 {
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
float64(out.completionN)
}
return out
}
@@ -1,152 +0,0 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func date(s string) pgtype.Date {
t, _ := time.Parse("2006-01-02", s)
return pgtype.Date{Time: t, Valid: true}
}
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
src := func(s string) *string { return &s }
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
rows := []dbq.RecommendationWeeklyTrendsRow{
// Two radio session sources in the same week collapse into one
// family point; completion weighting by sample count.
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
// NULL source = manual baseline family.
{WeekStart: date("2026-06-29"), Source: nil,
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
}
audits := []dbq.RecommendationTuningAudit{
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
// Older than the window → dropped.
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
}
resp := buildTrendsResponse(12, now, rows, audits)
if len(resp.Series) != 2 {
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
}
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
if radio.Key != "radio" || radio.Plays != 6 {
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
}
if len(radio.Points) != 2 {
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
}
wk1 := radio.Points[0]
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
}
if wk1.SkipRate != 0.5 {
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
}
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
}
// Taste hits: 3 of 4 plays.
if wk1.TasteHitRate != 0.75 {
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
}
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
}
if len(resp.Markers) != 1 {
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
}
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
}
}
func newTrendsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
return r
}
func TestRecommendationTrends_EndToEnd(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "trends", "pw", true)
artist := seedArtist(t, pool, "TrendArtist")
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
session := seedPlaySession(t, pool, user.ID, time.Now())
// Positive taste weight for the artist → plays count as taste hits.
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
user.ID, artist.ID); err != nil {
t.Fatalf("seed taste profile: %v", err)
}
radio := "radio"
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
// A knob turn to mark the timeline.
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
map[string]float64{"taste_weight": 2}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
var resp trendsResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Weeks != trendsDefaultWeeks {
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
}
var radioSeries *trendSeries
for i := range resp.Series {
if resp.Series[i].Key == "radio" {
radioSeries = &resp.Series[i]
}
}
if radioSeries == nil || len(radioSeries.Points) != 1 {
t.Fatalf("radio series = %+v, want one point", radioSeries)
}
if radioSeries.Points[0].TasteHitRate != 1.0 {
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
radioSeries.Points[0].TasteHitRate)
}
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
}
}
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
-152
View File
@@ -1,152 +0,0 @@
// Admin recommendation-tuning endpoints (#1250): the defaults-
// discovery lab. GET returns current values + shipped defaults for
// every scope; PATCH applies a partial update to one scope; reset
// restores a scope to shipped defaults. Every change writes an audit
// row (consumed by the metrics trend view, #1251).
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
}
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
return weightsResp{
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
}
}
type tasteTuningResp struct {
HalfLifeDays float64 `json:"half_life_days"`
EngagementHardSkip float64 `json:"engagement_hard_skip"`
EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
return tasteTuningResp{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
}
}
// tuningSnapshot is both the GET response and the post-mutation echo:
// current values alongside shipped defaults so the card can mark
// which knobs deviate.
type tuningSnapshot struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
Shipped struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
} `json:"shipped"`
}
func (h *handlers) tuningSnapshot() tuningSnapshot {
var out tuningSnapshot
out.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
}
out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
}
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
return out
}
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// patchTuningReq carries the partial update: field name → new value,
// using the same snake_case names the GET response emits.
type patchTuningReq struct {
Values map[string]float64 `json:"values"`
}
// handlePatchRecommendationTuning implements
// PATCH /api/admin/recommendation-tuning/{scope}.
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
var body patchTuningReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if len(body.Values) == 0 {
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
return
}
var err error
if scope == recsettings.ScopeTaste {
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
} else {
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
}
if err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// handleResetRecommendationTuning implements
// POST /api/admin/recommendation-tuning/{scope}/reset.
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// writeTuningErr maps recsettings validation errors to 400s and
// everything else to a logged 500.
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
switch {
case errors.Is(err, recsettings.ErrUnknownScope):
writeErr(w, &apierror.Error{
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
})
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
default:
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
writeErr(w, apierror.InternalMsg("tuning update failed", err))
}
}
@@ -1,110 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
func newTuningRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
return r
}
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
t.Helper()
var snap tuningSnapshot
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
t.Fatalf("decode: %v", err)
}
return snap
}
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
rec := httptest.NewRecorder()
newTuningRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
}
if snap.Taste.HalfLifeDays != 75 {
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
}
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
t.Error("untouched values must equal shipped defaults")
}
}
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 2.5 {
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
}
// The change is live for the radio scoring path.
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
}
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200", rec.Code)
}
snap = decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 {
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
}
}
func TestRecommendationTuning_PatchErrors(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
cases := []struct {
name, path, body string
want int
}{
{"unknown scope", "/api/admin/recommendation-tuning/banana",
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
{"unknown field", "/api/admin/recommendation-tuning/radio",
`{"values":{"vibes":1}}`, http.StatusBadRequest},
{"out of range", "/api/admin/recommendation-tuning/taste",
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
{"empty values", "/api/admin/recommendation-tuning/radio",
`{"values":{}}`, http.StatusBadRequest},
{"bad json", "/api/admin/recommendation-tuning/radio",
`{`, http.StatusBadRequest},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
}
}
}
-153
View File
@@ -1,153 +0,0 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// tagSourceResp is the wire shape for one tag-enrichment provider in the
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
// (admin_cover_sources.go) — a separate, independent settings card so a new
// tag source is added without touching art settings (#1490).
type tagSourceResp struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
RequiresAPIKey bool `json:"requires_api_key"`
Supports []string `json:"supports"`
Enabled bool `json:"enabled"`
APIKeySet bool `json:"api_key_set"`
DisplayOrder int32 `json:"display_order"`
Testable bool `json:"testable"`
}
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
supports := info.Supports
if supports == nil {
supports = []string{}
}
return tagSourceResp{
ID: info.ID,
DisplayName: info.DisplayName,
RequiresAPIKey: info.RequiresAPIKey,
Supports: supports,
Enabled: info.Enabled,
APIKeySet: info.APIKeySet,
DisplayOrder: info.DisplayOrder,
Testable: info.Testable,
}
}
type tagSourcesListResp struct {
Providers []tagSourceResp `json:"providers"`
SourcesVersion int32 `json:"sources_version"`
}
// handleListTagSources implements GET /api/admin/tag-sources.
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
infos := h.tagSettings.ListProviderInfo()
out := tagSourcesListResp{
Providers: make([]tagSourceResp, 0, len(infos)),
SourcesVersion: h.tagSettings.CurrentVersion(),
}
for _, info := range infos {
out.Providers = append(out.Providers, tagSourceRespFrom(info))
}
writeJSON(w, http.StatusOK, out)
}
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
// unchanged"; non-nil sets (empty api_key clears).
type updateTagSourceReq struct {
Enabled *bool `json:"enabled,omitempty"`
APIKey *string `json:"api_key,omitempty"`
}
type updateTagSourceResp struct {
tagSourceResp
VersionBumped bool `json:"version_bumped"`
}
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
var body updateTagSourceReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
Enabled: body.Enabled,
APIKey: body.APIKey,
})
if err != nil {
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
h.logger.Error("admin: update tag source", "id", id, "err", err)
writeErr(w, apierror.InternalMsg("update failed", err))
return
}
var found tagSourceResp
for _, info := range h.tagSettings.ListProviderInfo() {
if info.ID == id {
found = tagSourceRespFrom(info)
break
}
}
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
}
type testTagSourceResp struct {
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
start := time.Now()
err := h.tagSettings.TestProvider(r.Context(), id)
duration := time.Since(start).Milliseconds()
if err == nil {
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
return
}
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
// Not testable, or the test failed — both surface as ok=false with a
// 200 (the operation completed; the result is data, not an error).
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
}
type researchTagsResp struct {
SourcesVersion int32 `json:"sources_version"`
}
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
// the sources version so every settled ('none') track becomes eligible for
// a re-enrichment pass, then the background worker re-processes them.
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
newVer, err := h.tagSettings.BumpVersion(r.Context())
if err != nil {
h.logger.Error("admin: research tags", "err", err)
writeErr(w, apierror.InternalMsg("research failed", err))
return
}
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
}
-243
View File
@@ -1,243 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
// Does not implement TestableProvider.
type apiTestTagProvider struct {
id string
display string
}
func (p *apiTestTagProvider) ID() string { return p.id }
func (p *apiTestTagProvider) DisplayName() string { return p.display }
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
return []tags.Tag{{Name: "x", Weight: 1}}, nil
}
// apiTestTestableTagProvider also implements TestableProvider.
type apiTestTestableTagProvider struct {
apiTestTagProvider
}
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
func newAdminTagSourcesRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/tag-sources", h.handleListTagSources)
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
})
return r
}
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
// test DB. Register the needed fake providers BEFORE calling this, and pair
// with tags.ResetRegistryForTests in t.Cleanup.
func testHandlersWithTagSettings(t *testing.T) *handlers {
t.Helper()
h, pool := testHandlers(t)
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
if err != nil {
t.Fatalf("NewSettingsService: %v", err)
}
h.tagSettings = s
return h
}
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tslist", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp tagSourcesListResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
}
found := false
for _, s := range resp.Providers[0].Supports {
if s == "track_tags" {
found = true
}
}
if !found {
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
}
if resp.Providers[0].Testable {
t.Error("testable = true, want false (no TestConnection)")
}
}
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsflip", "pw", true)
disabled := false
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.VersionBumped {
t.Error("version_bumped = false, want true after flipping enabled")
}
}
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tskey", "pw", true)
newKey := "newkey"
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VersionBumped {
t.Error("version_bumped = true, want false for key-only change")
}
if !resp.APIKeySet {
t.Error("api_key_set = false, want true after setting key")
}
}
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "ts404", "pw", true)
body, _ := json.Marshal(updateTagSourceReq{})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
h := testHandlersWithTagSettings(t)
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
req = withUser(req, nonAdmin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsntest", "pw", true)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp testTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.OK {
t.Error("ok = true, want false for non-testable provider")
}
if resp.Error == "" {
t.Error("error string empty, want a message")
}
}
+1 -20
View File
@@ -22,19 +22,16 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs,
@@ -43,7 +40,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
playlists: playlistsSvc,
coverart: coverEnricher,
coverSettings: coverSettings,
tagSettings: tagSettings,
scanner: scanner,
scanCfg: scanCfg,
dataDir: dataDir,
@@ -202,22 +198,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
admin.Get("/tag-sources", h.handleListTagSources)
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
admin.Post("/tag-sources/research", h.handleResearchTags)
admin.Get("/smtp-config", h.handleGetSMTPConfig)
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
// Recommendation tuning lab (#1250): scoring-weight
// profiles + taste-build knobs, DB-backed, live effect.
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
// Weekly outcome trends + knob-turn markers (#1251).
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
})
authed.Get("/playlists", h.handleListPlaylists)
@@ -240,7 +223,6 @@ type handlers struct {
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
recSettings *recsettings.Service
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
@@ -249,7 +231,6 @@ type handlers struct {
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
+4 -6
View File
@@ -29,7 +29,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
@@ -57,12 +56,11 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dbtest.ResetDB(t, pool)
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
recCfg := config.RecommendationConfig{
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
SkipPenalty: 1.0, JitterMagnitude: 0.1,
ContextWeight: 2.0, SimilarityWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
}
recSettings, err := recsettings.New(context.Background(), pool, logger)
if err != nil {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
@@ -72,7 +70,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
return h, pool
}
+1 -21
View File
@@ -31,26 +31,6 @@ type playStartedResponse struct {
SessionID string `json:"session_id"`
}
// eventFutureSkew is the tolerated client-clock drift into the future
// before a timestamp is treated as bogus and replaced with now.
const eventFutureSkew = 5 * time.Minute
// clampEventTime bounds a client-supplied event timestamp to sanity:
// no earlier than the account's creation (offline replays can
// legitimately be days old, but no play can predate the user) and no
// later than now + a small skew allowance. Unbounded client clocks
// previously let a skewed device write arbitrarily old plays, which
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
if at.Before(userCreatedAt) {
return userCreatedAt
}
if at.After(now.Add(eventFutureSkew)) {
return now
}
return at
}
type okResponse struct {
OK bool `json:"ok"`
}
@@ -72,7 +52,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
return
}
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
at = parsed
}
clientID := ""
if req.ClientID != nil {
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
paths := []string{
"/api/artists",
+27 -257
View File
@@ -2,9 +2,7 @@ package api
import (
"net/http"
"sort"
"strconv"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -13,142 +11,28 @@ import (
const (
recMetricsDefaultDays = 30
recMetricsMaxDays = 365
// recMetricsLowVolume marks a family as low-confidence rather than
// hiding it: with fewer plays than this a skip rate is anecdote, not
// signal, but silently dropping the row would misread as "surface
// unused". The web renders low-confidence rows dimmed.
recMetricsLowVolume = 20
)
// Surface intents (milestone #127): each family is judged against its
// job, not one global bar — discovery mixes are EXPECTED to run higher
// skip rates than the go-to surfaces.
const (
intentGoTo = "go_to"
intentDiscovery = "discovery"
intentDirect = "direct"
)
// surfaceMetric is one bucketed surface family's outcomes.
type surfaceMetric struct {
Key string `json:"key"` // stable family key ("for_you", "radio", …)
Label string `json:"label"` // display label
Plays int64 `json:"plays"` // plays launched from this family
// recommendationMetric is one recommendation surface's outcomes.
type recommendationMetric struct {
Source string `json:"source"` // 'for_you' | 'discover' | mixes
Plays int64 `json:"plays"` // plays launched from this surface
Skips int64 `json:"skips"` // of those, marked skipped
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
// Breakdown splits the family into the pick-kind populations its
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
// that predate attribution. Present only when the family has at
// least one attributed play; the parent row remains the sum of its
// breakdown.
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
}
// surfaceGroup is one intent band of surface families.
type surfaceGroup struct {
Intent string `json:"intent"` // go_to | discovery | direct
Label string `json:"label"`
Surfaces []surfaceMetric `json:"surfaces"`
}
type recommendationMetricsResp struct {
WindowDays int `json:"window_days"`
// Baseline is the control group: plays the user picked manually
// (source IS NULL). Surfaces are judged as deltas against it; nil
// when the window holds no manual plays.
Baseline *surfaceMetric `json:"baseline"`
Groups []surfaceGroup `json:"groups"`
}
// recFamily is the bucketing target for a raw play_events.source value.
type recFamily struct {
key string
label string
intent string
}
// bucketRecSource maps a raw client-stamped source string to its stable
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
// their family so the table stays readable at any library size.
func bucketRecSource(src string) recFamily {
switch {
case src == "for_you":
return recFamily{"for_you", "For You", intentGoTo}
case src == "songs_like_artist":
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
case src == "radio" || strings.HasPrefix(src, "radio:"):
return recFamily{"radio", "Radio", intentGoTo}
case src == "discover":
return recFamily{"discover", "Discover", intentDiscovery}
case src == "deep_cuts":
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
case src == "rediscover":
return recFamily{"rediscover", "Rediscover", intentDiscovery}
case src == "new_for_you":
return recFamily{"new_for_you", "New for you", intentDiscovery}
case src == "on_this_day":
return recFamily{"on_this_day", "On this day", intentDiscovery}
case src == "first_listens":
return recFamily{"first_listens", "First listens", intentDiscovery}
case strings.HasPrefix(src, "album:"):
return recFamily{"direct_album", "Album plays", intentDirect}
case strings.HasPrefix(src, "artist:"):
return recFamily{"direct_artist", "Artist plays", intentDirect}
case strings.HasPrefix(src, "offline:"):
return recFamily{"offline", "Offline pools", intentDirect}
case strings.HasPrefix(src, "home:"):
return recFamily{"home", "Home sections", intentDirect}
case src == "history":
return recFamily{"history", "History", intentDirect}
default:
return recFamily{"other", "Other", intentDirect}
}
}
// familyAccum merges raw source rows into one family, carrying the
// completion sample count so the merged average stays play-weighted.
type familyAccum struct {
fam recFamily
plays int64
skips int64
completionN int64
// completionSum is avg*count re-expanded, so merging N raw rows
// reduces to a single weighted division at the end.
completionSum float64
}
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
a.plays += row.Plays
a.skips += row.Skips
a.completionN += row.CompletionN
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
}
func (a *familyAccum) metric() surfaceMetric {
m := surfaceMetric{
Key: a.fam.key,
Label: a.fam.label,
Plays: a.plays,
Skips: a.skips,
LowConfidence: a.plays < recMetricsLowVolume,
}
if a.plays > 0 {
m.SkipRate = float64(a.skips) / float64(a.plays)
}
if a.completionN > 0 {
m.AvgCompletion = a.completionSum / float64(a.completionN)
}
return m
WindowDays int `json:"window_days"`
Sources []recommendationMetric `json:"sources"`
}
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
// Bucketed per-surface-family outcomes for the caller over the last `days`
// (default 30, capped at 365), grouped by surface intent and anchored by the
// manual-plays baseline so the numbers are judgeable, not just observable.
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
// caller over the last `days` (default 30, capped at 365), so the operator can
// see which recommendation surfaces are landing and tune the taste weights.
// Only plays tagged with a system-playlist source count; library/radio plays
// (no source) are excluded.
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
@@ -167,142 +51,28 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
return
}
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
}
// pickKindLabels is the display vocabulary for play_events.pick_kind
// values (mirrors the CHECK in migration 0041). Every system mix that
// stamps provenance gets its breakdown from this one map — adding a
// stamping mix needs no metrics change.
var pickKindLabels = map[string]string{
"taste": "Taste picks",
"fresh": "Fresh picks",
"dormant": "Dormant artists",
"taste_unheard": "Taste-matched",
"cross_user": "Liked by others",
"random": "Random unheard",
"tier1": "Tier 1 (exact)",
"tier2": "Tier 2 (relaxed)",
"tier3": "Tier 3 (stretched)",
}
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
// pick_kind — plays recorded before the mix stamped provenance, or
// whose track had rotated out of the snapshot at ingestion) renders
// last, kept visible so the parent row's sums stay transparent instead
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
// vocabulary, so iterating the list is exhaustive.
var pickKindOrder = []string{
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
"tier1", "tier2", "tier3", "",
}
// pickKindFamily derives the sub-family for one (family, pick_kind)
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
func pickKindFamily(parent recFamily, kind string) recFamily {
if kind == "" {
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
out := recommendationMetricsResp{
WindowDays: days,
Sources: make([]recommendationMetric, 0, len(rows)),
}
label, ok := pickKindLabels[kind]
if !ok {
label = kind
}
return recFamily{parent.key + "_" + kind, label, parent.intent}
}
// pickKindBreakdown folds a family's per-pick-kind accums into its
// Breakdown rows. Attached only when at least one attributed play
// exists — an all-unattributed breakdown would just repeat the parent
// row, and families that never stamp (radio, direct plays) stay flat.
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
attributed := int64(0)
for kind, acc := range picks {
if kind != "" {
attributed += acc.plays
}
}
if attributed == 0 {
return nil
}
out := make([]surfaceMetric, 0, len(picks))
for _, kind := range pickKindOrder {
if acc, ok := picks[kind]; ok && acc.plays > 0 {
out = append(out, acc.metric())
}
}
return out
}
// bucketMetricsResponse folds the raw per-source rows into the grouped,
// baseline-anchored response shape. Split from the handler for pure-unit
// testability.
func bucketMetricsResponse(
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
) recommendationMetricsResp {
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
families := map[string]*familyAccum{}
picks := map[string]map[string]*familyAccum{}
for _, row := range rows {
if row.Source == nil || *row.Source == "" {
baseline.add(row)
continue
source := ""
if row.Source != nil {
source = *row.Source
}
fam := bucketRecSource(*row.Source)
acc, exists := families[fam.key]
if !exists {
acc = &familyAccum{fam: fam}
families[fam.key] = acc
var skipRate float64
if row.Plays > 0 {
skipRate = float64(row.Skips) / float64(row.Plays)
}
acc.add(row)
// Accumulate the pick-kind population unconditionally; families
// that never stamp end up all-unattributed and get no breakdown.
kind := ""
if row.PickKind != nil {
kind = *row.PickKind
}
byKind, ok := picks[fam.key]
if !ok {
byKind = map[string]*familyAccum{}
picks[fam.key] = byKind
}
pick, ok := byKind[kind]
if !ok {
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
byKind[kind] = pick
}
pick.add(row)
}
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
if baseline.plays > 0 {
m := baseline.metric()
resp.Baseline = &m
}
for _, g := range []struct{ intent, label string }{
{intentGoTo, "Go-to surfaces"},
{intentDiscovery, "Discovery mixes"},
{intentDirect, "Direct plays"},
} {
group := surfaceGroup{Intent: g.intent, Label: g.label}
for _, acc := range families {
if acc.fam.intent == g.intent {
m := acc.metric()
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
group.Surfaces = append(group.Surfaces, m)
}
}
if len(group.Surfaces) == 0 {
continue
}
sort.Slice(group.Surfaces, func(i, j int) bool {
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
}
return group.Surfaces[i].Key < group.Surfaces[j].Key
out.Sources = append(out.Sources, recommendationMetric{
Source: source,
Plays: row.Plays,
Skips: row.Skips,
SkipRate: skipRate,
AvgCompletion: row.AvgCompletion,
})
resp.Groups = append(resp.Groups, group)
}
return resp
writeJSON(w, http.StatusOK, out)
}
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
+24 -197
View File
@@ -11,8 +11,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func newMetricsRouter(h *handlers) chi.Router {
@@ -22,19 +20,17 @@ func newMetricsRouter(h *handlers) chi.Router {
}
// seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (manual library play → baseline).
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
// except attributed for_you plays.
// skip flag. A nil source inserts NULL (library/radio play).
func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
source, pickKind *string, completion float64, skipped bool,
source *string, completion float64, skipped bool,
) {
t.Helper()
if _, err := h.pool.Exec(context.Background(),
`INSERT INTO play_events
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
userID, trackID, sessionID, source, completion, skipped); err != nil {
t.Fatalf("seed sourced play: %v", err)
}
}
@@ -49,168 +45,7 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
}
}
// findSurface returns the named family from any group, or nil.
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
for _, g := range resp.Groups {
for i := range g.Surfaces {
if g.Surfaces[i].Key == key {
return &g.Surfaces[i]
}
}
}
return nil
}
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
src := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
// Two radio sessions collapse into one "radio" family; weighted
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
// NULL source = manual plays → baseline, not a group row.
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
if resp.Baseline == nil {
t.Fatal("baseline missing")
}
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
}
if resp.Baseline.LowConfidence {
t.Error("baseline with 25 plays should not be low-confidence")
}
radio := findSurface(resp, "radio")
if radio == nil {
t.Fatal("radio family missing")
}
if radio.Plays != 2 || radio.Skips != 1 {
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
}
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
}
if !radio.LowConfidence {
t.Error("radio with 2 plays should be low-confidence")
}
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
t.Errorf("direct_album = %+v, want plays=1", s)
}
if s := findSurface(resp, ""); s != nil {
t.Error("NULL source must not appear as a surface family")
}
// No attributed (taste/fresh) plays in this fixture → no breakdown;
// an all-unattributed breakdown would just repeat the parent row.
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
}
// Group ordering is intent-banded: go_to before discovery before direct.
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
if len(resp.Groups) != len(wantOrder) {
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
}
for i, g := range resp.Groups {
if g.Intent != wantOrder[i] {
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
}
}
}
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
CompletionN: 30, AvgCompletion: 0.9},
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
CompletionN: 10, AvgCompletion: 0.5},
// NULL pick_kind = plays that predate attribution.
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
fy := findSurface(resp, "for_you")
if fy == nil {
t.Fatal("for_you family missing")
}
// The parent row stays the sum of its breakdown.
if fy.Plays != 45 || fy.Skips != 8 {
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
}
if len(fy.Breakdown) != 3 {
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
}
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
for i, k := range wantKeys {
if fy.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
}
}
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
}
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
}
// Breakdown rows never appear as their own surface families.
if s := findSurface(resp, "for_you_taste"); s != nil {
t.Error("for_you_taste must not be a top-level surface")
}
}
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
// Provenance is standard (#1270): Discover's bucket stamps surface as
// a breakdown exactly like For You's taste/fresh — this is what makes
// the bucket allocation judgeable instead of a guess.
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
CompletionN: 8, AvgCompletion: 0.8},
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
CompletionN: 6, AvgCompletion: 0.6},
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
CompletionN: 4, AvgCompletion: 0.4},
// NULL pick_kind = plays that predate bucket stamping.
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
d := findSurface(resp, "discover")
if d == nil {
t.Fatal("discover family missing")
}
if d.Plays != 20 || d.Skips != 8 {
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
}
wantKeys := []string{
"discover_dormant", "discover_cross_user", "discover_random",
"discover_unattributed",
}
if len(d.Breakdown) != len(wantKeys) {
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
}
for i, k := range wantKeys {
if d.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
}
}
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
}
}
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
@@ -222,17 +57,15 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
session := seedPlaySession(t, pool, user.ID, time.Now())
forYou := "for_you"
radioA := "radio:11111111-1111-1111-1111-111111111111"
taste, fresh := "taste", "fresh"
discover := "discover"
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
// A radio session play collapses into the "radio" family.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
// Manual play (NULL source) — the baseline row.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
// discover: 1 play.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
// library play (NULL source) — must be excluded.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
req = withUser(req, user)
@@ -249,11 +82,15 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if resp.WindowDays != recMetricsDefaultDays {
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
}
if resp.Baseline == nil || resp.Baseline.Plays != 1 {
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
bySource := map[string]recommendationMetric{}
for _, m := range resp.Sources {
bySource[m.Source] = m
}
fy := findSurface(resp, "for_you")
if fy == nil {
if _, present := bySource[""]; present {
t.Error("NULL-source (library) plays should be excluded")
}
fy, ok := bySource["for_you"]
if !ok {
t.Fatal("for_you metrics missing")
}
if fy.Plays != 3 || fy.Skips != 1 {
@@ -265,17 +102,7 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
}
// Pick-kind attribution surfaces as the For You breakdown (#1249).
if len(fy.Breakdown) != 2 {
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
}
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
}
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
}
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
}
}
+10 -4
View File
@@ -15,7 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
// RadioResponse is the body of GET /api/radio.
@@ -101,9 +100,16 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
}
}
// Scoring weights come from the DB-backed tuning lab (#1250) —
// read per request so an admin change takes effect live.
weights := h.recSettings.Weights(recsettings.ScopeRadio)
weights := recommendation.ScoringWeights{
BaseWeight: h.recCfg.BaseWeight,
LikeBoost: h.recCfg.LikeBoost,
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
SimilarityWeight: h.recCfg.SimilarityWeight,
TasteWeight: h.recCfg.TasteWeight,
}
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
out := make([]TrackRef, 0, len(picks)+1)
+23 -7
View File
@@ -88,14 +88,20 @@ type EventsConfig struct {
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
}
// RecommendationConfig holds the radio path's operational knobs. The
// scoring WEIGHTS moved to DB-backed admin settings (#1250,
// internal/recsettings) — YAML is bootstrap-only; anything an operator
// tunes lives in the UI with live effect.
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
// All weights are operator-tunable; defaults match the spec recommendations.
type RecommendationConfig struct {
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"`
SimilarityWeight float64 `yaml:"similarity_weight"`
TasteWeight float64 `yaml:"taste_weight"`
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
}
func Default() Config {
@@ -114,6 +120,16 @@ func Default() Config {
SkipMaxDurationPlayedMs: 30000,
},
Recommendation: RecommendationConfig{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
// Radio is seed-directed (the user picked a direction), so taste
// is a lighter nudge here than in the daily mixes (1.5).
TasteWeight: 1.0,
RecentlyPlayedHours: 1,
RadioSize: 50,
RadioSizeMax: 200,
-66
View File
@@ -211,69 +211,3 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
}
return items, nil
}
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
LIMIT 120
`
type ListTasteUnheardTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
}
type ListTasteUnheardTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
// weight — "new to you, but your vibe" rather than the crude random arm.
// Genres live inline on tracks.genre as a delimited string, split the
// same way the radio tag_overlap arm does (regexp_split_to_table on
// [;,]). Same exclusion filters as the other buckets. Returns nothing
// when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteUnheardTracksForDiscoverRow
for rows.Next() {
var i ListTasteUnheardTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+7 -50
View File
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
}
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events
WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
@@ -79,13 +79,12 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1
`
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
@@ -105,48 +104,15 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
SELECT pt.pick_kind
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1
AND p.system_variant = $2
AND pt.track_id = $3
LIMIT 1
`
type GetSystemPickKindForTrackParams struct {
UserID pgtype.UUID
SystemVariant *string
TrackID pgtype.UUID
}
// Looks a track up in the user's CURRENT snapshot of the given system
// variant and returns its pick_kind. Used at play-ingestion time to
// freeze provenance onto the play_event — snapshots rebuild daily, so
// attribution can't be reconstructed at read time (#1249/#1270). No
// row = track not in today's snapshot (caller stores NULL); a row with
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
// is non-singleton (up to 3 mixes/user); a track in two of them takes
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
// same eligibility ladder.
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
var pick_kind *string
err := row.Scan(&pick_kind)
return pick_kind, err
}
const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
user_id, track_id, session_id, started_at, client_id, source
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
`
type InsertPlayEventParams struct {
@@ -156,12 +122,8 @@ type InsertPlayEventParams struct {
StartedAt pgtype.Timestamptz
ClientID *string
Source *string
PickKind *string
}
// pick_kind is non-NULL only for system-playlist plays whose track was
// found (with a stamped kind) in the user's live snapshot for that
// variant at ingestion time (#1249, generalized in #1270).
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID,
@@ -170,7 +132,6 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.StartedAt,
arg.ClientID,
arg.Source,
arg.PickKind,
)
var i PlayEvent
err := row.Scan(
@@ -187,7 +148,6 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
@@ -254,7 +214,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
}
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1
AND pe.started_at < $2
@@ -296,8 +256,6 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -333,7 +291,7 @@ SET ended_at = $2,
completion_ratio = $4,
was_skipped = $5
WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
`
type UpdatePlayEventEndedParams struct {
@@ -370,7 +328,6 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
+1 -3
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id,
pe.started_at,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM play_events pe
@@ -77,8 +77,6 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
+1 -3
View File
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
}
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
JOIN general_likes l ON l.track_id = t.id
WHERE l.user_id = $1
ORDER BY l.liked_at DESC
@@ -297,8 +297,6 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
+15 -70
View File
@@ -383,7 +383,6 @@ type PlayEvent struct {
SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz
Source *string
PickKind *string
}
type PlaySession struct {
@@ -434,28 +433,6 @@ type PlaylistTrack struct {
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
PickKind *string
}
type RecommendationTuningAudit struct {
ID int64
ChangedAt pgtype.Timestamptz
Scope string
Action string
Changes []byte
}
type RecommendationWeightProfile struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
}
type RegistrationSetting struct {
@@ -532,21 +509,6 @@ type SystemPlaylistRun struct {
LastError *string
}
type TagProviderSetting struct {
ProviderID string
Enabled bool
ApiKey *string
DisplayOrder int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type TagSourcesMetum struct {
ID bool
CurrentVersion int32
LastRegisteredProvidersHash string
}
type TasteProfileArtist struct {
UserID pgtype.UUID
ArtistID pgtype.UUID
@@ -561,33 +523,22 @@ type TasteProfileTag struct {
UpdatedAt pgtype.Timestamptz
}
type TasteTuning struct {
Singleton bool
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
UpdatedAt pgtype.Timestamptz
}
type Track struct {
ID pgtype.UUID
Title string
AlbumID pgtype.UUID
ArtistID pgtype.UUID
TrackNumber *int32
DiscNumber *int32
DurationMs int32
FilePath string
FileSize int64
FileFormat string
Bitrate *int32
Mbid *string
Genre *string
AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
TagSource *string
TagSourcesVersion int32
ID pgtype.UUID
Title string
AlbumID pgtype.UUID
ArtistID pgtype.UUID
TrackNumber *int32
DiscNumber *int32
DurationMs int32
FilePath string
FileSize int64
FileFormat string
Bitrate *int32
Mbid *string
Genre *string
AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type TrackSimilarity struct {
@@ -598,12 +549,6 @@ type TrackSimilarity struct {
FetchedAt pgtype.Timestamptz
}
type TrackTag struct {
TrackID pgtype.UUID
Tag string
Weight float64
}
type User struct {
ID pgtype.UUID
Username string
+7 -13
View File
@@ -12,7 +12,7 @@ import (
)
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
SELECT
$1::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
@@ -20,27 +20,24 @@ SELECT
t.title,
artists.name,
albums.title,
(t.duration_ms / 1000)::integer,
$2::text
(t.duration_ms / 1000)::integer
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.id = $3::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
WHERE t.id = $2::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
`
type AppendPlaylistTrackParams struct {
PlaylistID pgtype.UUID
PickKind *string
TrackID pgtype.UUID
}
// Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms
// is converted to seconds for the snapshot. pick_kind is NULL for manual
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
// is converted to seconds for the snapshot.
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID)
var i PlaylistTrack
err := row.Scan(
&i.PlaylistID,
@@ -51,7 +48,6 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
&i.PickKind,
)
return i, err
}
@@ -261,7 +257,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
}
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at,
t.id AS live_track_id,
albums.id AS album_id,
artists.id AS artist_id
@@ -282,7 +278,6 @@ type ListPlaylistTracksRow struct {
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
PickKind *string
LiveTrackID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
@@ -310,7 +305,6 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
&i.PickKind,
&i.LiveTrackID,
&i.AlbumID,
&i.ArtistID,
+4 -12
View File
@@ -104,7 +104,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -161,8 +161,6 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -183,7 +181,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -242,8 +240,6 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -581,7 +577,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -654,8 +650,6 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
@@ -770,7 +764,7 @@ random_fill AS (
LIMIT $9
)
SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -868,8 +862,6 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
+12 -91
View File
@@ -12,17 +12,19 @@ import (
)
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
SELECT
pe.source,
pe.pick_kind,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
COALESCE(
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
0)::float8 AS avg_completion
FROM play_events pe
WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source, pe.pick_kind
GROUP BY pe.source
ORDER BY plays DESC
`
@@ -33,17 +35,18 @@ type RecommendationSourceMetricsForUserParams struct {
type RecommendationSourceMetricsForUserRow struct {
Source *string
PickKind *string
Plays int64
Skips int64
CompletionN int64
AvgCompletion float64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
// NULL for library / radio / user-playlist plays, which are excluded here.
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
// mean completion ratio over the completion_n plays that recorded one.
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
// it is NULL for every other source, so those still group to one row.
// mean completion ratio over plays that recorded one (0 when none did).
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
if err != nil {
@@ -55,10 +58,8 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
var i RecommendationSourceMetricsForUserRow
if err := rows.Scan(
&i.Source,
&i.PickKind,
&i.Plays,
&i.Skips,
&i.CompletionN,
&i.AvgCompletion,
); err != nil {
return nil, err
@@ -70,83 +71,3 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
}
return items, nil
}
const recommendationWeeklyTrends = `-- name: RecommendationWeeklyTrends :many
SELECT
date_trunc('week', pe.started_at)::date AS week_start,
pe.source,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
LEFT JOIN taste_profile_artists tpa
ON tpa.user_id = pe.user_id
AND tpa.artist_id = t.artist_id
AND tpa.weight > 0
WHERE pe.started_at > now() - ($1::int * INTERVAL '1 week')
GROUP BY 1, 2
ORDER BY 1, 2
`
type RecommendationWeeklyTrendsRow struct {
WeekStart pgtype.Date
Source *string
Plays int64
Skips int64
CompletionN int64
AvgCompletion float64
TasteHits int64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a recommendation surface; NULL means the user picked the track manually —
// those rows are INCLUDED here as the baseline control group the surfaces are
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
// actionable). Raw source strings are bucketed into stable surface families in
// the Go handler; completion_n is carried so family merges can weight
// avg_completion correctly.
// Weekly per-source outcome series for the tuning lab's trend view
// (#1251). Aggregated across ALL users: the tuning knobs are global,
// so judging a knob turn needs global outcomes — rows carry rates
// only, no track or user identity. NULL-source (manual) rows are
// included as the baseline family.
//
// taste_hits counts plays whose track's artist has a positive weight
// in that user's CURRENT taste profile — the "cheap recompute" option:
// retroactive over the whole window, at the cost of drift (the profile
// is today's, the play may be weeks old). Good enough to read whether
// a surface is feeding taste-fitting tracks.
// $1 window in weeks.
func (q *Queries) RecommendationWeeklyTrends(ctx context.Context, weeks int32) ([]RecommendationWeeklyTrendsRow, error) {
rows, err := q.db.Query(ctx, recommendationWeeklyTrends, weeks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeeklyTrendsRow
for rows.Next() {
var i RecommendationWeeklyTrendsRow
if err := rows.Scan(
&i.WeekStart,
&i.Source,
&i.Plays,
&i.Skips,
&i.CompletionN,
&i.AvgCompletion,
&i.TasteHits,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -1,272 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: recommendation_tuning.sql
package dbq
import (
"context"
)
const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
`
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
row := q.db.QueryRow(ctx, getTasteTuning)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
)
return i, err
}
const insertTuningAudit = `-- name: InsertTuningAudit :exec
INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3)
`
type InsertTuningAuditParams struct {
Scope string
Action string
Changes []byte
}
// changes is a jsonb array of {field, old, new} objects.
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
return err
}
const listTuningAudit = `-- name: ListTuningAudit :many
SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit
ORDER BY changed_at DESC, id DESC
LIMIT $1
`
// Newest first; consumed by the metrics trend view (#1251) to annotate
// knob turns on the timeline.
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
rows, err := q.db.Query(ctx, listTuningAudit, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationTuningAudit
for rows.Next() {
var i RecommendationTuningAudit
if err := rows.Scan(
&i.ID,
&i.ChangedAt,
&i.Scope,
&i.Action,
&i.Changes,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWeightProfiles = `-- name: ListWeightProfiles :many
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
`
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
rows, err := q.db.Query(ctx, listWeightProfiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeightProfile
for rows.Next() {
var i RecommendationWeightProfile
if err := rows.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTasteTuning = `-- name: UpdateTasteTuning :one
UPDATE taste_tuning
SET half_life_days = $1,
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
`
type UpdateTasteTuningParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
row := q.db.QueryRow(ctx, updateTasteTuning,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
)
return i, err
}
const updateWeightProfile = `-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
WHERE profile = $1
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
`
type UpdateWeightProfileParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
}
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
row := q.db.QueryRow(ctx, updateWeightProfile,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
)
var i RecommendationWeightProfile
err := row.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
)
return i, err
}
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
ON CONFLICT (singleton) DO NOTHING
`
type UpsertTasteTuningDefaultsParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
)
return err
}
const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (profile) DO NOTHING
`
type UpsertWeightProfileDefaultsParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
}
// Recommendation tuning lab queries (#1250). Seeding happens via the
// recsettings boot reconcile; shipped defaults live in Go only.
// Boot reconcile: insert the shipped defaults for a profile if the row
// doesn't exist yet. Never overwrites operator-tuned values.
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
)
return err
}
+70 -170
View File
@@ -27,20 +27,12 @@ play_counts AS (
FROM play_events
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
),
skip_counts AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = true
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.track_id = t.id
WHERE COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -68,11 +60,6 @@ type ListDeepCutsTracksRow struct {
// gives per-play variety on top.
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
// has liked OR played heavily (>=5 non-skip plays across the artist).
// Tracks the user has skipped twice or more don't qualify (#1257):
// eligibility used to count only unskipped plays, so the most actively
// rejected tracks read as "barely heard" and kept being re-offered.
// Threshold 2 so a single accidental skip doesn't banish a track;
// passive-signal only (no dislike UI, rule #101).
// $1 user_id, $2 date string.
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
@@ -95,49 +82,31 @@ func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracks
}
const listFirstListensTracks = `-- name: ListFirstListensTracks :many
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
WITH heard_albums AS (
SELECT DISTINCT t.album_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
),
album_attempts AS (
SELECT t.album_id,
COUNT(a.track_id) AS attempted_count,
COUNT(*) AS track_count
FROM tracks t
LEFT JOIN attempted a ON a.track_id = t.id
GROUP BY t.album_id
),
attempted_artists AS (
played_artists AS (
SELECT DISTINCT t.artist_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
albums_tiered AS (
SELECT aa.album_id,
CASE
WHEN aa.attempted_count = 0 THEN 1
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
END AS tier
FROM album_attempts aa
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
)
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN albums_tiered alt ON alt.album_id = al.id
WHERE alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY
alt.tier,
(CASE
WHEN EXISTS (
SELECT 1 FROM general_likes_artists gla
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
) THEN 0
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
ELSE 2
END),
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
@@ -148,25 +117,12 @@ type ListFirstListensTracksRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
Tier int32
}
// #423 First Listens: songs the user has never heard or even
// attempted. "Attempted" is track-level with a >=30s listen threshold
// (#1268, operator decision 2026-07-03) — the old version disqualified
// a whole album on ANY play_event, so a 2-second accidental brush
// banished it permanently.
//
// tier 1 the exact desire: albums with ZERO attempted tracks
// tier 2 step back: barely-attempted albums (<=25% of tracks
// reached 30s) — brushed, never explored. The already-
// attempted tracks themselves are excluded; they're not
// first listens.
//
// Within each tier, affinity-ordered as before: liked artist, then
// attempted-played artist (>=30s — skip-only contact isn't trust),
// then the rest; album-coherent. The tier lands on
// playlist_tracks.pick_kind via the producer. $1 user_id.
// #423 First Listens: albums the user has never played any track of.
// Tiered: liked-artist albums first, then played-artist albums, then
// the rest — album-coherent within each tier. Not diversity-capped
// (whole-album discovery). $1 user_id.
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
if err != nil {
@@ -176,12 +132,7 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
var items []ListFirstListensTracksRow
for rows.Next() {
var i ListFirstListensTracksRow
if err := rows.Scan(
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
@@ -193,76 +144,39 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
}
const listNewForYouTracks = `-- name: ListNewForYouTracks :many
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
),
consumed_albums AS (
SELECT DISTINCT t.album_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
affinity_artists AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
UNION
SELECT t.artist_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
HAVING COUNT(*) >= 3
),
albums_tiered AS (
SELECT al.id AS album_id, al.created_at,
CASE
WHEN al.created_at >= now() - interval '30 days'
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
ELSE 3
END AS tier
FROM albums al
WHERE al.created_at >= now() - interval '90 days'
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
)
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN albums_tiered alt ON alt.album_id = t.album_id
WHERE NOT EXISTS (
JOIN albums al ON al.id = t.album_id
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
WHERE al.created_at >= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 300
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 200
`
type ListNewForYouTracksRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
Tier int32
}
// #421 New for you, tiered per project rule #131 (#1267). The old
// single hard rule (added <30d AND direct affinity) had two failure
// modes with one root: no notion of consumption — the album you
// devoured in week one crowded the mix for three more weeks, and
// after a quiet month nothing qualified at all.
//
// "Consumed" = any track attempted for >=30s (the First Listens
// threshold, #1268): once you've meaningfully engaged, the album is
// no longer news. Applies to every tier — played albums leave the
// mix the next build.
//
// tier 1 the exact desire: unconsumed albums added <30d by
// direct-affinity artists (liked, or >=3 attempted plays)
// tier 2 step back a little: unconsumed affinity albums from the
// wider 30-90d window — added while you weren't looking
// tier 3 step back more: any unconsumed album added <90d
// regardless of affinity, newest first
//
// Album-coherent within tiers (newest album first, then disc/track);
// the producer fills top-down and rotates within tier blocks. The
// tier lands on playlist_tracks.pick_kind so metrics can price what
// each step-back trades away. $1 user_id.
// #421 New for you: tracks from albums added in the last 30 days
// whose artist the user has liked OR played (>=3 non-skip). Album-
// coherent (newest album first, then disc/track) — the producer does
// NOT diversity-cap these; they're meant as whole-album discovery.
// $1 user_id.
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
if err != nil {
@@ -272,12 +186,7 @@ func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) (
var items []ListNewForYouTracksRow
for rows.Next() {
var i ListNewForYouTracksRow
if err := rows.Scan(
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
@@ -294,10 +203,7 @@ WITH windowed AS (
FROM play_events
WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '30 days'
AND LEAST(
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
) <= 10
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
@@ -329,13 +235,6 @@ type ListOnThisDayTracksRow struct {
// and window ±7→±10 so it surfaces on a months-old library instead
// of needing a full year of history; still skips cleanly (no rows →
// no playlist) when there's no qualifying history yet.
// Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
// Jan 3 read as 359 days apart, silently gutting the window for ~3
// weeks around every New Year — precisely when holiday nostalgia is
// the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
// ±1 is absorbed by the ±10 window. The build date ($2, already here
// for the md5 rotation) anchors "today" instead of now() so the
// window is testable and consistent with the mix's daily determinism.
// $1 user_id, $2 date string.
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
@@ -363,32 +262,37 @@ WITH stats AS (
FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id
),
deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 3
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
-- rediscover-sourced play is the user explicitly saying "I've moved
-- on" to the exact invitation this mix extends — such tracks sit
-- out ~90 days instead of re-qualifying the next day. Passive
-- signal only (rule #101).
AND NOT EXISTS (
SELECT 1 FROM play_events rp
WHERE rp.user_id = $1
AND rp.track_id = t.id
AND rp.source = 'rediscover'
AND rp.was_skipped = true
AND rp.started_at > now() - interval '90 days'
)
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
(s.c >= 5) DESC,
s.c DESC, t.id
SELECT id, album_id, artist_id
FROM (
SELECT id, album_id, artist_id, c, tier FROM deep
UNION ALL
SELECT id, album_id, artist_id, c, tier FROM shallow
WHERE NOT EXISTS (SELECT 1 FROM deep)
) u
ORDER BY tier, c DESC, id
LIMIT 200
`
@@ -398,17 +302,13 @@ type ListRediscoverTracksRow struct {
ArtistID pgtype.UUID
}
// #420 Rediscover: tracks the user played a lot but has drifted away
// from. One blended pool (issue #1246: the old two-tier UNION was
// all-or-nothing — a single ">=6 months cold" row suppressed the whole
// ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
// tier was also a strict subset of the 30d tier). Eligibility is >=3
// non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
// so a weeks-old library still fields a pool — on young histories the
// >=5-play tracks are precisely the ones still in rotation. Ordering
// prefers true rediscoveries (>=6 months cold), then strong affection
// (>=5 plays), then raw play count; the producer's minimum floor
// decides whether the pool is big enough to ship at all. $1 user_id.
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
// has drifted away from. Tiered so a young library still gets a mix:
//
// tier 0 not played in the last 6 months (true rediscovery)
// tier 1 (only if tier 0 empty) not played in the last 30 days
//
// Ordered by historical affection. $1 user_id.
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
if err != nil {
+20 -77
View File
@@ -290,13 +290,9 @@ func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlayli
}
const pickSeedArtists = `-- name: PickSeedArtists :many
WITH liked AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
),
recent7 AS (
WITH plays AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
0 AS tier
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
@@ -304,80 +300,27 @@ recent7 AS (
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
recent30 AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
alltime AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
2 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
likedonly AS (
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
FROM liked l
),
chosen AS (
SELECT artist_id, play_count, tier FROM recent7
UNION ALL
SELECT artist_id, play_count, tier FROM recent30
WHERE NOT EXISTS (SELECT 1 FROM recent7)
UNION ALL
SELECT artist_id, play_count, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
UNION ALL
SELECT artist_id, play_count, tier FROM likedonly
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
AND NOT EXISTS (SELECT 1 FROM alltime)
liked AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
)
SELECT c.artist_id,
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
c.tier::int AS tier
FROM chosen c
LEFT JOIN liked l ON l.artist_id = c.artist_id
ORDER BY score DESC, c.artist_id
LIMIT 12
SELECT p.artist_id,
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
FROM plays p
LEFT JOIN liked l ON l.artist_id = p.artist_id
ORDER BY score DESC, p.artist_id
LIMIT 5
`
type PickSeedArtistsRow struct {
ArtistID pgtype.UUID
Score int64
Tier int32
}
// Top-12 most-engaged distinct artist candidates, tiered so the
// "Songs like X" mixes never silently vanish (#1255): the old hard
// 7-day window emptied the seed pool after a quiet week, and the
// daily atomic-replace build then deleted every existing mix until
// the user played something again. Same fallback shape as
// PickTopPlayedTracksForUser (For You's seeds):
//
// tier 0 engagement in the last 7 days
// tier 1 (only if tier 0 empty) last 30 days
// tier 2 (only if tiers 0+1 empty) all-time
// tier 3 (only if 0-2 empty) liked artists with no play history
//
// All returned rows share one tier; produceSeedMixes maps it onto the
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
// can compare mixes seeded from fresh vs stale engagement.
// Score = unskipped-play count + 5 if user has liked the artist. The
// Go-side picker (pickSeedArtistsForDay) shuffles the 12
// daily-deterministically and takes songsLikeSeedCount (6) so the mix
// set both rotates day-to-day and fills the dedicated Home row (#1491).
// Top-5 most-engaged distinct artist candidates in the user's last 7
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these
// daily-deterministically and takes the first 3 so the set of
// "Songs like X" mixes rotates day-to-day.
// Score = unskipped-play count + 5 if user has liked the artist.
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
if err != nil {
@@ -387,7 +330,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi
var items []PickSeedArtistsRow
for rows.Next() {
var i PickSeedArtistsRow
if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil {
if err := rows.Scan(&i.ArtistID, &i.Score); err != nil {
return nil, err
}
items = append(items, i)
@@ -514,10 +457,10 @@ SELECT id
// tier 2 (only if tiers 0+1 empty) liked tracks
//
// Returns up to 5 ids; tie-break by track_id for determinism. The
// Go-side picker (pickDailySeeds) draws the day's seeds from these.
// Widened from a hard 7-day window, which made For-You disappear
// after a week of not listening and never recover on a self-hosted
// library with sparse history.
// Go-side picker (pickForYouSeedForDay) rotates one per day via
// userIDHash. Widened from a hard 7-day window, which made For-You
// disappear after a week of not listening and never recover on a
// self-hosted library with sparse history.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil {
-157
View File
@@ -1,157 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: tag_settings.sql
package dbq
import (
"context"
)
const bumpTagSourcesVersion = `-- name: BumpTagSourcesVersion :one
UPDATE tag_sources_meta
SET current_version = current_version + 1
WHERE id = true
RETURNING current_version
`
// Increment + return the new version. Called only when the enabled set
// changes (not on key edits).
func (q *Queries) BumpTagSourcesVersion(ctx context.Context) (int32, error) {
row := q.db.QueryRow(ctx, bumpTagSourcesVersion)
var current_version int32
err := row.Scan(&current_version)
return current_version, err
}
const bumpTagVersionAndSetProvidersHash = `-- name: BumpTagVersionAndSetProvidersHash :one
UPDATE tag_sources_meta
SET current_version = current_version + 1,
last_registered_providers_hash = $1
WHERE id = true
RETURNING current_version
`
// Atomically increment the version and store the new registered-provider
// hash. Called at boot when the compiled-in provider set changed, so
// 'none' rows become eligible for a retry through the new chain.
func (q *Queries) BumpTagVersionAndSetProvidersHash(ctx context.Context, lastRegisteredProvidersHash string) (int32, error) {
row := q.db.QueryRow(ctx, bumpTagVersionAndSetProvidersHash, lastRegisteredProvidersHash)
var current_version int32
err := row.Scan(&current_version)
return current_version, err
}
const getCurrentTagSourcesVersion = `-- name: GetCurrentTagSourcesVersion :one
SELECT current_version FROM tag_sources_meta WHERE id = true
`
func (q *Queries) GetCurrentTagSourcesVersion(ctx context.Context) (int32, error) {
row := q.db.QueryRow(ctx, getCurrentTagSourcesVersion)
var current_version int32
err := row.Scan(&current_version)
return current_version, err
}
const getTagProvidersHash = `-- name: GetTagProvidersHash :one
SELECT last_registered_providers_hash
FROM tag_sources_meta
WHERE id = true
`
func (q *Queries) GetTagProvidersHash(ctx context.Context) (string, error) {
row := q.db.QueryRow(ctx, getTagProvidersHash)
var last_registered_providers_hash string
err := row.Scan(&last_registered_providers_hash)
return last_registered_providers_hash, err
}
const listTagProviderSettings = `-- name: ListTagProviderSettings :many
SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
FROM tag_provider_settings
ORDER BY display_order, provider_id
`
// Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
// The tags.SettingsService consumes these — parallel to the cover-art
// SettingsService (coverart_settings.sql). Keeping the two independent
// means a new tag source is added without touching the art settings.
// All rows, for boot reconciliation and the admin GET handler.
func (q *Queries) ListTagProviderSettings(ctx context.Context) ([]TagProviderSetting, error) {
rows, err := q.db.Query(ctx, listTagProviderSettings)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TagProviderSetting
for rows.Next() {
var i TagProviderSetting
if err := rows.Scan(
&i.ProviderID,
&i.Enabled,
&i.ApiKey,
&i.DisplayOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTagProviderSettings = `-- name: UpdateTagProviderSettings :exec
UPDATE tag_provider_settings
SET enabled = COALESCE($2, enabled),
api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END,
updated_at = now()
WHERE provider_id = $1
`
type UpdateTagProviderSettingsParams struct {
ProviderID string
Enabled bool
Column3 bool
ApiKey *string
}
// Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag
// to leave api_key untouched; TRUE with empty string clears it, TRUE with
// a value sets it.
func (q *Queries) UpdateTagProviderSettings(ctx context.Context, arg UpdateTagProviderSettingsParams) error {
_, err := q.db.Exec(ctx, updateTagProviderSettings,
arg.ProviderID,
arg.Enabled,
arg.Column3,
arg.ApiKey,
)
return err
}
const upsertTagProviderSettings = `-- name: UpsertTagProviderSettings :exec
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
VALUES ($1, $2, $3)
ON CONFLICT (provider_id) DO UPDATE
SET display_order = EXCLUDED.display_order,
updated_at = now()
`
type UpsertTagProviderSettingsParams struct {
ProviderID string
Enabled bool
DisplayOrder int32
}
// INSERT a default row for a newly-registered provider, or refresh an
// existing row's display_order on boot. Does NOT overwrite enabled /
// api_key on conflict — the operator's settings persist across restarts.
func (q *Queries) UpsertTagProviderSettings(ctx context.Context, arg UpsertTagProviderSettingsParams) error {
_, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder)
return err
}
+4 -9
View File
@@ -87,21 +87,19 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
}
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
SELECT t.id AS track_id, t.artist_id, t.genre
SELECT t.artist_id, t.genre
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1
`
type ListLikedTrackTasteInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
}
// (track_id, artist_id, genre) for each track the user has explicitly
// liked. Feeds the track-like bonus into the liked track's artist and
// tags; track_id keys the enriched track_tags lookup (#1490).
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
// track-like bonus into the liked track's artist and tags.
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil {
@@ -111,7 +109,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
var items []ListLikedTrackTasteInputsForUserRow
for rows.Next() {
var i ListLikedTrackTasteInputsForUserRow
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
return nil, err
}
items = append(items, i)
@@ -125,7 +123,6 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
SELECT
t.id AS track_id,
t.artist_id,
t.genre,
LEAST(GREATEST(
@@ -149,7 +146,6 @@ type ListPlayEngagementInputsForUserParams struct {
}
type ListPlayEngagementInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
Completion float64
@@ -173,7 +169,6 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
for rows.Next() {
var i ListPlayEngagementInputsForUserRow
if err := rows.Scan(
&i.TrackID,
&i.ArtistID,
&i.Genre,
&i.Completion,
-185
View File
@@ -1,185 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: track_tags.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteTrackTags = `-- name: DeleteTrackTags :exec
DELETE FROM track_tags WHERE track_id = $1
`
// Clear a track's cached tags before rewriting (atomic replace by the caller).
func (q *Queries) DeleteTrackTags(ctx context.Context, trackID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTrackTags, trackID)
return err
}
const insertTrackTag = `-- name: InsertTrackTag :exec
INSERT INTO track_tags (track_id, tag, weight)
VALUES ($1, $2, $3)
ON CONFLICT (track_id, tag)
DO UPDATE SET weight = GREATEST(track_tags.weight, EXCLUDED.weight)
`
type InsertTrackTagParams struct {
TrackID pgtype.UUID
Tag string
Weight float64
}
// Upsert one (track, tag); keep the stronger weight when two providers
// agree on a tag with different folksonomy strengths.
func (q *Queries) InsertTrackTag(ctx context.Context, arg InsertTrackTagParams) error {
_, err := q.db.Exec(ctx, insertTrackTag, arg.TrackID, arg.Tag, arg.Weight)
return err
}
const listLikedTrackTagsForUser = `-- name: ListLikedTrackTagsForUser :many
SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
JOIN general_likes gl ON gl.track_id = tt.track_id
WHERE gl.user_id = $1
`
// Enriched tags for the user's liked tracks — the like-bonus path in the
// recompute mirrors the play path, so it needs the same tag lookup.
func (q *Queries) ListLikedTrackTagsForUser(ctx context.Context, userID pgtype.UUID) ([]TrackTag, error) {
rows, err := q.db.Query(ctx, listLikedTrackTagsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackTag
for rows.Next() {
var i TrackTag
if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlayedTrackTagsForUser = `-- name: ListPlayedTrackTagsForUser :many
SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
WHERE tt.track_id IN (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - make_interval(days => $2::int)
)
`
type ListPlayedTrackTagsForUserParams struct {
UserID pgtype.UUID
Column2 int32
}
// The enriched tags for every track this user played inside the taste
// window, so the recompute can union them into the tag facet alongside
// ID3 genre. $1 = user_id, $2 = window_days. One row per (track, tag).
func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayedTrackTagsForUserParams) ([]TrackTag, error) {
rows, err := q.db.Query(ctx, listPlayedTrackTagsForUser, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackTag
for rows.Next() {
var i TrackTag
if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
SELECT t.id, t.mbid, t.title, a.name AS artist_name
FROM tracks t
JOIN artists a ON a.id = t.artist_id
WHERE t.tag_source IS NULL
OR (t.tag_source = 'none' AND t.tag_sources_version < $1)
ORDER BY t.id
LIMIT $2
`
type ListTracksMissingTagsParams struct {
TagSourcesVersion int32
Limit int32
}
type ListTracksMissingTagsRow struct {
ID pgtype.UUID
Mbid *string
Title string
ArtistName string
}
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
// drains ListTracksMissingTags, fetches from the provider chain, and
// writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
// stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
// Tracks eligible for tag enrichment: never processed (tag_source NULL)
// or previously settled 'none' under an older provider version. Returns
// the fields the provider chain needs — recording MBID (nullable) for
// keyed lookups, plus title + artist name for name-based fallback.
// $1 = current tag_sources_version, $2 = limit.
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksMissingTagsRow
for rows.Next() {
var i ListTracksMissingTagsRow
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Title,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setTrackTagSource = `-- name: SetTrackTagSource :exec
UPDATE tracks SET tag_source = $2, tag_sources_version = $3 WHERE id = $1
`
type SetTrackTagSourceParams struct {
ID pgtype.UUID
TagSource *string
TagSourcesVersion int32
}
// Stamp the enrichment outcome so the batch drainer skips settled rows.
// $2 = 'lastfm' | 'musicbrainz' | 'mixed' | 'none', $3 = current version.
func (q *Queries) SetTrackTagSource(ctx context.Context, arg SetTrackTagSourceParams) error {
_, err := q.db.Exec(ctx, setTrackTagSource, arg.ID, arg.TagSource, arg.TagSourcesVersion)
return err
}
+8 -24
View File
@@ -90,7 +90,7 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
}
const getTrackByID = `-- name: GetTrackByID :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = $1
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1
`
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
@@ -112,14 +112,12 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
const getTrackByPath = `-- name: GetTrackByPath :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE file_path = $1
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE file_path = $1
`
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
@@ -141,14 +139,12 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
const getTracksByIDs = `-- name: GetTracksByIDs :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = ANY($1::uuid[])
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
@@ -178,8 +174,6 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -192,7 +186,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
}
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -248,8 +242,6 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -264,7 +256,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
}
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -317,8 +309,6 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -333,7 +323,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
}
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -375,8 +365,6 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -424,7 +412,7 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
}
const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -473,8 +461,6 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -522,7 +508,7 @@ ON CONFLICT (file_path) DO UPDATE SET
mbid = EXCLUDED.mbid,
genre = EXCLUDED.genre,
updated_at = now()
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at
`
type UpsertTrackParams struct {
@@ -573,8 +559,6 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
@@ -1,2 +0,0 @@
ALTER TABLE play_events DROP COLUMN pick_kind;
ALTER TABLE playlist_tracks DROP COLUMN pick_kind;
@@ -1,34 +0,0 @@
-- For You exploration attribution (milestone #127 step 2, Scribe #1249).
--
-- For You is deliberately composed of two populations: a head of
-- top-scored similarity matches ("taste" picks) and a tail sampled from
-- deeper in the candidate ranking ("fresh" picks — the freshness
-- injection). Until now the metrics could only judge For You as one
-- blob, so a high skip rate couldn't be read as "the taste engine is
-- missing" vs "the freshness tax is too high".
--
-- Two denormalized columns, both nullable:
--
-- 1. playlist_tracks.pick_kind — stamped by the system-playlist builder
-- at snapshot build time. NULL for manual playlists and for system
-- variants that don't split (Discover, Songs like X, discovery
-- mixes). System snapshots are atomically replaced daily, so the
-- column never goes stale within a snapshot.
--
-- 2. play_events.pick_kind — stamped at play-ingestion time by looking
-- the track up in the user's CURRENT For You snapshot. Attribution
-- must be frozen at play time: For You rebuilds daily, so a 30-day
-- metrics window spans ~30 snapshots and a join-at-read against the
-- live snapshot would misattribute nearly everything. Plays that
-- predate this feature (or whose track already rotated out, e.g. a
-- late offline replay) stay NULL → reported as unattributed.
ALTER TABLE playlist_tracks
ADD COLUMN pick_kind text
CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
ALTER TABLE play_events
ADD COLUMN pick_kind text
CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
@@ -1,19 +0,0 @@
-- Rows stamped with the widened vocabulary must be cleared before the
-- narrow CHECK can be re-added; NULL reads as "unattributed", which is
-- the honest downgrade.
UPDATE play_events SET pick_kind = NULL
WHERE pick_kind NOT IN ('taste', 'fresh');
UPDATE playlist_tracks SET pick_kind = NULL
WHERE pick_kind NOT IN ('taste', 'fresh');
ALTER TABLE play_events
DROP CONSTRAINT play_events_pick_kind_check;
ALTER TABLE play_events
ADD CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
ALTER TABLE playlist_tracks
DROP CONSTRAINT playlist_tracks_pick_kind_check;
ALTER TABLE playlist_tracks
ADD CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
@@ -1,41 +0,0 @@
-- Provenance as standard (milestone #127, Scribe #1270).
--
-- #1249 introduced pick_kind as a For You one-off ('taste'/'fresh').
-- The mechanism — stamp WHY a track is in the snapshot at build time,
-- freeze it onto the play at ingestion, break it down in metrics — is
-- now the standard for every system mix, so the CHECK vocabulary
-- widens to cover:
--
-- taste / fresh For You head vs freshness-injection tail
-- dormant / cross_user / random
-- Discover's three candidate buckets — makes
-- the 40/30/30 allocation measurable instead
-- of a guess
-- tier1 / tier2 / tier3 the tiered-eligibility ladder (project rule
-- #131): tier 1 pins the mix's exact desire,
-- each higher tier steps back a little. Tier
-- provenance is how we measure what the
-- step-back trades away.
--
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD
-- with the expanded list, in the same migration.
ALTER TABLE playlist_tracks
DROP CONSTRAINT playlist_tracks_pick_kind_check;
ALTER TABLE playlist_tracks
ADD CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random',
'tier1', 'tier2', 'tier3'
));
ALTER TABLE play_events
DROP CONSTRAINT play_events_pick_kind_check;
ALTER TABLE play_events
ADD CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random',
'tier1', 'tier2', 'tier3'
));
@@ -1,3 +0,0 @@
DROP TABLE recommendation_tuning_audit;
DROP TABLE taste_tuning;
DROP TABLE recommendation_weight_profiles;
@@ -1,57 +0,0 @@
-- Recommendation tuning lab (milestone #127, Scribe #1250).
--
-- The scoring weights move out of YAML (radio profile) and out of the
-- systemMixWeights hard-code (daily_mix profile) into DB-backed
-- settings with live effect — the defaults-discovery lab (decision
-- #1247): the operator turns knobs here to FIND good values, which
-- then get baked into shipped defaults; end users and other operators
-- should never need this card.
--
-- Rows are seeded by the recsettings service's boot reconcile (the
-- coverart SettingsService pattern), not by this migration, so shipped
-- defaults live in exactly one place (Go).
CREATE TABLE recommendation_weight_profiles (
profile text PRIMARY KEY
CONSTRAINT recommendation_weight_profiles_profile_check
CHECK (profile IN ('radio', 'daily_mix')),
base_weight double precision NOT NULL,
like_boost double precision NOT NULL,
recency_weight double precision NOT NULL,
skip_penalty double precision NOT NULL,
jitter_magnitude double precision NOT NULL,
context_weight double precision NOT NULL,
similarity_weight double precision NOT NULL,
taste_weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Taste-profile build knobs: engagement half-life + the completion→
-- engagement curve points (taste.Config's tunable subset).
CREATE TABLE taste_tuning (
singleton boolean PRIMARY KEY DEFAULT true
CONSTRAINT taste_tuning_singleton_check CHECK (singleton),
half_life_days double precision NOT NULL,
engagement_hard_skip double precision NOT NULL,
engagement_neutral double precision NOT NULL,
engagement_full double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Every knob turn writes one audit row; the metrics trend view
-- (#1251) annotates these on its timeline so cause→effect is visible
-- after a change. changes is a jsonb array of {field, old, new}.
CREATE TABLE recommendation_tuning_audit (
id bigserial PRIMARY KEY,
changed_at timestamptz NOT NULL DEFAULT now(),
scope text NOT NULL
CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste')),
action text NOT NULL
CONSTRAINT recommendation_tuning_audit_action_check
CHECK (action IN ('update', 'reset')),
changes jsonb NOT NULL
);
CREATE INDEX recommendation_tuning_audit_changed_at_idx
ON recommendation_tuning_audit (changed_at DESC);
@@ -1,27 +0,0 @@
-- Reverse 0041_discover_taste_unheard.up.sql.
--
-- Null out any rows stamped with the new value before shrinking the
-- CHECK back to the 0039 vocabulary, or the ADD CONSTRAINT would fail
-- on existing 'taste_unheard' rows (mirrors 0039's down).
UPDATE play_events SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
UPDATE playlist_tracks SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
ALTER TABLE playlist_tracks
DROP CONSTRAINT playlist_tracks_pick_kind_check;
ALTER TABLE playlist_tracks
ADD CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random',
'tier1', 'tier2', 'tier3'
));
ALTER TABLE play_events
DROP CONSTRAINT play_events_pick_kind_check;
ALTER TABLE play_events
ADD CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random',
'tier1', 'tier2', 'tier3'
));
@@ -1,32 +0,0 @@
-- Discover taste-targeted novelty bucket (milestone #127, Scribe #1252/#1488).
--
-- The 2-week metrics review found Discover beating the manual baseline on
-- skip rate — which for a discovery surface means it is playing it safe:
-- on a single-user server it is effectively dormant + crude-random, and
-- the per-user taste profile (n_tags, #796) went unused. This adds a
-- fourth Discover candidate bucket that ranks unheard tracks by the
-- user's taste-profile tag weights ("novelty that fits your vibe"), so
-- its picks need a new pick_kind provenance value.
--
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD with
-- the expanded list, in the same migration (family rule — new CHECK-enum
-- value needs a same-change migration).
ALTER TABLE playlist_tracks
DROP CONSTRAINT playlist_tracks_pick_kind_check;
ALTER TABLE playlist_tracks
ADD CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random', 'taste_unheard',
'tier1', 'tier2', 'tier3'
));
ALTER TABLE play_events
DROP CONSTRAINT play_events_pick_kind_check;
ALTER TABLE play_events
ADD CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random', 'taste_unheard',
'tier1', 'tier2', 'tier3'
));
@@ -1,5 +0,0 @@
-- Reverse 0042_track_tags.up.sql.
DROP INDEX IF EXISTS tracks_tag_source_idx;
ALTER TABLE tracks DROP COLUMN IF EXISTS tag_sources_version;
ALTER TABLE tracks DROP COLUMN IF EXISTS tag_source;
DROP TABLE IF EXISTS track_tags;
@@ -1,26 +0,0 @@
-- Track-level folksonomy tags for taste enrichment (milestone #160, #1490).
--
-- A global (not per-user) cache of style/mood tags fetched from
-- MusicBrainz (keyless) + Last.fm (when a key is configured), keyed by
-- track. Feeds the taste recompute's tag facet alongside raw ID3 genre,
-- lifting resolution from coarse "Rock" to "post-punk / shoegaze /
-- melancholic". The enricher caps to the top-K tags per track at write
-- time so a heavily-tagged track can't dominate the profile. weight is a
-- normalized folksonomy strength in [0,1]. Mirrors the coverart
-- enricher's versioned source-tracking pattern.
CREATE TABLE track_tags (
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
tag text NOT NULL,
weight double precision NOT NULL DEFAULT 1,
PRIMARY KEY (track_id, tag)
);
CREATE INDEX track_tags_track_idx ON track_tags (track_id);
-- Enrichment bookkeeping (mirror artists.artist_art_source):
-- tag_source NULL → not yet processed (eligible)
-- tag_source 'lastfm' / 'musicbrainz' / 'mixed' → found, cached
-- tag_source 'none' → providers returned nothing
-- tag_sources_version → bump to re-process none/stale
ALTER TABLE tracks ADD COLUMN tag_source text;
ALTER TABLE tracks ADD COLUMN tag_sources_version integer NOT NULL DEFAULT 0;
CREATE INDEX tracks_tag_source_idx ON tracks (tag_source, tag_sources_version);
@@ -1,3 +0,0 @@
-- Reverse 0043_tag_provider_settings.up.sql.
DROP TABLE IF EXISTS tag_sources_meta;
DROP TABLE IF EXISTS tag_provider_settings;
@@ -1,37 +0,0 @@
-- Tag-enrichment provider settings (milestone #160, #1490 Step 2).
--
-- Mirrors the cover-art provider-settings substrate (migration 0018) so
-- adding a new tag source later is "register a provider + it auto-upserts
-- a row here" — never a schema change. The tag enricher reads the enabled
-- set + api keys from tag_provider_settings and stamps the current version
-- from tag_sources_meta onto tracks.tag_sources_version (added in 0042).
--
-- current_version starts at 1 while tracks.tag_sources_version defaults to
-- 0 (migration 0042), so every existing track is "stale" relative to
-- current and gets enriched on the first pass after this migrates.
CREATE TABLE tag_provider_settings (
provider_id text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT true,
api_key text,
display_order int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tag_sources_meta (
id boolean PRIMARY KEY DEFAULT true,
current_version int NOT NULL DEFAULT 1,
last_registered_providers_hash text NOT NULL DEFAULT '',
CONSTRAINT tag_sources_meta_singleton CHECK (id = true)
);
INSERT INTO tag_sources_meta (id, current_version) VALUES (true, 1);
-- Seed the v1 providers. MusicBrainz is the keyless always-on default;
-- Last.fm ships disabled and is opt-in once the operator supplies a key
-- (Step 4 Settings UI), per the no-coercive-settings rule (#26). The
-- SettingsService also upserts these at boot, so the seed is belt-and-
-- suspenders / documents the intended defaults.
INSERT INTO tag_provider_settings (provider_id, enabled, display_order) VALUES
('musicbrainz', true, 0),
('lastfm', false, 1);
-34
View File
@@ -102,37 +102,3 @@ SELECT t.id, t.album_id, t.artist_id
)
ORDER BY md5(t.id::text || $2::text)
LIMIT 200;
-- name: ListTasteUnheardTracksForDiscover :many
-- Taste-targeted novelty: unheard tracks whose genres overlap the user's
-- taste-profile tags (taste_profile_tags, #796), ranked by summed tag
-- weight — "new to you, but your vibe" rather than the crude random arm.
-- Genres live inline on tracks.genre as a delimited string, split the
-- same way the radio tag_overlap arm does (regexp_split_to_table on
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
-- when the user has no taste tags yet (cold start), so the caller
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
-- $1 = user_id, $2 = date string for md5 tiebreak ordering.
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
LIMIT 120;
+2 -23
View File
@@ -24,32 +24,11 @@ ORDER BY started_at DESC
LIMIT 1;
-- name: InsertPlayEvent :one
-- pick_kind is non-NULL only for system-playlist plays whose track was
-- found (with a stamped kind) in the user's live snapshot for that
-- variant at ingestion time (#1249, generalized in #1270).
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
user_id, track_id, session_id, started_at, client_id, source
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: GetSystemPickKindForTrack :one
-- Looks a track up in the user's CURRENT snapshot of the given system
-- variant and returns its pick_kind. Used at play-ingestion time to
-- freeze provenance onto the play_event — snapshots rebuild daily, so
-- attribution can't be reconstructed at read time (#1249/#1270). No
-- row = track not in today's snapshot (caller stores NULL); a row with
-- NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
-- is non-singleton (up to 3 mixes/user); a track in two of them takes
-- whichever LIMIT 1 hits — acceptable, tier stamps there describe the
-- same eligibility ladder.
SELECT pt.pick_kind
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1
AND p.system_variant = $2
AND pt.track_id = $3
LIMIT 1;
-- name: UpdatePlayEventEnded :one
-- Closes a play_event by id with the given ended_at, duration, and skip flag.
-- completion_ratio is computed from duration_played_ms and the track duration
+3 -5
View File
@@ -68,9 +68,8 @@ ORDER BY pt.position;
-- name: AppendPlaylistTrack :one
-- Inserts at the next available position. Snapshot fields are copied
-- from the tracks/albums/artists join at insert time. tracks.duration_ms
-- is converted to seconds for the snapshot. pick_kind is NULL for manual
-- playlists; the For You builder stamps 'taste'/'fresh' (#1249).
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
-- is converted to seconds for the snapshot.
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
SELECT
sqlc.arg(playlist_id)::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
@@ -78,8 +77,7 @@ SELECT
t.title,
artists.name,
albums.title,
(t.duration_ms / 1000)::integer,
sqlc.narg(pick_kind)::text
(t.duration_ms / 1000)::integer
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
+8 -44
View File
@@ -1,58 +1,22 @@
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
-- operator can see whether each recommendation surface is landing and tune the
-- taste weights. Source is stamped on play_events when a play is launched from
-- a recommendation surface; NULL means the user picked the track manually —
-- those rows are INCLUDED here as the baseline control group the surfaces are
-- judged against (milestone #127: delta-vs-baseline is what makes the numbers
-- actionable). Raw source strings are bucketed into stable surface families in
-- the Go handler; completion_n is carried so family merges can weight
-- avg_completion correctly.
-- name: RecommendationWeeklyTrends :many
-- Weekly per-source outcome series for the tuning lab's trend view
-- (#1251). Aggregated across ALL users: the tuning knobs are global,
-- so judging a knob turn needs global outcomes — rows carry rates
-- only, no track or user identity. NULL-source (manual) rows are
-- included as the baseline family.
--
-- taste_hits counts plays whose track's artist has a positive weight
-- in that user's CURRENT taste profile — the "cheap recompute" option:
-- retroactive over the whole window, at the cost of drift (the profile
-- is today's, the play may be weeks old). Good enough to read whether
-- a surface is feeding taste-fitting tracks.
-- $1 window in weeks.
SELECT
date_trunc('week', pe.started_at)::date AS week_start,
pe.source,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
LEFT JOIN taste_profile_artists tpa
ON tpa.user_id = pe.user_id
AND tpa.artist_id = t.artist_id
AND tpa.weight > 0
WHERE pe.started_at > now() - (sqlc.arg(weeks)::int * INTERVAL '1 week')
GROUP BY 1, 2
ORDER BY 1, 2;
-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
-- NULL for library / radio / user-playlist plays, which are excluded here.
-- name: RecommendationSourceMetricsForUser :many
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
-- mean completion ratio over the completion_n plays that recorded one.
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
-- it is NULL for every other source, so those still group to one row.
-- mean completion ratio over plays that recorded one (0 when none did).
SELECT
pe.source,
pe.pick_kind,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
COALESCE(
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
0)::float8 AS avg_completion
FROM play_events pe
WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source, pe.pick_kind
GROUP BY pe.source
ORDER BY plays DESC;
@@ -1,61 +0,0 @@
-- Recommendation tuning lab queries (#1250). Seeding happens via the
-- recsettings boot reconcile; shipped defaults live in Go only.
-- name: UpsertWeightProfileDefaults :exec
-- Boot reconcile: insert the shipped defaults for a profile if the row
-- doesn't exist yet. Never overwrites operator-tuned values.
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (profile) DO NOTHING;
-- name: ListWeightProfiles :many
SELECT * FROM recommendation_weight_profiles ORDER BY profile;
-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
WHERE profile = $1
RETURNING *;
-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
ON CONFLICT (singleton) DO NOTHING;
-- name: GetTasteTuning :one
SELECT * FROM taste_tuning WHERE singleton = true;
-- name: UpdateTasteTuning :one
UPDATE taste_tuning
SET half_life_days = $1,
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
updated_at = now()
WHERE singleton = true
RETURNING *;
-- name: InsertTuningAudit :exec
-- changes is a jsonb array of {field, old, new} objects.
INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3);
-- name: ListTuningAudit :many
-- Newest first; consumed by the metrics trend view (#1251) to annotate
-- knob turns on the timeline.
SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit
ORDER BY changed_at DESC, id DESC
LIMIT $1;
+66 -156
View File
@@ -8,11 +8,6 @@
-- name: ListDeepCutsTracks :many
-- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
-- has liked OR played heavily (>=5 non-skip plays across the artist).
-- Tracks the user has skipped twice or more don't qualify (#1257):
-- eligibility used to count only unskipped plays, so the most actively
-- rejected tracks read as "barely heard" and kept being re-offered.
-- Threshold 2 so a single accidental skip doesn't banish a track;
-- passive-signal only (no dislike UI, rule #101).
-- $1 user_id, $2 date string.
WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
@@ -28,20 +23,12 @@ play_counts AS (
FROM play_events
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
),
skip_counts AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = true
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.track_id = t.id
WHERE COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -50,112 +37,75 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200;
-- name: ListRediscoverTracks :many
-- #420 Rediscover: tracks the user played a lot but has drifted away
-- from. One blended pool (issue #1246: the old two-tier UNION was
-- all-or-nothing — a single ">=6 months cold" row suppressed the whole
-- ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
-- tier was also a strict subset of the 30d tier). Eligibility is >=3
-- non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
-- so a weeks-old library still fields a pool — on young histories the
-- >=5-play tracks are precisely the ones still in rotation. Ordering
-- prefers true rediscoveries (>=6 months cold), then strong affection
-- (>=5 plays), then raw play count; the producer's minimum floor
-- decides whether the pool is big enough to ship at all. $1 user_id.
-- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
-- has drifted away from. Tiered so a young library still gets a mix:
-- tier 0 not played in the last 6 months (true rediscovery)
-- tier 1 (only if tier 0 empty) not played in the last 30 days
-- Ordered by historical affection. $1 user_id.
WITH stats AS (
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id
),
deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 3
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
-- rediscover-sourced play is the user explicitly saying "I've moved
-- on" to the exact invitation this mix extends — such tracks sit
-- out ~90 days instead of re-qualifying the next day. Passive
-- signal only (rule #101).
AND NOT EXISTS (
SELECT 1 FROM play_events rp
WHERE rp.user_id = $1
AND rp.track_id = t.id
AND rp.source = 'rediscover'
AND rp.was_skipped = true
AND rp.started_at > now() - interval '90 days'
)
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
(s.c >= 5) DESC,
s.c DESC, t.id
SELECT id, album_id, artist_id
FROM (
SELECT id, album_id, artist_id, c, tier FROM deep
UNION ALL
SELECT id, album_id, artist_id, c, tier FROM shallow
WHERE NOT EXISTS (SELECT 1 FROM deep)
) u
ORDER BY tier, c DESC, id
LIMIT 200;
-- name: ListNewForYouTracks :many
-- #421 New for you, tiered per project rule #131 (#1267). The old
-- single hard rule (added <30d AND direct affinity) had two failure
-- modes with one root: no notion of consumption — the album you
-- devoured in week one crowded the mix for three more weeks, and
-- after a quiet month nothing qualified at all.
--
-- "Consumed" = any track attempted for >=30s (the First Listens
-- threshold, #1268): once you've meaningfully engaged, the album is
-- no longer news. Applies to every tier — played albums leave the
-- mix the next build.
--
-- tier 1 the exact desire: unconsumed albums added <30d by
-- direct-affinity artists (liked, or >=3 attempted plays)
-- tier 2 step back a little: unconsumed affinity albums from the
-- wider 30-90d window — added while you weren't looking
-- tier 3 step back more: any unconsumed album added <90d
-- regardless of affinity, newest first
--
-- Album-coherent within tiers (newest album first, then disc/track);
-- the producer fills top-down and rotates within tier blocks. The
-- tier lands on playlist_tracks.pick_kind so metrics can price what
-- each step-back trades away. $1 user_id.
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
),
consumed_albums AS (
SELECT DISTINCT t.album_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
affinity_artists AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
-- #421 New for you: tracks from albums added in the last 30 days
-- whose artist the user has liked OR played (>=3 non-skip). Album-
-- coherent (newest album first, then disc/track) — the producer does
-- NOT diversity-cap these; they're meant as whole-album discovery.
-- $1 user_id.
WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
UNION
SELECT t.artist_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
HAVING COUNT(*) >= 3
),
albums_tiered AS (
SELECT al.id AS album_id, al.created_at,
CASE
WHEN al.created_at >= now() - interval '30 days'
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
ELSE 3
END AS tier
FROM albums al
WHERE al.created_at >= now() - interval '90 days'
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
)
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN albums_tiered alt ON alt.album_id = t.album_id
WHERE NOT EXISTS (
JOIN albums al ON al.id = t.album_id
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
WHERE al.created_at >= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 300;
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 200;
-- name: ListOnThisDayTracks :many
-- #422 On This Day: tracks the user played around this calendar date
@@ -165,23 +115,13 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
-- and window ±7→±10 so it surfaces on a months-old library instead
-- of needing a full year of history; still skips cleanly (no rows →
-- no playlist) when there's no qualifying history yet.
-- Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
-- Jan 3 read as 359 days apart, silently gutting the window for ~3
-- weeks around every New Year — precisely when holiday nostalgia is
-- the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
-- ±1 is absorbed by the ±10 window. The build date ($2, already here
-- for the md5 rotation) anchors "today" instead of now() so the
-- window is testable and consistent with the mix's daily determinism.
-- $1 user_id, $2 date string.
WITH windowed AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '30 days'
AND LEAST(
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
) <= 10
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
@@ -195,65 +135,35 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200;
-- name: ListFirstListensTracks :many
-- #423 First Listens: songs the user has never heard or even
-- attempted. "Attempted" is track-level with a >=30s listen threshold
-- (#1268, operator decision 2026-07-03) — the old version disqualified
-- a whole album on ANY play_event, so a 2-second accidental brush
-- banished it permanently.
--
-- tier 1 the exact desire: albums with ZERO attempted tracks
-- tier 2 step back: barely-attempted albums (<=25% of tracks
-- reached 30s) — brushed, never explored. The already-
-- attempted tracks themselves are excluded; they're not
-- first listens.
--
-- Within each tier, affinity-ordered as before: liked artist, then
-- attempted-played artist (>=30s — skip-only contact isn't trust),
-- then the rest; album-coherent. The tier lands on
-- playlist_tracks.pick_kind via the producer. $1 user_id.
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
-- #423 First Listens: albums the user has never played any track of.
-- Tiered: liked-artist albums first, then played-artist albums, then
-- the rest — album-coherent within each tier. Not diversity-capped
-- (whole-album discovery). $1 user_id.
WITH heard_albums AS (
SELECT DISTINCT t.album_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
),
album_attempts AS (
SELECT t.album_id,
COUNT(a.track_id) AS attempted_count,
COUNT(*) AS track_count
FROM tracks t
LEFT JOIN attempted a ON a.track_id = t.id
GROUP BY t.album_id
),
attempted_artists AS (
played_artists AS (
SELECT DISTINCT t.artist_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
albums_tiered AS (
SELECT aa.album_id,
CASE
WHEN aa.attempted_count = 0 THEN 1
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
END AS tier
FROM album_attempts aa
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
)
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN albums_tiered alt ON alt.album_id = al.id
WHERE alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY
alt.tier,
(CASE
WHEN EXISTS (
SELECT 1 FROM general_likes_artists gla
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
) THEN 0
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
ELSE 2
END),
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
+19 -73
View File
@@ -47,30 +47,14 @@ UPDATE system_playlist_runs
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
-- name: PickSeedArtists :many
-- Top-12 most-engaged distinct artist candidates, tiered so the
-- "Songs like X" mixes never silently vanish (#1255): the old hard
-- 7-day window emptied the seed pool after a quiet week, and the
-- daily atomic-replace build then deleted every existing mix until
-- the user played something again. Same fallback shape as
-- PickTopPlayedTracksForUser (For You's seeds):
-- tier 0 engagement in the last 7 days
-- tier 1 (only if tier 0 empty) last 30 days
-- tier 2 (only if tiers 0+1 empty) all-time
-- tier 3 (only if 0-2 empty) liked artists with no play history
-- All returned rows share one tier; produceSeedMixes maps it onto the
-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics
-- can compare mixes seeded from fresh vs stale engagement.
-- Score = unskipped-play count + 5 if user has liked the artist. The
-- Go-side picker (pickSeedArtistsForDay) shuffles the 12
-- daily-deterministically and takes songsLikeSeedCount (6) so the mix
-- set both rotates day-to-day and fills the dedicated Home row (#1491).
WITH liked AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
),
recent7 AS (
-- Top-5 most-engaged distinct artist candidates in the user's last 7
-- days. The Go-side picker (pickSeedArtistsForDay) shuffles these
-- daily-deterministically and takes the first 3 so the set of
-- "Songs like X" mixes rotates day-to-day.
-- Score = unskipped-play count + 5 if user has liked the artist.
WITH plays AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
0 AS tier
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
@@ -78,53 +62,15 @@ recent7 AS (
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
recent30 AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
alltime AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
2 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
likedonly AS (
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
FROM liked l
),
chosen AS (
SELECT artist_id, play_count, tier FROM recent7
UNION ALL
SELECT artist_id, play_count, tier FROM recent30
WHERE NOT EXISTS (SELECT 1 FROM recent7)
UNION ALL
SELECT artist_id, play_count, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
UNION ALL
SELECT artist_id, play_count, tier FROM likedonly
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
AND NOT EXISTS (SELECT 1 FROM alltime)
liked AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
)
SELECT c.artist_id,
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
c.tier::int AS tier
FROM chosen c
LEFT JOIN liked l ON l.artist_id = c.artist_id
ORDER BY score DESC, c.artist_id
LIMIT 12;
SELECT p.artist_id,
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
FROM plays p
LEFT JOIN liked l ON l.artist_id = p.artist_id
ORDER BY score DESC, p.artist_id
LIMIT 5;
-- name: PickTopPlayedTracksForUser :many
-- For-You candidate seeds, tiered so For-You never silently vanishes:
@@ -132,10 +78,10 @@ SELECT c.artist_id,
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
-- tier 2 (only if tiers 0+1 empty) liked tracks
-- Returns up to 5 ids; tie-break by track_id for determinism. The
-- Go-side picker (pickDailySeeds) draws the day's seeds from these.
-- Widened from a hard 7-day window, which made For-You disappear
-- after a week of not listening and never recover on a self-hosted
-- library with sparse history.
-- Go-side picker (pickForYouSeedForDay) rotates one per day via
-- userIDHash. Widened from a hard 7-day window, which made For-You
-- disappear after a week of not listening and never recover on a
-- self-hosted library with sparse history.
WITH recent AS (
SELECT t.id, COUNT(*) AS c, 0 AS tier
FROM play_events pe
-56
View File
@@ -1,56 +0,0 @@
-- Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
-- The tags.SettingsService consumes these — parallel to the cover-art
-- SettingsService (coverart_settings.sql). Keeping the two independent
-- means a new tag source is added without touching the art settings.
-- name: ListTagProviderSettings :many
-- All rows, for boot reconciliation and the admin GET handler.
SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
FROM tag_provider_settings
ORDER BY display_order, provider_id;
-- name: UpsertTagProviderSettings :exec
-- INSERT a default row for a newly-registered provider, or refresh an
-- existing row's display_order on boot. Does NOT overwrite enabled /
-- api_key on conflict — the operator's settings persist across restarts.
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
VALUES ($1, $2, $3)
ON CONFLICT (provider_id) DO UPDATE
SET display_order = EXCLUDED.display_order,
updated_at = now();
-- name: UpdateTagProviderSettings :exec
-- Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag
-- to leave api_key untouched; TRUE with empty string clears it, TRUE with
-- a value sets it.
UPDATE tag_provider_settings
SET enabled = COALESCE($2, enabled),
api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END,
updated_at = now()
WHERE provider_id = $1;
-- name: GetCurrentTagSourcesVersion :one
SELECT current_version FROM tag_sources_meta WHERE id = true;
-- name: BumpTagSourcesVersion :one
-- Increment + return the new version. Called only when the enabled set
-- changes (not on key edits).
UPDATE tag_sources_meta
SET current_version = current_version + 1
WHERE id = true
RETURNING current_version;
-- name: GetTagProvidersHash :one
SELECT last_registered_providers_hash
FROM tag_sources_meta
WHERE id = true;
-- name: BumpTagVersionAndSetProvidersHash :one
-- Atomically increment the version and store the new registered-provider
-- hash. Called at boot when the compiled-in provider set changed, so
-- 'none' rows become eligible for a retry through the new chain.
UPDATE tag_sources_meta
SET current_version = current_version + 1,
last_registered_providers_hash = $1
WHERE id = true
RETURNING current_version;
+3 -5
View File
@@ -8,7 +8,6 @@
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
-- is split into tags in Go. Quarantined tracks are excluded.
SELECT
t.id AS track_id,
t.artist_id,
t.genre,
LEAST(GREATEST(
@@ -26,10 +25,9 @@ WHERE pe.user_id = $1
);
-- name: ListLikedTrackTasteInputsForUser :many
-- (track_id, artist_id, genre) for each track the user has explicitly
-- liked. Feeds the track-like bonus into the liked track's artist and
-- tags; track_id keys the enriched track_tags lookup (#1490).
SELECT t.id AS track_id, t.artist_id, t.genre
-- (artist_id, genre) for each track the user has explicitly liked. Feeds the
-- track-like bonus into the liked track's artist and tags.
SELECT t.artist_id, t.genre
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1;
-57
View File
@@ -1,57 +0,0 @@
-- Track-tag enrichment queries (milestone #160, #1490). The tag enricher
-- drains ListTracksMissingTags, fetches from the provider chain, and
-- writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
-- stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
-- name: ListTracksMissingTags :many
-- Tracks eligible for tag enrichment: never processed (tag_source NULL)
-- or previously settled 'none' under an older provider version. Returns
-- the fields the provider chain needs — recording MBID (nullable) for
-- keyed lookups, plus title + artist name for name-based fallback.
-- $1 = current tag_sources_version, $2 = limit.
SELECT t.id, t.mbid, t.title, a.name AS artist_name
FROM tracks t
JOIN artists a ON a.id = t.artist_id
WHERE t.tag_source IS NULL
OR (t.tag_source = 'none' AND t.tag_sources_version < $1)
ORDER BY t.id
LIMIT $2;
-- name: DeleteTrackTags :exec
-- Clear a track's cached tags before rewriting (atomic replace by the caller).
DELETE FROM track_tags WHERE track_id = $1;
-- name: InsertTrackTag :exec
-- Upsert one (track, tag); keep the stronger weight when two providers
-- agree on a tag with different folksonomy strengths.
INSERT INTO track_tags (track_id, tag, weight)
VALUES ($1, $2, $3)
ON CONFLICT (track_id, tag)
DO UPDATE SET weight = GREATEST(track_tags.weight, EXCLUDED.weight);
-- name: SetTrackTagSource :exec
-- Stamp the enrichment outcome so the batch drainer skips settled rows.
-- $2 = 'lastfm' | 'musicbrainz' | 'mixed' | 'none', $3 = current version.
UPDATE tracks SET tag_source = $2, tag_sources_version = $3 WHERE id = $1;
-- name: ListPlayedTrackTagsForUser :many
-- The enriched tags for every track this user played inside the taste
-- window, so the recompute can union them into the tag facet alongside
-- ID3 genre. $1 = user_id, $2 = window_days. One row per (track, tag).
SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
WHERE tt.track_id IN (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - make_interval(days => $2::int)
);
-- name: ListLikedTrackTagsForUser :many
-- Enriched tags for the user's liked tracks — the like-bonus path in the
-- recompute mirrors the play path, so it needs the same tag lookup.
SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
JOIN general_likes gl ON gl.track_id = tt.track_id
WHERE gl.user_id = $1;
-17
View File
@@ -62,15 +62,6 @@ var dataTables = []string{
// (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead.
"cover_art_provider_settings",
// Same reasoning for the tag-enrichment settings (#1490): truncate the
// per-provider rows (reconcile re-seeds registered ones), and reset the
// tag_sources_meta counter via UPDATE below rather than truncating it.
"tag_provider_settings",
// recsettings.New reconciles shipped defaults on every construction
// (#1250), so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
"taste_tuning",
"recommendation_tuning_audit",
"tracks",
"albums",
"artists",
@@ -102,12 +93,4 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
}
// Same for the tag-sources counter (#1490) — clears cross-test version
// accumulation that would spuriously report version_bumped on a
// key-only change.
if _, err := pool.Exec(ctx,
"UPDATE tag_sources_meta SET current_version = 1, last_registered_providers_hash = ''",
); err != nil {
t.Fatalf("dbtest.ResetDB reset tag-sources version: %v", err)
}
}
-46
View File
@@ -97,36 +97,6 @@ var systemPlaylistSources = map[string]bool{
"songs_like_artist": true,
}
// lookupSystemPickKind resolves a system-playlist play's pick_kind
// (taste/fresh, Discover's bucket, or a rule-#131 tier) from the user's
// live snapshot of that variant at ingestion time. Attribution must be
// frozen now — snapshots rebuild daily, so it can't be reconstructed at
// metrics-read time (#1249, generalized in #1270). Source strings
// double as system_variant values (see systemPlaylistSources). Returns
// nil (unattributed) when the track isn't in the current snapshot —
// plays predating the feature, or an offline replay arriving after the
// track rotated out — and when the variant doesn't stamp yet. Real DB
// errors propagate and fail the caller's transaction.
func lookupSystemPickKind(
ctx context.Context,
q *dbq.Queries,
userID, trackID pgtype.UUID,
variant string,
) (*string, error) {
kind, err := q.GetSystemPickKindForTrack(ctx, dbq.GetSystemPickKindForTrackParams{
UserID: userID,
SystemVariant: &variant,
TrackID: trackID,
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return kind, nil
}
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
// tag identifying which surface the play came from. When source is a
// known system-playlist kind the track is appended to that user's
@@ -157,13 +127,6 @@ func (w *Writer) RecordPlayStartedWithSource(
if source != "" {
sourcePtr = &source
}
var pickKind *string
if systemPlaylistSources[source] {
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
if err != nil {
return err
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
@@ -171,7 +134,6 @@ func (w *Writer) RecordPlayStartedWithSource(
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
})
if err != nil {
return err
@@ -433,13 +395,6 @@ func (w *Writer) RecordOfflinePlay(
if source != "" {
sourcePtr = &source
}
var pickKind *string
if systemPlaylistSources[source] {
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
if err != nil {
return err
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
@@ -447,7 +402,6 @@ func (w *Writer) RecordOfflinePlay(
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
})
if err != nil {
return err
-120
View File
@@ -488,123 +488,3 @@ func TestRecordPlayStarted_NoRotationWithoutSource(t *testing.T) {
t.Errorf("expected no rotation row for a source-less play")
}
}
// seedSystemSnapshot creates a system playlist of the given variant
// containing the fixture track with the given pick_kind, mimicking
// what the builder persists (#1249, generalized #1270).
func seedSystemSnapshot(t *testing.T, f fixture, variant, pickKind string) {
t.Helper()
pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{
UserID: f.user, Name: variant, SystemVariant: &variant,
})
if err != nil {
t.Fatalf("CreateSystemPlaylist: %v", err)
}
if _, err := f.q.AppendPlaylistTrack(context.Background(), dbq.AppendPlaylistTrackParams{
PlaylistID: pl.ID, TrackID: f.track, PickKind: &pickKind,
}); err != nil {
t.Fatalf("AppendPlaylistTrack: %v", err)
}
}
func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "fresh")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind == nil || *got.PickKind != "fresh" {
t.Errorf("pick_kind = %v, want fresh (frozen from live snapshot)", got.PickKind)
}
}
func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T) {
// A for_you play whose track isn't in the current snapshot (rotated
// out, or no snapshot exists) stays unattributed rather than guessing.
f := newFixture(t, 200_000)
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind != nil {
t.Errorf("pick_kind = %q, want NULL (track not in snapshot)", *got.PickKind)
}
}
func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
// Attribution is variant-scoped: even with a For You snapshot
// containing the track, a play from a different surface must not
// pick up For You's stamp — it reads its OWN variant's snapshot,
// which here doesn't exist.
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "taste")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind != nil {
t.Errorf("pick_kind = %q, want NULL for a cross-variant play", *got.PickKind)
}
}
func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
// Discover stamps its candidate bucket (#1270); a discover play whose
// track is in the live snapshot freezes that bucket onto the play.
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "discover", "dormant")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind == nil || *got.PickKind != "dormant" {
t.Errorf("pick_kind = %v, want dormant (frozen from live snapshot)", got.PickKind)
}
}
func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
// The offline replay path threads source the same way; a replayed
// for_you play whose track is still in the snapshot gets attributed.
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "taste")
at := time.Now().UTC().Add(-time.Hour)
if err := f.w.RecordOfflinePlay(
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
); err != nil {
t.Fatalf("RecordOfflinePlay: %v", err)
}
var pickKind *string
if err := f.pool.QueryRow(context.Background(),
`SELECT pick_kind FROM play_events WHERE user_id = $1 AND track_id = $2`,
f.user, f.track,
).Scan(&pickKind); err != nil {
t.Fatalf("select: %v", err)
}
if pickKind == nil || *pickKind != "taste" {
t.Errorf("pick_kind = %v, want taste", pickKind)
}
}
+39 -98
View File
@@ -10,39 +10,26 @@ import (
)
const (
discoverTotalSlots = 100
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488):
// the taste-matched-unheard arm gets the plurality, dormant stays a
// familiar anchor, and the crude random arm is trimmed to a
// serendipity sliver. On a single-user server cross_user is empty and
// its slots redistribute, so taste_unheard becomes the dominant real
// arm — Discover actually discovers instead of re-warming known artists.
discoverTasteUnheardSlots = 35
discoverDormantSlots = 30
discoverCrossUserSlots = 20
discoverRandomSlots = 15
discoverTotalSlots = 100
discoverDormantSlots = 40
discoverCrossUserSlots = 30
discoverRandomSlots = 30
discoverMaxTracksPerAlbum = 2
discoverMaxTracksPerArtist = 3
)
// discoverTrack is the common shape used by the bucket allocator. The
// three sqlc-generated row types collapse into this internal struct so
// downstream functions don't need to be generic over them. PickKind is
// the originating bucket (taste_unheard/dormant/cross_user/random,
// #1270/#1488), stamped in the row adapters so it survives the
// interleave — bucket identity is what makes the slot allocation
// measurable in the metrics.
// downstream functions don't need to be generic over them.
type discoverTrack struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
PickKind string
}
// buildDiscoverCandidates assembles the Discover playlist track list.
// Pulls from four buckets (taste_unheard / dormant / cross_user /
// random), applies per-album/per-artist caps, then redistributes any
// deficit equally across the remaining buckets.
// Pulls from three buckets, applies per-album/per-artist caps, then
// redistributes any deficit equally across the remaining buckets.
//
// Returns up to discoverTotalSlots track IDs in the order they should
// appear in the playlist (round-robin interleaved across buckets).
@@ -52,57 +39,6 @@ type discoverTrack struct {
// the redistribution algorithm rolls the deficit into the surviving
// buckets so one broken bucket can't silently kill the whole playlist.
func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) {
pools := loadDiscoverPools(ctx, q, logger, userID, dateStr)
// Allocate slots with redistribution. Order fixes both the allocation
// index mapping and (below) the interleave/dedup priority.
allocations := redistributeSlots([]bucketRequest{
{want: discoverTasteUnheardSlots, available: len(pools.taste)},
{want: discoverDormantSlots, available: len(pools.dormant)},
{want: discoverCrossUserSlots, available: len(pools.crossUser)},
{want: discoverRandomSlots, available: len(pools.random)},
})
// Round-robin interleave so the playlist doesn't front-load one
// flavour. taste_unheard leads so a track shared with another bucket
// keeps the taste stamp — the targeted-novelty arm stays measurable.
out := interleaveBuckets(
pools.taste[:allocations[0]],
pools.dormant[:allocations[1]],
pools.crossUser[:allocations[2]],
pools.random[:allocations[3]],
)
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
// Score is unused for Discover — the daily-deterministic md5 ordering
// already gave us the ranking.
ranked := make([]rankedCandidate, 0, len(out))
for _, t := range out {
ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
}
return ranked, nil
}
// discoverPools holds the four Discover candidate buckets after adapting
// + per-album/per-artist capping. Bucket query failures degrade to an
// empty pool (logged) so one broken bucket can't kill the whole playlist;
// the slot redistribution rolls its deficit into the survivors.
type discoverPools struct {
taste []discoverTrack
dormant []discoverTrack
crossUser []discoverTrack
random []discoverTrack
}
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
UserID: userID, Column2: dateStr,
})
if err != nil {
logger.Warn("discover: taste-unheard bucket failed; continuing with empty pool",
"user_id", uuidStringPL(userID), "err", err)
tasteRows = nil
}
dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
UserID: userID, Column2: dateStr,
})
@@ -127,32 +63,43 @@ func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger,
"user_id", uuidStringPL(userID), "err", err)
randomRows = nil
}
return discoverPools{
taste: capByAlbumAndArtist(tasteUnheardRowsToTracks(tasteRows)),
dormant: capByAlbumAndArtist(dormantRowsToTracks(dormantRows)),
crossUser: capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows)),
random: capByAlbumAndArtist(randomRowsToTracks(randomRows)),
}
}
func tasteUnheardRowsToTracks(rows []dbq.ListTasteUnheardTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindTasteUnheard,
}
// Adapt sqlc-generated rows into the internal struct, then apply
// per-album / per-artist caps.
dormantPool := capByAlbumAndArtist(dormantRowsToTracks(dormantRows))
crossUserPool := capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows))
randomPool := capByAlbumAndArtist(randomRowsToTracks(randomRows))
// Allocate slots with redistribution.
allocations := redistributeSlots([]bucketRequest{
{want: discoverDormantSlots, available: len(dormantPool)},
{want: discoverCrossUserSlots, available: len(crossUserPool)},
{want: discoverRandomSlots, available: len(randomPool)},
})
// Take the head of each bucket's capped pool.
dormantTake := dormantPool[:allocations[0]]
crossUserTake := crossUserPool[:allocations[1]]
randomTake := randomPool[:allocations[2]]
// Round-robin interleave so the playlist doesn't front-load one
// flavour.
out := interleaveBuckets(dormantTake, crossUserTake, randomTake)
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
// Score is unused for Discover — the daily-deterministic md5 ordering
// already gave us the ranking.
ranked := make([]rankedCandidate, 0, len(out))
for _, t := range out {
ranked = append(ranked, rankedCandidate{TrackID: t.ID})
}
return out
return ranked, nil
}
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindDormant,
}
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out
}
@@ -160,10 +107,7 @@ func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []dis
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindCrossUser,
}
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out
}
@@ -171,10 +115,7 @@ func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindRandom,
}
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out
}
-24
View File
@@ -161,27 +161,3 @@ func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
}
}
}
func TestInterleaveBuckets_PreservesBucketPickKind(t *testing.T) {
// Bucket provenance (#1270) is stamped on discoverTrack before the
// interleave and must survive it — including dedup, where a track in
// two buckets keeps the stamp of the bucket it was taken from.
dormant := []discoverTrack{
{ID: uuidN(1), PickKind: pickKindDormant},
{ID: uuidN(2), PickKind: pickKindDormant},
}
random := []discoverTrack{
{ID: uuidN(1), PickKind: pickKindRandom}, // shared with dormant
{ID: uuidN(3), PickKind: pickKindRandom},
}
got := interleaveBuckets(dormant, nil, random)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
want := map[byte]string{1: pickKindDormant, 2: pickKindDormant, 3: pickKindRandom}
for _, tr := range got {
if w := want[tr.ID.Bytes[15]]; tr.PickKind != w {
t.Errorf("track %d pick_kind = %q, want %q", tr.ID.Bytes[15], tr.PickKind, w)
}
}
}
+8 -195
View File
@@ -99,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) {
makeCand(2, 11, 101, 0.9),
makeCand(3, 12, 102, 0.8),
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
if len(got) != 3 {
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
}
@@ -112,7 +112,7 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
for i := 0; i < 7; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
if len(got) != 7 {
t.Errorf("len = %d, want 7 (pool == total)", len(got))
}
@@ -126,7 +126,7 @@ func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
for i := 0; i < 100; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
if len(got) != 25 {
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
}
@@ -139,8 +139,8 @@ func TestPickHeadAndTail_Determinism(t *testing.T) {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
}
now := time.Now()
got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
if len(got1) != len(got2) {
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
}
@@ -161,8 +161,8 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
}
now := time.Now()
day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
day2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-08", now, 20, 5)
day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5)
if len(day1) != 25 || len(day2) != 25 {
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
}
@@ -205,7 +205,7 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
sim := float64(50 - i)
in = append(in, makeCand(i+1, i+1, i+1, sim))
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 3)
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3)
if len(got) != 8 {
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
}
@@ -243,190 +243,3 @@ func TestPickTopN_DiversityCap(t *testing.T) {
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
}
}
func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
// taste picks, the 5 tail entries the freshness injection (#1249).
in := make([]recommendation.Candidate, 0, 100)
for i := 0; i < 100; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
if len(got) != 25 {
t.Fatalf("len = %d, want 25", len(got))
}
for i := 0; i < 20; i++ {
if got[i].PickKind != pickKindTaste {
t.Errorf("head[%d].PickKind = %q, want %q", i, got[i].PickKind, pickKindTaste)
}
}
for i := 20; i < 25; i++ {
if got[i].PickKind != pickKindFresh {
t.Errorf("tail[%d].PickKind = %q, want %q", i-20, got[i].PickKind, pickKindFresh)
}
}
}
func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
// The small-pool fallback is pure top-N-by-score — that IS the taste
// mechanism, so nothing on this path is an exploration pick.
in := []recommendation.Candidate{
makeCand(1, 10, 100, 1.0),
makeCand(2, 11, 101, 0.9),
makeCand(3, 12, 102, 0.8),
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
for i, rc := range got {
if rc.PickKind != pickKindTaste {
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
i, rc.PickKind, pickKindTaste)
}
}
}
func TestPickTopN_NoPickKind(t *testing.T) {
// Songs-like-X and the discovery mixes don't split; their rows must
// persist pick_kind NULL (empty string here).
in := []recommendation.Candidate{makeCand(1, 10, 100, 1.0)}
got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
if len(got) != 1 || got[0].PickKind != "" {
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
}
}
func TestHeadQuotas(t *testing.T) {
cases := []struct {
seeds, headN int
want []int
}{
{1, 50, []int{50}},
{2, 50, []int{30, 20}},
{3, 50, []int{25, 15, 10}},
{3, 7, []int{4, 2, 1}}, // rounding remainder → primary seed
}
for _, c := range cases {
got := headQuotas(c.seeds, c.headN)
if len(got) != len(c.want) {
t.Fatalf("headQuotas(%d, %d) len = %d, want %d", c.seeds, c.headN, len(got), len(c.want))
}
sum := 0
for i := range got {
sum += got[i]
if got[i] != c.want[i] {
t.Errorf("headQuotas(%d, %d)[%d] = %d, want %d", c.seeds, c.headN, i, got[i], c.want[i])
}
}
if sum != c.headN {
t.Errorf("headQuotas(%d, %d) sums to %d, want %d", c.seeds, c.headN, sum, c.headN)
}
}
}
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
// Seed 0's neighborhood out-scores everything (#1269): without
// quotas the head would be all seed-0. With headN=10 and quotas
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
// 2 get their guaranteed slots from deeper ranks.
in := make([]recommendation.Candidate, 0, 30)
seedOf := map[pgtype.UUID]int{}
for i := 0; i < 30; i++ {
c := makeCand(i+1, i+1, i+1, float64(30-i))
si := 0
if i >= 20 && i < 25 {
si = 1
} else if i >= 25 {
si = 2
}
seedOf[c.Track.ID] = si
in = append(in, c)
}
// Input is already score-descending and cap-free.
head := pickQuotaHead(in, seedOf, 3, 10)
if len(head) != 10 {
t.Fatalf("head len = %d, want 10", len(head))
}
want := []byte{1, 2, 3, 4, 5, 21, 22, 23, 26, 27}
for i, w := range want {
if head[i].Track.ID.Bytes[15] != w {
t.Errorf("head[%d] = track %d, want %d", i, head[i].Track.ID.Bytes[15], w)
}
}
}
func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
// Seed 1 has a single candidate; its unfilled quota goes to the
// best remaining candidates regardless of seed.
in := make([]recommendation.Candidate, 0, 12)
seedOf := map[pgtype.UUID]int{}
for i := 0; i < 12; i++ {
c := makeCand(i+1, i+1, i+1, float64(12-i))
si := 0
if i == 11 {
si = 1 // the worst-ranked candidate is seed 1's only one
}
seedOf[c.Track.ID] = si
in = append(in, c)
}
head := pickQuotaHead(in, seedOf, 2, 10)
if len(head) != 10 {
t.Fatalf("head len = %d, want 10", len(head))
}
// Quotas [6,4]: seed 0 fills 6 (tracks 1-6), seed 1 fills 1
// (track 12), spill tops up with tracks 7-9.
got := map[byte]bool{}
for _, c := range head {
got[c.Track.ID.Bytes[15]] = true
}
for _, w := range []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 12} {
if !got[w] {
t.Errorf("head missing track %d (spill should fill best-first)", w)
}
}
}
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
// The fresh tail's sample is rank-weighted (#1269): across many
// days, the mean selected rank must sit well below the uniform
// draw's ~99.5 for a 200-deep pool.
pool := make([]recommendation.Candidate, 0, 200)
for i := 0; i < 200; i++ {
c := makeCand(1, i+1, i+1, 1.0)
// Distinct track IDs beyond one byte: bytes wrap at 256.
c.Track.ID.Bytes[14] = byte(i / 200)
c.Track.ID.Bytes[15] = byte(i % 200)
pool = append(pool, c)
}
rankOf := map[pgtype.UUID]int{}
for i, c := range pool {
rankOf[c.Track.ID] = i
}
totalRank, picks := 0, 0
for day := 1; day <= 28; day++ {
got := pickWeightedTail(pool, "2026-06-"+twoDigits(day), 10)
if len(got) != 10 {
t.Fatalf("day %d: len = %d, want 10", day, len(got))
}
for _, c := range got {
totalRank += rankOf[c.Track.ID]
picks++
}
}
mean := float64(totalRank) / float64(picks)
if mean > 80 {
t.Errorf("mean selected rank = %.1f; want well below uniform ~99.5", mean)
}
}
func TestPickWeightedTail_DeterministicWithinDay(t *testing.T) {
pool := make([]recommendation.Candidate, 0, 60)
for i := 0; i < 60; i++ {
pool = append(pool, makeCand(i+1, i+1, i+1, 1.0))
}
a := pickWeightedTail(pool, "2026-06-05", 8)
b := pickWeightedTail(pool, "2026-06-05", 8)
for i := range a {
if a[i].Track.ID != b[i].Track.ID {
t.Fatal("weighted tail must be deterministic within a day")
}
}
}
+1 -1
View File
@@ -236,7 +236,7 @@ func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiv
// build can read a fresh profile (phase-2 consumption); both steps are
// best-effort and a failure in one is logged without blocking the other.
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, currentTasteConfig()); err != nil {
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil {
s.logger.Warn("scheduler: taste profile rebuild failed",
"user_id", uuidStringPL(userID), "err", err)
}
+63 -86
View File
@@ -1,7 +1,6 @@
package playlists
import (
"sort"
"testing"
"github.com/jackc/pgx/v5/pgtype"
@@ -47,26 +46,6 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) {
}
}
func TestPickKindForSeedTier(t *testing.T) {
// The seed query's fallback tiers map onto the rule-#131 ladder:
// fresh 7-day engagement is the exact desire, everything past the
// 30-day step-back collapses into the far tier (#1255).
cases := []struct {
tier int32
want string
}{
{0, pickKindTier1},
{1, pickKindTier2},
{2, pickKindTier3},
{3, pickKindTier3},
}
for _, c := range cases {
if got := pickKindForSeedTier(c.tier); got != c.want {
t.Errorf("pickKindForSeedTier(%d) = %q, want %q", c.tier, got, c.want)
}
}
}
// userIDHash is the per-user, per-day hash that drives the daily-
// determinism RNGs. Same family as tieBreakHash, just keyed on user
// ID instead of track ID.
@@ -97,100 +76,81 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
}
}
// pickDailySeeds shuffles the candidate pool daily-deterministically
// and takes up to n — For-You uses n=forYouSeedCount for its
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day,
// variation across days, and graceful degradation on small pools.
// pickForYouSeedForDay rotates the chosen For-You seed across the
// user's top-played candidates using userIDHash. Verifies the picker
// is deterministic within a day, varies across days, and degrades
// gracefully when fewer than 5 candidates exist.
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := []pgtype.UUID{
seeds := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true},
}
a := pickDailySeeds(pool, u, "2026-05-04", 3)
b := pickDailySeeds(pool, u, "2026-05-04", 3)
if len(a) != 3 || len(b) != 3 {
t.Fatalf("lens = %d, %d; want 3, 3", len(a), len(b))
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("same day should pick same seeds; got %v then %v", a, b)
}
a := pickForYouSeedForDay(seeds, u, "2026-05-04")
b := pickForYouSeedForDay(seeds, u, "2026-05-04")
if a != b {
t.Fatalf("same day should pick same seed; got %v then %v", a, b)
}
}
func TestPickDailySeeds_VariesAcrossDays(t *testing.T) {
func TestPickForYouSeedForDay_VariesAcrossDays(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := []pgtype.UUID{
seeds := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true},
}
firstPicks := map[[16]byte]bool{}
picks := map[[16]byte]bool{}
for i := 1; i <= 30; i++ {
date := "2026-05-" + twoDigits(i)
firstPicks[pickDailySeeds(pool, u, date, 3)[0].Bytes] = true
picks[pickForYouSeedForDay(seeds, u, date).Bytes] = true
}
if len(firstPicks) < 2 {
t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks))
if len(picks) < 2 {
t.Errorf("expected >=2 distinct seeds across 30 days; got %d", len(picks))
}
}
func TestPickDailySeeds_SmallPool(t *testing.T) {
func TestPickForYouSeedForDay_SingleCandidate(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true}
got := pickDailySeeds([]pgtype.UUID{only}, u, "2026-05-04", 3)
if len(got) != 1 || got[0] != only {
t.Errorf("single-entry pool should return just that entry; got %v", got)
got := pickForYouSeedForDay([]pgtype.UUID{only}, u, "2026-05-04")
if got != only {
t.Errorf("single-seed pool should return that seed; got %v", got)
}
}
func TestPickDailySeeds_EmptyPool(t *testing.T) {
func TestPickForYouSeedForDay_EmptyPool(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 {
t.Errorf("empty pool should return nothing; got %v", got)
got := pickForYouSeedForDay(nil, u, "2026-05-04")
if got.Valid {
t.Errorf("empty pool should return zero UUID; got %v", got)
}
}
// pickSeedArtistsForDay takes the user's top-12 candidate artists and
// returns songsLikeSeedCount of them via daily-deterministic shuffle.
// Verifies the picker is deterministic within a day, varies across days,
// and degrades gracefully when fewer than songsLikeSeedCount candidates
// exist. bigSeedPool is a pool comfortably larger than songsLikeSeedCount
// so the "returns exactly the count" cases have room.
func bigSeedPool(n int) []pgtype.UUID {
pool := make([]pgtype.UUID, 0, n)
for i := 0; i < n; i++ {
pool = append(pool, pgtype.UUID{Bytes: [16]byte{byte(10 * (i + 1))}, Valid: true})
}
return pool
}
// seedSetKey sorts the picked seeds' lead bytes into a stable string so
// two picks with the same members (any order) compare equal.
func seedSetKey(seeds []pgtype.UUID) string {
bs := make([]byte, 0, len(seeds))
for _, s := range seeds {
bs = append(bs, s.Bytes[0])
}
sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
return string(bs)
}
// pickSeedArtistsForDay takes the user's top-5 candidate artists and
// returns 3 of them via daily-deterministic shuffle. Verifies the
// picker is deterministic within a day, varies across days, and
// degrades gracefully when fewer than 3 or 5 candidates exist.
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := bigSeedPool(10)
pool := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true},
}
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
if len(a) != songsLikeSeedCount || len(b) != songsLikeSeedCount {
t.Fatalf("expected %d seeds; got %d / %d", songsLikeSeedCount, len(a), len(b))
if len(a) != 3 || len(b) != 3 {
t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b))
}
for i := range a {
if a[i] != b[i] {
@@ -201,23 +161,40 @@ func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := bigSeedPool(10)
// Collect the seed set (order-independent) across many dates. With
// C(10, songsLikeSeedCount) combinations, 30 dates yield >=2 distinct sets.
seen := map[string]bool{}
pool := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true},
}
// Collect the trio (as a sorted byte tuple) across many dates.
// With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets.
seen := map[[3]byte]bool{}
for i := 1; i <= 30; i++ {
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
if len(got) != songsLikeSeedCount {
t.Fatalf("expected %d seeds; got %d", songsLikeSeedCount, len(got))
if len(got) != 3 {
t.Fatalf("expected 3 seeds; got %d", len(got))
}
seen[seedSetKey(got)] = true
// Sort the three byte values for set-equivalence comparison.
v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]}
if v[0] > v[1] {
v[0], v[1] = v[1], v[0]
}
if v[1] > v[2] {
v[1], v[2] = v[2], v[1]
}
if v[0] > v[1] {
v[0], v[1] = v[1], v[0]
}
seen[v] = true
}
if len(seen) < 2 {
t.Errorf("expected >=2 distinct seed sets across 30 days; got %d", len(seen))
t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen))
}
}
func TestPickSeedArtistsForDay_FewerThanCount(t *testing.T) {
func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true},
+89 -352
View File
@@ -12,10 +12,8 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"math/rand"
"sort"
"sync"
"time"
"github.com/jackc/pgx/v5"
@@ -24,7 +22,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
@@ -35,7 +32,7 @@ type seedArtistRow struct {
}
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
// SQL already orders by score DESC + artist_id and LIMIT 12, so this is
// SQL already orders by score DESC + artist_id and LIMIT 3, so this is
// just a column projection — but pulling it into a function keeps the
// call-site readable and makes the post-fetch path testable without
// a database.
@@ -86,15 +83,32 @@ func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
return binary.BigEndian.Uint64(sum[:8])
}
// pickDailySeeds takes a candidate pool of UUIDs and returns up to n
// of them, daily-deterministically shuffled. Each day gets a different
// ordering / selection, but within-day stability is preserved (same
// inputs always produce the same output).
// pickForYouSeedForDay picks one of the user's top-played candidate
// tracks as today's For-You seed. With 5 candidates and SHA-256-based
// rotation, each candidate gets picked roughly 1 day in 5; the chosen
// seed drives the similarity candidate pool, so the resulting mix is
// substantially different across days for a user with diverse top-N
// plays.
//
// Degrades gracefully: 1 candidate → returns it; 0 → returns zero UUID
// (caller treats as "no seed available" and skips For-You).
func pickForYouSeedForDay(seeds []pgtype.UUID, userID pgtype.UUID, dateStr string) pgtype.UUID {
if len(seeds) == 0 {
return pgtype.UUID{}
}
idx := int(userIDHash(userID, dateStr) % uint64(len(seeds)))
return seeds[idx]
}
// pickSeedArtistsForDay takes a candidate pool of up to N artists and
// returns up to 3 of them, daily-deterministically shuffled. Each day
// gets a different ordering / selection, but within-day stability is
// preserved (same inputs always produce the same output).
//
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
// Degrades gracefully: <n candidates returns whatever is available in
// Degrades gracefully: <3 candidates returns whatever is available in
// shuffled order.
func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n int) []pgtype.UUID {
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
if len(pool) == 0 {
return nil
}
@@ -104,148 +118,41 @@ func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n in
rng.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
n := 3
if len(shuffled) < n {
n = len(shuffled)
}
return shuffled[:n]
}
// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
// each day (#1491). Bumped from 3 when Songs-like was promoted to its
// own dedicated Home row — the best-performing surface (low skip / high
// completion) was buried as 3 tiles in the shared Playlists carousel, so
// the wider row now shows a wider spread. Drawn from PickSeedArtists'
// deeper top-12 pool via a daily-deterministic shuffle so the set still
// rotates day to day rather than pinning the same six artists.
const songsLikeSeedCount = 6
// pickSeedArtistsForDay picks up to songsLikeSeedCount seed artists for
// the Songs-like mixes; For-You uses pickDailySeeds directly with
// forYouSeedCount.
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
return pickDailySeeds(pool, userID, dateStr, songsLikeSeedCount)
}
// rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from
// recommendation.Candidate scores. PickKind is the track's provenance
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
// recommendation.Candidate scores.
type rankedCandidate struct {
TrackID pgtype.UUID
Score float64
PickKind string
}
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
// attribute skips to the population that sourced the track — For You's
// "taste engine missing" vs "freshness tax", Discover's three buckets
// (#1270). Values mirror the CHECK in migration 0041; taste_unheard is
// Discover's taste-targeted-novelty arm (#1488).
const (
pickKindTaste = "taste"
pickKindFresh = "fresh"
pickKindDormant = "dormant"
pickKindTasteUnheard = "taste_unheard"
pickKindCrossUser = "cross_user"
pickKindRandom = "random"
pickKindTier1 = "tier1"
pickKindTier2 = "tier2"
pickKindTier3 = "tier3"
)
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
// mix's exact desire (tier1), the 30-day window steps back a little
// (tier2), and all-time / liked-only seeds are the far fallback
// (tier3). Stamped mix-wide — seed staleness is a property of the
// whole build, and it's what the metrics breakdown attributes skips to.
func pickKindForSeedTier(tier int32) string {
switch tier {
case 0:
return pickKindTier1
case 1:
return pickKindTier2
default:
return pickKindTier3
}
}
// pickKindForMixTier maps a tiered mix query's 1-based tier column
// (numbered to match rule #131's ladder directly) onto the pick-kind
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
// count fallback steps of the seed pool rather than eligibility rungs.
func pickKindForMixTier(tier int32) string {
switch tier {
case 1:
return pickKindTier1
case 2:
return pickKindTier2
default:
return pickKindTier3
}
TrackID pgtype.UUID
Score float64
}
const systemMixLength = 25
// systemMixWeights are the scoring weights used by the daily builds.
// systemMixWeights are the fixed scoring weights used by the cron worker.
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
// scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen.
//
// DB-tunable since #1250: the recsettings service pushes the current
// daily_mix profile via SetSystemMixWeights at boot and on every admin
// change (coverart Configure() pattern — no signature threading, live
// effect without restart). The literal here is only the pre-push
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
// which must stay in sync with it.
var (
systemTuningMu sync.RWMutex
systemMixWeights = recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
// Taste profile (#796 phase 2): the daily mixes are the primary
// taste-driven surface, so they lean on it. TasteMatchScore is in
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes.
TasteWeight: 1.5,
}
systemTasteConfig = taste.DefaultConfig()
)
// SetSystemMixWeights installs the current daily_mix scoring weights.
// Called by the recsettings service at boot and on admin updates.
func SetSystemMixWeights(w recommendation.ScoringWeights) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
systemMixWeights = w
}
// SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as
// SetSystemMixWeights.
func SetTasteConfig(c taste.Config) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
systemTasteConfig = c
}
func currentSystemMixWeights() recommendation.ScoringWeights {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return systemMixWeights
}
func currentTasteConfig() taste.Config {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return systemTasteConfig
var systemMixWeights = recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
// Taste profile (#796 phase 2): the daily mixes are the primary
// taste-driven surface, so they lean on it. TasteMatchScore is in
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes.
TasteWeight: 1.5,
}
// forYouHeadN is the number of top-scored tracks that anchor the For-You
@@ -268,122 +175,6 @@ const (
forYouTailN = 50
)
// forYouSeedCount is how many of the user's top-5 tracks seed each
// day's build (#1269). A single rotating seed made the mix bipolar
// day-to-day — swinging whole neighborhoods as the rotation moved
// between dissimilar anchors; blending 3 spans neighborhoods within
// one day and smooths the swing across days.
const forYouSeedCount = 3
// headQuotas fixes each seed's share of the For-You head so one
// neighborhood can't monopolize it even when its candidates out-score
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
// it for one. Rounding remainder goes to the primary seed; a thin
// seed's unfilled quota spills best-first in pickQuotaHead.
func headQuotas(numSeeds, headN int) []int {
if numSeeds <= 1 {
return []int{headN}
}
fractions := []float64{0.5, 0.3, 0.2}
if numSeeds == 2 {
fractions = []float64{0.6, 0.4}
}
if numSeeds > len(fractions) {
// Not reachable at forYouSeedCount = 3; even split keeps any
// future seed-count change from silently starving seeds.
fractions = make([]float64, numSeeds)
for i := range fractions {
fractions[i] = 1.0 / float64(numSeeds)
}
}
quotas := make([]int, numSeeds)
assigned := 0
for i := 0; i < numSeeds; i++ {
quotas[i] = int(float64(headN) * fractions[i])
assigned += quotas[i]
}
quotas[0] += headN - assigned
return quotas
}
// pickQuotaHead walks the score-sorted capped pool best-first, taking
// candidates whose originating seed still has head quota; slots a thin
// seed can't fill spill best-first regardless of seed in a second
// pass. A nil seedOf (single-seed path, tests) attributes everything
// to seed 0, which reduces to plain top-headN.
func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds, headN int) []recommendation.Candidate {
quotas := headQuotas(numSeeds, headN)
taken := make([]int, len(quotas))
inHead := make(map[pgtype.UUID]bool, headN)
head := make([]recommendation.Candidate, 0, headN)
for _, c := range capped {
if len(head) >= headN {
break
}
si := seedOf[c.Track.ID]
if si < 0 || si >= len(quotas) {
si = 0
}
if taken[si] >= quotas[si] {
continue
}
taken[si]++
head = append(head, c)
inHead[c.Track.ID] = true
}
for _, c := range capped {
if len(head) >= headN {
break
}
if inHead[c.Track.ID] {
continue
}
head = append(head, c)
inHead[c.Track.ID] = true
}
return head
}
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
// candidate's sampling weight halves every 50 ranks, so the 101st-best
// candidate is far likelier than the 401st-best instead of equal —
// freshness keeps its "you'll probably enjoy this" half.
const forYouTailHalfLifeRanks = 50.0
// tailSampleUniverse quantizes tieBreakHash into a uniform (0,1] draw
// for the weighted sample; 1e6 buckets is plenty of resolution for
// pools of a few hundred.
const tailSampleUniverse uint64 = 1_000_000
// pickWeightedTail samples up to tailN candidates from the rank-
// ordered tail pool with exponentially rank-decaying weights, using
// deterministic Efraimidis-Spirakis keys (u^(1/w), u derived from
// tieBreakHash): same (track, day) → same key, so the sample is stable
// within a day and rotates across days. Replaces the uniform daily
// draw where rank 380 had the same chance as rank 101.
func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN int) []recommendation.Candidate {
if len(tailPool) <= tailN {
return tailPool
}
type keyed struct {
c recommendation.Candidate
key float64
}
keys := make([]keyed, len(tailPool))
for i, c := range tailPool {
u := (float64(tieBreakHash(c.Track.ID, dateStr)%tailSampleUniverse) + 1) /
float64(tailSampleUniverse+1)
w := math.Exp2(-float64(i) / forYouTailHalfLifeRanks)
keys[i] = keyed{c: c, key: math.Pow(u, 1/w)}
}
sort.SliceStable(keys, func(i, j int) bool { return keys[i].key > keys[j].key })
out := make([]recommendation.Candidate, tailN)
for i := 0; i < tailN; i++ {
out[i] = keys[i].c
}
return out
}
// scoreAndSortCandidates scores every candidate with recommendation.Score
// and returns a new slice sorted by score DESC (ties broken by
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
@@ -408,10 +199,9 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
sort.SliceStable(ordered, func(i, j int) bool {
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
})
weights := currentSystemMixWeights()
pairs := make([]scored, len(ordered))
for i, c := range ordered {
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
}
sort.SliceStable(pairs, func(i, j int) bool {
if pairs[i].score != pairs[j].score {
@@ -546,11 +336,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
}
}
// produceForYou: blend candidate pools from up to forYouSeedCount of
// the user's top-5 played tracks (rotating daily via pickDailySeeds),
// then head+tail composition with per-seed head quotas (#1269). The
// base seed query failing is fatal; a per-seed candidate-load failure
// is logged and that seed just contributes nothing.
// produceForYou: today's seed from the user's top-5 played tracks
// (rotates daily via userIDHash), similarity candidate pool, head+
// tail composition. The base seed query failing is fatal; a
// candidate-load failure is logged and yields no For-You.
func produceForYou(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time,
@@ -559,52 +348,33 @@ func produceForYou(
if err != nil {
return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
}
seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount)
if len(seeds) == 0 {
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
if !forYouSeed.Valid {
return nil, nil
}
zeroVec := recommendation.SessionVector{Seed: true}
// Merge per-seed pools; first-seen wins on dedup, and seedOf
// remembers which seed sourced each track for the head quotas.
var merged []recommendation.Candidate
seedOf := map[pgtype.UUID]int{}
for i, seed := range seeds {
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seed,
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
zeroVec,
seeds,
systemForYouSourceLimits(),
)
if cerr != nil {
logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
"user_id", uuidStringPL(userID), "seed", uuidStringPL(seed), "err", cerr)
continue
}
for _, c := range cands {
if _, seen := seedOf[c.Track.ID]; seen {
continue
}
seedOf[c.Track.ID] = i
merged = append(merged, c)
}
}
if len(merged) == 0 {
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, forYouSeed,
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
zeroVec,
[]pgtype.UUID{forYouSeed},
systemForYouSourceLimits(),
)
if cerr != nil {
logger.Warn("system playlist: for-you candidates load failed; skipping",
"user_id", uuidStringPL(userID), "err", cerr)
return nil, nil
}
tracks := pickHeadAndTail(merged, seedOf, len(seeds), userID, dateStr, now, forYouHeadN, forYouTailN)
tracks := pickHeadAndTail(cands, userID, dateStr, now, forYouHeadN, forYouTailN)
if len(tracks) == 0 {
return nil, nil
}
return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
}
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
// through widening engagement windows (#1255) and every returned row
// shares the winning tier, stamped onto the built tracks as their
// pick_kind. The base seed-artist query failing is fatal; per-artist
// failures are logged + skipped.
// produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
// artists rotate daily-deterministically. The base seed-artist
// query failing is fatal; per-artist failures are logged + skipped.
func produceSeedMixes(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time,
@@ -613,10 +383,6 @@ func produceSeedMixes(
if err != nil {
return nil, fmt.Errorf("pick seed artists: %w", err)
}
seedTierKind := ""
if len(seedRows) > 0 {
seedTierKind = pickKindForSeedTier(seedRows[0].Tier)
}
seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
for _, r := range seedRows {
seedRowsLocal = append(seedRowsLocal, seedArtistRow{
@@ -661,9 +427,6 @@ func produceSeedMixes(
if len(tracks) == 0 {
continue
}
for i := range tracks {
tracks[i].PickKind = seedTierKind
}
out = append(out, builtPlaylist{
Name: fmt.Sprintf("Songs like %s", artistRow.Name),
Variant: "songs_like_artist",
@@ -695,7 +458,7 @@ func produceDiscover(
}
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
// up to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
// up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx;
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
//
@@ -838,22 +601,20 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
capped = capped[:n]
}
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
out := make([]rankedCandidate, len(capped))
for i, c := range capped {
out[i] = rankedCandidate{
TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
}
}
return out
}
// pickHeadAndTail picks headN taste anchors from the score-sorted pool
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed
// blend, #1269) plus tailN freshness picks sampled rank-weighted from
// positions 2*headN onward. Caps applied before the head/tail split.
// Used by For-You only.
// pickHeadAndTail picks headN from the score-sorted head plus tailN from
// positions 2*headN onward (the tail), with the tail sampled
// daily-deterministically via tieBreakHash. Caps applied before the
// head/tail split. Used by For-You only.
//
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
// related (every candidate passed the similarity filter) but isn't among
@@ -863,76 +624,58 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
// Falls back to standard pickTopN behavior when the candidate pool is too
// small to support a meaningful head/tail split (capped pool <=
// headN+tailN, or no candidates at or beyond position 2*headN).
func pickHeadAndTail(
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
) []rankedCandidate {
func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
total := headN + tailN
if len(capped) <= total {
// Pool too small for a head/tail split — return up to total entries.
// All marked taste: top-N-by-score IS the taste mechanism; no
// exploration sampling happens on this path.
if len(capped) < total {
total = len(capped)
}
out := make([]rankedCandidate, total)
for i := 0; i < total; i++ {
out[i] = rankedCandidate{
TrackID: capped[i].Track.ID,
Score: recommendation.Score(capped[i].Inputs, weights, now, rng.Float64),
PickKind: pickKindTaste,
TrackID: capped[i].Track.ID,
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
}
}
return out
}
head := pickQuotaHead(capped, seedOf, numSeeds, headN)
inHead := make(map[pgtype.UUID]bool, len(head))
for _, c := range head {
inHead[c.Track.ID] = true
}
head := capped[:headN]
tailStart := 2 * headN
if tailStart >= len(capped) {
tailStart = headN
}
// The tail pool keeps its rank order (position drives the sampling
// weight); head members are excluded — a quota walk can reach past
// tailStart when a seed's candidates rank deep.
tailPool := make([]recommendation.Candidate, 0, len(capped)-tailStart)
for _, c := range capped[tailStart:] {
if inHead[c.Track.ID] {
continue
}
tailPool = append(tailPool, c)
}
tail := pickWeightedTail(tailPool, dateStr, tailN)
// Defensive copy so that sorting the tail pool does not mutate capped.
tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...)
// Combine: head first (score-sorted under quotas), then the fresh
// sample. "First similar, then surprise" reads naturally in
// playback. Head entries are the taste picks; tail entries are the
// freshness injection (#1249) — the split the metrics page
// attributes skips to.
// Sort tail pool by tieBreakHash (daily-deterministic), take tailN.
// Sample is stable across requests within a day but varies across days.
sort.SliceStable(tailPool, func(i, j int) bool {
return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr)
})
tail := tailPool
if len(tail) > tailN {
tail = tail[:tailN]
}
// Combine: head order preserved (score-sorted), tail in tieBreakHash
// order. "First similar, then surprise" reads naturally in playback.
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
combined = append(combined, head...)
combined = append(combined, tail...)
out := make([]rankedCandidate, len(combined))
for i, c := range combined {
kind := pickKindTaste
if i >= len(head) {
kind = pickKindFresh
}
out[i] = rankedCandidate{
TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
PickKind: kind,
TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
}
}
return out
@@ -966,15 +709,9 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
}
for _, t := range tracks {
var pickKind *string
if t.PickKind != "" {
k := t.PickKind
pickKind = &k
}
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
PlaylistID: p.ID,
TrackID: t.TrackID,
PickKind: pickKind,
}); err != nil {
// Track may have been deleted between candidate-load and insert;
// skip silently rather than failing the whole build.
+13 -64
View File
@@ -40,17 +40,6 @@ import (
// Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100
// Minimum viable mix sizes (issue #1246): below the floor the variant
// is withheld entirely so Home renders its "listen more to unlock"
// placeholder — a playlist with a couple of songs reads as a build
// bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get
// a lower floor because one legitimate new album (~5+ tracks) is a
// useful mix on its own; the scattered mixes need more to feel real.
const (
discoveryMixMinLen = 15
discoveryMixMinLenAlbum = 5
)
// discoveryMixSpec describes one discovery mix. The unified producer
// reads the spec and runs a single code path for all variants.
type discoveryMixSpec struct {
@@ -66,11 +55,6 @@ type discoveryMixSpec struct {
// or when day-over-day stability is the intended UX (NewForYou).
dailyRotate bool
// minLen is the minimum viable mix size: a finished pool below it
// is withheld (no playlist row) so the client renders the locked
// placeholder instead of a mix that looks built-wrong (#1246).
minLen int
// fetch returns the raw ranked rows. dateStr is supplied for
// queries that accept it (passed as the second positional arg
// historically); queries that don't accept it ignore the param.
@@ -95,49 +79,25 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
if spec.dailyRotate {
pool = rotateForDay(pool, userID, dateStr)
}
tracks := finishMix(pool, spec.diversify)
if len(tracks) < spec.minLen {
logger.Info("system playlist: "+spec.variant+" below minimum viable size; withholding",
"user_id", uuidStringPL(userID), "pool", len(tracks), "min", spec.minLen)
return nil, nil
}
return emit(spec.name, spec.variant, tracks), nil
return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil
}
}
// rotateForDay rotates the pool left by a daily-deterministic offset
// so each day's downstream truncate-to-N surfaces a different slice
// while contiguous-block ordering inside the slice is preserved.
//
// Rotation happens WITHIN each contiguous same-pick-kind block
// (#1267): tiered mixes arrive tier-ordered, and a whole-pool
// rotation would hoist tier-3 filler above tier-1's exact fits.
// Untiered pools are a single block, which reduces to the original
// whole-pool rotation (same seed, same first draw). Empty /
// single-element pools pass through unchanged.
// rotateForDay rotates pool left by a daily-deterministic offset so
// each day's downstream truncate-to-N surfaces a different slice of
// the pool while contiguous-block ordering inside the slice is
// preserved. Empty / single-element pools pass through unchanged.
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
n := len(pool)
if n <= 1 {
return pool
}
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
out := make([]discoverTrack, 0, n)
for start := 0; start < n; {
end := start + 1
for end < n && pool[end].PickKind == pool[start].PickKind {
end++
}
block := pool[start:end]
if len(block) > 1 {
offset := rng.Intn(len(block))
out = append(out, block[offset:]...)
out = append(out, block[:offset]...)
} else {
out = append(out, block...)
}
start = end
}
return out
offset := rng.Intn(n)
rotated := make([]discoverTrack, 0, n)
rotated = append(rotated, pool[offset:]...)
rotated = append(rotated, pool[:offset]...)
return rotated
}
// discoveryMixSpecs is the concrete spec list used by the registry in
@@ -149,7 +109,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{
name: "Deep Cuts", variant: "deep_cuts",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
minLen: discoveryMixMinLen,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
UserID: uid, Column2: ds,
@@ -167,7 +126,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{
name: "Rediscover", variant: "rediscover",
diversify: true, dailyRotate: true, // SQL has no date arg
minLen: discoveryMixMinLen,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListRediscoverTracks(ctx, uid)
if err != nil {
@@ -183,7 +141,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{
name: "New for you", variant: "new_for_you",
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
minLen: discoveryMixMinLenAlbum,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListNewForYouTracks(ctx, uid)
if err != nil {
@@ -191,10 +148,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindForMixTier(r.Tier),
}
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
@@ -202,7 +156,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{
name: "On this day", variant: "on_this_day",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
minLen: discoveryMixMinLen,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
UserID: uid, Column2: ds,
@@ -220,7 +173,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{
name: "First listens", variant: "first_listens",
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
minLen: discoveryMixMinLenAlbum,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListFirstListensTracks(ctx, uid)
if err != nil {
@@ -228,10 +180,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindForMixTier(r.Tier),
}
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
@@ -268,7 +217,7 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
}
tracks := make([]rankedCandidate, len(pool))
for i, t := range pool {
tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind}
tracks[i] = rankedCandidate{TrackID: t.ID}
}
return tracks
}
-342
View File
@@ -1,342 +0,0 @@
package playlists_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// seedTrackForArtist creates another track under an existing artist +
// album pair. seedTrack's mbid-less upsert never dedupes artists, and
// Deep Cuts' affinity rule needs several tracks sharing one artist_id.
func seedTrackForArtist(t *testing.T, pool *pgxpool.Pool, title string, albumID, artistID pgtype.UUID) dbq.Track {
t.Helper()
track, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: albumID, ArtistID: artistID,
DurationMs: 1000,
FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("%s.mp3", title)),
FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track for artist: %v", err)
}
return track
}
// seedSourcedPlayEvent is seedPlayEvent plus a source tag — needed by
// the Rediscover cooldown, which keys off rediscover-sourced skips.
func seedSourcedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool, source string) {
t.Helper()
_, err := pool.Exec(context.Background(), `
WITH s AS (
INSERT INTO play_sessions (user_id, started_at, last_event_at)
VALUES ($1, $3, $3)
RETURNING id
)
INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped, source)
SELECT $1, $2, s.id, $3, $4, $5 FROM s
`, userID, trackID, startedAt, wasSkipped, source)
if err != nil {
t.Fatalf("seed sourced play_event: %v", err)
}
}
// idSet collapses candidate rows into a lookup by track id.
func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool {
out := make(map[pgtype.UUID]bool, len(rows))
for _, r := range rows {
out[id(r)] = true
}
return out
}
// seedPlayWithDuration inserts a completed play with an explicit
// duration_played_ms — the "attempted >= 30s" queries key off it.
func seedPlayWithDuration(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, durationMs int32) {
t.Helper()
_, err := pool.Exec(context.Background(), `
WITH s AS (
INSERT INTO play_sessions (user_id, started_at, last_event_at)
VALUES ($1, $3, $3)
RETURNING id
)
INSERT INTO play_events (user_id, track_id, session_id, started_at, duration_played_ms)
SELECT $1, $2, s.id, $3, $4 FROM s
`, userID, trackID, startedAt, durationMs)
if err != nil {
t.Fatalf("seed play with duration: %v", err)
}
}
// seedAlbumForArtist creates a fresh album under an existing artist —
// the tiered album queries need several albums sharing one artist_id.
func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, title string, artistID pgtype.UUID) pgtype.UUID {
t.Helper()
al, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: title, SortTitle: title, ArtistID: artistID,
})
if err != nil {
t.Fatalf("seed album for artist: %v", err)
}
return al.ID
}
func backdateAlbum(t *testing.T, pool *pgxpool.Pool, albumID pgtype.UUID, to time.Time) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`UPDATE albums SET created_at = $2 WHERE id = $1`, albumID, to); err != nil {
t.Fatalf("backdate album: %v", err)
}
}
func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`,
userID, artistID); err != nil {
t.Fatalf("like artist: %v", err)
}
}
func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
// #1257: eligibility used to count only unskipped plays, so a track
// skipped repeatedly (and never finished) read as "barely heard".
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "dc1")
now := time.Now().UTC()
// Affinity artist: 5 non-skip plays on the heavy track.
heavy := seedTrack(t, pool, "dc1-heavy", "dc1-artist")
for p := 0; p < 5; p++ {
seedPlayEvent(t, pool, u.ID, heavy.ID, now.Add(-time.Duration(p+1)*time.Hour), false)
}
// Actively rejected: two skips, zero completed plays.
rejected := seedTrackForArtist(t, pool, "dc1-rejected", heavy.AlbumID, heavy.ArtistID)
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-2*time.Hour), true)
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-3*time.Hour), true)
// Genuinely unexplored.
unheard := seedTrackForArtist(t, pool, "dc1-unheard", heavy.AlbumID, heavy.ArtistID)
// One accidental skip stays below the threshold.
oneSkip := seedTrackForArtist(t, pool, "dc1-oneskip", heavy.AlbumID, heavy.ArtistID)
seedPlayEvent(t, pool, u.ID, oneSkip.ID, now.Add(-4*time.Hour), true)
rows, err := q.ListDeepCutsTracks(context.Background(), dbq.ListDeepCutsTracksParams{
UserID: u.ID, Column2: "2026-07-03",
})
if err != nil {
t.Fatalf("ListDeepCutsTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListDeepCutsTracksRow) pgtype.UUID { return r.ID })
if got[rejected.ID] {
t.Error("twice-skipped track qualified as a deep cut; skips must disqualify")
}
if !got[unheard.ID] {
t.Error("unexplored track missing from deep cuts")
}
if !got[oneSkip.ID] {
t.Error("single accidental skip must not banish a track")
}
if got[heavy.ID] {
t.Error("heavily-played track must not be a deep cut")
}
}
func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
// #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359
// days apart. The build date parameter anchors "today" so the wrap
// is testable at any run date.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "otd1")
// Played Dec 28, 2025 — 6 circular days from the Jan 3 build date.
winter := seedTrack(t, pool, "otd1-winter", "otd1-artist")
seedPlayEvent(t, pool, u.ID, winter.ID,
time.Date(2025, 12, 28, 12, 0, 0, 0, time.UTC), false)
// Played mid-March — far outside the ±10-day window.
spring := seedTrack(t, pool, "otd1-spring", "otd1-artist")
seedPlayEvent(t, pool, u.ID, spring.ID,
time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), false)
rows, err := q.ListOnThisDayTracks(context.Background(), dbq.ListOnThisDayTracksParams{
UserID: u.ID, Column2: "2026-01-03",
})
if err != nil {
t.Fatalf("ListOnThisDayTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListOnThisDayTracksRow) pgtype.UUID { return r.ID })
if !got[winter.ID] {
t.Error("play 6 calendar days across the year boundary must qualify (circular DOY)")
}
if got[spring.ID] {
t.Error("play ~70 days away must not qualify")
}
}
func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
// #1267: tiered eligibility with consume-on-play. Tier 1 = fresh
// affinity albums; tier 2 = affinity albums from the wider 90-day
// window; tier 3 = fresh additions without affinity; any attempted
// (>=30s) track consumes the album out of the mix entirely.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "nfy1")
now := time.Now().UTC()
// Affinity artist via a like; anchor track establishes the artist.
anchor := seedTrack(t, pool, "nfy1-anchor", "nfy1-artistX")
likeArtist(t, pool, u.ID, anchor.ArtistID)
backdateAlbum(t, pool, anchor.AlbumID, now.Add(-200*24*time.Hour)) // out of every window
// Fresh affinity album → tier 1.
freshAlbum := seedAlbumForArtist(t, pool, "nfy1-fresh", anchor.ArtistID)
freshTrack := seedTrackForArtist(t, pool, "nfy1-fresh-1", freshAlbum, anchor.ArtistID)
// Affinity album added 60 days ago → tier 2.
olderAlbum := seedAlbumForArtist(t, pool, "nfy1-older", anchor.ArtistID)
olderTrack := seedTrackForArtist(t, pool, "nfy1-older-1", olderAlbum, anchor.ArtistID)
backdateAlbum(t, pool, olderAlbum, now.Add(-60*24*time.Hour))
// Fresh album by an unrelated artist → tier 3.
otherTrack := seedTrack(t, pool, "nfy1-other-1", "nfy1-artistY")
// Fresh affinity album with one attempted track → consumed, absent.
consumedAlbum := seedAlbumForArtist(t, pool, "nfy1-consumed", anchor.ArtistID)
consumedTrack := seedTrackForArtist(t, pool, "nfy1-consumed-1", consumedAlbum, anchor.ArtistID)
seedPlayWithDuration(t, pool, u.ID, consumedTrack.ID, now.Add(-time.Hour), 35_000)
// Affinity album added 120 days ago → outside every window, absent.
ancientAlbum := seedAlbumForArtist(t, pool, "nfy1-ancient", anchor.ArtistID)
ancientTrack := seedTrackForArtist(t, pool, "nfy1-ancient-1", ancientAlbum, anchor.ArtistID)
backdateAlbum(t, pool, ancientAlbum, now.Add(-120*24*time.Hour))
rows, err := q.ListNewForYouTracks(context.Background(), u.ID)
if err != nil {
t.Fatalf("ListNewForYouTracks: %v", err)
}
tiers := map[pgtype.UUID]int32{}
for _, r := range rows {
tiers[r.ID] = r.Tier
}
if got := tiers[freshTrack.ID]; got != 1 {
t.Errorf("fresh affinity album tier = %d, want 1", got)
}
if got := tiers[olderTrack.ID]; got != 2 {
t.Errorf("60-day affinity album tier = %d, want 2", got)
}
if got := tiers[otherTrack.ID]; got != 3 {
t.Errorf("fresh non-affinity album tier = %d, want 3", got)
}
if _, in := tiers[consumedTrack.ID]; in {
t.Error("album with an attempted track must be consumed out of the mix")
}
if _, in := tiers[ancientTrack.ID]; in {
t.Error("album added 120 days ago must be outside every window")
}
}
func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
// #1268: "attempted" is track-level at >=30s. A 2-second brush no
// longer disqualifies an album; barely-attempted albums step back
// to tier 2 with the attempted tracks themselves excluded.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "fl1")
now := time.Now().UTC()
// Never touched → tier 1.
untouched := seedTrack(t, pool, "fl1-untouched-1", "fl1-artistA")
// One 2-second accidental brush → still tier 1 (the headline bug).
brushed := seedTrack(t, pool, "fl1-brushed-1", "fl1-artistB")
seedPlayWithDuration(t, pool, u.ID, brushed.ID, now.Add(-time.Hour), 2_000)
// 4-track album with one attempted (25%) → tier 2, attempted track
// excluded, siblings present.
barely1 := seedTrack(t, pool, "fl1-barely-1", "fl1-artistC")
barely2 := seedTrackForArtist(t, pool, "fl1-barely-2", barely1.AlbumID, barely1.ArtistID)
barely3 := seedTrackForArtist(t, pool, "fl1-barely-3", barely1.AlbumID, barely1.ArtistID)
barely4 := seedTrackForArtist(t, pool, "fl1-barely-4", barely1.AlbumID, barely1.ArtistID)
seedPlayWithDuration(t, pool, u.ID, barely1.ID, now.Add(-time.Hour), 40_000)
// 2-track album with one attempted (50%) → past the brush
// threshold, absent entirely.
explored1 := seedTrack(t, pool, "fl1-explored-1", "fl1-artistD")
explored2 := seedTrackForArtist(t, pool, "fl1-explored-2", explored1.AlbumID, explored1.ArtistID)
seedPlayWithDuration(t, pool, u.ID, explored1.ID, now.Add(-time.Hour), 40_000)
rows, err := q.ListFirstListensTracks(context.Background(), u.ID)
if err != nil {
t.Fatalf("ListFirstListensTracks: %v", err)
}
tiers := map[pgtype.UUID]int32{}
for _, r := range rows {
tiers[r.ID] = r.Tier
}
if got := tiers[untouched.ID]; got != 1 {
t.Errorf("untouched album tier = %d, want 1", got)
}
if got := tiers[brushed.ID]; got != 1 {
t.Errorf("2-second brush must not disqualify: tier = %d, want 1", got)
}
if _, in := tiers[barely1.ID]; in {
t.Error("the attempted track itself is not a first listen")
}
for _, tr := range []dbq.Track{barely2, barely3, barely4} {
if got := tiers[tr.ID]; got != 2 {
t.Errorf("sibling of attempted track tier = %d, want 2", got)
}
}
if _, in := tiers[explored1.ID]; in {
t.Error("half-attempted album must be excluded (attempted track)")
}
if _, in := tiers[explored2.ID]; in {
t.Error("half-attempted album must be excluded (sibling)")
}
}
func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) {
// #1258: a skip on a rediscover-sourced play is a declined
// invitation — the track sits out ~90 days instead of re-qualifying
// the next day. Skips from other surfaces don't cool down, and the
// cooldown expires.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "rd1")
now := time.Now().UTC()
eligible := func(name string, playsAge time.Duration) dbq.Track {
tk := seedTrack(t, pool, name, name+"-artist")
for p := 0; p < 3; p++ {
seedPlayEvent(t, pool, u.ID, tk.ID,
now.Add(-playsAge-time.Duration(p)*time.Hour), false)
}
return tk
}
// Declined 5 days ago from Rediscover itself → cooling down.
declined := eligible("rd1-declined", 60*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, declined.ID, now.Add(-5*24*time.Hour), true, "rediscover")
// Skipped 5 days ago, but from a different surface → still eligible.
otherSkip := eligible("rd1-otherskip", 60*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, otherSkip.ID, now.Add(-5*24*time.Hour), true, "radio")
// Declined 100 days ago → cooldown expired, eligible again.
lapsed := eligible("rd1-lapsed", 120*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, lapsed.ID, now.Add(-100*24*time.Hour), true, "rediscover")
rows, err := q.ListRediscoverTracks(context.Background(), u.ID)
if err != nil {
t.Fatalf("ListRediscoverTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListRediscoverTracksRow) pgtype.UUID { return r.ID })
if got[declined.ID] {
t.Error("track declined from rediscover 5 days ago must be cooling down")
}
if !got[otherSkip.ID] {
t.Error("skip from a non-rediscover surface must not cool the track down")
}
if !got[lapsed.ID] {
t.Error("cooldown must expire after ~90 days")
}
}
-104
View File
@@ -1,104 +0,0 @@
package playlists
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func mixTrack(b byte, kind string) discoverTrack {
return discoverTrack{ID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, PickKind: kind}
}
func idsOf(pool []discoverTrack) []byte {
out := make([]byte, len(pool))
for i, t := range pool {
out[i] = t.ID.Bytes[0]
}
return out
}
func TestRotateForDay_RotatesWithinTierBlocks(t *testing.T) {
// Tiered pools arrive tier-ordered; rotation must vary the daily
// slice WITHIN each tier without hoisting tier-3 filler above
// tier-1's exact fits (#1267).
pool := []discoverTrack{
mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1),
mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2),
mixTrack(6, pickKindTier3),
}
u := pgtype.UUID{Bytes: [16]byte{42}, Valid: true}
got := rotateForDay(pool, u, "2026-07-03")
if len(got) != len(pool) {
t.Fatalf("len = %d, want %d", len(got), len(pool))
}
wantKinds := []string{
pickKindTier1, pickKindTier1, pickKindTier1,
pickKindTier2, pickKindTier2, pickKindTier3,
}
for i, k := range wantKinds {
if got[i].PickKind != k {
t.Fatalf("pos %d kind = %q, want %q (tier order broken: %v)",
i, got[i].PickKind, k, idsOf(got))
}
}
// Each block is a rotation of its input: contiguity check for the
// 3-element tier-1 block (successor relation preserved cyclically).
wantOrder := map[byte]byte{1: 2, 2: 3, 3: 1}
for i := 0; i < 2; i++ {
if got[i+1].ID.Bytes[0] != wantOrder[got[i].ID.Bytes[0]] {
t.Errorf("tier1 block is not a rotation: %v", idsOf(got[:3]))
break
}
}
// Determinism within a day.
again := rotateForDay(pool, u, "2026-07-03")
for i := range got {
if got[i].ID != again[i].ID {
t.Fatal("rotation must be deterministic for the same (user, day)")
}
}
}
func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) {
// Untiered pools (all PickKind "") are one block — the original
// whole-pool rotation semantics.
pool := []discoverTrack{mixTrack(1, ""), mixTrack(2, ""), mixTrack(3, ""), mixTrack(4, "")}
got := rotateForDay(pool, pgtype.UUID{Bytes: [16]byte{7}, Valid: true}, "2026-07-03")
if len(got) != 4 {
t.Fatalf("len = %d, want 4", len(got))
}
wantNext := map[byte]byte{1: 2, 2: 3, 3: 4, 4: 1}
for i := 0; i < 3; i++ {
cur, next := got[i].ID.Bytes[0], got[i+1].ID.Bytes[0]
if wantNext[cur] != next {
t.Errorf("not a rotation of the input: %v", idsOf(got))
break
}
}
}
func TestFinishMix_PropagatesPickKind(t *testing.T) {
// Tier stamps ride discoverTrack through diversify/truncate into
// the rankedCandidates that insertSystemPlaylist persists (#1267).
pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)}
got := finishMix(pool, false)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].PickKind != pickKindTier1 || got[1].PickKind != pickKindTier2 {
t.Errorf("pick kinds = %q, %q — want tier1, tier2", got[0].PickKind, got[1].PickKind)
}
}
func TestPickKindForMixTier(t *testing.T) {
cases := []struct {
tier int32
want string
}{{1, pickKindTier1}, {2, pickKindTier2}, {3, pickKindTier3}, {9, pickKindTier3}}
for _, c := range cases {
if got := pickKindForMixTier(c.tier); got != c.want {
t.Errorf("pickKindForMixTier(%d) = %q, want %q", c.tier, got, c.want)
}
}
}
-53
View File
@@ -135,59 +135,6 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
}
}
func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) {
// #1255: the seed-artist query's old hard 7-day window emptied the
// pool after a quiet week, and the atomic-replace build then deleted
// every "Songs like X" mix. With the tiered fallback, plays that are
// ~20 days old (outside 7d, inside 30d) must still seed the mixes —
// and the built tracks carry the tier2 stamp so metrics can compare
// stale-seeded mixes against fresh ones.
pool := newPool(t)
logger := discardLogger()
u := seedUser(t, pool, "stale1")
old := time.Now().UTC().Add(-20 * 24 * time.Hour)
for a := 0; a < 4; a++ {
for k := 0; k < 3; k++ {
tk := seedTrack(t, pool,
"stale1-track-"+string(rune('A'+a))+string(rune('0'+k)),
"stale1-artist-"+string(rune('A'+a)))
for p := 0; p < 3; p++ {
seedPlayEvent(t, pool, u.ID, tk.ID,
old.Add(-time.Duration(a*10+k+p)*time.Hour), false)
}
}
}
if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
t.Fatalf("build: %v", err)
}
rows, err := pool.Query(context.Background(), `
SELECT DISTINCT COALESCE(pt.pick_kind, '<null>')
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
`, u.ID)
if err != nil {
t.Fatalf("query pick_kinds: %v", err)
}
defer rows.Close()
var kinds []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
t.Fatalf("scan: %v", err)
}
kinds = append(kinds, k)
}
if len(kinds) == 0 {
t.Fatal("expected songs_like_artist tracks from the 30-day fallback tier; got none (the vanish bug)")
}
if len(kinds) != 1 || kinds[0] != "tier2" {
t.Errorf("pick_kinds = %v, want exactly [tier2] (30-day fallback seeds)", kinds)
}
}
func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
+3 -8
View File
@@ -83,14 +83,10 @@ func buildYouMightLike(
"user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false}
}
// One rotating seed is right here (unlike For-You's multi-seed
// blend, #1269): the row is a short shelf, not a mix, and a single
// neighborhood per day keeps it coherent.
daily := pickDailySeeds(seeds, userID, dateStr, 1)
if len(daily) == 0 {
seed := pickForYouSeedForDay(seeds, userID, dateStr)
if !seed.Valid {
return youMightLikeResult{built: true}
}
seed := daily[0]
zeroVec := recommendation.SessionVector{Seed: true}
// You-might-like surfaces in-library artists the user does NOT actively
@@ -127,12 +123,11 @@ func rollUpCandidates(
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
) (albumIDs, artistIDs []pgtype.UUID) {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
albumScores := map[pgtype.UUID][]float64{}
artistScores := map[pgtype.UUID][]float64{}
albumArtist := map[pgtype.UUID]pgtype.UUID{}
for _, c := range cands {
s := recommendation.Score(c.Inputs, weights, now, rng.Float64)
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
if c.Track.AlbumID.Valid {
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
-178
View File
@@ -1,178 +0,0 @@
// patch.go — field-name mapping + validation for the tuning patches.
// Wire field names are the snake_case column names; the admin API and
// web card use them verbatim.
package recsettings
import (
"errors"
"fmt"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
var (
ErrUnknownScope = errors.New("unknown tuning scope")
ErrUnknownField = errors.New("unknown tuning field")
ErrOutOfRange = errors.New("tuning value out of range")
)
// weightBound caps every scoring weight's magnitude. The scoring
// terms are all in [-1, 1] before weighting, so ±10 is far past any
// useful setting — the bound exists to catch typos (e.g. 100 for
// 1.00), not to constrain exploration.
const weightBound = 10.0
// weightField describes one patchable ScoringWeights field.
type weightField struct {
get func(recommendation.ScoringWeights) float64
set func(*recommendation.ScoringWeights, float64)
// nonNegative marks fields where a negative value is meaningless
// (a negative jitter magnitude or skip penalty inverts intent in a
// way the score formula already expresses through its sign).
nonNegative bool
}
var weightFields = map[string]weightField{
"base_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.BaseWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.BaseWeight = v },
},
"like_boost": {
get: func(w recommendation.ScoringWeights) float64 { return w.LikeBoost },
set: func(w *recommendation.ScoringWeights, v float64) { w.LikeBoost = v },
},
"recency_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.RecencyWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.RecencyWeight = v },
},
"skip_penalty": {
get: func(w recommendation.ScoringWeights) float64 { return w.SkipPenalty },
set: func(w *recommendation.ScoringWeights, v float64) { w.SkipPenalty = v },
nonNegative: true,
},
"jitter_magnitude": {
get: func(w recommendation.ScoringWeights) float64 { return w.JitterMagnitude },
set: func(w *recommendation.ScoringWeights, v float64) { w.JitterMagnitude = v },
nonNegative: true,
},
"context_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.ContextWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextWeight = v },
},
"similarity_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.SimilarityWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.SimilarityWeight = v },
},
"taste_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
},
}
// applyWeightPatch validates and applies a partial update, returning
// the new weights and the list of actual changes (values equal to the
// current setting are dropped, so a re-submitted form is a no-op).
func applyWeightPatch(
current recommendation.ScoringWeights, patch map[string]float64,
) (recommendation.ScoringWeights, []fieldChange, error) {
next := current
var changes []fieldChange
for field, v := range patch {
f, ok := weightFields[field]
if !ok {
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
if v < -weightBound || v > weightBound {
return current, nil, fmt.Errorf("%w: %s = %v (|v| must be <= %v)",
ErrOutOfRange, field, v, weightBound)
}
if f.nonNegative && v < 0 {
return current, nil, fmt.Errorf("%w: %s = %v (must be >= 0)",
ErrOutOfRange, field, v)
}
old := f.get(next)
if old == v {
continue
}
f.set(&next, v)
changes = append(changes, fieldChange{Field: field, Old: old, New: v})
}
return next, changes, nil
}
// Taste tuning bounds. The half-life window is generous — from "taste
// is last week" to "taste is a decade" — and the curve points must
// stay ordered inside [0, 1] or the engagement ramps degenerate.
const (
tasteHalfLifeMin = 1.0
tasteHalfLifeMax = 3650.0
)
// applyTastePatch validates and applies a partial taste update. The
// curve-ordering invariant (hard_skip < neutral < full) is checked on
// the PATCHED result, so a patch may move several points at once.
func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning, []fieldChange, error) {
next := current
var changes []fieldChange
for field, v := range patch {
var target *float64
switch field {
case "half_life_days":
if v < tasteHalfLifeMin || v > tasteHalfLifeMax {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
ErrOutOfRange, field, v, tasteHalfLifeMin, tasteHalfLifeMax)
}
target = &next.HalfLifeDays
case "engagement_hard_skip":
target = &next.EngagementHardSkip
case "engagement_neutral":
target = &next.EngagementNeutral
case "engagement_full":
target = &next.EngagementFull
default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
if field != "half_life_days" && (v < 0 || v > 1) {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, 1])",
ErrOutOfRange, field, v)
}
if *target == v {
continue
}
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
*target = v
}
if !(next.EngagementHardSkip < next.EngagementNeutral &&
next.EngagementNeutral < next.EngagementFull) {
return current, nil, fmt.Errorf(
"%w: engagement curve must satisfy hard_skip < neutral < full (got %v < %v < %v)",
ErrOutOfRange, next.EngagementHardSkip, next.EngagementNeutral, next.EngagementFull)
}
return next, changes, nil
}
// diffWeights returns per-field changes from a to b (empty when equal).
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
var out []fieldChange
for field, f := range weightFields {
if f.get(a) != f.get(b) {
out = append(out, fieldChange{Field: field, Old: f.get(a), New: f.get(b)})
}
}
return out
}
// diffTaste returns per-field changes from a to b (empty when equal).
func diffTaste(a, b TasteTuning) []fieldChange {
var out []fieldChange
add := func(field string, oldV, newV float64) {
if oldV != newV {
out = append(out, fieldChange{Field: field, Old: oldV, New: newV})
}
}
add("half_life_days", a.HalfLifeDays, b.HalfLifeDays)
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
add("engagement_full", a.EngagementFull, b.EngagementFull)
return out
}
-374
View File
@@ -1,374 +0,0 @@
// Package recsettings is the DB-backed home of the recommendation
// tuning knobs (#1250): the two scoring-weight profiles (radio /
// daily_mix) and the taste-profile build settings (engagement
// half-life + completion curve). It follows the coverart
// SettingsService pattern — boot reconcile seeds shipped defaults for
// missing rows, values are cached under a RWMutex, and every change
// takes effect live (the daily_mix profile + taste config are pushed
// into package playlists; radio reads the cache per request).
//
// Framing (decision #1247): this is the defaults-discovery lab. The
// operator turns knobs to FIND good values; found-good values get
// baked back into the Shipped* functions below as new shipped
// defaults. End users and other operators should never need the card.
package recsettings
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)
// Scope names — the three tunable groups. radio + daily_mix are weight
// profiles; taste is the profile-build settings singleton.
const (
ScopeRadio = "radio"
ScopeDailyMix = "daily_mix"
ScopeTaste = "taste"
)
// TasteTuning is the tunable subset of taste.Config: the engagement
// half-life and the completion→engagement curve points.
type TasteTuning struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
// ShippedRadioWeights are the shipped radio-profile defaults (moved
// here from config.RecommendationConfig — YAML is bootstrap-only,
// rule: config in UI). Radio is seed-directed (the user picked a
// direction), so taste is a lighter nudge than in the daily mixes.
func ShippedRadioWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
TasteWeight: 1.0,
}
}
// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
// Must stay in sync with the pre-push literal in playlists/system.go.
func ShippedDailyMixWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
TasteWeight: 1.5,
}
}
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
func ShippedTasteTuning() TasteTuning {
d := taste.DefaultConfig()
return TasteTuning{
HalfLifeDays: d.HalfLifeDays,
EngagementHardSkip: d.Engagement.HardSkip,
EngagementNeutral: d.Engagement.NeutralCompletion,
EngagementFull: d.Engagement.FullCompletion,
}
}
// Service caches the tuning values and owns their DB persistence +
// audit trail. Construct with New at boot.
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
mu sync.RWMutex
profiles map[string]recommendation.ScoringWeights
taste TasteTuning
}
// New boots the service: seeds shipped defaults for missing rows,
// loads the current values, and pushes the daily_mix weights + taste
// config into package playlists so the daily builds pick them up.
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
s := &Service{
pool: pool,
logger: logger,
profiles: map[string]recommendation.ScoringWeights{},
}
if err := s.reconcile(ctx); err != nil {
return nil, fmt.Errorf("recsettings boot: %w", err)
}
return s, nil
}
// reconcile seeds missing rows with shipped defaults, reads everything
// back into the cache, and pushes the playlist-side values.
func (s *Service) reconcile(ctx context.Context) error {
q := dbq.New(s.pool)
for profile, w := range map[string]recommendation.ScoringWeights{
ScopeRadio: ShippedRadioWeights(),
ScopeDailyMix: ShippedDailyMixWeights(),
} {
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
return fmt.Errorf("seed profile %q: %w", profile, err)
}
}
st := ShippedTasteTuning()
if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
HalfLifeDays: st.HalfLifeDays,
EngagementHardSkip: st.EngagementHardSkip,
EngagementNeutral: st.EngagementNeutral,
EngagementFull: st.EngagementFull,
}); err != nil {
return fmt.Errorf("seed taste tuning: %w", err)
}
rows, err := q.ListWeightProfiles(ctx)
if err != nil {
return fmt.Errorf("list weight profiles: %w", err)
}
tt, err := q.GetTasteTuning(ctx)
if err != nil {
return fmt.Errorf("get taste tuning: %w", err)
}
s.mu.Lock()
s.profiles = map[string]recommendation.ScoringWeights{}
for _, r := range rows {
s.profiles[r.Profile] = weightsFromRow(r)
}
s.taste = TasteTuning{
HalfLifeDays: tt.HalfLifeDays,
EngagementHardSkip: tt.EngagementHardSkip,
EngagementNeutral: tt.EngagementNeutral,
EngagementFull: tt.EngagementFull,
}
s.mu.Unlock()
s.push()
return nil
}
// push installs the daily-build values into package playlists (the
// coverart Configure() pattern). Radio needs no push — its handler
// reads Weights(ScopeRadio) per request.
func (s *Service) push() {
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
playlists.SetTasteConfig(s.TasteConfig())
}
// Weights returns the cached weights for a profile scope. Unknown
// scopes return the shipped radio defaults (defensive; callers pass
// the Scope* constants).
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
s.mu.RLock()
defer s.mu.RUnlock()
if w, ok := s.profiles[profile]; ok {
return w
}
return ShippedRadioWeights()
}
// Taste returns the cached taste-tuning values.
func (s *Service) Taste() TasteTuning {
s.mu.RLock()
defer s.mu.RUnlock()
return s.taste
}
// TasteConfig assembles the full taste.Config the profile builder
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
// plus the tuned half-life and curve. WindowDays scales with the
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
// window is a query-cost bound, not an independent knob.
func (s *Service) TasteConfig() taste.Config {
t := s.Taste()
cfg := taste.DefaultConfig()
cfg.HalfLifeDays = t.HalfLifeDays
cfg.WindowDays = t.HalfLifeDays * 3.6
cfg.Engagement = taste.EngagementParams{
HardSkip: t.EngagementHardSkip,
NeutralCompletion: t.EngagementNeutral,
FullCompletion: t.EngagementFull,
}
return cfg
}
// fieldChange is one entry of an audit row's changes array.
type fieldChange struct {
Field string `json:"field"`
Old float64 `json:"old"`
New float64 `json:"new"`
}
// UpdateProfile applies a partial update to one weight profile.
// Unknown fields and out-of-range values reject the whole patch. A
// no-op patch (all values equal to current) writes no audit row.
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
if profile != ScopeRadio && profile != ScopeDailyMix {
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
}
current := s.Weights(profile)
next, changes, err := applyWeightPatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistProfile(ctx, profile, next, "update", changes)
}
// UpdateTaste applies a partial update to the taste tuning singleton.
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
current := s.Taste()
next, changes, err := applyTastePatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistTaste(ctx, next, "update", changes)
}
// Reset restores a scope to its shipped defaults, with one audit row
// carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error {
switch scope {
case ScopeRadio, ScopeDailyMix:
shipped := ShippedRadioWeights()
if scope == ScopeDailyMix {
shipped = ShippedDailyMixWeights()
}
changes := diffWeights(s.Weights(scope), shipped)
if len(changes) == 0 {
return nil
}
return s.persistProfile(ctx, scope, shipped, "reset", changes)
case ScopeTaste:
shipped := ShippedTasteTuning()
changes := diffTaste(s.Taste(), shipped)
if len(changes) == 0 {
return nil
}
return s.persistTaste(ctx, shipped, "reset", changes)
default:
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
}
}
// persistProfile writes the profile row + audit entry, refreshes the
// cache, and pushes daily-build values.
func (s *Service) persistProfile(
ctx context.Context, profile string, w recommendation.ScoringWeights,
action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
return fmt.Errorf("update profile %q: %w", profile, err)
}
if err := s.audit(ctx, q, profile, action, changes); err != nil {
return err
}
s.mu.Lock()
s.profiles[profile] = w
s.mu.Unlock()
s.push()
return nil
}
func (s *Service) persistTaste(
ctx context.Context, t TasteTuning, action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
}); err != nil {
return fmt.Errorf("update taste tuning: %w", err)
}
if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
return err
}
s.mu.Lock()
s.taste = t
s.mu.Unlock()
s.push()
return nil
}
// audit writes one recommendation_tuning_audit row. Changes are
// sorted by field so rows are deterministic and diff-friendly.
func (s *Service) audit(
ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
) error {
sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
payload, err := json.Marshal(changes)
if err != nil {
return fmt.Errorf("marshal audit changes: %w", err)
}
if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
Scope: scope, Action: action, Changes: payload,
}); err != nil {
return fmt.Errorf("insert audit row: %w", err)
}
return nil
}
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
return dbq.UpsertWeightProfileDefaultsParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
}
}
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
return dbq.UpdateWeightProfileParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
}
}
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: r.BaseWeight,
LikeBoost: r.LikeBoost,
RecencyWeight: r.RecencyWeight,
SkipPenalty: r.SkipPenalty,
JitterMagnitude: r.JitterMagnitude,
ContextWeight: r.ContextWeight,
SimilarityWeight: r.SimilarityWeight,
TasteWeight: r.TasteWeight,
}
}
-222
View File
@@ -1,222 +0,0 @@
package recsettings
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func newService(t *testing.T, pool *pgxpool.Pool) *Service {
t.Helper()
s, err := New(context.Background(), pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("recsettings.New: %v", err)
}
return s
}
func auditRows(t *testing.T, pool *pgxpool.Pool) []struct {
Scope, Action string
Changes []fieldChange
} {
t.Helper()
rows, err := pool.Query(context.Background(),
`SELECT scope, action, changes FROM recommendation_tuning_audit ORDER BY id`)
if err != nil {
t.Fatalf("query audit: %v", err)
}
defer rows.Close()
var out []struct {
Scope, Action string
Changes []fieldChange
}
for rows.Next() {
var scope, action string
var raw []byte
if err := rows.Scan(&scope, &action, &raw); err != nil {
t.Fatalf("scan audit: %v", err)
}
var changes []fieldChange
if err := json.Unmarshal(raw, &changes); err != nil {
t.Fatalf("unmarshal audit changes: %v", err)
}
out = append(out, struct {
Scope, Action string
Changes []fieldChange
}{scope, action, changes})
}
return out
}
func TestNew_SeedsShippedDefaults(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if got := s.Weights(ScopeRadio); got != ShippedRadioWeights() {
t.Errorf("radio weights = %+v, want shipped defaults", got)
}
if got := s.Weights(ScopeDailyMix); got != ShippedDailyMixWeights() {
t.Errorf("daily_mix weights = %+v, want shipped defaults", got)
}
if got := s.Taste(); got != ShippedTasteTuning() {
t.Errorf("taste tuning = %+v, want shipped defaults", got)
}
// Seeding must not write audit rows — nothing changed.
if rows := auditRows(t, pool); len(rows) != 0 {
t.Errorf("boot reconcile wrote %d audit rows, want 0", len(rows))
}
}
func TestUpdateProfile_PersistsAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateProfile(context.Background(), ScopeRadio,
map[string]float64{"taste_weight": 2.5, "skip_penalty": 3.0}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
got := s.Weights(ScopeRadio)
if got.TasteWeight != 2.5 || got.SkipPenalty != 3.0 {
t.Errorf("weights = %+v, want taste 2.5 / skip 3.0", got)
}
// The other profile is untouched.
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
t.Error("daily_mix must be unaffected by a radio update")
}
// Values survive a fresh boot (persisted, not just cached), and the
// reconcile's ON CONFLICT DO NOTHING doesn't clobber tuned rows.
s2 := newService(t, pool)
if got := s2.Weights(ScopeRadio); got.TasteWeight != 2.5 {
t.Errorf("rebooted taste_weight = %v, want 2.5", got.TasteWeight)
}
rows := auditRows(t, pool)
if len(rows) != 1 {
t.Fatalf("audit rows = %d, want 1", len(rows))
}
if rows[0].Scope != ScopeRadio || rows[0].Action != "update" || len(rows[0].Changes) != 2 {
t.Errorf("audit row = %+v, want radio/update with 2 changes", rows[0])
}
// Changes are field-sorted: skip_penalty before taste_weight.
if rows[0].Changes[0].Field != "skip_penalty" || rows[0].Changes[0].New != 3.0 {
t.Errorf("changes[0] = %+v, want skip_penalty → 3.0", rows[0].Changes[0])
}
}
func TestUpdateProfile_Validation(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
cases := []struct {
name string
scope string
patch map[string]float64
want error
}{
{"unknown scope", "banana", map[string]float64{"taste_weight": 1}, ErrUnknownScope},
{"unknown field", ScopeRadio, map[string]float64{"vibes": 1}, ErrUnknownField},
{"over bound", ScopeRadio, map[string]float64{"taste_weight": 11}, ErrOutOfRange},
{"negative jitter", ScopeRadio, map[string]float64{"jitter_magnitude": -0.1}, ErrOutOfRange},
}
for _, c := range cases {
if err := s.UpdateProfile(context.Background(), c.scope, c.patch); !errors.Is(err, c.want) {
t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
}
}
if s.Weights(ScopeRadio) != ShippedRadioWeights() {
t.Error("rejected patches must not partially apply")
}
if rows := auditRows(t, pool); len(rows) != 0 {
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
}
}
func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// Moving neutral above full must reject on the PATCHED result.
err := s.UpdateTaste(context.Background(), map[string]float64{"engagement_neutral": 0.95})
if !errors.Is(err, ErrOutOfRange) {
t.Fatalf("err = %v, want ErrOutOfRange (curve ordering)", err)
}
// A coherent multi-point move is fine.
if err := s.UpdateTaste(context.Background(), map[string]float64{
"engagement_neutral": 0.40, "engagement_full": 0.95, "half_life_days": 30,
}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
cfg := s.TasteConfig()
if cfg.HalfLifeDays != 30 || cfg.Engagement.NeutralCompletion != 0.40 {
t.Errorf("taste config = %+v, want tuned values", cfg)
}
// WindowDays scales with the half-life at the shipped ratio.
if cfg.WindowDays != 30*3.6 {
t.Errorf("WindowDays = %v, want %v", cfg.WindowDays, 30*3.6)
}
}
func TestReset_RestoresShippedAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// Reset with nothing changed is a no-op (no audit row).
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
t.Fatalf("no-op reset: %v", err)
}
if rows := auditRows(t, pool); len(rows) != 0 {
t.Fatalf("no-op reset wrote audit rows")
}
if err := s.UpdateProfile(context.Background(), ScopeDailyMix,
map[string]float64{"similarity_weight": 4}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
t.Fatalf("Reset: %v", err)
}
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
t.Error("reset must restore shipped defaults")
}
rows := auditRows(t, pool)
if len(rows) != 2 || rows[1].Action != "reset" {
t.Fatalf("audit rows = %+v, want update then reset", rows)
}
}
func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
shipped := ShippedRadioWeights()
if err := s.UpdateProfile(context.Background(), ScopeRadio,
map[string]float64{"taste_weight": shipped.TasteWeight}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if rows := auditRows(t, pool); len(rows) != 0 {
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
}
}
+1 -22
View File
@@ -27,9 +27,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/web"
)
@@ -78,7 +76,6 @@ type Server struct {
BrandingCfg config.BrandingConfig
CoverEnricher *coverart.Enricher
CoverSettings *coverart.SettingsService
TagSettings *tags.SettingsService
LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig
// Bus is the live-event bus shared with background workers (the
@@ -91,13 +88,6 @@ type Server struct {
// PUT /api/me/timezone and POST /api/auth/register can call
// Refresh synchronously.
PlaylistScheduler *playlists.Scheduler
// RecSettings is the DB-backed recommendation tuning lab (#1250):
// scoring-weight profiles + taste-build knobs. Constructed in
// cmd/minstrel/main.go (it pushes daily-mix weights into package
// playlists at boot); the API layer reads radio weights per request
// and serves the admin tuning endpoints from it. Router() constructs
// a fallback when nil (tests).
RecSettings *recsettings.Service
// StreamSecret is the HMAC key used by /api/cast/stream-token to
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
// to verify them. Sourced from config.Config.StreamSecret. Tests that
@@ -153,18 +143,7 @@ func (s *Server) Router() http.Handler {
if bus == nil {
bus = eventbus.New()
}
recSettings := s.RecSettings
if recSettings == nil {
// Test contexts construct Server directly without main.go's
// boot wiring; reconcile here so radio + the admin tuning
// endpoints work against the same pool.
var err error
recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger)
if err != nil {
s.Logger.Error("server: recsettings boot failed", "err", err)
}
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
-258
View File
@@ -1,258 +0,0 @@
package tags
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// defaultTopK caps how many tags are cached per track. Enough for a rich
// facet, small enough that a heavily-tagged track can't swamp the taste
// profile when the recompute unions them in.
const defaultTopK = 8
// Source labels stamped onto tracks.tag_source. A single contributing
// provider stamps its own ID; two or more stamp "mixed". Kept in sync with
// the SetTrackTagSource query's documented whitelist.
const (
sourceNone = "none"
sourceMixed = "mixed"
)
// outcome classifies one track's enrichment for the batch tally.
type outcome int
const (
outcomeEnriched outcome = iota // wrote ≥1 tag
outcomeNone // every provider had nothing → settled 'none'
outcomeLeftNull // a transient failure → left NULL for retry
)
// Enricher drains tracks needing tags, fetches from the enabled provider
// chain, MERGES the results (union across providers, unlike coverart's
// first-success-wins for a single image), and caches the top-K into
// track_tags. Owns no pool; receives one via the constructor so the boot
// backfill, post-scan trigger, and admin endpoints share state.
type Enricher struct {
pool *pgxpool.Pool
logger *slog.Logger
settings *SettingsService
topK int
}
// NewEnricher constructs an Enricher.
func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, settings *SettingsService) *Enricher {
return &Enricher{pool: pool, logger: logger, settings: settings, topK: defaultTopK}
}
// EnrichTrack runs the chain for one track and persists the merged result.
//
// Every enabled TrackTagProvider is queried and its tags unioned (max
// weight wins on overlap). If any tags surface they're written with the
// source stamped by which providers contributed (musicbrainz / lastfm /
// mixed). If every provider cleanly reports ErrNotFound (or none are
// enabled) the row settles to 'none'. If a provider fails transiently and
// no tags surfaced, the row is left NULL for a next-pass retry.
func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref TrackRef) (outcome, error) {
merged := map[string]float64{}
contributors := map[string]bool{}
anyTransient := false
for _, provider := range e.settings.EnabledTrackTagProviders() {
tags, perr := provider.FetchTrackTags(ctx, ref)
switch {
case perr == nil:
for _, t := range tags {
if t.Weight > merged[t.Name] {
merged[t.Name] = t.Weight
}
}
if len(tags) > 0 {
contributors[provider.ID()] = true
}
case errors.Is(perr, ErrNotFound):
// Clean "no data from this source" — try the next provider.
default:
anyTransient = true
e.logger.Warn("tags: provider fetch failed; continuing",
"track_id", uuidString(trackID), "provider", provider.ID(), "err", perr)
}
}
if len(merged) > 0 {
top := topKByWeight(merged, e.topK)
source := sourceLabel(contributorIDs(contributors))
if err := e.writeTags(ctx, trackID, top, source, e.settings.CurrentVersion()); err != nil {
return outcomeLeftNull, err
}
return outcomeEnriched, nil
}
if anyTransient {
// Nothing landed but a source may recover — leave NULL for retry.
return outcomeLeftNull, nil
}
// Every provider (if any) had nothing: settle 'none' so we don't
// re-process until the version bumps (a provider enabled/added).
if err := e.writeTags(ctx, trackID, nil, sourceNone, e.settings.CurrentVersion()); err != nil {
return outcomeNone, err
}
return outcomeNone, nil
}
// writeTags atomically replaces a track's cached tags and stamps the source
// + version. tags may be empty (the 'none' settle path), which just clears
// any prior tags and records the outcome.
func (e *Enricher) writeTags(ctx context.Context, trackID pgtype.UUID, tags []Tag, source string, version int32) error {
tx, err := e.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbq.New(tx)
if err := q.DeleteTrackTags(ctx, trackID); err != nil {
return fmt.Errorf("delete track tags: %w", err)
}
for _, t := range tags {
if err := q.InsertTrackTag(ctx, dbq.InsertTrackTagParams{
TrackID: trackID, Tag: t.Name, Weight: t.Weight,
}); err != nil {
return fmt.Errorf("insert track tag: %w", err)
}
}
src := source
if err := q.SetTrackTagSource(ctx, dbq.SetTrackTagSourceParams{
ID: trackID, TagSource: &src, TagSourcesVersion: version,
}); err != nil {
return fmt.Errorf("set track tag source: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
// EnrichTrackBatch drains up to limit tracks needing tags and enriches each
// serially. limit: 0 = disabled; >0 = bounded; <0 = unbounded (remote
// providers self-throttle via their httpClient MinInterval). Returns the
// processed/succeeded/failed tally for the scan-run record.
func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit == 0 {
return 0, 0, 0, nil
}
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1
}
q := dbq.New(e.pool)
rows, qerr := q.ListTracksMissingTags(ctx, dbq.ListTracksMissingTagsParams{
TagSourcesVersion: e.settings.CurrentVersion(),
Limit: queryLimit,
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list tracks missing tags: %w", qerr)
}
var enriched, settledNone, leftNull, errored int
for _, r := range rows {
if ctx.Err() != nil {
e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
return processed, enriched, settledNone + leftNull + errored, ctx.Err()
}
processed++
ref := TrackRef{ArtistName: r.ArtistName, Title: r.Title}
if r.Mbid != nil {
ref.MBID = *r.Mbid
}
oc, eerr := e.EnrichTrack(ctx, r.ID, ref)
if eerr != nil {
e.logger.Warn("tags: batch entry failed", "track_id", uuidString(r.ID), "err", eerr)
errored++
} else {
switch oc {
case outcomeEnriched:
enriched++
case outcomeNone:
settledNone++
case outcomeLeftNull:
leftNull++
}
}
if progressCb != nil {
progressCb(processed, enriched, settledNone+leftNull+errored)
}
}
e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
return processed, enriched, settledNone + leftNull + errored, nil
}
// logBatchSummary emits one Info line with the category breakdown — the
// enriched/settled/left-null split is the operator's diagnostic for a
// "0 enriched" symptom the collapsed tally can't explain.
func (e *Enricher) logBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
e.logger.Info("tags: enrichment batch complete",
"eligible", eligible,
"processed", processed,
"enriched", enriched,
"settled_none", settledNone,
"left_null", leftNull,
"errored", errored)
}
// topKByWeight returns the highest-weighted tags, tie-broken by name for
// determinism, capped at k (k<=0 means no cap).
func topKByWeight(merged map[string]float64, k int) []Tag {
tags := make([]Tag, 0, len(merged))
for name, w := range merged {
tags = append(tags, Tag{Name: name, Weight: w})
}
sort.Slice(tags, func(i, j int) bool {
if tags[i].Weight != tags[j].Weight {
return tags[i].Weight > tags[j].Weight
}
return tags[i].Name < tags[j].Name
})
if k > 0 && len(tags) > k {
tags = tags[:k]
}
return tags
}
// contributorIDs returns the sorted set of provider IDs that returned tags.
func contributorIDs(set map[string]bool) []string {
ids := make([]string, 0, len(set))
for id := range set {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// sourceLabel maps the contributing providers onto the tag_source value:
// none / a single provider's own ID / "mixed" for two or more.
func sourceLabel(ids []string) string {
switch len(ids) {
case 0:
return sourceNone
case 1:
return ids[0]
default:
return sourceMixed
}
}
// uuidString renders a pgtype.UUID for logs.
func uuidString(u pgtype.UUID) string {
if !u.Valid {
return "<nil>"
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}
-53
View File
@@ -1,53 +0,0 @@
package tags
import "testing"
func TestTopKByWeight(t *testing.T) {
merged := map[string]float64{
"a": 0.9,
"b": 0.5,
"c": 0.5, // ties with b → name asc breaks it (b before c)
"d": 0.1,
}
got := topKByWeight(merged, 3)
if len(got) != 3 {
t.Fatalf("cap not applied: got %d", len(got))
}
wantOrder := []string{"a", "b", "c"}
for i, w := range wantOrder {
if got[i].Name != w {
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Name, w, got)
}
}
// k<=0 means no cap.
if len(topKByWeight(merged, 0)) != 4 {
t.Error("k=0 should not cap")
}
if len(topKByWeight(nil, 5)) != 0 {
t.Error("empty input yields empty output")
}
}
func TestSourceLabel(t *testing.T) {
cases := []struct {
ids []string
want string
}{
{nil, sourceNone},
{[]string{"musicbrainz"}, "musicbrainz"},
{[]string{"lastfm"}, "lastfm"},
{[]string{"lastfm", "musicbrainz"}, sourceMixed},
}
for _, c := range cases {
if got := sourceLabel(c.ids); got != c.want {
t.Errorf("sourceLabel(%v) = %q, want %q", c.ids, got, c.want)
}
}
}
func TestContributorIDs_Sorted(t *testing.T) {
got := contributorIDs(map[string]bool{"lastfm": true, "musicbrainz": true})
if len(got) != 2 || got[0] != "lastfm" || got[1] != "musicbrainz" {
t.Errorf("contributorIDs sorted = %v, want [lastfm musicbrainz]", got)
}
}
-184
View File
@@ -1,184 +0,0 @@
package tags
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"sync"
"time"
)
// httpClientOptions configures a httpClient. A trimmed sibling of the
// coverart HTTP helper — JSON-only (tag APIs return no binary), plus a
// User-Agent field because MusicBrainz rejects requests without one.
// Kept local to this package so tag enrichment never depends on
// coverart's internals.
type httpClientOptions struct {
HTTPClient *http.Client
// Name prefixes wrapped error messages (e.g. "tags lastfm: ...").
Name string
// UserAgent is sent on every request. MusicBrainz mandates a
// descriptive one; empty omits the header.
UserAgent string
// MinInterval throttles requests; 0 disables rate limiting.
MinInterval time.Duration
// MaxRetries is the number of retry attempts on 429 / 5xx.
MaxRetries int
// BaseBackoff is the base unit for exponential backoff (2^attempt × base ± 20%).
BaseBackoff time.Duration
// TreatAuthAsTransient maps 401/403 to ErrTransient (keyed providers
// want this so a bad/expired key retries rather than settling 'none').
TreatAuthAsTransient bool
// JSONBodyLimit caps the response body read.
JSONBodyLimit int64
}
// httpClient performs rate-limited, retrying JSON GETs. Each provider
// owns one, configured with provider-specific intervals + auth semantics.
type httpClient struct {
opts httpClientOptions
mu sync.Mutex
lastCall time.Time
}
func newHTTPClient(opts httpClientOptions) *httpClient {
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Timeout: 30 * time.Second}
}
if opts.MaxRetries == 0 {
opts.MaxRetries = 3
}
if opts.BaseBackoff == 0 {
opts.BaseBackoff = 250 * time.Millisecond
}
if opts.JSONBodyLimit == 0 {
opts.JSONBodyLimit = 1 * 1024 * 1024
}
return &httpClient{opts: opts}
}
// getJSON sends GET fullURL and decodes the 200 body into out (capped at
// JSONBodyLimit). Retries 429/5xx with exponential backoff up to
// MaxRetries. 404 → ErrNotFound; other non-2xx → wrapped ErrTransient.
func (c *httpClient) getJSON(ctx context.Context, fullURL string, out any) error {
for attempt := 0; attempt <= c.opts.MaxRetries; attempt++ {
if err := c.rateLimitWait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return fmt.Errorf("tags %s: build request: %w", c.opts.Name, err)
}
req.Header.Set("Accept", "application/json")
if c.opts.UserAgent != "" {
req.Header.Set("User-Agent", c.opts.UserAgent)
}
resp, err := c.opts.HTTPClient.Do(req)
if err != nil {
if attempt < c.opts.MaxRetries {
if waitErr := c.backoff(ctx, attempt); waitErr != nil {
return waitErr
}
continue
}
return fmt.Errorf("%w: %v", ErrTransient, err)
}
retry, ferr := c.handleStatus(ctx, resp, attempt)
if retry {
continue
}
if ferr != nil {
return ferr
}
body, rerr := io.ReadAll(io.LimitReader(resp.Body, c.opts.JSONBodyLimit))
_ = resp.Body.Close()
if rerr != nil {
return fmt.Errorf("%w: read body: %v", ErrTransient, rerr)
}
if uerr := json.Unmarshal(body, out); uerr != nil {
return fmt.Errorf("%w: decode body: %v", ErrTransient, uerr)
}
return nil
}
return fmt.Errorf("%w: exhausted retries", ErrTransient)
}
// handleStatus classifies a response. Returns retry=true to loop again
// (after backing off), or a terminal error, or (false, nil) to proceed to
// body read. Closes the body on every path except the proceed case.
func (c *httpClient) handleStatus(ctx context.Context, resp *http.Response, attempt int) (retry bool, err error) {
switch {
case resp.StatusCode == http.StatusNotFound:
_ = resp.Body.Close()
return false, ErrNotFound
case c.opts.TreatAuthAsTransient &&
(resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden):
_ = resp.Body.Close()
return false, fmt.Errorf("%w: status %d (auth)", ErrTransient, resp.StatusCode)
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
_ = resp.Body.Close()
if attempt < c.opts.MaxRetries {
if waitErr := c.backoff(ctx, attempt); waitErr != nil {
return false, waitErr
}
return true, nil
}
return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
case resp.StatusCode != http.StatusOK:
_ = resp.Body.Close()
return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
}
return false, nil
}
// rateLimitWait blocks until MinInterval has elapsed since the last call,
// then claims the slot. Disabled when MinInterval is 0.
func (c *httpClient) rateLimitWait(ctx context.Context) error {
if c.opts.MinInterval <= 0 {
return nil
}
c.mu.Lock()
wait := time.Until(c.lastCall.Add(c.opts.MinInterval))
if wait > 0 {
timer := time.NewTimer(wait)
c.mu.Unlock()
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
c.mu.Lock()
}
c.lastCall = time.Now()
c.mu.Unlock()
return nil
}
// backoff sleeps for 2^attempt × BaseBackoff ± 20% jitter, or until ctx is done.
func (c *httpClient) backoff(ctx context.Context, attempt int) error {
base := time.Duration(1<<attempt) * c.opts.BaseBackoff
jitterRange := int64(base) / 5
if jitterRange <= 0 {
jitterRange = 1
}
jitter := time.Duration(rand.Int63n(jitterRange))
if rand.Intn(2) == 0 {
jitter = -jitter
}
timer := time.NewTimer(base + jitter)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
-158
View File
@@ -1,158 +0,0 @@
// Package tags enriches the taste profile's tag facet beyond raw ID3
// genre (milestone #160, task #1490). It fetches folksonomy style/mood
// tags for tracks from a chain of pluggable providers (MusicBrainz,
// Last.fm, …), caches them in track_tags, and the taste recompute unions
// them alongside tracks.genre.
//
// The provider model mirrors internal/coverart deliberately: a source is
// added by implementing TrackTagProvider and calling Register() in the
// provider file's init() — no enricher, settings, or schema change. The
// DB-backed SettingsService then auto-upserts a settings row for it, and
// the boot-time provider-hash bump makes previously-settled rows eligible
// for a retry through the widened chain.
package tags
import (
"context"
"errors"
"fmt"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
)
// Tag is one folksonomy tag with a normalized strength in [0,1]. Each
// provider normalizes its own upstream weighting (Last.fm's 0-100 count,
// MusicBrainz's vote count) into this range so the enricher can merge
// across providers on a common scale.
type Tag struct {
Name string
Weight float64
}
// TrackRef is the lookup key passed to a provider. MBID is the track's
// recording MBID (preferred by MBID-keyed providers); ArtistName + Title
// are the always-populated fallback for name-based providers (Last.fm).
type TrackRef struct {
MBID string
ArtistName string
Title string
}
// Provider is the base shape every tag source implements. Providers
// register at init() via Register(). The SettingsService reads the
// per-provider row (enabled, api_key) and calls Configure().
type Provider interface {
// ID is a stable lowercase token persisted in tracks.tag_source and
// keyed in tag_provider_settings.provider_id, and shown in the admin UI.
ID() string
// DisplayName is the human-readable label for the admin Settings card.
DisplayName() string
// RequiresAPIKey reports whether the provider needs a key to function.
// The admin UI shows/hides the key field on this; it does NOT gate the
// enabled toggle (rule #26 — providers with usable defaults ship working).
RequiresAPIKey() bool
// DefaultEnabled is whether a freshly-installed Minstrel ships with
// this provider on. MusicBrainz (keyless) returns true; Last.fm
// (keyed) returns false — opt-in once a key is supplied.
DefaultEnabled() bool
// Configure applies the operator's settings. Called at boot and on
// every admin PATCH; must be idempotent and goroutine-safe (the
// enricher may read concurrently).
Configure(ProviderSettings) error
}
// ProviderSettings is the runtime config a provider receives on Configure.
type ProviderSettings struct {
Enabled bool
APIKey string
}
// TrackTagProvider is the tag-fetch capability. Split from Provider so a
// future source with a different capability (e.g. artist-level tags) can
// be added without widening this one.
type TrackTagProvider interface {
Provider
// FetchTrackTags returns the folksonomy tags for the track, or
// ErrNotFound (terminal — nothing upstream) / ErrTransient (retry).
// A disabled provider, or one missing a required key, returns
// ErrNotFound so the chain simply skips it.
FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error)
}
// TestableProvider is an opt-in capability for the admin Test-Connection
// button: answer "is my config working?" without a full enrichment cycle.
type TestableProvider interface {
Provider
TestConnection(ctx context.Context) error
}
// ErrNotFound: the provider confirmed it has no tags for this track — a
// terminal-this-pass result. The enricher moves to the next provider; if
// every provider returns ErrNotFound the row settles to tag_source='none'.
// Aliased to apierror.ErrNotFound for consistency with the rest of the app.
var ErrNotFound = apierror.ErrNotFound
// ErrTransient: a temporary failure (5xx, network, timeout, auth). The
// enricher leaves the row's tag_source NULL so the next pass retries.
var ErrTransient = errors.New("tags: transient error")
// ErrProviderNotFound is returned when a named provider is not registered.
var ErrProviderNotFound = errors.New("tags: provider not registered")
// ErrNotTestable is returned when a provider doesn't implement
// TestableProvider.
var ErrNotTestable = errors.New("tags: provider does not support test connection")
// registry holds all compiled-in providers. Providers register at init()
// via Register(); the enricher iterates this list filtered by settings.
var registry = struct {
mu sync.Mutex
providers []Provider
}{}
// Register adds a provider. Must be called from a provider file's init().
// Duplicate IDs panic — providers are compiled-in, so it's a build bug.
func Register(p Provider) {
registry.mu.Lock()
defer registry.mu.Unlock()
for _, existing := range registry.providers {
if existing.ID() == p.ID() {
panic(fmt.Sprintf("tags: duplicate provider ID %q", p.ID()))
}
}
registry.providers = append(registry.providers, p)
}
// AllProviders returns a snapshot in registration order. Do not mutate.
func AllProviders() []Provider {
registry.mu.Lock()
defer registry.mu.Unlock()
out := make([]Provider, len(registry.providers))
copy(out, registry.providers)
return out
}
// ProviderByID looks up a registered provider. Returns ErrProviderNotFound
// if none matches.
func ProviderByID(id string) (Provider, error) {
registry.mu.Lock()
defer registry.mu.Unlock()
for _, p := range registry.providers {
if p.ID() == id {
return p, nil
}
}
return nil, ErrProviderNotFound
}
// ResetRegistryForTests clears the registry. Test-only.
func ResetRegistryForTests() {
registry.mu.Lock()
defer registry.mu.Unlock()
registry.providers = nil
}
-168
View File
@@ -1,168 +0,0 @@
package tags
import (
"context"
"errors"
"net/http"
"net/url"
"sync/atomic"
"time"
)
// lastfmBaseURL is a var (not const) so tests can override it.
var lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/"
const (
// lastfmMinPeriod throttles to 4 req/s under the 5/s per-key ceiling.
lastfmMinPeriod = 250 * time.Millisecond
// lastfmTagMaxCount is Last.fm's top-tag count scale (0-100 popularity).
lastfmTagMaxCount = 100.0
)
// lastfmTransientErrors are Last.fm JSON-level error codes that mean
// "try again" rather than "no data": 8 operation failed, 11/16 service
// temporarily unavailable, 29 rate limit. Everything else non-zero
// (6 not found, 10 invalid key, …) is treated as ErrNotFound so the
// chain simply skips this provider for the track.
var lastfmTransientErrors = map[int]bool{8: true, 11: true, 16: true, 29: true}
// lastfmProvider fetches track-level folksonomy tags via Last.fm's
// track.getTopTags. Requires an operator-supplied API key; stays inert
// (ErrNotFound on every call) when disabled or unkeyed. Name-based, so it
// covers the many local tracks that lack a recording MBID.
type lastfmProvider struct {
enabled atomic.Bool
apiKey atomic.Pointer[string]
client *httpClient
}
func init() {
Register(&lastfmProvider{
client: newHTTPClient(httpClientOptions{
Name: "lastfm",
HTTPClient: &http.Client{Timeout: 30 * time.Second},
MinInterval: lastfmMinPeriod,
MaxRetries: 3,
TreatAuthAsTransient: true,
}),
})
}
func (p *lastfmProvider) ID() string { return "lastfm" }
func (p *lastfmProvider) DisplayName() string { return "Last.fm" }
func (p *lastfmProvider) RequiresAPIKey() bool { return true }
func (p *lastfmProvider) DefaultEnabled() bool { return false }
func (p *lastfmProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
key := s.APIKey
p.apiKey.Store(&key)
return nil
}
func (p *lastfmProvider) currentKey() string {
if k := p.apiKey.Load(); k != nil {
return *k
}
return ""
}
// lastfmTopTags models the subset of track.getTopTags we read. Last.fm
// returns `error` on failure responses instead of the toptags block.
type lastfmTopTags struct {
TopTags struct {
Tag []lastfmTag `json:"tag"`
} `json:"toptags"`
Error int `json:"error"`
}
type lastfmTag struct {
Name string `json:"name"`
Count int `json:"count"`
}
// FetchTrackTags looks up the track's top tags by artist + title (with the
// recording MBID as an extra hint when present). Returns ErrNotFound when
// disabled, unkeyed, missing artist/title, or when Last.fm has no tags.
func (p *lastfmProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
if !p.enabled.Load() || p.currentKey() == "" {
return nil, ErrNotFound
}
if ref.ArtistName == "" || ref.Title == "" {
return nil, ErrNotFound
}
q := url.Values{
"method": {"track.gettoptags"},
"api_key": {p.currentKey()},
"format": {"json"},
"artist": {ref.ArtistName},
"track": {ref.Title},
"autocorrect": {"1"},
}
if ref.MBID != "" {
q.Set("mbid", ref.MBID)
}
var resp lastfmTopTags
if err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp); err != nil {
return nil, err
}
if resp.Error != 0 {
if lastfmTransientErrors[resp.Error] {
return nil, ErrTransient
}
return nil, ErrNotFound
}
out := normalizeLastfmTags(resp.TopTags.Tag)
if len(out) == 0 {
return nil, ErrNotFound
}
return out, nil
}
// TestConnection verifies the key against a well-known track.
func (p *lastfmProvider) TestConnection(ctx context.Context) error {
if p.currentKey() == "" {
return errors.New("tags: lastfm api key not set")
}
q := url.Values{
"method": {"track.gettoptags"},
"api_key": {p.currentKey()},
"format": {"json"},
"artist": {"Radiohead"},
"track": {"Creep"},
}
var resp lastfmTopTags
err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp)
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if resp.Error == 10 { // invalid API key
return errors.New("tags: lastfm rejected the api key")
}
return nil
}
// normalizeLastfmTags scales the 0-100 popularity counts into [0,1] and
// drops zero-count / unnamed tags.
func normalizeLastfmTags(raw []lastfmTag) []Tag {
out := make([]Tag, 0, len(raw))
for _, t := range raw {
if t.Count <= 0 || t.Name == "" {
continue
}
w := float64(t.Count) / lastfmTagMaxCount
if w > 1 {
w = 1
}
out = append(out, Tag{Name: t.Name, Weight: w})
}
return out
}
// Compile-time capability checks.
var (
_ TrackTagProvider = (*lastfmProvider)(nil)
_ TestableProvider = (*lastfmProvider)(nil)
)
-101
View File
@@ -1,101 +0,0 @@
package tags
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func newLastfmProvider(enabled bool, key string) *lastfmProvider {
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{
Name: "lastfm", MaxRetries: 1, TreatAuthAsTransient: true,
})}
_ = p.Configure(ProviderSettings{Enabled: enabled, APIKey: key})
return p
}
func TestNormalizeLastfmTags(t *testing.T) {
got := tagsByName(normalizeLastfmTags([]lastfmTag{
{Name: "indie", Count: 100},
{Name: "dream pop", Count: 40},
{Name: "zero-drop", Count: 0},
{Name: "", Count: 50},
{Name: "clamp", Count: 150}, // >100 clamps to 1.0
}))
if len(got) != 3 {
t.Fatalf("want 3 tags, got %v", got)
}
if got["indie"] != 1.0 || got["dream pop"] != 0.4 || got["clamp"] != 1.0 {
t.Errorf("weights = %v", got)
}
}
func TestLastfmFetch_GatedOff(t *testing.T) {
// Enabled but unkeyed → ErrNotFound.
if _, err := newLastfmProvider(true, "").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"}); !errors.Is(err, ErrNotFound) {
t.Errorf("unkeyed: err = %v, want ErrNotFound", err)
}
// Keyed but disabled → ErrNotFound.
if _, err := newLastfmProvider(false, "k").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"}); !errors.Is(err, ErrNotFound) {
t.Errorf("disabled: err = %v, want ErrNotFound", err)
}
// Keyed + enabled but missing artist/title → ErrNotFound.
if _, err := newLastfmProvider(true, "k").FetchTrackTags(context.Background(),
TrackRef{MBID: "x"}); !errors.Is(err, ErrNotFound) {
t.Errorf("no artist/title: err = %v, want ErrNotFound", err)
}
}
func TestLastfmFetch_ParsesTags(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"toptags":{"tag":[{"name":"shoegaze","count":100},{"name":"90s","count":30}]}}`))
}))
defer srv.Close()
old := lastfmBaseURL
lastfmBaseURL = srv.URL + "/"
defer func() { lastfmBaseURL = old }()
tags, err := newLastfmProvider(true, "k").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "Slowdive", Title: "Alison"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
if m["shoegaze"] != 1.0 || m["90s"] != 0.3 {
t.Errorf("weights = %v", m)
}
}
func TestLastfmFetch_ErrorCodes(t *testing.T) {
cases := []struct {
name string
errorCode int
want error
}{
{"not-found", 6, ErrNotFound},
{"transient-rate-limit", 29, ErrTransient},
{"transient-unavailable", 16, ErrTransient},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"error":` + strconv.Itoa(c.errorCode) + `,"message":"x"}`))
}))
defer srv.Close()
old := lastfmBaseURL
lastfmBaseURL = srv.URL + "/"
defer func() { lastfmBaseURL = old }()
_, err := newLastfmProvider(true, "k").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"})
if !errors.Is(err, c.want) {
t.Errorf("error %d: got %v, want %v", c.errorCode, err, c.want)
}
})
}
}
-123
View File
@@ -1,123 +0,0 @@
package tags
import (
"context"
"errors"
"net/http"
"net/url"
"sync/atomic"
"time"
)
// mbBaseURL is a var (not const) so tests can point it at an httptest server.
var mbBaseURL = "https://musicbrainz.org/ws/2"
const (
// mbMinPeriod honors MusicBrainz's 1 req/s anonymous ceiling (with headroom).
mbMinPeriod = 1100 * time.Millisecond
// mbUserAgent identifies the app — MusicBrainz rejects requests without one.
mbUserAgent = "Minstrel/1.0 ( https://git.fabledsword.com/bvandeusen/minstrel )"
)
// musicbrainzProvider fetches recording-level folksonomy tags from the
// MusicBrainz web service. Keyless (the public endpoint needs no auth) and
// on by default — the always-available baseline source. MBID-only: a track
// with no recording MBID yields ErrNotFound (name search is ambiguous for
// recordings and deferred to name-based providers like Last.fm).
type musicbrainzProvider struct {
enabled atomic.Bool
client *httpClient
}
func init() {
Register(&musicbrainzProvider{
client: newHTTPClient(httpClientOptions{
Name: "musicbrainz",
UserAgent: mbUserAgent,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
MinInterval: mbMinPeriod,
MaxRetries: 3,
}),
})
}
func (p *musicbrainzProvider) ID() string { return "musicbrainz" }
func (p *musicbrainzProvider) DisplayName() string { return "MusicBrainz" }
func (p *musicbrainzProvider) RequiresAPIKey() bool { return false }
func (p *musicbrainzProvider) DefaultEnabled() bool { return true }
func (p *musicbrainzProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
return nil
}
// mbTagsResponse models the subset of /ws/2/recording/{mbid}?inc=tags we read.
type mbTagsResponse struct {
Tags []mbTag `json:"tags"`
}
type mbTag struct {
Count int `json:"count"`
Name string `json:"name"`
}
// FetchTrackTags looks up the recording's tags by MBID. Returns ErrNotFound
// when disabled, when the track has no MBID, or when MusicBrainz has no tags.
func (p *musicbrainzProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
if !p.enabled.Load() || ref.MBID == "" {
return nil, ErrNotFound
}
q := url.Values{"inc": {"tags"}, "fmt": {"json"}}
full := mbBaseURL + "/recording/" + url.PathEscape(ref.MBID) + "?" + q.Encode()
var resp mbTagsResponse
if err := p.client.getJSON(ctx, full, &resp); err != nil {
return nil, err
}
out := normalizeMBTags(resp.Tags)
if len(out) == 0 {
return nil, ErrNotFound
}
return out, nil
}
// TestConnection issues a tiny search request; any non-transient response
// means MusicBrainz is reachable.
func (p *musicbrainzProvider) TestConnection(ctx context.Context) error {
full := mbBaseURL + "/recording?query=*&limit=1&fmt=json"
var discard map[string]any
err := p.client.getJSON(ctx, full, &discard)
if err == nil || errors.Is(err, ErrNotFound) {
return nil
}
return err
}
// normalizeMBTags drops non-positive-vote tags (net-downvoted or zero) and
// scales the remaining vote counts into [0,1] relative to the strongest tag
// on this recording, so one recording's raw counts don't dominate another's.
func normalizeMBTags(raw []mbTag) []Tag {
maxCount := 0
for _, t := range raw {
if t.Count > maxCount {
maxCount = t.Count
}
}
if maxCount == 0 {
return nil
}
out := make([]Tag, 0, len(raw))
for _, t := range raw {
if t.Count <= 0 || t.Name == "" {
continue
}
out = append(out, Tag{Name: t.Name, Weight: float64(t.Count) / float64(maxCount)})
}
return out
}
// Compile-time capability checks.
var (
_ TrackTagProvider = (*musicbrainzProvider)(nil)
_ TestableProvider = (*musicbrainzProvider)(nil)
)
@@ -1,92 +0,0 @@
package tags
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func newMBProvider(enabled bool) *musicbrainzProvider {
p := &musicbrainzProvider{client: newHTTPClient(httpClientOptions{Name: "musicbrainz", MaxRetries: 1})}
_ = p.Configure(ProviderSettings{Enabled: enabled})
return p
}
func tagsByName(ts []Tag) map[string]float64 {
m := make(map[string]float64, len(ts))
for _, t := range ts {
m[t.Name] = t.Weight
}
return m
}
func TestNormalizeMBTags(t *testing.T) {
got := tagsByName(normalizeMBTags([]mbTag{
{Count: 3, Name: "rock"},
{Count: 6, Name: "shoegaze"},
{Count: 0, Name: "zero-drop"},
{Count: -2, Name: "downvoted-drop"},
{Count: 5, Name: ""},
}))
if len(got) != 2 {
t.Fatalf("want 2 tags, got %v", got)
}
if got["shoegaze"] != 1.0 {
t.Errorf("shoegaze weight = %v, want 1.0", got["shoegaze"])
}
if got["rock"] != 0.5 {
t.Errorf("rock weight = %v, want 0.5", got["rock"])
}
if normalizeMBTags(nil) != nil {
t.Error("nil input should yield nil")
}
}
func TestMusicBrainzFetch_GatedOff(t *testing.T) {
// Disabled → ErrNotFound without any network call.
if _, err := newMBProvider(false).FetchTrackTags(context.Background(),
TrackRef{MBID: "abc"}); !errors.Is(err, ErrNotFound) {
t.Errorf("disabled: err = %v, want ErrNotFound", err)
}
// No MBID → ErrNotFound (MBID-only provider).
if _, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"}); !errors.Is(err, ErrNotFound) {
t.Errorf("no MBID: err = %v, want ErrNotFound", err)
}
}
func TestMusicBrainzFetch_ParsesTags(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"tags":[{"count":2,"name":"post-punk"},{"count":4,"name":"melancholic"}]}`))
}))
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(), TrackRef{MBID: "mbid-1"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
if m["melancholic"] != 1.0 || m["post-punk"] != 0.5 {
t.Errorf("weights = %v, want melancholic=1.0 post-punk=0.5", m)
}
}
func TestMusicBrainzFetch_EmptyTags(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"tags":[]}`))
}))
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
if _, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{MBID: "mbid-1"}); !errors.Is(err, ErrNotFound) {
t.Errorf("empty tags: err = %v, want ErrNotFound", err)
}
}
-85
View File
@@ -1,85 +0,0 @@
package tags
import "testing"
// fakeProvider is a minimal Provider for registry mechanics tests.
type fakeProvider struct {
id string
deflt bool
needKey bool
}
func (f *fakeProvider) ID() string { return f.id }
func (f *fakeProvider) DisplayName() string { return f.id }
func (f *fakeProvider) RequiresAPIKey() bool { return f.needKey }
func (f *fakeProvider) DefaultEnabled() bool { return f.deflt }
func (f *fakeProvider) Configure(ProviderSettings) error { return nil }
func TestRegisterAndLookup(t *testing.T) {
ResetRegistryForTests()
t.Cleanup(ResetRegistryForTests)
a := &fakeProvider{id: "alpha", deflt: true}
b := &fakeProvider{id: "beta"}
Register(a)
Register(b)
all := AllProviders()
if len(all) != 2 || all[0].ID() != "alpha" || all[1].ID() != "beta" {
t.Fatalf("AllProviders preserves registration order; got %v", all)
}
got, err := ProviderByID("beta")
if err != nil || got.ID() != "beta" {
t.Fatalf("ProviderByID(beta) = %v, %v", got, err)
}
if _, err := ProviderByID("missing"); err != ErrProviderNotFound {
t.Fatalf("ProviderByID(missing) err = %v, want ErrProviderNotFound", err)
}
}
func TestRegisterDuplicatePanics(t *testing.T) {
ResetRegistryForTests()
t.Cleanup(ResetRegistryForTests)
Register(&fakeProvider{id: "dup"})
defer func() {
if recover() == nil {
t.Error("expected panic on duplicate provider ID")
}
}()
Register(&fakeProvider{id: "dup"})
}
// TestBuiltinProvidersRegister confirms the two v1 sources register with
// the expected identities/defaults. Runs against a fresh registry so it is
// independent of test ordering: re-register constructed instances.
func TestBuiltinProvidersRegister(t *testing.T) {
ResetRegistryForTests()
t.Cleanup(ResetRegistryForTests)
Register(&musicbrainzProvider{})
Register(&lastfmProvider{})
mb, err := ProviderByID("musicbrainz")
if err != nil {
t.Fatalf("musicbrainz not registered: %v", err)
}
if mb.RequiresAPIKey() || !mb.DefaultEnabled() {
t.Errorf("musicbrainz should be keyless + default-on; got key=%v deflt=%v",
mb.RequiresAPIKey(), mb.DefaultEnabled())
}
lf, err := ProviderByID("lastfm")
if err != nil {
t.Fatalf("lastfm not registered: %v", err)
}
if !lf.RequiresAPIKey() || lf.DefaultEnabled() {
t.Errorf("lastfm should require a key + default-off; got key=%v deflt=%v",
lf.RequiresAPIKey(), lf.DefaultEnabled())
}
// Both implement the tag-fetch + test capabilities.
if _, ok := mb.(TrackTagProvider); !ok {
t.Error("musicbrainz must implement TrackTagProvider")
}
if _, ok := lf.(TestableProvider); !ok {
t.Error("lastfm must implement TestableProvider")
}
}
-305
View File
@@ -1,305 +0,0 @@
package tags
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// SettingsService reconciles the compiled-in provider registry with the
// tag_provider_settings table at boot and on every admin update, and owns
// the tag_sources_meta current_version stamp. Parallel to (and independent
// of) coverart's SettingsService — the two never share a row, so a new tag
// source is added without touching art settings.
type SettingsService struct {
pool *pgxpool.Pool
logger *slog.Logger
mu sync.RWMutex
enabledIDs map[string]bool
apiKeys map[string]string
currentVersion int32
}
// ProviderUpdatePatch is the admin-PATCH body. nil pointers mean "leave
// unchanged"; non-nil means "set" (empty APIKey clears it).
type ProviderUpdatePatch struct {
Enabled *bool
APIKey *string
}
// ProviderInfo is the settings view of a provider for the admin GET handler.
type ProviderInfo struct {
ID string
DisplayName string
RequiresAPIKey bool
Supports []string
Enabled bool
APIKeySet bool
DisplayOrder int32
Testable bool
}
// NewSettingsService boots the service: upserts default rows for any
// registered provider, Configures each with its row, and loads the version.
func NewSettingsService(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*SettingsService, error) {
s := &SettingsService{
pool: pool,
logger: logger,
enabledIDs: map[string]bool{},
apiKeys: map[string]string{},
}
if err := s.reconcile(ctx); err != nil {
return nil, fmt.Errorf("tag settings boot: %w", err)
}
return s, nil
}
// reconcile reads the DB, inserts defaults for missing providers, Configures
// each registered provider, and caches the current version.
func (s *SettingsService) reconcile(ctx context.Context) error {
q := dbq.New(s.pool)
registered := AllProviders()
for i, p := range registered {
if err := q.UpsertTagProviderSettings(ctx, dbq.UpsertTagProviderSettingsParams{
ProviderID: p.ID(),
Enabled: p.DefaultEnabled(),
DisplayOrder: int32(i),
}); err != nil {
return fmt.Errorf("upsert provider %q: %w", p.ID(), err)
}
}
rows, err := q.ListTagProviderSettings(ctx)
if err != nil {
return fmt.Errorf("list provider settings: %w", err)
}
settingsByID := map[string]dbq.TagProviderSetting{}
for _, r := range rows {
settingsByID[r.ProviderID] = r
}
s.mu.Lock()
defer s.mu.Unlock()
s.enabledIDs = map[string]bool{}
s.apiKeys = map[string]string{}
for _, p := range registered {
row, ok := settingsByID[p.ID()]
if !ok {
s.logger.Warn("tag settings: missing row after upsert", "provider", p.ID())
continue
}
key := ""
if row.ApiKey != nil {
key = *row.ApiKey
}
s.enabledIDs[p.ID()] = row.Enabled
s.apiKeys[p.ID()] = key
if err := p.Configure(ProviderSettings{Enabled: row.Enabled, APIKey: key}); err != nil {
s.logger.Warn("tag settings: provider Configure failed", "provider", p.ID(), "err", err)
}
}
v, err := q.GetCurrentTagSourcesVersion(ctx)
if err != nil {
return fmt.Errorf("get current tag sources version: %w", err)
}
s.currentVersion = v
return nil
}
// EnabledTrackTagProviders returns the enabled providers implementing
// TrackTagProvider, in registration order. Snapshot — do not mutate.
func (s *SettingsService) EnabledTrackTagProviders() []TrackTagProvider {
s.mu.RLock()
defer s.mu.RUnlock()
var out []TrackTagProvider
for _, p := range AllProviders() {
if !s.enabledIDs[p.ID()] {
continue
}
if tp, ok := p.(TrackTagProvider); ok {
out = append(out, tp)
}
}
return out
}
// CurrentVersion returns the version the enricher stamps onto rows.
func (s *SettingsService) CurrentVersion() int32 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.currentVersion
}
// UpdateProvider applies an admin patch. Returns versionBumped=true if the
// enabled set changed (not on key-only changes). Re-Configures the provider.
func (s *SettingsService) UpdateProvider(ctx context.Context, providerID string, patch ProviderUpdatePatch) (bool, error) {
p, err := ProviderByID(providerID)
if err != nil {
return false, err
}
q := dbq.New(s.pool)
s.mu.RLock()
beforeSig := enabledSetSignature(s.enabledIDs)
currentEnabled := s.enabledIDs[providerID]
s.mu.RUnlock()
enabledVal := currentEnabled
if patch.Enabled != nil {
enabledVal = *patch.Enabled
}
if err := q.UpdateTagProviderSettings(ctx, dbq.UpdateTagProviderSettingsParams{
ProviderID: providerID,
Enabled: enabledVal,
Column3: patch.APIKey != nil,
ApiKey: patch.APIKey,
}); err != nil {
return false, fmt.Errorf("update provider settings: %w", err)
}
rows, err := q.ListTagProviderSettings(ctx)
if err != nil {
return false, fmt.Errorf("re-list provider settings: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range rows {
key := ""
if r.ApiKey != nil {
key = *r.ApiKey
}
s.enabledIDs[r.ProviderID] = r.Enabled
s.apiKeys[r.ProviderID] = key
if r.ProviderID == providerID {
if err := p.Configure(ProviderSettings{Enabled: r.Enabled, APIKey: key}); err != nil {
s.logger.Warn("tag settings: Configure after update failed", "provider", providerID, "err", err)
}
}
}
if enabledSetSignature(s.enabledIDs) == beforeSig {
return false, nil
}
newVersion, err := q.BumpTagSourcesVersion(ctx)
if err != nil {
return false, fmt.Errorf("bump tag sources version: %w", err)
}
s.currentVersion = newVersion
return true, nil
}
// enabledSetSignature is a deterministic string of the enabled provider IDs.
func enabledSetSignature(enabledIDs map[string]bool) string {
ids := make([]string, 0, len(enabledIDs))
for id, on := range enabledIDs {
if on {
ids = append(ids, id)
}
}
sort.Strings(ids)
return strings.Join(ids, ",")
}
// BumpVersionIfProvidersChanged bumps the version + stores the new provider
// hash when the compiled-in provider set changed since the last boot, so
// 'none' rows become eligible for a retry through the widened chain. This
// is what makes "add a source" a zero-touch change — a new registered
// provider trips this on the next start.
func (s *SettingsService) BumpVersionIfProvidersChanged(ctx context.Context) (version int32, bumped bool, err error) {
hash := registeredProvidersHash()
q := dbq.New(s.pool)
stored, qerr := q.GetTagProvidersHash(ctx)
if qerr != nil {
return 0, false, fmt.Errorf("get providers hash: %w", qerr)
}
if stored == hash {
return s.CurrentVersion(), false, nil
}
newVer, berr := q.BumpTagVersionAndSetProvidersHash(ctx, hash)
if berr != nil {
return 0, false, fmt.Errorf("bump version: %w", berr)
}
s.mu.Lock()
s.currentVersion = newVer
s.mu.Unlock()
return newVer, true, nil
}
// registeredProvidersHash is the SHA-256 hex of the sorted, colon-joined
// registered provider IDs.
func registeredProvidersHash() string {
provs := AllProviders()
ids := make([]string, 0, len(provs))
for _, p := range provs {
ids = append(ids, p.ID())
}
sort.Strings(ids)
sum := sha256.Sum256([]byte(strings.Join(ids, ":")))
return hex.EncodeToString(sum[:])
}
// BumpVersion unconditionally increments the version. Used by the admin
// "re-search missing tags" action to re-attempt every 'none' row next pass.
func (s *SettingsService) BumpVersion(ctx context.Context) (int32, error) {
q := dbq.New(s.pool)
newVer, err := q.BumpTagVersionAndSetProvidersHash(ctx, registeredProvidersHash())
if err != nil {
return 0, fmt.Errorf("bump version: %w", err)
}
s.mu.Lock()
s.currentVersion = newVer
s.mu.Unlock()
return newVer, nil
}
// TestProvider invokes TestConnection if the provider supports it.
func (s *SettingsService) TestProvider(ctx context.Context, providerID string) error {
p, err := ProviderByID(providerID)
if err != nil {
return err
}
tp, ok := p.(TestableProvider)
if !ok {
return ErrNotTestable
}
return tp.TestConnection(ctx)
}
// ListProviderInfo builds the admin-GET view in registration order.
func (s *SettingsService) ListProviderInfo() []ProviderInfo {
s.mu.RLock()
defer s.mu.RUnlock()
var out []ProviderInfo
for i, p := range AllProviders() {
info := ProviderInfo{
ID: p.ID(),
DisplayName: p.DisplayName(),
RequiresAPIKey: p.RequiresAPIKey(),
Enabled: s.enabledIDs[p.ID()],
APIKeySet: s.apiKeys[p.ID()] != "",
DisplayOrder: int32(i),
}
if _, ok := p.(TrackTagProvider); ok {
info.Supports = append(info.Supports, "track_tags")
}
if _, ok := p.(TestableProvider); ok {
info.Testable = true
}
out = append(out, info)
}
return out
}
-59
View File
@@ -1,59 +0,0 @@
package tags
import (
"context"
"log/slog"
"time"
)
// Worker periodically drains tracks needing tag enrichment. Unlike cover
// art (threaded through the file-scan chain because it needs track file
// paths), tag enrichment only needs data already in the DB — recording
// MBID / artist / title — so it runs as a standalone background worker,
// mirroring the ListenBrainz similarity worker. Rate limiting lives in the
// providers' httpClients, so a tick just drains a bounded batch and the
// external APIs pace themselves.
type Worker struct {
enricher *Enricher
logger *slog.Logger
tick time.Duration
batch int
}
// NewWorker constructs a worker with production defaults: an initial drain
// shortly after boot, then every 30 minutes, up to 200 tracks per tick.
// MusicBrainz's 1 req/s ceiling is the real throttle, so the batch size
// mainly bounds how long one tick runs, not the request rate.
func NewWorker(enricher *Enricher, logger *slog.Logger) *Worker {
return &Worker{
enricher: enricher,
logger: logger,
tick: 30 * time.Minute,
batch: 200,
}
}
// Run blocks until ctx is cancelled: an initial drain, then every w.tick.
func (w *Worker) Run(ctx context.Context) {
w.tickOnce(ctx)
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tickOnce(ctx)
}
}
}
// tickOnce drains one bounded batch. EnrichTrackBatch already logs a
// category breakdown, so this only surfaces a fatal batch error.
func (w *Worker) tickOnce(ctx context.Context) {
if _, _, _, err := w.enricher.EnrichTrackBatch(ctx, w.batch, nil); err != nil {
if ctx.Err() == nil {
w.logger.Error("tags: enrichment tick failed", "err", err)
}
}
}
+13 -67
View File
@@ -36,16 +36,6 @@ type Config struct {
TrackLikeBonus float64
TagLikeBonus float64
// EnrichedTagScale weights folksonomy tags (from the track_tags cache,
// #1490) relative to a track's raw ID3 genre when both fold into the tag
// facet. Each enriched tag contributes base × tag.weight × this scale,
// where base is the play's decayed engagement (or the tag-like bonus for
// a liked track) and tag.weight is the normalized folksonomy strength in
// [0,1]. Below 1 so the richer-but-noisier enriched vocabulary augments
// the ID3 genre signal without swamping it. 0 disables the enriched
// contribution entirely (falls back to genre-only).
EnrichedTagScale float64
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
// go. Aggregation already protects an artist the user likes (one skip
// nets out against many good plays); the floor additionally bounds the
@@ -68,20 +58,19 @@ type Config struct {
// DefaultConfig returns the tuned starting configuration.
func DefaultConfig() Config {
return Config{
Engagement: DefaultEngagementParams(),
HalfLifeDays: 75,
WindowDays: 270,
ArtistLikeBonus: 3.0,
TrackLikeBonus: 1.0,
TagLikeBonus: 0.5,
EnrichedTagScale: 0.5,
ArtistFloor: -3.0,
TagFloor: -3.0,
WeightEpsilon: 0.05,
MaxArtists: 500,
MaxNegArtists: 150,
MaxTags: 200,
MaxNegTags: 60,
Engagement: DefaultEngagementParams(),
HalfLifeDays: 75,
WindowDays: 270,
ArtistLikeBonus: 3.0,
TrackLikeBonus: 1.0,
TagLikeBonus: 0.5,
ArtistFloor: -3.0,
TagFloor: -3.0,
WeightEpsilon: 0.05,
MaxArtists: 500,
MaxNegArtists: 150,
MaxTags: 200,
MaxNegTags: 60,
}
}
@@ -132,19 +121,6 @@ func accumulate(
if err != nil {
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
}
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
// "post-punk / shoegaze / melancholic" vocabulary. Window matches the
// play window; a track with no cached tags simply contributes genre only.
playedTagRows, err := q.ListPlayedTrackTagsForUser(ctx, dbq.ListPlayedTrackTagsForUserParams{
UserID: userID,
Column2: int32(cfg.WindowDays),
})
if err != nil {
return nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
}
playedTags := groupTagsByTrack(playedTagRows)
artistW := make(map[pgtype.UUID]float64)
tagW := make(map[string]float64)
for _, p := range plays {
@@ -153,24 +129,17 @@ func accumulate(
for _, tag := range splitGenres(p.Genre) {
tagW[tag] += e
}
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
}
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
}
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
}
likedTags := groupTagsByTrack(likedTagRows)
for _, lt := range likedTracks {
artistW[lt.ArtistID] += cfg.TrackLikeBonus
for _, tag := range splitGenres(lt.Genre) {
tagW[tag] += cfg.TagLikeBonus
}
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
}
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
@@ -183,29 +152,6 @@ func accumulate(
return artistW, tagW, nil
}
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
// each play/like can fold in its track's enriched tags in one lookup.
func groupTagsByTrack(rows []dbq.TrackTag) map[pgtype.UUID][]dbq.TrackTag {
m := make(map[pgtype.UUID][]dbq.TrackTag)
for _, r := range rows {
m[r.TrackID] = append(m[r.TrackID], r)
}
return m
}
// foldEnrichedTags adds each enriched tag to tagW weighted by
// base × tag.weight × scale — base is the play's decayed engagement or the
// tag-like bonus, tag.weight is the folksonomy strength in [0,1]. scale=0
// disables the enriched contribution (genre-only fallback).
func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
if scale == 0 {
return
}
for _, t := range tags {
tagW[t.Tag] += base * t.Weight * scale
}
}
// persist atomic-replaces the user's profile rows inside one transaction.
func persist(
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
-29
View File
@@ -4,39 +4,10 @@ import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func strLess(a, b string) bool { return a < b }
func TestFoldEnrichedTags(t *testing.T) {
tagW := map[string]float64{"rock": 1.0} // pre-existing ID3 genre weight
tags := []dbq.TrackTag{
{Tag: "shoegaze", Weight: 1.0},
{Tag: "melancholic", Weight: 0.5},
{Tag: "rock", Weight: 0.4}, // overlaps genre → accumulates
}
// base=2 (engagement), scale=0.5 → contribution = 2 * weight * 0.5 = weight.
foldEnrichedTags(tagW, tags, 2.0, 0.5)
if got := tagW["shoegaze"]; got != 1.0 {
t.Errorf("shoegaze = %v, want 1.0", got)
}
if got := tagW["melancholic"]; got != 0.5 {
t.Errorf("melancholic = %v, want 0.5", got)
}
if got := tagW["rock"]; got != 1.4 { // 1.0 genre + 2*0.4*0.5
t.Errorf("rock = %v, want 1.4", got)
}
// scale=0 disables the enriched contribution entirely.
base := map[string]float64{"rock": 1.0}
foldEnrichedTags(base, tags, 2.0, 0)
if len(base) != 1 || base["rock"] != 1.0 {
t.Errorf("scale=0 should leave tagW untouched; got %v", base)
}
}
func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) {
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
got := rankWeighted(m, 0.05, 10, 10, strLess)
+61
View File
@@ -0,0 +1,61 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommits"
],
"baseBranches": ["dev"],
"timezone": "America/New_York",
"schedule": ["every weekend"],
"prHourlyLimit": 2,
"prConcurrentLimit": 8,
"ignorePaths": [
"**/node_modules/**",
"**/vendor/**",
"flutter_client/**"
],
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 5am on the first day of the month"]
},
"packageRules": [
{
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
},
{
"description": "Hold all major bumps for manual approval via the dependency dashboard",
"matchUpdateTypes": ["major"],
"automerge": false,
"dependencyDashboardApproval": true,
"addLabels": ["deps", "deps:major"]
},
{
"description": "Group Go module updates into one PR",
"matchManagers": ["gomod"],
"groupName": "go modules"
},
{
"description": "Group CI workflow action bumps",
"matchManagers": ["github-actions"],
"groupName": "ci actions"
},
{
"description": "Group Docker base-image bumps (Dockerfile + compose)",
"matchManagers": ["dockerfile", "docker-compose"],
"groupName": "docker images"
},
{
"description": "Group the Android Gradle/Kotlin toolchain",
"matchManagers": ["gradle", "gradle-wrapper"],
"groupName": "android gradle"
},
{
"description": "Group web npm non-major bumps",
"matchManagers": ["npm"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "web npm (non-major)"
}
]
}
-84
View File
@@ -1,84 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getTagProviders,
updateTagProvider,
testTagProvider,
type TagProvider,
type TagProvidersResponse
} from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
}));
import { api } from './client';
describe('admin tag-sources API', () => {
beforeEach(() => vi.clearAllMocks());
it('getTagProviders GETs the correct path', async () => {
const sample: TagProvidersResponse = {
providers: [
{
id: 'musicbrainz',
display_name: 'MusicBrainz',
requires_api_key: false,
supports: ['track_tags'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
}
],
sources_version: 1
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await getTagProviders();
expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources');
expect(got.providers[0].id).toBe('musicbrainz');
expect(got.sources_version).toBe(1);
});
it('updateTagProvider PATCHes the right path with patch body', async () => {
const patched: TagProvider & { version_bumped: boolean } = {
id: 'lastfm',
display_name: 'Last.fm',
requires_api_key: true,
supports: ['track_tags'],
enabled: true,
api_key_set: true,
display_order: 1,
testable: true,
version_bumped: true
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' });
expect(api.patch).toHaveBeenCalledWith(
'/api/admin/tag-sources/lastfm',
{ enabled: true, api_key: 'mykey' }
);
expect(got.api_key_set).toBe(true);
expect(got.version_bumped).toBe(true);
});
it('testTagProvider POSTs to /test endpoint with empty body', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
duration_ms: 55
});
const got = await testTagProvider('musicbrainz');
expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {});
expect(got.ok).toBe(true);
expect(got.duration_ms).toBe(55);
});
it('testTagProvider returns ok=false with error string on failure', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'lastfm api key not set'
});
const got = await testTagProvider('lastfm');
expect(got.ok).toBe(false);
expect(got.error).toBe('lastfm api key not set');
});
});
-61
View File
@@ -383,67 +383,6 @@ export function createCoverProvidersQuery() {
});
}
// Tag-enrichment providers -------------------------------------------------
// Parallel to the cover-art providers surface, over /api/admin/tag-sources.
// Independent settings so a new folksonomy source is added without touching
// art config (#1490).
export type TagProviderCapability = 'track_tags';
export type TagProvider = {
id: string;
display_name: string;
requires_api_key: boolean;
supports: TagProviderCapability[];
enabled: boolean;
api_key_set: boolean;
display_order: number;
testable: boolean;
};
export type TagProvidersResponse = {
providers: TagProvider[];
sources_version: number;
};
export async function getTagProviders(): Promise<TagProvidersResponse> {
return api.get<TagProvidersResponse>('/api/admin/tag-sources');
}
export type UpdateTagProviderPatch = {
enabled?: boolean;
api_key?: string;
};
export type UpdateTagProviderResponse = TagProvider & {
version_bumped: boolean;
};
export async function updateTagProvider(
id: string,
patch: UpdateTagProviderPatch
): Promise<UpdateTagProviderResponse> {
return api.patch<UpdateTagProviderResponse>(`/api/admin/tag-sources/${id}`, patch);
}
export type TestTagProviderResponse = {
ok: boolean;
duration_ms?: number;
error?: string;
};
export async function testTagProvider(id: string): Promise<TestTagProviderResponse> {
return api.post<TestTagProviderResponse>(`/api/admin/tag-sources/${id}/test`, {});
}
export function createTagProvidersQuery() {
return createQuery({
queryKey: qk.tagProviders(),
queryFn: getTagProviders,
staleTime: 60_000
});
}
// Admin user-management ------------------------------------------------------
export type AdminUser = {
+20 -22
View File
@@ -1,36 +1,18 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
// Mirrors internal/api/me_recommendation_metrics.go: raw play sources are
// bucketed server-side into stable surface families, grouped by intent, and
// anchored by the manual-plays baseline (milestone #127).
export type SurfaceMetric = {
key: string;
label: string;
// Mirrors internal/api/me_recommendation_metrics.go.
export type RecommendationMetric = {
source: string;
plays: number;
skips: number;
skip_rate: number;
avg_completion: number;
low_confidence: boolean;
// Present when the surface's builder stamps pick-kind provenance and
// the window holds attributed plays (#1249, generalized #1270): For
// You's taste/fresh split, Discover's candidate buckets, the tiered
// mixes' tier1-3 — plus pre-attribution plays.
breakdown?: SurfaceMetric[];
};
export type SurfaceIntent = 'go_to' | 'discovery' | 'direct';
export type SurfaceGroup = {
intent: SurfaceIntent;
label: string;
surfaces: SurfaceMetric[];
};
export type RecommendationMetrics = {
window_days: number;
baseline: SurfaceMetric | null;
groups: SurfaceGroup[];
sources: RecommendationMetric[];
};
export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
@@ -46,3 +28,19 @@ export function createRecommendationMetricsQuery() {
staleTime: 60_000
});
}
// Friendly labels for the system-playlist source keys (play_events.source).
const SOURCE_LABELS: Record<string, string> = {
for_you: 'For You',
discover: 'Discover',
deep_cuts: 'Deep cuts',
rediscover: 'Rediscover',
new_for_you: 'New for you',
on_this_day: 'On this day',
first_listens: 'First listens',
songs_like_artist: 'Songs like…'
};
export function sourceLabel(source: string): string {
return SOURCE_LABELS[source] ?? source;
}
-1
View File
@@ -48,7 +48,6 @@ export const qk = {
scanStatus: () => ['scanStatus'] as const,
coverage: () => ['coverage'] as const,
coverProviders: () => ['coverProviders'] as const,
tagProviders: () => ['tagProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
adminInvites: () => ['adminInvites'] as const,
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
-86
View File
@@ -1,86 +0,0 @@
import { api } from './client';
// Mirrors internal/api/admin_recommendation_tuning.go (#1250): the
// recommendation tuning lab. Field names are the server's snake_case
// keys, used verbatim in PATCH bodies.
export type WeightProfile = {
base_weight: number;
like_boost: number;
recency_weight: number;
skip_penalty: number;
jitter_magnitude: number;
context_weight: number;
similarity_weight: number;
taste_weight: number;
};
export type TasteTuning = {
half_life_days: number;
engagement_hard_skip: number;
engagement_neutral: number;
engagement_full: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
export type TuningSnapshot = {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
shipped: {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
};
};
export function getTuning(): Promise<TuningSnapshot> {
return api.get<TuningSnapshot>('/api/admin/recommendation-tuning');
}
export function patchTuning(
scope: TuningScope,
values: Record<string, number>
): Promise<TuningSnapshot> {
return api.patch<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}`, { values });
}
export function resetTuning(scope: TuningScope): Promise<TuningSnapshot> {
return api.post<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}/reset`, {});
}
// Trends (#1251): weekly per-surface outcome series with knob-turn
// markers. Mirrors internal/api/admin_recommendation_trends.go.
export type TrendPoint = {
week_start: string;
plays: number;
skips: number;
skip_rate: number;
avg_completion: number;
taste_hit_rate: number;
};
export type TrendSeries = {
key: string;
label: string;
intent: string;
plays: number;
points: TrendPoint[];
};
export type TrendMarker = {
changed_at: string;
scope: string;
action: string;
changes: { field: string; old: number; new: number }[];
};
export type TrendsResponse = {
weeks: number;
series: TrendSeries[];
markers: TrendMarker[];
};
export function getTrends(weeks = 12): Promise<TrendsResponse> {
return api.get<TrendsResponse>(`/api/admin/recommendation-trends?weeks=${weeks}`);
}
-1
View File
@@ -10,7 +10,6 @@
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' },
{ href: '/admin/users', label: 'Users' }
];
+1 -2
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
);
});
test('renders all eight tabs in order', () => {
test('renders all seven tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -63,7 +63,6 @@ describe('AdminTabs', () => {
'Quarantine',
'Playback errors',
'Diagnostics',
'Tuning',
'Users'
]);
});
+9 -43
View File
@@ -43,11 +43,10 @@
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'discover') ?? null
);
// Songs-like mixes get their own dedicated row (#1491) — no longer
// capped to 3 carousel slots, so take all the server generated.
const songsLikePlaylists = $derived(
(systemPlaylistsQ.data?.owned ?? [])
.filter((p) => p.system_variant === 'songs_like_artist')
.slice(0, 3)
);
// Secondary system kinds the server generates that don't get pinned
@@ -109,7 +108,14 @@
out.push({ kind: 'placeholder', label: 'Discover', variant: placeholderVariant('discover') });
}
// Songs-like moved to its own dedicated row below (#1491).
// Slots 3-5: Songs-like (real first, padded with placeholders).
for (let i = 0; i < 3; i++) {
if (songsLikePlaylists[i]) {
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
} else {
out.push({ kind: 'placeholder', label: 'Songs like…', variant: placeholderVariant('songs-like') });
}
}
// Secondary system kinds in server-registry order, when generated.
for (const p of secondarySystemPlaylists) {
@@ -123,25 +129,6 @@
return out;
});
// Dedicated Songs-like row (#1491): every generated "Songs like {artist}"
// mix — the best-performing surface, promoted out of the Playlists
// carousel to show a wider spread. Shows a few placeholders while the
// mixes haven't generated yet (building / seed-needed).
const SONGS_LIKE_PLACEHOLDER_SLOTS = 3;
const songsLikeRow = $derived.by((): PlaylistRowItem[] => {
if (songsLikePlaylists.length > 0) {
return songsLikePlaylists.map((playlist) => ({ kind: 'real', playlist }) as PlaylistRowItem);
}
return Array.from(
{ length: SONGS_LIKE_PLACEHOLDER_SLOTS },
(): PlaylistRowItem => ({
kind: 'placeholder',
label: 'Songs like…',
variant: placeholderVariant('songs-like')
})
);
});
</script>
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
@@ -169,27 +156,6 @@
</HorizontalScrollRow>
</section>
<!-- Songs like…: the best-performing surface, its own row (#1491).
Depends on the system-playlists query (same as Playlists above), so
it sits outside the home-sections {#if data} block. -->
<section class="space-y-3">
<HorizontalScrollRow
rows={[songsLikeRow]}
title="Songs like…"
ariaLabel="Songs like"
>
{#snippet item(rowItem: PlaylistRowItem)}
<div class="w-56">
{#if rowItem.kind === 'real'}
<PlaylistCard playlist={rowItem.playlist} />
{:else}
<PlaylistPlaceholderCard label={rowItem.label} variant={rowItem.variant} />
{/if}
</div>
{/snippet}
</HorizontalScrollRow>
</section>
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !data}
@@ -13,14 +13,10 @@
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
updateSMTPConfig,
testSMTPConfig,
createSMTPConfigQuery,
type CoverProvider,
type TagProvider,
type SMTPConfig
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
@@ -284,62 +280,6 @@
}
}
// ---- Tag enrichment providers ----
// Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490).
const tagProvidersStore = $derived(createTagProvidersQuery());
const tagProvidersQ = $derived($tagProvidersStore);
let tagLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let tagTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
let tagSaving = $state<Record<string, boolean>>({});
let tagTesting = $state<Record<string, boolean>>({});
$effect(() => {
if (tagProvidersQ.data) {
for (const p of tagProvidersQ.data.providers) {
if (!tagLocalState[p.id]) {
tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
}
}
}
});
function tagHasChanges(provider: TagProvider): boolean {
const local = tagLocalState[provider.id];
if (!local) return false;
return provider.enabled !== local.enabled || local.apiKey !== '';
}
async function tagSave(provider: TagProvider) {
const local = tagLocalState[provider.id];
if (!local || !tagHasChanges(provider)) return;
const patch: { enabled?: boolean; api_key?: string } = {};
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
if (local.apiKey !== '') patch.api_key = local.apiKey;
tagSaving[provider.id] = true;
try {
const result = await updateTagProvider(provider.id, patch);
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
await client.invalidateQueries({ queryKey: qk.tagProviders() });
} catch (_e) {
// Inline error surfacing handled by the mutation toast layer; swallow here.
} finally {
tagSaving[provider.id] = false;
}
}
async function tagTest(provider: TagProvider) {
tagTesting[provider.id] = true;
try {
tagTestResults[provider.id] = await testTagProvider(provider.id);
} catch (e) {
tagTestResults[provider.id] = { ok: false, error: (e as Error).message };
} finally {
tagTesting[provider.id] = false;
}
}
// SMTP config -------------------------------------------------------------
const smtpStore = $derived(createSMTPConfigQuery());
@@ -657,108 +597,6 @@
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- Tag enrichment providers -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Tag enrichment sources</h3>
<p class="mt-1 text-sm text-text-secondary">
Folksonomy style/mood tags fetched per track and folded into each
listener's taste profile alongside the raw file genre — so "Rock"
gains "post-punk / shoegaze / melancholic". All enabled sources are
merged. Enabling or disabling a source re-opens previously-processed
tracks for a fresh pass.
</p>
</div>
{#if tagProvidersQ.data}
{#each tagProvidersQ.data.providers as provider (provider.id)}
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
<header class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
<span
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
title={provider.enabled ? 'Active' : 'Disabled'}
></span>
</div>
<div class="flex flex-wrap gap-1">
{#each provider.supports as cap}
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
{/each}
</div>
</header>
{#if tagLocalState[provider.id]}
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="tag-enabled-{provider.id}">Enabled</label>
<div>
<input id="tag-enabled-{provider.id}" type="checkbox"
bind:checked={tagLocalState[provider.id].enabled} />
</div>
{#if provider.requires_api_key}
<label class="text-sm text-text-secondary" for="tag-key-{provider.id}">API key</label>
<div class="flex items-center gap-2">
<input id="tag-key-{provider.id}" type="password"
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Paste your API key to enable this source'}
bind:value={tagLocalState[provider.id].apiKey}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent flex-1" />
{#if provider.api_key_set}
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 pt-1">
<button type="button"
disabled={!tagHasChanges(provider) || tagSaving[provider.id]}
onclick={() => tagSave(provider)}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{tagSaving[provider.id] ? 'Saving…' : 'Save changes'}
</button>
{#if provider.testable}
<button type="button"
disabled={tagTesting[provider.id]}
onclick={() => tagTest(provider)}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{tagTesting[provider.id] ? 'Testing…' : 'Test connection'}
</button>
{/if}
{#if tagTestResults[provider.id]}
{#if tagTestResults[provider.id].ok}
<p class="text-sm text-action-primary">
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error">
Failed — {tagTestResults[provider.id].error}
</p>
{/if}
{/if}
</div>
{/if}
{#if provider.id === 'musicbrainz'}
<p class="text-xs text-text-muted">
Keyless and on by default — the always-available baseline. Matches tags by
recording MBID, so coverage tracks how well your library is MusicBrainz-tagged.
</p>
{:else if provider.id === 'lastfm'}
<p class="text-xs text-text-muted">
Name-based (artist + title), so it covers tracks without an MBID. Requires a free
API key — get one at <a class="underline" href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer">last.fm/api</a>.
</p>
{/if}
</article>
{/each}
{:else if tagProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- SMTP / email config -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '$lib/api/types';
import type { CoverProvider, TagProvider, SMTPConfig } from '$lib/api/admin';
import type { CoverProvider, SMTPConfig } from '$lib/api/admin';
// useQueryClient is wrapped so the page can call invalidateQueries() without
// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default
@@ -23,9 +23,6 @@ vi.mock('$lib/api/admin', () => ({
createCoverProvidersQuery: vi.fn(),
updateCoverProvider: vi.fn(),
testCoverProvider: vi.fn(),
createTagProvidersQuery: vi.fn(),
updateTagProvider: vi.fn(),
testTagProvider: vi.fn(),
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn()
@@ -42,9 +39,6 @@ import {
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
createSMTPConfigQuery,
updateSMTPConfig,
testSMTPConfig
@@ -101,34 +95,6 @@ const defaultCoverProviders = {
sources_version: 1
};
// Fixture tag providers
const providerMusicbrainz: TagProvider = {
id: 'musicbrainz',
display_name: 'MusicBrainz',
requires_api_key: false,
supports: ['track_tags'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
};
const providerLastfm: TagProvider = {
id: 'lastfm',
display_name: 'Last.fm',
requires_api_key: true,
supports: ['track_tags'],
enabled: false,
api_key_set: false,
display_order: 1,
testable: true
};
const defaultTagProviders = {
providers: [providerMusicbrainz, providerLastfm],
sources_version: 1
};
const testResponseOk = {
ok: true as const,
version: '2.0.5',
@@ -167,7 +133,7 @@ afterEach(() => {
invalidateQueries.mockReset();
});
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) {
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
@@ -194,10 +160,6 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
);
const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders);
(createTagProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: tpData, isPending: opts.tagProviders === null })
);
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.smtp ?? smtpUnset })
);
@@ -233,16 +195,6 @@ function providerCard(providerName: string): HTMLElement {
.closest('article') as HTMLElement;
}
function tagProvidersSection(): HTMLElement {
return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement;
}
function tagProviderCard(providerName: string): HTMLElement {
return within(tagProvidersSection())
.getByRole('heading', { name: providerName, level: 4 })
.closest('article') as HTMLElement;
}
describe('/admin/integrations', () => {
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
setup();
@@ -689,48 +641,3 @@ describe('Lidarr first-time setup (Test-then-Save)', () => {
});
});
});
describe('/admin/integrations tag sources', () => {
test('renders tag enrichment providers', () => {
setup();
const section = tagProvidersSection();
expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument();
expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument();
expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1);
});
test('enabling Last.fm and typing a key calls updateTagProvider', async () => {
setup();
(updateTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerLastfm,
enabled: true,
api_key_set: true,
version_bumped: true
});
const card = tagProviderCard('Last.fm');
const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const keyInput = within(card).getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'lfmkey' } });
await fireEvent.click(within(card).getByRole('button', { name: /save changes/i }));
expect(updateTagProvider).toHaveBeenCalledWith(
'lastfm',
expect.objectContaining({ enabled: true, api_key: 'lfmkey' })
);
await waitFor(() =>
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['tagProviders'] })
)
);
});
test('Test connection on a tag provider calls testTagProvider and shows OK', async () => {
setup();
(testTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
const card = tagProviderCard('MusicBrainz');
await fireEvent.click(within(card).getByRole('button', { name: /test connection/i }));
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument());
});
});
-434
View File
@@ -1,434 +0,0 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import {
getTuning,
patchTuning,
resetTuning,
getTrends,
type TuningScope,
type TuningSnapshot,
type WeightProfile,
type TasteTuning,
type TrendsResponse,
type TrendSeries,
type TrendMarker
} from '$lib/api/tuning';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// The defaults-discovery lab (#1250): DB-backed scoring-weight
// profiles + taste-build knobs with live effect. This card exists to
// FIND good values — found-good values get baked into shipped
// defaults, so end users and other operators never need it.
const weightFields: { key: keyof WeightProfile; label: string; hint: string }[] = [
{ key: 'base_weight', label: 'Base weight', hint: 'Floor score every candidate starts from.' },
{ key: 'like_boost', label: 'Like boost', hint: 'Added when the track is liked.' },
{ key: 'recency_weight', label: 'Recency weight', hint: 'Rewards tracks not played recently (030d ramp).' },
{ key: 'skip_penalty', label: 'Skip penalty', hint: 'Subtracts skips/plays ratio.' },
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' },
{ key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' },
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' },
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }
];
const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [
{ key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." },
{ key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads 1.' },
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }
];
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
];
let snapshot = $state<TuningSnapshot | null>(null);
let loadFailed = $state(false);
// Editable copies, string-typed for the inputs; parsed on save.
let form = $state<Record<string, Record<string, string>>>({});
let saving = $state<TuningScope | null>(null);
function fillForm(snap: TuningSnapshot) {
const f: Record<string, Record<string, string>> = { radio: {}, daily_mix: {}, taste: {} };
for (const p of ['radio', 'daily_mix'] as const) {
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
}
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
form = f;
}
$effect(() => {
getTuning()
.then((snap) => {
fillForm(snap);
snapshot = snap;
})
.catch(() => {
loadFailed = true;
});
});
// A knob deviates when its CURRENT SAVED value differs from shipped;
// the dot marks where this install has drifted from defaults.
function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean {
if (!snapshot) return false;
if (scope === 'taste') {
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning];
}
return (
snapshot.profiles[scope][key as keyof WeightProfile] !==
snapshot.shipped.profiles[scope][key as keyof WeightProfile]
);
}
function currentValue(scope: TuningScope, key: string): number {
if (!snapshot) return 0;
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning];
return snapshot.profiles[scope][key as keyof WeightProfile];
}
async function save(scope: TuningScope) {
if (!snapshot) return;
const values: Record<string, number> = {};
for (const [key, raw] of Object.entries(form[scope] ?? {})) {
const v = Number(raw);
if (!Number.isFinite(v)) {
pushToast(`${key} is not a number.`, 'error');
return;
}
if (v !== currentValue(scope, key)) values[key] = v;
}
if (Object.keys(values).length === 0) {
pushToast('Nothing changed.');
return;
}
saving = scope;
try {
snapshot = await patchTuning(scope, values);
fillForm(snapshot);
pushToast('Saved — takes effect on the next scoring pass.');
} catch (e: unknown) {
pushToast(`Save failed: ${errMessage(e)}`, 'error');
} finally {
saving = null;
}
}
async function reset(scope: TuningScope) {
saving = scope;
try {
snapshot = await resetTuning(scope);
fillForm(snapshot);
pushToast('Reset to shipped defaults.');
} catch (e: unknown) {
pushToast(`Reset failed: ${errMessage(e)}`, 'error');
} finally {
saving = null;
}
}
// Trends (#1251): weekly skip-rate sparklines per surface with
// knob-turn markers — the verify half of the tune→verify loop.
let trends = $state<TrendsResponse | null>(null);
let trendsFailed = $state(false);
$effect(() => {
getTrends()
.then((t) => {
trends = t;
})
.catch(() => {
trendsFailed = true;
});
});
const SPARK_W = 220;
const SPARK_H = 36;
const TREND_LOW_VOLUME = 20;
// The week axis is the sorted union of every series' buckets, so all
// sparklines and markers share one x scale.
const weekAxis = $derived.by(() => {
if (!trends) return [] as string[];
const set = new Set<string>();
for (const s of trends.series) for (const p of s.points) set.add(p.week_start);
return [...set].sort();
});
function xFor(week: string): number {
const i = weekAxis.indexOf(week);
if (i < 0 || weekAxis.length < 2) return 0;
return (i / (weekAxis.length - 1)) * SPARK_W;
}
// Polyline of the series' weekly skip rate on a fixed [0,1] y-scale
// (higher = worse, drawn upward) so rows are visually comparable.
function sparkPoints(s: TrendSeries): string {
return s.points
.map((p) => `${xFor(p.week_start).toFixed(1)},${(SPARK_H - p.skip_rate * SPARK_H).toFixed(1)}`)
.join(' ');
}
// A marker lands on the latest axis week that starts at/before it.
function markerX(m: TrendMarker): number {
const day = m.changed_at.slice(0, 10);
let idx = -1;
for (let i = 0; i < weekAxis.length; i++) {
if (weekAxis[i] <= day) idx = i;
}
if (idx < 0 || weekAxis.length < 2) return 0;
return (idx / (weekAxis.length - 1)) * SPARK_W;
}
function windowTasteHitRate(s: TrendSeries): number {
let plays = 0;
let hits = 0;
for (const p of s.points) {
plays += p.plays;
hits += p.taste_hit_rate * p.plays;
}
return plays > 0 ? hits / plays : 0;
}
function latest(s: TrendSeries): { skip: number; completion: number } {
const last = s.points[s.points.length - 1];
return last ? { skip: last.skip_rate, completion: last.avg_completion } : { skip: 0, completion: 0 };
}
function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`;
}
function markerSummary(m: TrendMarker): string {
const when = m.changed_at.slice(0, 10);
if (m.action === 'reset') return `${when}${m.scope} reset to defaults`;
const fields = (m.changes ?? []).map((c) => `${c.field} ${c.old}${c.new}`).join(', ');
return `${when}${m.scope}: ${fields}`;
}
</script>
<svelte:head>
<title>{pageTitle('Tuning')}</title>
</svelte:head>
<div class="space-y-6 p-4">
<div>
<h1 class="text-xl font-semibold">Recommendation tuning</h1>
<p class="mt-1 max-w-3xl text-sm text-text-secondary">
The defaults-discovery lab. Changes apply live — radio on the next request, daily mixes on
the next rebuild — and every change is recorded so the metrics page can tie outcome shifts
to knob turns. Found-good values get baked into shipped defaults; a dot marks knobs that
currently deviate from them.
</p>
</div>
{#if loadFailed}
<p class="text-sm text-text-secondary">Couldn't load tuning settings.</p>
{:else if !snapshot}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
<section class="grid gap-4 lg:grid-cols-2">
{#each profileScopes as p (p.scope)}
<div class="space-y-3 rounded border border-border bg-surface p-4">
<div>
<h2 class="text-lg font-semibold">{p.label}</h2>
<p class="text-xs text-text-secondary">{p.blurb}</p>
</div>
<div class="space-y-2">
{#each weightFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="{p.scope}-{f.key}" title={f.hint}>
{f.label}
{#if deviates(p.scope, f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.profiles[p.scope][f.key]})"
></span>
{/if}
</label>
<input
id="{p.scope}-{f.key}"
type="number"
step="0.1"
bind:value={form[p.scope][f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save(p.scope)}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save {p.label.toLowerCase()}
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset(p.scope)}
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</div>
{/each}
</section>
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
<div>
<h2 class="text-lg font-semibold">Taste profile build</h2>
<p class="text-xs text-text-secondary">
How plays become the learned taste profile: the influence half-life and the
completion→engagement curve (must stay ordered: hard-skip &lt; neutral &lt; full).
</p>
</div>
<div class="space-y-2">
{#each tasteFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="taste-{f.key}" title={f.hint}>
{f.label}
{#if deviates('taste', f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.taste[f.key]})"
></span>
{/if}
</label>
<input
id="taste-{f.key}"
type="number"
step={f.key === 'half_life_days' ? '1' : '0.05'}
bind:value={form.taste[f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save('taste')}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save taste
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset('taste')}
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</section>
{/if}
<!-- Weekly trends (#1251): the verify half. Sparklines share one
week axis; dashed ticks mark knob turns so cause→effect reads
off the chart. -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<div>
<h2 class="text-lg font-semibold">Weekly trends</h2>
<p class="text-xs text-text-secondary">
Skip rate per surface over the last {trends?.weeks ?? 12} weeks (lower is better; all
users aggregated, rates only). Dashed ticks mark tuning changes. Taste hit is the share
of plays whose artist fits the current taste profile.
</p>
</div>
{#if trendsFailed}
<p class="text-sm text-text-secondary">Couldn't load trends.</p>
{:else if !trends}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if trends.series.length === 0}
<p class="text-sm text-text-secondary">
No plays recorded yet — trends appear once listening accumulates.
</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</th>
<th class="py-1 font-medium">Skip rate by week</th>
<th class="py-1 text-right font-medium">Plays</th>
<th class="py-1 text-right font-medium">Latest skip</th>
<th class="py-1 text-right font-medium">Latest completion</th>
<th class="py-1 text-right font-medium">Taste hit</th>
</tr>
</thead>
<tbody>
{#each trends.series as s (s.key)}
<tr
class="border-t border-border"
class:opacity-60={s.plays < TREND_LOW_VOLUME}
title={s.plays < TREND_LOW_VOLUME
? 'Fewer than 20 plays in the window — treat as anecdote, not signal.'
: undefined}
>
<td class="py-1.5 pr-2">{s.label}</td>
<td class="py-1.5">
<svg
width={SPARK_W}
height={SPARK_H}
viewBox="0 0 {SPARK_W} {SPARK_H}"
role="img"
aria-label="{s.label} weekly skip rate"
data-testid="sparkline-{s.key}"
>
<line x1="0" y1={SPARK_H - 0.5} x2={SPARK_W} y2={SPARK_H - 0.5}
stroke="currentColor" opacity="0.15" />
{#each trends.markers as m, i (i)}
<line
x1={markerX(m)} y1="0" x2={markerX(m)} y2={SPARK_H}
stroke="currentColor" opacity="0.35" stroke-dasharray="2,2"
>
<title>{markerSummary(m)}</title>
</line>
{/each}
{#if s.points.length > 1}
<polyline
class="text-accent"
points={sparkPoints(s)}
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
{:else if s.points.length === 1}
<circle
class="text-accent"
cx={xFor(s.points[0].week_start)}
cy={SPARK_H - s.points[0].skip_rate * SPARK_H}
r="2" fill="currentColor"
/>
{/if}
</svg>
</td>
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).skip)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).completion)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(windowTasteHitRate(s))}</td>
</tr>
{/each}
</tbody>
</table>
{#if trends.markers.length > 0}
<div class="space-y-1">
<h3 class="text-sm font-medium">Tuning changes in this window</h3>
<ul class="space-y-0.5 text-xs text-text-secondary">
{#each trends.markers as m, i (i)}
<li>{markerSummary(m)}</li>
{/each}
</ul>
</div>
{/if}
{/if}
</section>
</div>
-197
View File
@@ -1,197 +0,0 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { TuningSnapshot, TrendsResponse } from '$lib/api/tuning';
vi.mock('$lib/api/tuning', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
getTuning: vi.fn(),
patchTuning: vi.fn(),
resetTuning: vi.fn(),
getTrends: vi.fn()
};
});
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
import TuningPage from './+page.svelte';
import { getTuning, patchTuning, resetTuning, getTrends } from '$lib/api/tuning';
const weights = (over: Partial<Record<string, number>> = {}) => ({
base_weight: 1,
like_boost: 2,
recency_weight: 1,
skip_penalty: 2,
jitter_magnitude: 0.1,
context_weight: 0.5,
similarity_weight: 1.5,
taste_weight: 1.5,
...over
});
const taste = (over: Partial<Record<string, number>> = {}) => ({
half_life_days: 75,
engagement_hard_skip: 0.05,
engagement_neutral: 0.3,
engagement_full: 0.9,
...over
});
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(),
shipped: {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste()
},
...over
} as TuningSnapshot;
}
const emptyTrends: TrendsResponse = { weeks: 12, series: [], markers: [] };
beforeEach(() => {
vi.clearAllMocks();
(getTrends as ReturnType<typeof vi.fn>).mockResolvedValue(emptyTrends);
});
describe('Admin tuning page', () => {
test('renders both profiles and the taste card with current values', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.getByText('Daily mixes')).toBeInTheDocument();
expect(screen.getByText('Taste profile build')).toBeInTheDocument();
const radioTaste = screen.getByLabelText(/taste weight/i, {
selector: '#radio-taste_weight'
}) as HTMLInputElement;
expect(radioTaste.value).toBe('1');
const halfLife = document.getElementById('taste-half_life_days') as HTMLInputElement;
expect(halfLife.value).toBe('75');
});
test('save sends only the changed fields for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
const input = document.getElementById('radio-taste_weight') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '2.5' } });
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('radio', { taste_weight: 2.5 })
);
});
test('save with no changes calls nothing and says so', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
expect(patchTuning).not.toHaveBeenCalled();
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
});
test('reset calls resetTuning for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
await fireEvent.click(resetButtons[resetButtons.length - 1]);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
});
test('marks knobs that deviate from shipped defaults', async () => {
const snap = snapshot();
snap.profiles.daily_mix = weights({ similarity_weight: 4 });
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Daily mixes')).toBeInTheDocument());
expect(
screen.getByTitle(/deviates from the shipped default \(1\.5\)/i)
).toBeInTheDocument();
});
test('renders weekly trend rows with sparklines and knob-turn markers (#1251)', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(getTrends as ReturnType<typeof vi.fn>).mockResolvedValue({
weeks: 12,
series: [
{
key: 'radio',
label: 'Radio',
intent: 'go_to',
plays: 40,
points: [
{
week_start: '2026-06-22',
plays: 25,
skips: 5,
skip_rate: 0.2,
avg_completion: 0.8,
taste_hit_rate: 0.6
},
{
week_start: '2026-06-29',
plays: 15,
skips: 6,
skip_rate: 0.4,
avg_completion: 0.7,
taste_hit_rate: 0.5
}
]
},
{
key: 'discover',
label: 'Discover',
intent: 'discovery',
plays: 5,
points: [
{
week_start: '2026-06-29',
plays: 5,
skips: 3,
skip_rate: 0.6,
avg_completion: 0.4,
taste_hit_rate: 0.2
}
]
}
],
markers: [
{
changed_at: '2026-06-30T10:00:00Z',
scope: 'radio',
action: 'update',
changes: [{ field: 'taste_weight', old: 1, new: 2 }]
}
]
} satisfies TrendsResponse);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Weekly trends')).toBeInTheDocument());
expect(screen.getByTestId('sparkline-radio')).toBeInTheDocument();
expect(screen.getByTestId('sparkline-discover')).toBeInTheDocument();
// Latest skip rate column for radio = 40% (also discover's latest
// completion, hence getAllBy).
expect(screen.getAllByText('40%').length).toBeGreaterThan(0);
// The knob turn is listed under the chart AND tooltipped on each
// sparkline's marker tick, hence getAllBy.
expect(
screen.getAllByText(/2026-06-30 — radio: taste_weight 1→2/).length
).toBeGreaterThan(0);
// Low-volume series (5 plays) is dimmed as anecdote.
expect(screen.getByTitle(/fewer than 20 plays in the window/i)).toBeInTheDocument();
});
test('empty trends show the accumulation hint', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() =>
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
);
});
});
+5 -46
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, within } from '@testing-library/svelte';
import { render, screen } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { emptyLikesMock } from '../test-utils/mocks/likes';
import type { Playlist } from '$lib/api/types';
@@ -57,8 +57,7 @@ afterEach(() => vi.clearAllMocks());
describe('home Playlists section', () => {
test('renders 5 placeholder cards when no playlists exist', () => {
// For-You + Discover in the Playlists row, plus 3 in the dedicated
// Songs-like row (#1491) = 5 placeholders total.
// Slot layout: For-You, Discover, 3× Songs-like.
render(Page);
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
expect(placeholders).toHaveLength(5);
@@ -108,52 +107,12 @@ describe('home Playlists section', () => {
render(Page);
expect(screen.getByText('For You')).toBeInTheDocument();
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
// 1 Discover placeholder (Playlists row) + 3 Songs-like placeholders
// (dedicated row) = 4 alongside the real For-You tile.
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
// real For-You tile.
expect(placeholders).toHaveLength(4);
});
test('dedicated Songs-like row shows every generated mix, uncapped and out of Playlists', () => {
const makeSongsLike = (id: string, name: string): Playlist => ({
id,
user_id: 'u1',
owner_username: 'u1',
name,
description: '',
is_public: false,
kind: 'system',
system_variant: 'songs_like_artist',
refreshable: false,
seed_artist_id: 'a1',
cover_url: '',
track_count: 25,
duration_sec: 1500,
created_at: '2026-05-04T00:00:00Z',
updated_at: '2026-05-04T00:00:00Z'
});
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
const owned = Array.from({ length: 6 }, (_, i) => makeSongsLike(`sl${i}`, `Songs like ${i}`));
(createPlaylistsQuery as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(kind?: string) =>
kind === 'system'
? readable({ data: { owned, public: [] }, isPending: false, isError: false })
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
);
const { container } = render(Page);
for (let i = 0; i < 6; i++) {
expect(screen.getByText(`Songs like ${i}`)).toBeInTheDocument();
}
// The mixes live in the Songs-like row, not the Playlists carousel.
const playlistsSection = container.querySelector('[aria-label="Playlists"]') as HTMLElement;
expect(within(playlistsSection).queryByText('Songs like 0')).toBeNull();
const songsSection = container.querySelector('[aria-label="Songs like"]') as HTMLElement;
expect(within(songsSection).getByText('Songs like 0')).toBeInTheDocument();
// No placeholders once real mixes exist.
expect(within(songsSection).queryByTestId('playlist-placeholder-card')).toBeNull();
});
test('renders secondary system kinds (deep_cuts / new_for_you) in the Playlists row', () => {
test('renders secondary system kinds (deep_cuts / new_for_you) after Songs-like slots', () => {
// Operator backflow 2026-06-01: web Home surfaces the 5 secondary
// system kinds when generated. No placeholders for them — they
// depend on library shape, so missing means "not enough data."

Some files were not shown because too many files have changed in this diff Show More