Compare commits

..
76 Commits
Author SHA1 Message Date
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
125 changed files with 871 additions and 9331 deletions
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.Invite import com.fabledsword.minstrel.models.Invite
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -27,7 +25,6 @@ data class AdminInvitesUiState(
@HiltViewModel @HiltViewModel
class AdminInvitesViewModel @Inject constructor( class AdminInvitesViewModel @Inject constructor(
private val repository: AdminInvitesRepository, private val repository: AdminInvitesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow(AdminInvitesUiState()) private val internal = MutableStateFlow(AdminInvitesUiState())
@@ -39,13 +36,6 @@ class AdminInvitesViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value.message != null) refresh()
}
}
} }
fun refresh() { fun refresh() {
@@ -29,7 +29,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -63,10 +62,9 @@ fun AdminQuarantineScreen(
body = "When users flag tracks as bad rips, wrong tags, or " + body = "When users flag tracks as bad rips, wrong tags, or " +
"duplicates, their reports get aggregated and surfaced here.", "duplicates, their reports get aggregated and surfaced here.",
) )
is AdminQuarantineUiState.Error -> ErrorRetry( is AdminQuarantineUiState.Error -> EmptyState(
title = "Couldn't load queue", title = "Couldn't load queue",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is AdminQuarantineUiState.Success -> QueueList( is AdminQuarantineUiState.Success -> QueueList(
rows = s.rows, rows = s.rows,
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -28,7 +26,6 @@ sealed interface AdminQuarantineUiState {
class AdminQuarantineViewModel @Inject constructor( class AdminQuarantineViewModel @Inject constructor(
private val repository: AdminQuarantineRepository, private val repository: AdminQuarantineRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading) private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
@@ -41,13 +38,6 @@ class AdminQuarantineViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") } .filter { it.kind.startsWith("quarantine.") }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminQuarantineUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -28,7 +28,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -62,10 +61,9 @@ fun AdminRequestsScreen(
body = "When users ask Lidarr for new music, their pending " + body = "When users ask Lidarr for new music, their pending " +
"requests show up here for approval.", "requests show up here for approval.",
) )
is AdminRequestsUiState.Error -> ErrorRetry( is AdminRequestsUiState.Error -> EmptyState(
title = "Couldn't load requests", title = "Couldn't load requests",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is AdminRequestsUiState.Success -> RequestList( is AdminRequestsUiState.Success -> RequestList(
rows = s.rows, rows = s.rows,
@@ -5,8 +5,6 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -37,7 +35,6 @@ class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository, private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository, private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading) private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
@@ -50,13 +47,6 @@ class AdminRequestsViewModel @Inject constructor(
.filter { it.kind in RELEVANT_EVENT_KINDS } .filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminRequestsUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.AdminUserRef import com.fabledsword.minstrel.models.AdminUserRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -25,7 +23,6 @@ sealed interface AdminUsersUiState {
@HiltViewModel @HiltViewModel
class AdminUsersViewModel @Inject constructor( class AdminUsersViewModel @Inject constructor(
private val repository: AdminUsersRepository, private val repository: AdminUsersRepository,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading) private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
@@ -33,13 +30,6 @@ class AdminUsersViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminUsersUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -1,6 +1,5 @@
package com.fabledsword.minstrel.cache.sync package com.fabledsword.minstrel.cache.sync
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.api.endpoints.SyncApi import com.fabledsword.minstrel.api.endpoints.SyncApi
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
@@ -18,9 +17,6 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
import com.fabledsword.minstrel.models.wire.SyncArtistWire import com.fabledsword.minstrel.models.wire.SyncArtistWire
import com.fabledsword.minstrel.models.wire.SyncTrackWire import com.fabledsword.minstrel.models.wire.SyncTrackWire
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@@ -68,17 +64,6 @@ class SyncController @Inject constructor(
// pull-to-refresh) can fire concurrently; coalesce them into one pass. // pull-to-refresh) can fire concurrently; coalesce them into one pass.
private val mutex = Mutex() private val mutex = Mutex()
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
/**
* Human copy for the most recent sync failure; null after any clean
* pass. Lets the Library screen distinguish "empty because the first
* sync failed" (show error + retry) from "genuinely empty library"
* (show welcome copy) — a failed sync over a populated cache stays
* silent, since the cached content is still the better surface.
*/
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
init { init {
scope.launch { scope.launch {
authStore.sessionCookie authStore.sessionCookie
@@ -97,13 +82,11 @@ class SyncController @Inject constructor(
} }
} }
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */ /** Public entry-point for "Sync now" affordances. Swallows errors. */
suspend fun syncSafe() { suspend fun syncSafe() {
if (!mutex.tryLock()) return if (!mutex.tryLock()) return
try { try {
runCatching { sync() } runCatching { sync() }
.onSuccess { lastSyncErrorInternal.value = null }
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
} finally { } finally {
mutex.unlock() mutex.unlock()
} }
@@ -1,27 +0,0 @@
package com.fabledsword.minstrel.connectivity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
/**
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
* after the collector subscribed — the StateFlow's replayed current value
* is dropped, so only genuine down→up transitions fire (a screen that
* subscribes while already Healthy doesn't double-load).
*
* This is the screen-level half of the recovery idiom the app-lifetime
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
* already use: a ViewModel collects this in its viewModelScope and re-runs
* its load, so a surface that failed while the server was unreachable
* heals itself the moment connectivity returns instead of sitting in the
* failed state until a manual pull-to-refresh.
*/
fun NetworkStatusController.recoveries(): Flow<Unit> = state
.map { it == ServerHealth.Healthy }
.distinctUntilChanged()
.drop(1)
.filter { it }
.map { }
@@ -41,7 +41,6 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -110,7 +109,6 @@ private fun DiscoverBody(
snackbar.showSnackbar(snackbarFor(outcome, s.name)) snackbar.showSnackbar(snackbarFor(outcome, s.name))
} }
}, },
onRetry = { viewModel.loadSuggestions() },
) )
ResultsState.Loading -> LoadingCentered() ResultsState.Loading -> LoadingCentered()
is ResultsState.Loaded -> ResultsList( is ResultsState.Loaded -> ResultsList(
@@ -123,11 +121,7 @@ private fun DiscoverBody(
} }
}, },
) )
is ResultsState.Error -> ErrorRetry( is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
title = "Search failed",
message = r.message,
onRetry = viewModel::runSearch,
)
} }
} }
} }
@@ -183,15 +177,10 @@ private fun SuggestionsPane(
state: SuggestionState, state: SuggestionState,
locallyRequestedMbids: Set<String>, locallyRequestedMbids: Set<String>,
onRequest: (ArtistSuggestionRef) -> Unit, onRequest: (ArtistSuggestionRef) -> Unit,
onRetry: () -> Unit,
) { ) {
when (state) { when (state) {
SuggestionState.Loading -> LoadingCentered() SuggestionState.Loading -> LoadingCentered()
is SuggestionState.Error -> ErrorRetry( is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
title = "Couldn't load suggestions",
message = state.message,
onRetry = onRetry,
)
is SuggestionState.Loaded -> SuggestionsList( is SuggestionState.Loaded -> SuggestionsList(
items = state.items.filter { it.mbid !in locallyRequestedMbids }, items = state.items.filter { it.mbid !in locallyRequestedMbids },
onRequest = onRequest, onRequest = onRequest,
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.discover.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.discover.data.DiscoverRepository import com.fabledsword.minstrel.discover.data.DiscoverRepository
import com.fabledsword.minstrel.discover.data.RequestOutcome import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef import com.fabledsword.minstrel.models.ArtistSuggestionRef
@@ -48,7 +46,6 @@ sealed interface ResultsState {
@HiltViewModel @HiltViewModel
class DiscoverViewModel @Inject constructor( class DiscoverViewModel @Inject constructor(
private val repository: DiscoverRepository, private val repository: DiscoverRepository,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow(DiscoverState()) private val internal = MutableStateFlow(DiscoverState())
@@ -56,15 +53,6 @@ class DiscoverViewModel @Inject constructor(
init { init {
loadSuggestions() loadSuggestions()
// Screen-level auto-recovery (issue #1245): re-run whichever pane
// failed while the server was unreachable once health returns.
viewModelScope.launch {
networkStatus.recoveries().collect {
val s = internal.value
if (s.suggestions is SuggestionState.Error) loadSuggestions()
if (s.results is ResultsState.Error) runSearch()
}
}
} }
fun setQuery(value: String) { fun setQuery(value: String) {
@@ -19,8 +19,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.history.data.HistoryEntry import com.fabledsword.minstrel.history.data.HistoryEntry
import com.fabledsword.minstrel.history.data.HistoryRepository import com.fabledsword.minstrel.history.data.HistoryRepository
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
@@ -28,7 +26,6 @@ import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -59,7 +56,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
class HistoryTabViewModel @Inject constructor( class HistoryTabViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val player: PlayerController, private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val refreshError = MutableStateFlow<String?>(null) private val refreshError = MutableStateFlow<String?>(null)
@@ -86,11 +82,6 @@ class HistoryTabViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -125,10 +116,9 @@ fun HistoryTab(
title = "No listening history yet", title = "No listening history yet",
body = "Play something — your recent plays will show up here.", body = "Play something — your recent plays will show up here.",
) )
is UiState.Error -> ErrorRetry( is UiState.Error -> EmptyState(
title = "Couldn't load history", title = "Couldn't load history",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> HistoryList( is UiState.Success -> HistoryList(
entries = s.data, entries = s.data,
@@ -2,19 +2,11 @@
package com.fabledsword.minstrel.home.ui package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -64,7 +55,6 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
@@ -87,7 +77,6 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -97,7 +86,6 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -120,16 +108,6 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_GRID_ROWS = 2 private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440 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 ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
data class HomeSections( data class HomeSections(
@@ -189,44 +167,16 @@ class HomeViewModel @Inject constructor(
/** Transient snackbar messages from offline-pool taps. */ /** Transient snackbar messages from offline-pool taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow() val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/**
* Copy for the most recent /home/index refresh failure; null once a
* refresh succeeds. Folded into [uiState] so an empty cache + failed
* refresh renders as Error (with retry) instead of masquerading as
* the "Welcome to Minstrel" empty state.
*/
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 { init {
refresh() 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() }
}
// #968: the daily 03:00 rebuild (and manual refresh) emit // #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles // playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual // and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer. Veiled so the multi-section // reload. Mirrors the web SSE consumer.
// rebuild churn hides behind "Updating your mixes…".
viewModelScope.launch { viewModelScope.launch {
eventsStream.events eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" } .filter { it.kind == "playlist.system_rebuilt" }
.collect { refreshBehindVeil() } .collect { refresh() }
} }
} }
@@ -339,14 +289,7 @@ class HomeViewModel @Inject constructor(
* actual completion before hiding the indicator. * actual completion before hiding the indicator.
*/ */
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
refreshError.value = null val home = launch { runCatching { homeRepository.refreshIndex() } }
val home = launch {
// /home/index is the load-bearing pull: its failure drives the
// empty-cache Error state. A failure over a populated cache
// stays silent — cached sections beat a full-screen error.
runCatching { homeRepository.refreshIndex() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
}
val lists = launch { runCatching { playlistsRepository.refreshList() } } val lists = launch { runCatching { playlistsRepository.refreshList() } }
val status = launch { val status = launch {
runCatching { homeRepository.getSystemPlaylistsStatus() } runCatching { homeRepository.getSystemPlaylistsStatus() }
@@ -357,26 +300,6 @@ class HomeViewModel @Inject constructor(
status.join() 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>> = val uiState: StateFlow<UiState<HomeSections>> =
combineHomeFlows().asCacheFirstStateFlow(viewModelScope) combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
@@ -413,17 +336,9 @@ class HomeViewModel @Inject constructor(
} }
private fun combineHomeFlows() = private fun combineHomeFlows() =
combine( observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
observeHomeSections(),
playlistsRepository.observeAll(),
refreshError,
) { sections, playlists, err ->
val merged = sections.copy(playlists = playlists) val merged = sections.copy(playlists = playlists)
when { if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
!merged.isAllEmpty -> UiState.Success(merged)
err != null -> UiState.Error(err)
else -> UiState.Empty
}
} }
} }
@@ -452,108 +367,46 @@ fun HomeScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle() val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
) { ) {
Box(Modifier.fillMaxSize()) { // Key Crossfade on the state CLASS, not the instance. Each
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel) // section emission produces a new UiState.Success(data); if
// Automatic-refresh veil: the daily rebuild / reconnect // we keyed on `state` directly, every per-section
// churn hides behind an "Updating your mixes…" wipe. Manual // hydration tick would re-run the 300ms crossfade, and
// pull owns the PullToRefreshBox spinner instead. // first-sign-in (six sections cascading in) reads as
UpdatingVeil(visible = updating) // 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()
* The Loading/Empty/Error/Success switch for Home, crossfaded on state. UiState.Empty -> EmptyState(
* title = "Welcome to Minstrel",
* Key the Crossfade on the state CLASS, not the instance. Each section body = "Nothing to show yet — scan a folder in your server " +
* emission produces a new UiState.Success(data); if we keyed on `state` "settings, then come back here for system playlists " +
* directly, every per-section hydration tick would re-run the 300ms "and recommendations.",
* crossfade, and first-sign-in (six sections cascading in) reads as )
* continuous flicker. Keying on the class restricts the animation to is UiState.Error -> EmptyState(
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success title = "Couldn't load home",
* recompositions update the LazyColumn without a fade. body = s.message,
*/ )
@Composable is UiState.Success -> HomeSuccessContent(
private fun HomeStateCrossfade( sections = s.data,
state: UiState<HomeSections>, systemStatus = systemStatus,
systemStatus: SystemPlaylistsStatus, offline = offline,
offline: Boolean, onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
navController: NavHostController, onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
viewModel: HomeViewModel, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
) { onMostPlayedTap = viewModel::playMostPlayed,
Crossfade(targetState = state::class, label = "home-state") { _ -> onPlayPool = viewModel::playPool,
when (val s = state) { onPlayAlbum = viewModel::playAlbum,
UiState.Loading -> HomeSkeletonContent() onPlayArtist = viewModel::playArtistShuffled,
UiState.Empty -> EmptyState( onPlayPlaylist = viewModel::playPlaylist,
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,
)
} }
} }
} }
@@ -623,12 +476,10 @@ private fun HomeSuccessContent(
) { ) {
item { item {
// Always rendered: real system/user playlists, with // Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover slots // placeholder cards filling the For You / Discover /
// that haven't generated yet. When offline, the cache-backed // 3× Songs-like slots that haven't generated yet. When
// pool cards lead the row. Songs-like now lives in its own // offline, the cache-backed pool cards lead the row.
// dedicated row below (#1491), no longer inside this carousel.
PlaylistsRow( PlaylistsRow(
title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline), rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
offline = offline, offline = offline,
onPlaylistClick = onPlaylistClick, onPlaylistClick = onPlaylistClick,
@@ -636,18 +487,6 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist, 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( youMightLikeSection(
albums = sections.youMightLikeAlbums, albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists, artists = sections.youMightLikeArtists,
@@ -671,35 +510,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( private fun LazyListScope.recentlyAddedSection(
albums: List<HomeTile<AlbumRef>>, albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
@@ -969,14 +779,13 @@ private fun AlbumsRow(
@Composable @Composable
private fun PlaylistsRow( private fun PlaylistsRow(
title: String,
rowItems: List<PlaylistRowItem>, rowItems: List<PlaylistRowItem>,
offline: Boolean, offline: Boolean,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit, onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit, onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) { ) {
HorizontalScrollRow(title = title) { HorizontalScrollRow(title = "Playlists") {
itemsIndexed(items = rowItems) { _, item -> itemsIndexed(items = rowItems) { _, item ->
when (item) { when (item) {
is PlaylistRowItem.OfflinePool -> OfflinePoolCard( is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
@@ -1027,12 +836,11 @@ enum class OfflinePoolKind(val label: String) {
/** /**
* Builds the Home Playlists row. * Builds the Home Playlists row.
* *
* Online: For You + Discover fixed slots (real card when generated, * Online: For You + Discover + 3× Songs-like fixed slots (real card when
* placeholder otherwise), then the secondary system kinds (deep cuts / * generated, placeholder otherwise), then the secondary system kinds (deep cuts
* rediscover / new for you / on this day / first listens) when they exist — * / rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then * no placeholders for these since they're conditional on library shape — then
* user-owned playlists. Songs-like has its own dedicated row (#1491) via * user-owned playlists.
* [buildSongsLikeRow] and no longer appears in this carousel.
* *
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the * Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached * same real playlists in curated order but stably partitioned fully-cached
@@ -1059,7 +867,7 @@ internal fun buildPlaylistsRow(
return out 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( private fun buildOnlineRow(
owned: List<PlaylistRef>, owned: List<PlaylistRef>,
status: SystemPlaylistsStatus, status: SystemPlaylistsStatus,
@@ -1071,7 +879,12 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" } out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) ?: 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) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) } owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
} }
@@ -1079,44 +892,16 @@ private fun buildOnlineRow(
return out 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). * Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and * Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first. * 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> { private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>() val out = mutableListOf<PlaylistRef>()
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it } owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
owned.firstOrNull { it.systemVariant == "discover" }?.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) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += it } owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
} }
@@ -1131,10 +916,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
else -> "pending" else -> "pending"
} }
// How many "Songs like…" placeholder cards the dedicated row shows while private const val SONGS_LIKE_SLOTS = 3
// 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
/** /**
* The 5 system playlist kinds the server generates that aren't pinned * The 5 system playlist kinds the server generates that aren't pinned
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.ServerImage
@@ -116,10 +116,9 @@ private fun AlbumDetailStateContent(
when (val s = state) { when (val s = state) {
is AlbumDetailUiState.Loading -> is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList() if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> ErrorRetry( is AlbumDetailUiState.Error -> EmptyState(
title = "Couldn't load album", title = "Couldn't load album",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is AlbumDetailUiState.Success -> { is AlbumDetailUiState.Success -> {
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle() val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
@@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.models.AlbumDetailRef
@@ -38,7 +36,6 @@ class AlbumDetailViewModel @Inject constructor(
private val likes: LikesRepository, private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
private val seedCache: DetailSeedCache, private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -67,14 +64,6 @@ class AlbumDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): a detail that failed to
// load while the server was unreachable re-fetches when health
// returns, so coming back to the screen shows content, not an error.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AlbumDetailUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -107,10 +107,9 @@ fun ArtistDetailScreen(
} else { } else {
SkeletonArtistAlbumsGrid() SkeletonArtistAlbumsGrid()
} }
is ArtistDetailUiState.Error -> ErrorRetry( is ArtistDetailUiState.Error -> EmptyState(
title = "Couldn't load artist", title = "Couldn't load artist",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is ArtistDetailUiState.Success -> is ArtistDetailUiState.Success ->
ArtistSuccessBody(s, viewModel, playerViewModel, navController) ArtistSuccessBody(s, viewModel, playerViewModel, navController)
@@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef import com.fabledsword.minstrel.models.ArtistDetailRef
@@ -47,7 +45,6 @@ class ArtistDetailViewModel @Inject constructor(
private val likes: LikesRepository, private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
private val seedCache: DetailSeedCache, private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -72,19 +69,6 @@ class ArtistDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245). From Error, redo the
// whole load; from Success, re-pull just the secondary sections —
// their fetch failures are swallowed to emptyList, so a dead zone
// during the first load leaves them permanently absent otherwise.
viewModelScope.launch {
networkStatus.recoveries().collect {
when (internal.value) {
is ArtistDetailUiState.Error -> refresh()
is ArtistDetailUiState.Success -> loadSecondarySections()
is ArtistDetailUiState.Loading -> Unit
}
}
}
} }
fun toggleLikeArtist() { fun toggleLikeArtist() {
@@ -24,17 +24,11 @@ class LibraryViewModel @Inject constructor(
combine( combine(
repository.observeArtists(), repository.observeArtists(),
repository.observeAlbums(), repository.observeAlbums(),
syncController.lastSyncError, ) { artists, albums ->
) { artists, albums, syncError -> if (artists.isEmpty() && albums.isEmpty()) {
when { UiState.Empty
artists.isNotEmpty() || albums.isNotEmpty() -> } else {
UiState.Success(LibraryData(artists, albums)) UiState.Success(LibraryData(artists, albums))
// Empty cache + failed sync is a load failure, not an empty
// library — surface it so the user gets a Retry instead of
// the welcome copy. Heals automatically: SyncController
// re-syncs on health recovery, clearing lastSyncError.
syncError != null -> UiState.Error(syncError)
else -> UiState.Empty
} }
} }
.asCacheFirstStateFlow(viewModelScope) .asCacheFirstStateFlow(viewModelScope)
@@ -25,9 +25,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.library.widgets.ArtistCard
@@ -41,7 +38,6 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -50,7 +46,6 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -73,25 +68,14 @@ data class LikedSections(
class LikedTabViewModel @Inject constructor( class LikedTabViewModel @Inject constructor(
private val repository: LikesRepository, private val repository: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
/** Copy for the most recent refresh failure; null once one succeeds. */
private val refreshError = MutableStateFlow<String?>(null)
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
runCatching { repository.refreshIds() } runCatching { repository.refreshIds() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
} }
/** /**
@@ -108,16 +92,9 @@ class LikedTabViewModel @Inject constructor(
repository.observeLikedArtists(), repository.observeLikedArtists(),
repository.observeLikedAlbums(), repository.observeLikedAlbums(),
repository.observeLikedTracks(), repository.observeLikedTracks(),
refreshError, ) { artists, albums, tracks ->
) { artists, albums, tracks, err ->
val sections = LikedSections(artists, albums, tracks) val sections = LikedSections(artists, albums, tracks)
when { if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
!sections.isAllEmpty -> UiState.Success(sections)
// Empty cache + failed refresh is a load failure, not "no
// likes yet" — surface Error so the user gets a Retry.
err != null -> UiState.Error(err)
else -> UiState.Empty
}
}.asCacheFirstStateFlow(viewModelScope) }.asCacheFirstStateFlow(viewModelScope)
/** /**
@@ -153,10 +130,9 @@ fun LikedTab(
body = "Tap the heart on an artist, album, or track to start " + body = "Tap the heart on an artist, album, or track to start " +
"building your liked collection.", "building your liked collection.",
) )
is UiState.Error -> ErrorRetry( is UiState.Error -> EmptyState(
title = "Couldn't load likes", title = "Couldn't load likes",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> LikedContent( is UiState.Success -> LikedContent(
sections = s.data, sections = s.data,
@@ -38,7 +38,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -58,8 +60,6 @@ import com.composables.icons.lucide.Play
import com.composables.icons.lucide.RefreshCw import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Shuffle import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEvent import com.fabledsword.minstrel.events.LiveEvent
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
@@ -76,7 +76,6 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
@@ -118,7 +117,6 @@ class PlaylistDetailViewModel @Inject constructor(
private val player: PlayerController, private val player: PlayerController,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
private val seedCache: DetailSeedCache, private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -169,14 +167,6 @@ class PlaylistDetailViewModel @Inject constructor(
.filter { it.kind.startsWith("playlist.") } .filter { it.kind.startsWith("playlist.") }
.collect { handlePlaylistEvent(it) } .collect { handlePlaylistEvent(it) }
} }
// Screen-level auto-recovery (issue #1245): a detail that failed to
// load while the server was unreachable re-fetches when health
// returns, instead of waiting for the Retry tap.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is PlaylistDetailUiState.Error) refresh()
}
}
} }
/** /**
@@ -379,11 +369,7 @@ private fun PlaylistDetailContent(
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) { Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
is PlaylistDetailUiState.Loading -> is PlaylistDetailUiState.Loading ->
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList() s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorRetry( is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
title = "Couldn't load playlist",
message = s.message,
onRetry = { viewModel.refresh() },
)
is PlaylistDetailUiState.Success -> { is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState() val likedTrackIds by viewModel.likedTrackIds.collectAsState()
val playerState by playerViewModel.uiState.collectAsState() val playerState by playerViewModel.uiState.collectAsState()
@@ -667,6 +653,33 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) {
} }
} }
@Composable
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
var retried by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = {
if (!retried) {
retried = true
onRetry()
}
},
) { Text("Retry") }
}
}
// ─── Helpers ───────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────
private const val UNAVAILABLE_ALPHA = 0.4f private const val UNAVAILABLE_ALPHA = 0.4f
@@ -27,10 +27,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
@@ -42,7 +40,6 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -50,10 +47,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -89,9 +84,6 @@ class PlaylistsListViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
/** Copy for the most recent list-refresh failure; null once one succeeds. */
private val refreshError = MutableStateFlow<String?>(null)
init { init {
refresh() refresh()
// Live updates: a playlist created/updated/deleted from another // Live updates: a playlist created/updated/deleted from another
@@ -101,18 +93,11 @@ class PlaylistsListViewModel @Inject constructor(
.filter { it.kind.startsWith("playlist.") } .filter { it.kind.startsWith("playlist.") }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */ /** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
runCatching { repository.refreshList() } runCatching { repository.refreshList() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
} }
/** Tile play button: shuffle the playlist's tracks and start at index 0. */ /** Tile play button: shuffle the playlist's tracks and start at index 0. */
@@ -125,15 +110,14 @@ class PlaylistsListViewModel @Inject constructor(
} }
val uiState: StateFlow<UiState<List<PlaylistRef>>> = val uiState: StateFlow<UiState<List<PlaylistRef>>> =
combine(repository.observeAll(), refreshError) { list, err -> repository.observeAll()
when { .map { list ->
list.isNotEmpty() -> UiState.Success(list) if (list.isEmpty()) {
// Empty cache + failed refresh is a load failure, not "no UiState.Empty
// playlists yet" — surface Error so the user gets a Retry. } else {
err != null -> UiState.Error(err) UiState.Success(list)
else -> UiState.Empty }
} }
}
.asCacheFirstStateFlow(viewModelScope) .asCacheFirstStateFlow(viewModelScope)
} }
@@ -173,10 +157,9 @@ fun PlaylistsListScreen(
"appear once your library has enough plays. Create your " + "appear once your library has enough plays. Create your " +
"own playlist from any album or track in the meantime.", "own playlist from any album or track in the meantime.",
) )
is UiState.Error -> ErrorRetry( is UiState.Error -> EmptyState(
title = "Couldn't load playlists", title = "Couldn't load playlists",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> PlaylistsGrid( is UiState.Success -> PlaylistsGrid(
playlists = s.data, playlists = s.data,
@@ -27,7 +27,6 @@ import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -44,10 +43,9 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
"and it'll show up here. The track stays out of system " + "and it'll show up here. The track stays out of system " +
"playlists until you unhide it.", "playlists until you unhide it.",
) )
is UiState.Error -> ErrorRetry( is UiState.Error -> EmptyState(
title = "Couldn't load hidden tracks", title = "Couldn't load hidden tracks",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> HiddenList( is UiState.Success -> HiddenList(
rows = s.data, rows = s.data,
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.quarantine.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
@@ -26,7 +24,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
class HiddenTabViewModel @Inject constructor( class HiddenTabViewModel @Inject constructor(
private val repository: QuarantineRepository, private val repository: QuarantineRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val refreshError = MutableStateFlow<String?>(null) private val refreshError = MutableStateFlow<String?>(null)
@@ -64,11 +61,6 @@ class HiddenTabViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") } .filter { it.kind.startsWith("quarantine.") }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -46,7 +46,6 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -79,10 +78,9 @@ fun RequestsScreen(
body = "Use Discover to ask Lidarr for new music; your " + body = "Use Discover to ask Lidarr for new music; your " +
"requests show up here.", "requests show up here.",
) )
is UiState.Error -> ErrorRetry( is UiState.Error -> EmptyState(
title = "Couldn't load requests", title = "Couldn't load requests",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> RequestList( is UiState.Success -> RequestList(
rows = s.data, rows = s.data,
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.requests.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus import com.fabledsword.minstrel.models.RequestStatus
@@ -32,7 +30,6 @@ private const val POLL_INTERVAL_MS = 12_000L
class RequestsViewModel @Inject constructor( class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository, private val repository: RequestsRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading) private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
@@ -48,14 +45,6 @@ class RequestsViewModel @Inject constructor(
.collect { silentReload() } .collect { silentReload() }
} }
viewModelScope.launch { pollWhileInFlight() } viewModelScope.launch { pollWhileInFlight() }
// Screen-level auto-recovery (issue #1245): a load that failed while
// the server was unreachable heals the moment health returns, without
// waiting for the next poll tick.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is UiState.Error) silentReload()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -82,11 +71,7 @@ class RequestsViewModel @Inject constructor(
*/ */
private suspend fun pollWhileInFlight() { private suspend fun pollWhileInFlight() {
while (true) { while (true) {
// Also retry from a failed load (issue #1245): from UiState.Error if (hasInFlightRequest()) {
// the in-flight predicate can never become true, so without this
// the poll never self-heals the screen — e.g. after a transient
// 500 that health monitoring (transport-level) never saw.
if (hasInFlightRequest() || internal.value is UiState.Error) {
silentReload() silentReload()
} }
delay(POLL_INTERVAL_MS) delay(POLL_INTERVAL_MS)
@@ -56,7 +56,6 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Search as SearchRoute import com.fabledsword.minstrel.nav.Search as SearchRoute
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -114,7 +113,6 @@ fun SearchScreen(
onTrackPlay = viewModel::playTrack, onTrackPlay = viewModel::playTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
onRetry = viewModel::retry,
) )
} }
} }
@@ -153,16 +151,11 @@ private fun ResultsPane(
onTrackPlay: (TrackRef) -> Unit, onTrackPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit, onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit, onNavigateToArtist: (String) -> Unit,
onRetry: () -> Unit,
) { ) {
when (state) { when (state) {
SearchResultsState.Idle -> CenteredHint("Type to search your library.") SearchResultsState.Idle -> CenteredHint("Type to search your library.")
SearchResultsState.Loading -> LoadingCentered() SearchResultsState.Loading -> LoadingCentered()
is SearchResultsState.Error -> ErrorRetry( is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
title = "Search failed",
message = state.message,
onRetry = onRetry,
)
is SearchResultsState.Loaded -> { is SearchResultsState.Loaded -> {
if (state.response.isEmpty) { if (state.response.isEmpty) {
CenteredHint( CenteredHint(
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.search.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.SearchResponseRef import com.fabledsword.minstrel.models.SearchResponseRef
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
@@ -45,7 +43,6 @@ data class SearchState(
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val repository: SearchRepository, private val repository: SearchRepository,
private val player: PlayerController, private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val queryFlow = MutableStateFlow("") private val queryFlow = MutableStateFlow("")
@@ -72,14 +69,6 @@ class SearchViewModel @Inject constructor(
runSearch(q) runSearch(q)
} }
} }
// Screen-level auto-recovery (issue #1245): a search that failed
// while the server was unreachable re-runs when health returns —
// the queryFlow pipeline can't, since the text hasn't changed.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value.results is SearchResultsState.Error) retry()
}
}
} }
fun setQuery(value: String) { fun setQuery(value: String) {
@@ -91,18 +80,6 @@ class SearchViewModel @Inject constructor(
setQuery("") setQuery("")
} }
/**
* Re-run the current query after a failure. Bypasses the queryFlow
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
* without this, a transient error pins "Search failed" until the
* user edits the query.
*/
fun retry() {
val q = internal.value.query.trim()
if (q.isEmpty()) return
viewModelScope.launch { runSearch(q) }
}
/** /**
* Tapping a search result builds a queue from the full visible * Tapping a search result builds a queue from the full visible
* track-results list starting at the tapped entry, matching Flutter. * track-results list starting at the tapped entry, matching Flutter.
@@ -23,12 +23,8 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* ServerImage + fallback structure was identical at all three sites. * ServerImage + fallback structure was identical at all three sites.
* *
* [overlay] is a `BoxScope` slot for things drawn on top of the cover * [overlay] is a `BoxScope` slot for things drawn on top of the cover
* (e.g. the `VariantPill` system-playlist label, the play button) — * (e.g. the `VariantPill` system-playlist label) — callers can use
* callers can use `Modifier.align(...)` inside it. The overlay sits on * `Modifier.align(...)` inside it.
* 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].
*/ */
@Composable @Composable
fun CoverTile( fun CoverTile(
@@ -42,23 +38,18 @@ fun CoverTile(
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
) { ) {
Box( Box(
modifier = modifier.size(size), modifier = modifier
.size(size)
.clip(shape)
.background(background),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Box( ServerImage(
modifier = Modifier url = url,
.fillMaxSize() contentDescription = contentDescription,
.clip(shape) modifier = Modifier.fillMaxSize(),
.background(background),
contentAlignment = Alignment.Center,
) { ) {
ServerImage( fallback()
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
fallback()
}
} }
overlay() overlay()
} }
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -24,63 +23,44 @@ import com.fabledsword.minstrel.theme.LocalActionColors
* Shared error-with-retry widget. Surfaces a brief message and a * Shared error-with-retry widget. Surfaces a brief message and a
* primary action button. Per the design system, the button uses * primary action button. Per the design system, the button uses
* `LocalActionColors.primary` (Moss) — NEVER the accent. * `LocalActionColors.primary` (Moss) — NEVER the accent.
*
* Renders inside a single-item LazyColumn (same rationale as
* [EmptyState]) so the widget participates in nested-scroll dispatch
* under PullToRefreshBox — both the Retry button and a pull gesture
* can recover from the error state.
*/ */
@Composable @Composable
fun ErrorRetry( fun ErrorRetry(
message: String, message: String,
onRetry: () -> Unit, onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String? = null,
retryLabel: String = "Retry", retryLabel: String = "Retry",
) { ) {
val actionColors = LocalActionColors.current val actionColors = LocalActionColors.current
LazyColumn(modifier = modifier.fillMaxSize()) { Column(
item { modifier = modifier
Column( .fillMaxSize()
modifier = Modifier .padding(32.dp),
.fillParentMaxSize() verticalArrangement = Arrangement.Center,
.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, ) {
horizontalAlignment = Alignment.CenterHorizontally, Icon(
) { imageVector = Lucide.TriangleAlert,
Icon( contentDescription = null,
imageVector = Lucide.TriangleAlert, modifier = Modifier.size(48.dp),
contentDescription = null, tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp), )
tint = MaterialTheme.colorScheme.error, Text(
) text = message,
if (title != null) { modifier = Modifier.padding(top = 16.dp),
Text( style = MaterialTheme.typography.bodyMedium,
text = title, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium, )
color = MaterialTheme.colorScheme.onSurface, Button(
textAlign = TextAlign.Center, onClick = onRetry,
) modifier = Modifier.padding(top = 16.dp),
} colors = ButtonDefaults.buttonColors(
Text( containerColor = actionColors.primary,
text = message, contentColor = actionColors.onAction,
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp), ),
style = MaterialTheme.typography.bodyMedium, ) {
color = MaterialTheme.colorScheme.onSurface, Text(retryLabel)
textAlign = TextAlign.Center,
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = actionColors.primary,
contentColor = actionColors.onAction,
),
) {
Text(retryLabel)
}
}
} }
} }
} }
@@ -56,51 +56,4 @@ class BuildPlaylistsRowTest {
assertTrue(row.none { it is PlaylistRowItem.OfflinePool }) assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
assertTrue(row.any { it is PlaylistRowItem.Placeholder }) 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())
}
} }
@@ -11,7 +11,6 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -22,13 +21,6 @@ import kotlin.test.assertEquals
@ExtendWith(MainDispatcherExtension::class) @ExtendWith(MainDispatcherExtension::class)
class LibraryViewModelTest { class LibraryViewModelTest {
// lastSyncError needs a real StateFlow: the VM combines it into uiState,
// and a relaxed-mock flow never emits, so the combine would never fire.
private fun syncControllerMock(): SyncController =
mockk<SyncController>(relaxed = true) {
every { lastSyncError } returns MutableStateFlow(null)
}
@Test @Test
fun `initial state is Loading before any DAO emission`() = runTest { fun `initial state is Loading before any DAO emission`() = runTest {
val repo = mockk<LibraryRepository>() val repo = mockk<LibraryRepository>()
@@ -37,7 +29,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
syncControllerMock(), mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
@@ -59,7 +51,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
syncControllerMock(), mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
@@ -79,7 +71,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
syncControllerMock(), mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
@@ -102,7 +94,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
syncControllerMock(), mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
-33
View File
@@ -25,14 +25,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging" "git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "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"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/server" "git.fabledsword.com/bvandeusen/minstrel/internal/server"
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity" "git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
) )
func main() { func main() {
@@ -148,16 +146,6 @@ func run() error {
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings) coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
coverEnricher.DataDir = cfg.Storage.DataDir 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. // One unified scan chain: library walk → MBID backfill → cover enrich.
// Boot-time scan and manual-trigger scans share this path; results land // Boot-time scan and manual-trigger scans share this path; results land
// in scan_runs for the admin overview. // in scan_runs for the admin overview.
@@ -206,25 +194,6 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity")) similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx) 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 // Start the GC worker. Runs every 1h and sweeps lifecycle tables
// that have no writer-side close path or retention policy: // that have no writer-side close path or retention policy:
// orphan play_events, stale play_sessions, expired // 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) }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
srv.Bus = bus srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler srv.PlaylistScheduler = playlistScheduler
srv.RecSettings = recSettings
srv.TagSettings = tagSettings
srv.StreamSecret = cfg.StreamSecret srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{ httpServer := &http.Server{
Addr: cfg.Server.Address, 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/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "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" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
) )
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside // Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer // RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store. // 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())) rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{ h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg, pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64, rng: rng.Float64,
lidarrCfg: lidarrCfg, lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs, lidarrRequests: lidarrReqs,
@@ -43,7 +40,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
playlists: playlistsSvc, playlists: playlistsSvc,
coverart: coverEnricher, coverart: coverEnricher,
coverSettings: coverSettings, coverSettings: coverSettings,
tagSettings: tagSettings,
scanner: scanner, scanner: scanner,
scanCfg: scanCfg, scanCfg: scanCfg,
dataDir: dataDir, 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/{provider_id}/test", h.handleTestCoverSource)
admin.Post("/cover-sources/research", h.handleResearchMissingArt) 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.Get("/smtp-config", h.handleGetSMTPConfig)
admin.Put("/smtp-config", h.handleUpdateSMTPConfig) admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig) 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) authed.Get("/playlists", h.handleListPlaylists)
@@ -240,7 +223,6 @@ type handlers struct {
logger *slog.Logger logger *slog.Logger
events *playevents.Writer events *playevents.Writer
recCfg config.RecommendationConfig recCfg config.RecommendationConfig
recSettings *recsettings.Service
rng func() float64 rng func() float64
lidarrCfg *lidarrconfig.Service lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service lidarrRequests *lidarrrequests.Service
@@ -249,7 +231,6 @@ type handlers struct {
playlists *playlists.Service playlists *playlists.Service
coverart *coverart.Enricher coverart *coverart.Enricher
coverSettings *coverart.SettingsService coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner scanner *library.Scanner
scanCfg library.RunScanConfig scanCfg library.RunScanConfig
dataDir string dataDir string
+4 -6
View File
@@ -29,7 +29,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
) )
@@ -57,12 +56,11 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dbtest.ResetDB(t, pool) dbtest.ResetDB(t, pool)
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
recCfg := config.RecommendationConfig{ 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, 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) lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil) lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil) lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
@@ -72,7 +70,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir() dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir) tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, 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 return h, pool
} }
+1 -21
View File
@@ -31,26 +31,6 @@ type playStartedResponse struct {
SessionID string `json:"session_id"` 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 { type okResponse struct {
OK bool `json:"ok"` 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")) writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
return return
} }
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC()) at = parsed
} }
clientID := "" clientID := ""
if req.ClientID != nil { if req.ClientID != nil {
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000) 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{ paths := []string{
"/api/artists", "/api/artists",
+27 -257
View File
@@ -2,9 +2,7 @@ package api
import ( import (
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -13,142 +11,28 @@ import (
const ( const (
recMetricsDefaultDays = 30 recMetricsDefaultDays = 30
recMetricsMaxDays = 365 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 // recommendationMetric is one recommendation surface's outcomes.
// job, not one global bar — discovery mixes are EXPECTED to run higher type recommendationMetric struct {
// skip rates than the go-to surfaces. Source string `json:"source"` // 'for_you' | 'discover' | mixes
const ( Plays int64 `json:"plays"` // plays launched from this surface
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
Skips int64 `json:"skips"` // of those, marked skipped Skips int64 `json:"skips"` // of those, marked skipped
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1] SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [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 { type recommendationMetricsResp struct {
WindowDays int `json:"window_days"` WindowDays int `json:"window_days"`
// Baseline is the control group: plays the user picked manually Sources []recommendationMetric `json:"sources"`
// (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
} }
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics. // handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
// Bucketed per-surface-family outcomes for the caller over the last `days` // Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
// (default 30, capped at 365), grouped by surface intent and anchored by the // caller over the last `days` (default 30, capped at 365), so the operator can
// manual-plays baseline so the numbers are judgeable, not just observable. // 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) { func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r) caller, ok := requireUser(w, r)
if !ok { if !ok {
@@ -167,142 +51,28 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
return return
} }
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows)) out := recommendationMetricsResp{
} WindowDays: days,
Sources: make([]recommendationMetric, 0, len(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}
} }
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 { for _, row := range rows {
if row.Source == nil || *row.Source == "" { source := ""
baseline.add(row) if row.Source != nil {
continue source = *row.Source
} }
fam := bucketRecSource(*row.Source) var skipRate float64
acc, exists := families[fam.key] if row.Plays > 0 {
if !exists { skipRate = float64(row.Skips) / float64(row.Plays)
acc = &familyAccum{fam: fam}
families[fam.key] = acc
} }
acc.add(row) out.Sources = append(out.Sources, recommendationMetric{
// Accumulate the pick-kind population unconditionally; families Source: source,
// that never stamp end up all-unattributed and get no breakdown. Plays: row.Plays,
kind := "" Skips: row.Skips,
if row.PickKind != nil { SkipRate: skipRate,
kind = *row.PickKind AvgCompletion: row.AvgCompletion,
}
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
}) })
resp.Groups = append(resp.Groups, group)
} }
return resp writeJSON(w, http.StatusOK, out)
} }
// parseMetricsDays reads the `days` query param (default 30, capped at 365). // 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/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
func newMetricsRouter(h *handlers) chi.Router { 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 + // seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (manual library play → baseline). // skip flag. A nil source inserts NULL (library/radio play).
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
// except attributed for_you plays.
func seedSourcedPlay( func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID, 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() t.Helper()
if _, err := h.pool.Exec(context.Background(), if _, err := h.pool.Exec(context.Background(),
`INSERT INTO play_events `INSERT INTO play_events
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped) (user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`, VALUES ($1, $2, $3, now(), $4, $5, $6)`,
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil { userID, trackID, sessionID, source, completion, skipped); err != nil {
t.Fatalf("seed sourced play: %v", err) 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 TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
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) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set") 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()) session := seedPlaySession(t, pool, user.ID, time.Now())
forYou := "for_you" forYou := "for_you"
radioA := "radio:11111111-1111-1111-1111-111111111111" discover := "discover"
taste, fresh := "taste", "fresh"
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667. // 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, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false) seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false) seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true) // discover: 1 play.
// A radio session play collapses into the "radio" family. seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false) // library play (NULL source) — must be excluded.
// Manual play (NULL source) — the baseline row. seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil) req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
req = withUser(req, user) req = withUser(req, user)
@@ -249,11 +82,15 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if resp.WindowDays != recMetricsDefaultDays { if resp.WindowDays != recMetricsDefaultDays {
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays) t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
} }
if resp.Baseline == nil || resp.Baseline.Plays != 1 { bySource := map[string]recommendationMetric{}
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline) for _, m := range resp.Sources {
bySource[m.Source] = m
} }
fy := findSurface(resp, "for_you") if _, present := bySource[""]; present {
if fy == nil { t.Error("NULL-source (library) plays should be excluded")
}
fy, ok := bySource["for_you"]
if !ok {
t.Fatal("for_you metrics missing") t.Fatal("for_you metrics missing")
} }
if fy.Plays != 3 || fy.Skips != 1 { 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 { if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion) t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
} }
// Pick-kind attribution surfaces as the For You breakdown (#1249). if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
if len(fy.Breakdown) != 2 { t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
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)
} }
} }
+10 -4
View File
@@ -15,7 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
) )
// RadioResponse is the body of GET /api/radio. // 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) — weights := recommendation.ScoringWeights{
// read per request so an admin change takes effect live. BaseWeight: h.recCfg.BaseWeight,
weights := h.recSettings.Weights(recsettings.ScopeRadio) 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) picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
out := make([]TrackRef, 0, len(picks)+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"` SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
} }
// RecommendationConfig holds the radio path's operational knobs. The // RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
// scoring WEIGHTS moved to DB-backed admin settings (#1250, // All weights are operator-tunable; defaults match the spec recommendations.
// internal/recsettings) — YAML is bootstrap-only; anything an operator
// tunes lives in the UI with live effect.
type RecommendationConfig struct { type RecommendationConfig struct {
RecentlyPlayedHours int `yaml:"recently_played_hours"` BaseWeight float64 `yaml:"base_weight"`
RadioSize int `yaml:"radio_size"` LikeBoost float64 `yaml:"like_boost"`
RadioSizeMax int `yaml:"radio_size_max"` 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 { func Default() Config {
@@ -114,6 +120,16 @@ func Default() Config {
SkipMaxDurationPlayedMs: 30000, SkipMaxDurationPlayedMs: 30000,
}, },
Recommendation: RecommendationConfig{ 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, RecentlyPlayedHours: 1,
RadioSize: 50, RadioSize: 50,
RadioSizeMax: 200, RadioSizeMax: 200,
-66
View File
@@ -211,69 +211,3 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
} }
return items, nil 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 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 WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT 1 LIMIT 1
@@ -79,13 +79,12 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err return i, err
} }
const getPlayEventByID = `-- name: GetPlayEventByID :one 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) { 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.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err 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 const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events ( INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind user_id, track_id, session_id, started_at, client_id, source
) VALUES ($1, $2, $3, $4, $5, $6, $7::text) ) 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, 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 InsertPlayEventParams struct { type InsertPlayEventParams struct {
@@ -156,12 +122,8 @@ type InsertPlayEventParams struct {
StartedAt pgtype.Timestamptz StartedAt pgtype.Timestamptz
ClientID *string ClientID *string
Source *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) { func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent, row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID, arg.UserID,
@@ -170,7 +132,6 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.StartedAt, arg.StartedAt,
arg.ClientID, arg.ClientID,
arg.Source, arg.Source,
arg.PickKind,
) )
var i PlayEvent var i PlayEvent
err := row.Scan( err := row.Scan(
@@ -187,7 +148,6 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err return i, err
} }
@@ -254,7 +214,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
} }
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many 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 JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1 WHERE pe.session_id = $1
AND pe.started_at < $2 AND pe.started_at < $2
@@ -296,8 +256,6 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.Genre, &i.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -333,7 +291,7 @@ SET ended_at = $2,
completion_ratio = $4, completion_ratio = $4,
was_skipped = $5 was_skipped = $5
WHERE id = $1 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 { type UpdatePlayEventEndedParams struct {
@@ -370,7 +328,6 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err return i, err
} }
+1 -3
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id, SELECT pe.id AS event_id,
pe.started_at, 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, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM play_events pe FROM play_events pe
@@ -77,8 +77,6 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); 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 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 JOIN general_likes l ON l.track_id = t.id
WHERE l.user_id = $1 WHERE l.user_id = $1
ORDER BY l.liked_at DESC ORDER BY l.liked_at DESC
@@ -297,8 +297,6 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.Genre, &i.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+15 -70
View File
@@ -383,7 +383,6 @@ type PlayEvent struct {
SessionVectorAtPlay []byte SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz ScrobbledAt pgtype.Timestamptz
Source *string Source *string
PickKind *string
} }
type PlaySession struct { type PlaySession struct {
@@ -434,28 +433,6 @@ type PlaylistTrack struct {
AlbumTitle string AlbumTitle string
DurationSec int32 DurationSec int32
AddedAt pgtype.Timestamptz 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 { type RegistrationSetting struct {
@@ -532,21 +509,6 @@ type SystemPlaylistRun struct {
LastError *string 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 { type TasteProfileArtist struct {
UserID pgtype.UUID UserID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
@@ -561,33 +523,22 @@ type TasteProfileTag struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type TasteTuning struct {
Singleton bool
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
UpdatedAt pgtype.Timestamptz
}
type Track struct { type Track struct {
ID pgtype.UUID ID pgtype.UUID
Title string Title string
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
TrackNumber *int32 TrackNumber *int32
DiscNumber *int32 DiscNumber *int32
DurationMs int32 DurationMs int32
FilePath string FilePath string
FileSize int64 FileSize int64
FileFormat string FileFormat string
Bitrate *int32 Bitrate *int32
Mbid *string Mbid *string
Genre *string Genre *string
AddedAt pgtype.Timestamptz AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
TagSource *string
TagSourcesVersion int32
} }
type TrackSimilarity struct { type TrackSimilarity struct {
@@ -598,12 +549,6 @@ type TrackSimilarity struct {
FetchedAt pgtype.Timestamptz FetchedAt pgtype.Timestamptz
} }
type TrackTag struct {
TrackID pgtype.UUID
Tag string
Weight float64
}
type User struct { type User struct {
ID pgtype.UUID ID pgtype.UUID
Username string Username string
+7 -13
View File
@@ -12,7 +12,7 @@ import (
) )
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one 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 SELECT
$1::uuid, $1::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0), COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
@@ -20,27 +20,24 @@ SELECT
t.title, t.title,
artists.name, artists.name,
albums.title, albums.title,
(t.duration_ms / 1000)::integer, (t.duration_ms / 1000)::integer
$2::text
FROM tracks t FROM tracks t
JOIN albums ON albums.id = t.album_id JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id JOIN artists ON artists.id = t.artist_id
WHERE t.id = $3::uuid WHERE t.id = $2::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
` `
type AppendPlaylistTrackParams struct { type AppendPlaylistTrackParams struct {
PlaylistID pgtype.UUID PlaylistID pgtype.UUID
PickKind *string
TrackID pgtype.UUID TrackID pgtype.UUID
} }
// Inserts at the next available position. Snapshot fields are copied // Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms // 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 // is converted to seconds for the snapshot.
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) { 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 var i PlaylistTrack
err := row.Scan( err := row.Scan(
&i.PlaylistID, &i.PlaylistID,
@@ -51,7 +48,6 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
&i.AlbumTitle, &i.AlbumTitle,
&i.DurationSec, &i.DurationSec,
&i.AddedAt, &i.AddedAt,
&i.PickKind,
) )
return i, err return i, err
} }
@@ -261,7 +257,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
} }
const listPlaylistTracks = `-- name: ListPlaylistTracks :many 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, t.id AS live_track_id,
albums.id AS album_id, albums.id AS album_id,
artists.id AS artist_id artists.id AS artist_id
@@ -282,7 +278,6 @@ type ListPlaylistTracksRow struct {
AlbumTitle string AlbumTitle string
DurationSec int32 DurationSec int32
AddedAt pgtype.Timestamptz AddedAt pgtype.Timestamptz
PickKind *string
LiveTrackID pgtype.UUID LiveTrackID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
@@ -310,7 +305,6 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
&i.AlbumTitle, &i.AlbumTitle,
&i.DurationSec, &i.DurationSec,
&i.AddedAt, &i.AddedAt,
&i.PickKind,
&i.LiveTrackID, &i.LiveTrackID,
&i.AlbumID, &i.AlbumID,
&i.ArtistID, &i.ArtistID,
+4 -12
View File
@@ -104,7 +104,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id 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, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -161,8 +161,6 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -183,7 +181,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id 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, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -242,8 +240,6 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -581,7 +577,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT 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, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
@@ -654,8 +650,6 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
@@ -770,7 +764,7 @@ random_fill AS (
LIMIT $9 LIMIT $9
) )
SELECT 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, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
@@ -868,8 +862,6 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
+12 -91
View File
@@ -12,17 +12,19 @@ import (
) )
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
SELECT SELECT
pe.source, pe.source,
pe.pick_kind,
count(*)::bigint AS plays, count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips, count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n, COALESCE(
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
0)::float8 AS avg_completion
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') 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 ORDER BY plays DESC
` `
@@ -33,17 +35,18 @@ type RecommendationSourceMetricsForUserParams struct {
type RecommendationSourceMetricsForUserRow struct { type RecommendationSourceMetricsForUserRow struct {
Source *string Source *string
PickKind *string
Plays int64 Plays int64
Skips int64 Skips int64
CompletionN int64
AvgCompletion float64 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 // $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. // mean completion ratio over plays that recorded one (0 when none did).
// 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.
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) { func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
if err != nil { if err != nil {
@@ -55,10 +58,8 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
var i RecommendationSourceMetricsForUserRow var i RecommendationSourceMetricsForUserRow
if err := rows.Scan( if err := rows.Scan(
&i.Source, &i.Source,
&i.PickKind,
&i.Plays, &i.Plays,
&i.Skips, &i.Skips,
&i.CompletionN,
&i.AvgCompletion, &i.AvgCompletion,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -70,83 +71,3 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
} }
return items, nil 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 FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id 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 SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN affinity_artists aa ON aa.artist_id = t.artist_id JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.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 WHERE COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
@@ -68,11 +60,6 @@ type ListDeepCutsTracksRow struct {
// gives per-play variety on top. // gives per-play variety on top.
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user // #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
// has liked OR played heavily (>=5 non-skip plays across the artist). // 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. // $1 user_id, $2 date string.
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) { func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2) 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 const listFirstListensTracks = `-- name: ListFirstListensTracks :many
WITH attempted AS ( WITH heard_albums AS (
SELECT DISTINCT pe.track_id SELECT DISTINCT t.album_id
FROM play_events pe FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000 WHERE pe.user_id = $1
), ),
album_attempts AS ( played_artists 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 (
SELECT DISTINCT t.artist_id SELECT DISTINCT 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
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
) )
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 FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
JOIN albums_tiered alt ON alt.album_id = al.id WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
WHERE alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY ORDER BY
alt.tier,
(CASE (CASE
WHEN EXISTS ( WHEN EXISTS (
SELECT 1 FROM general_likes_artists gla SELECT 1 FROM general_likes_artists gla
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
) THEN 0 ) 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 ELSE 2
END), END),
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
@@ -148,25 +117,12 @@ type ListFirstListensTracksRow struct {
ID pgtype.UUID ID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Tier int32
} }
// #423 First Listens: songs the user has never heard or even // #423 First Listens: albums the user has never played any track of.
// attempted. "Attempted" is track-level with a >=30s listen threshold // Tiered: liked-artist albums first, then played-artist albums, then
// (#1268, operator decision 2026-07-03) — the old version disqualified // the rest — album-coherent within each tier. Not diversity-capped
// a whole album on ANY play_event, so a 2-second accidental brush // (whole-album discovery). $1 user_id.
// 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.
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) { func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
rows, err := q.db.Query(ctx, listFirstListensTracks, userID) rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
if err != nil { if err != nil {
@@ -176,12 +132,7 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
var items []ListFirstListensTracksRow var items []ListFirstListensTracksRow
for rows.Next() { for rows.Next() {
var i ListFirstListensTracksRow var i ListFirstListensTracksRow
if err := rows.Scan( if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -193,76 +144,39 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
} }
const listNewForYouTracks = `-- name: ListNewForYouTracks :many const listNewForYouTracks = `-- name: ListNewForYouTracks :many
WITH attempted AS ( WITH affinity_artists AS (
SELECT DISTINCT pe.track_id SELECT artist_id FROM general_likes_artists WHERE user_id = $1
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
UNION UNION
SELECT t.artist_id 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 GROUP BY t.artist_id
HAVING COUNT(*) >= 3 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 FROM tracks t
JOIN albums_tiered alt ON alt.album_id = t.album_id JOIN albums al ON al.id = t.album_id
WHERE NOT EXISTS ( 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 SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY alt.tier, alt.created_at DESC, t.album_id, ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
t.disc_number NULLS FIRST, t.track_number NULLS FIRST LIMIT 200
LIMIT 300
` `
type ListNewForYouTracksRow struct { type ListNewForYouTracksRow struct {
ID pgtype.UUID ID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Tier int32
} }
// #421 New for you, tiered per project rule #131 (#1267). The old // #421 New for you: tracks from albums added in the last 30 days
// single hard rule (added <30d AND direct affinity) had two failure // whose artist the user has liked OR played (>=3 non-skip). Album-
// modes with one root: no notion of consumption — the album you // coherent (newest album first, then disc/track) — the producer does
// devoured in week one crowded the mix for three more weeks, and // NOT diversity-cap these; they're meant as whole-album discovery.
// after a quiet month nothing qualified at all. // $1 user_id.
//
// "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.
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) { func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
rows, err := q.db.Query(ctx, listNewForYouTracks, userID) rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
if err != nil { if err != nil {
@@ -272,12 +186,7 @@ func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) (
var items []ListNewForYouTracksRow var items []ListNewForYouTracksRow
for rows.Next() { for rows.Next() {
var i ListNewForYouTracksRow var i ListNewForYouTracksRow
if err := rows.Scan( if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -294,10 +203,7 @@ WITH windowed AS (
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '30 days' AND started_at < now() - interval '30 days'
AND LEAST( AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
) <= 10
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_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 // 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 → // of needing a full year of history; still skips cleanly (no rows →
// no playlist) when there's no qualifying history yet. // 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. // $1 user_id, $2 date string.
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) { func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
@@ -363,32 +262,37 @@ WITH stats AS (
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id 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 SELECT id, album_id, artist_id
FROM tracks t FROM (
JOIN stats s ON s.track_id = t.id SELECT id, album_id, artist_id, c, tier FROM deep
WHERE s.c >= 3 UNION ALL
AND s.last_at <= now() - interval '30 days' SELECT id, album_id, artist_id, c, tier FROM shallow
AND NOT EXISTS ( WHERE NOT EXISTS (SELECT 1 FROM deep)
SELECT 1 FROM lidarr_quarantine q ) u
WHERE q.user_id = $1 AND q.track_id = t.id ORDER BY tier, c DESC, 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
LIMIT 200 LIMIT 200
` `
@@ -398,17 +302,13 @@ type ListRediscoverTracksRow struct {
ArtistID pgtype.UUID ArtistID pgtype.UUID
} }
// #420 Rediscover: tracks the user played a lot but has drifted away // #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
// from. One blended pool (issue #1246: the old two-tier UNION was // has drifted away from. Tiered so a young library still gets a mix:
// 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 0 not played in the last 6 months (true rediscovery)
// tier was also a strict subset of the 30d tier). Eligibility is >=3 // tier 1 (only if tier 0 empty) not played in the last 30 days
// 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 // Ordered by historical affection. $1 user_id.
// >=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.
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) { func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
rows, err := q.db.Query(ctx, listRediscoverTracks, userID) rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
if err != nil { 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 const pickSeedArtists = `-- name: PickSeedArtists :many
WITH liked AS ( WITH plays AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
),
recent7 AS (
SELECT t.artist_id, SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
0 AS tier
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 WHERE pe.user_id = $1
@@ -304,80 +300,27 @@ recent7 AS (
AND t.artist_id IS NOT NULL AND t.artist_id IS NOT NULL
GROUP BY t.artist_id GROUP BY t.artist_id
), ),
recent30 AS ( liked AS (
SELECT t.artist_id, SELECT artist_id FROM general_likes_artists WHERE user_id = $1
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)
) )
SELECT c.artist_id, SELECT p.artist_id,
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score, (p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
c.tier::int AS tier FROM plays p
FROM chosen c LEFT JOIN liked l ON l.artist_id = p.artist_id
LEFT JOIN liked l ON l.artist_id = c.artist_id ORDER BY score DESC, p.artist_id
ORDER BY score DESC, c.artist_id LIMIT 5
LIMIT 12
` `
type PickSeedArtistsRow struct { type PickSeedArtistsRow struct {
ArtistID pgtype.UUID ArtistID pgtype.UUID
Score int64 Score int64
Tier int32
} }
// Top-12 most-engaged distinct artist candidates, tiered so the // Top-5 most-engaged distinct artist candidates in the user's last 7
// "Songs like X" mixes never silently vanish (#1255): the old hard // days. The Go-side picker (pickSeedArtistsForDay) shuffles these
// 7-day window emptied the seed pool after a quiet week, and the // daily-deterministically and takes the first 3 so the set of
// daily atomic-replace build then deleted every existing mix until // "Songs like X" mixes rotates day-to-day.
// the user played something again. Same fallback shape as // Score = unskipped-play count + 5 if user has liked the artist.
// 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).
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) { func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID) rows, err := q.db.Query(ctx, pickSeedArtists, userID)
if err != nil { if err != nil {
@@ -387,7 +330,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi
var items []PickSeedArtistsRow var items []PickSeedArtistsRow
for rows.Next() { for rows.Next() {
var i PickSeedArtistsRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -514,10 +457,10 @@ SELECT id
// tier 2 (only if tiers 0+1 empty) liked tracks // tier 2 (only if tiers 0+1 empty) liked tracks
// //
// Returns up to 5 ids; tie-break by track_id for determinism. The // Returns up to 5 ids; tie-break by track_id for determinism. The
// Go-side picker (pickDailySeeds) draws the day's seeds from these. // Go-side picker (pickForYouSeedForDay) rotates one per day via
// Widened from a hard 7-day window, which made For-You disappear // userIDHash. Widened from a hard 7-day window, which made For-You
// after a week of not listening and never recover on a self-hosted // disappear after a week of not listening and never recover on a
// library with sparse history. // self-hosted library with sparse history.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID) rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil { 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 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 FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1 WHERE gl.user_id = $1
` `
type ListLikedTrackTasteInputsForUserRow struct { type ListLikedTrackTasteInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Genre *string Genre *string
} }
// (track_id, artist_id, genre) for each track the user has explicitly // (artist_id, genre) for each track the user has explicitly liked. Feeds the
// liked. Feeds the track-like bonus into the liked track's artist and // track-like bonus into the liked track's artist and tags.
// tags; track_id keys the enriched track_tags lookup (#1490).
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) { func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID) rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil { if err != nil {
@@ -111,7 +109,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
var items []ListLikedTrackTasteInputsForUserRow var items []ListLikedTrackTasteInputsForUserRow
for rows.Next() { for rows.Next() {
var i ListLikedTrackTasteInputsForUserRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -125,7 +123,6 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
SELECT SELECT
t.id AS track_id,
t.artist_id, t.artist_id,
t.genre, t.genre,
LEAST(GREATEST( LEAST(GREATEST(
@@ -149,7 +146,6 @@ type ListPlayEngagementInputsForUserParams struct {
} }
type ListPlayEngagementInputsForUserRow struct { type ListPlayEngagementInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Genre *string Genre *string
Completion float64 Completion float64
@@ -173,7 +169,6 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
for rows.Next() { for rows.Next() {
var i ListPlayEngagementInputsForUserRow var i ListPlayEngagementInputsForUserRow
if err := rows.Scan( if err := rows.Scan(
&i.TrackID,
&i.ArtistID, &i.ArtistID,
&i.Genre, &i.Genre,
&i.Completion, &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 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) { 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.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
) )
return i, err return i, err
} }
const getTrackByPath = `-- name: GetTrackByPath :one 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) { 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.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
) )
return i, err return i, err
} }
const getTracksByIDs = `-- name: GetTracksByIDs :many 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 // 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.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -192,7 +186,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
} }
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many 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, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM tracks t FROM tracks t
@@ -248,8 +242,6 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -264,7 +256,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
} }
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many 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, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM tracks t FROM tracks t
@@ -317,8 +309,6 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -333,7 +323,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
} }
const listTracksByAlbum = `-- name: ListTracksByAlbum :many 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 WHERE album_id = $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
@@ -375,8 +365,6 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.Genre, &i.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -424,7 +412,7 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
} }
const searchTracks = `-- name: SearchTracks :many 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 || '%' WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
@@ -473,8 +461,6 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.Genre, &i.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -522,7 +508,7 @@ ON CONFLICT (file_path) DO UPDATE SET
mbid = EXCLUDED.mbid, mbid = EXCLUDED.mbid,
genre = EXCLUDED.genre, genre = EXCLUDED.genre,
updated_at = now() 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 { type UpsertTrackParams struct {
@@ -573,8 +559,6 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.Genre, &i.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
) )
return i, err 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) ORDER BY md5(t.id::text || $2::text)
LIMIT 200; 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; LIMIT 1;
-- name: InsertPlayEvent :one -- 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 ( INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind user_id, track_id, session_id, started_at, client_id, source
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text) ) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *; 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 -- name: UpdatePlayEventEnded :one
-- Closes a play_event by id with the given ended_at, duration, and skip flag. -- 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 -- 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 -- name: AppendPlaylistTrack :one
-- Inserts at the next available position. Snapshot fields are copied -- Inserts at the next available position. Snapshot fields are copied
-- from the tracks/albums/artists join at insert time. tracks.duration_ms -- 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 -- is converted to seconds for the snapshot.
-- 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)
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT SELECT
sqlc.arg(playlist_id)::uuid, sqlc.arg(playlist_id)::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0), COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
@@ -78,8 +77,7 @@ SELECT
t.title, t.title,
artists.name, artists.name,
albums.title, albums.title,
(t.duration_ms / 1000)::integer, (t.duration_ms / 1000)::integer
sqlc.narg(pick_kind)::text
FROM tracks t FROM tracks t
JOIN albums ON albums.id = t.album_id JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_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 -- Recommendation observability (#796 phase 4). Per-source play outcomes so the
-- operator can see whether each recommendation surface is landing and tune 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 -- 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 — -- a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
-- those rows are INCLUDED here as the baseline control group the surfaces are -- NULL for library / radio / user-playlist plays, which are excluded here.
-- 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;
-- name: RecommendationSourceMetricsForUser :many -- name: RecommendationSourceMetricsForUser :many
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the -- $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. -- mean completion ratio over plays that recorded one (0 when none did).
-- 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.
SELECT SELECT
pe.source, pe.source,
pe.pick_kind,
count(*)::bigint AS plays, count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips, count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n, COALESCE(
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
0)::float8 AS avg_completion
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') 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; 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 -- name: ListDeepCutsTracks :many
-- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user -- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
-- has liked OR played heavily (>=5 non-skip plays across the artist). -- 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. -- $1 user_id, $2 date string.
WITH affinity_artists AS ( WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 SELECT artist_id FROM general_likes_artists WHERE user_id = $1
@@ -28,20 +23,12 @@ play_counts AS (
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id 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 SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN affinity_artists aa ON aa.artist_id = t.artist_id JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.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 WHERE COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id 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; LIMIT 200;
-- name: ListRediscoverTracks :many -- name: ListRediscoverTracks :many
-- #420 Rediscover: tracks the user played a lot but has drifted away -- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
-- from. One blended pool (issue #1246: the old two-tier UNION was -- has drifted away from. Tiered so a young library still gets a mix:
-- all-or-nothing — a single ">=6 months cold" row suppressed the whole -- tier 0 not played in the last 6 months (true rediscovery)
-- ">=30 days" tier, which is how a one-song playlist shipped; the 6mo -- tier 1 (only if tier 0 empty) not played in the last 30 days
-- tier was also a strict subset of the 30d tier). Eligibility is >=3 -- Ordered by historical affection. $1 user_id.
-- 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.
WITH stats AS ( WITH stats AS (
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id 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 SELECT id, album_id, artist_id
FROM tracks t FROM (
JOIN stats s ON s.track_id = t.id SELECT id, album_id, artist_id, c, tier FROM deep
WHERE s.c >= 3 UNION ALL
AND s.last_at <= now() - interval '30 days' SELECT id, album_id, artist_id, c, tier FROM shallow
AND NOT EXISTS ( WHERE NOT EXISTS (SELECT 1 FROM deep)
SELECT 1 FROM lidarr_quarantine q ) u
WHERE q.user_id = $1 AND q.track_id = t.id ORDER BY tier, c DESC, 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
LIMIT 200; LIMIT 200;
-- name: ListNewForYouTracks :many -- name: ListNewForYouTracks :many
-- #421 New for you, tiered per project rule #131 (#1267). The old -- #421 New for you: tracks from albums added in the last 30 days
-- single hard rule (added <30d AND direct affinity) had two failure -- whose artist the user has liked OR played (>=3 non-skip). Album-
-- modes with one root: no notion of consumption — the album you -- coherent (newest album first, then disc/track) — the producer does
-- devoured in week one crowded the mix for three more weeks, and -- NOT diversity-cap these; they're meant as whole-album discovery.
-- after a quiet month nothing qualified at all. -- $1 user_id.
-- WITH affinity_artists AS (
-- "Consumed" = any track attempted for >=30s (the First Listens SELECT artist_id FROM general_likes_artists WHERE user_id = $1
-- 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
UNION UNION
SELECT t.artist_id 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 GROUP BY t.artist_id
HAVING COUNT(*) >= 3 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 FROM tracks t
JOIN albums_tiered alt ON alt.album_id = t.album_id JOIN albums al ON al.id = t.album_id
WHERE NOT EXISTS ( 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 SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY alt.tier, alt.created_at DESC, t.album_id, ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
t.disc_number NULLS FIRST, t.track_number NULLS FIRST LIMIT 200;
LIMIT 300;
-- name: ListOnThisDayTracks :many -- name: ListOnThisDayTracks :many
-- #422 On This Day: tracks the user played around this calendar date -- #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 -- 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 → -- of needing a full year of history; still skips cleanly (no rows →
-- no playlist) when there's no qualifying history yet. -- 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. -- $1 user_id, $2 date string.
WITH windowed AS ( WITH windowed AS (
SELECT track_id, COUNT(*) AS c SELECT track_id, COUNT(*) AS c
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '30 days' AND started_at < now() - interval '30 days'
AND LEAST( AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
) <= 10
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_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; LIMIT 200;
-- name: ListFirstListensTracks :many -- name: ListFirstListensTracks :many
-- #423 First Listens: songs the user has never heard or even -- #423 First Listens: albums the user has never played any track of.
-- attempted. "Attempted" is track-level with a >=30s listen threshold -- Tiered: liked-artist albums first, then played-artist albums, then
-- (#1268, operator decision 2026-07-03) — the old version disqualified -- the rest — album-coherent within each tier. Not diversity-capped
-- a whole album on ANY play_event, so a 2-second accidental brush -- (whole-album discovery). $1 user_id.
-- banished it permanently. WITH heard_albums AS (
-- SELECT DISTINCT t.album_id
-- tier 1 the exact desire: albums with ZERO attempted tracks FROM play_events pe JOIN tracks t ON t.id = pe.track_id
-- tier 2 step back: barely-attempted albums (<=25% of tracks WHERE pe.user_id = $1
-- 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
), ),
album_attempts AS ( played_artists 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 (
SELECT DISTINCT t.artist_id SELECT DISTINCT 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
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
) )
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 FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
JOIN albums_tiered alt ON alt.album_id = al.id WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
WHERE alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY ORDER BY
alt.tier,
(CASE (CASE
WHEN EXISTS ( WHEN EXISTS (
SELECT 1 FROM general_likes_artists gla SELECT 1 FROM general_likes_artists gla
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
) THEN 0 ) 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 ELSE 2
END), END),
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST 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; UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
-- name: PickSeedArtists :many -- name: PickSeedArtists :many
-- Top-12 most-engaged distinct artist candidates, tiered so the -- Top-5 most-engaged distinct artist candidates in the user's last 7
-- "Songs like X" mixes never silently vanish (#1255): the old hard -- days. The Go-side picker (pickSeedArtistsForDay) shuffles these
-- 7-day window emptied the seed pool after a quiet week, and the -- daily-deterministically and takes the first 3 so the set of
-- daily atomic-replace build then deleted every existing mix until -- "Songs like X" mixes rotates day-to-day.
-- the user played something again. Same fallback shape as -- Score = unskipped-play count + 5 if user has liked the artist.
-- PickTopPlayedTracksForUser (For You's seeds): WITH plays AS (
-- 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 (
SELECT t.artist_id, SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count, COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
0 AS tier
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 WHERE pe.user_id = $1
@@ -78,53 +62,15 @@ recent7 AS (
AND t.artist_id IS NOT NULL AND t.artist_id IS NOT NULL
GROUP BY t.artist_id GROUP BY t.artist_id
), ),
recent30 AS ( liked AS (
SELECT t.artist_id, SELECT artist_id FROM general_likes_artists WHERE user_id = $1
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)
) )
SELECT c.artist_id, SELECT p.artist_id,
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score, (p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
c.tier::int AS tier FROM plays p
FROM chosen c LEFT JOIN liked l ON l.artist_id = p.artist_id
LEFT JOIN liked l ON l.artist_id = c.artist_id ORDER BY score DESC, p.artist_id
ORDER BY score DESC, c.artist_id LIMIT 5;
LIMIT 12;
-- name: PickTopPlayedTracksForUser :many -- name: PickTopPlayedTracksForUser :many
-- For-You candidate seeds, tiered so For-You never silently vanishes: -- 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 1 (only if tier 0 empty) all-time top non-skip plays
-- tier 2 (only if tiers 0+1 empty) liked tracks -- tier 2 (only if tiers 0+1 empty) liked tracks
-- Returns up to 5 ids; tie-break by track_id for determinism. The -- Returns up to 5 ids; tie-break by track_id for determinism. The
-- Go-side picker (pickDailySeeds) draws the day's seeds from these. -- Go-side picker (pickForYouSeedForDay) rotates one per day via
-- Widened from a hard 7-day window, which made For-You disappear -- userIDHash. Widened from a hard 7-day window, which made For-You
-- after a week of not listening and never recover on a self-hosted -- disappear after a week of not listening and never recover on a
-- library with sparse history. -- self-hosted library with sparse history.
WITH recent AS ( WITH recent AS (
SELECT t.id, COUNT(*) AS c, 0 AS tier SELECT t.id, COUNT(*) AS c, 0 AS tier
FROM play_events pe 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 -- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
-- is split into tags in Go. Quarantined tracks are excluded. -- is split into tags in Go. Quarantined tracks are excluded.
SELECT SELECT
t.id AS track_id,
t.artist_id, t.artist_id,
t.genre, t.genre,
LEAST(GREATEST( LEAST(GREATEST(
@@ -26,10 +25,9 @@ WHERE pe.user_id = $1
); );
-- name: ListLikedTrackTasteInputsForUser :many -- name: ListLikedTrackTasteInputsForUser :many
-- (track_id, artist_id, genre) for each track the user has explicitly -- (artist_id, genre) for each track the user has explicitly liked. Feeds the
-- liked. Feeds the track-like bonus into the liked track's artist and -- track-like bonus into the liked track's artist and tags.
-- tags; track_id keys the enriched track_tags lookup (#1490). SELECT t.artist_id, t.genre
SELECT t.id AS track_id, t.artist_id, t.genre
FROM general_likes gl FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1; 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;
-5
View File
@@ -62,11 +62,6 @@ var dataTables = []string{
// (never recreates the singleton, seeded once by 0018); ResetDB // (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead. // resets its counter via UPDATE below instead.
"cover_art_provider_settings", "cover_art_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", "tracks",
"albums", "albums",
"artists", "artists",
-46
View File
@@ -97,36 +97,6 @@ var systemPlaylistSources = map[string]bool{
"songs_like_artist": true, "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` // RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
// tag identifying which surface the play came from. When source is a // tag identifying which surface the play came from. When source is a
// known system-playlist kind the track is appended to that user's // known system-playlist kind the track is appended to that user's
@@ -157,13 +127,6 @@ func (w *Writer) RecordPlayStartedWithSource(
if source != "" { if source != "" {
sourcePtr = &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{ ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID, UserID: userID,
TrackID: trackID, TrackID: trackID,
@@ -171,7 +134,6 @@ func (w *Writer) RecordPlayStartedWithSource(
StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr, ClientID: clientIDPtr,
Source: sourcePtr, Source: sourcePtr,
PickKind: pickKind,
}) })
if err != nil { if err != nil {
return err return err
@@ -433,13 +395,6 @@ func (w *Writer) RecordOfflinePlay(
if source != "" { if source != "" {
sourcePtr = &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{ ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID, UserID: userID,
TrackID: trackID, TrackID: trackID,
@@ -447,7 +402,6 @@ func (w *Writer) RecordOfflinePlay(
StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr, ClientID: clientIDPtr,
Source: sourcePtr, Source: sourcePtr,
PickKind: pickKind,
}) })
if err != nil { if err != nil {
return err 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") 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 ( const (
discoverTotalSlots = 100 discoverTotalSlots = 100
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488): discoverDormantSlots = 40
// the taste-matched-unheard arm gets the plurality, dormant stays a discoverCrossUserSlots = 30
// familiar anchor, and the crude random arm is trimmed to a discoverRandomSlots = 30
// 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
discoverMaxTracksPerAlbum = 2 discoverMaxTracksPerAlbum = 2
discoverMaxTracksPerArtist = 3 discoverMaxTracksPerArtist = 3
) )
// discoverTrack is the common shape used by the bucket allocator. The // discoverTrack is the common shape used by the bucket allocator. The
// three sqlc-generated row types collapse into this internal struct so // three sqlc-generated row types collapse into this internal struct so
// downstream functions don't need to be generic over them. PickKind is // downstream functions don't need to be generic over them.
// 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.
type discoverTrack struct { type discoverTrack struct {
ID pgtype.UUID ID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
PickKind string
} }
// buildDiscoverCandidates assembles the Discover playlist track list. // buildDiscoverCandidates assembles the Discover playlist track list.
// Pulls from four buckets (taste_unheard / dormant / cross_user / // Pulls from three buckets, applies per-album/per-artist caps, then
// random), applies per-album/per-artist caps, then redistributes any // redistributes any deficit equally across the remaining buckets.
// deficit equally across the remaining buckets.
// //
// Returns up to discoverTotalSlots track IDs in the order they should // Returns up to discoverTotalSlots track IDs in the order they should
// appear in the playlist (round-robin interleaved across buckets). // 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 // the redistribution algorithm rolls the deficit into the surviving
// buckets so one broken bucket can't silently kill the whole playlist. // 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) { 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{ dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
UserID: userID, Column2: dateStr, 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) "user_id", uuidStringPL(userID), "err", err)
randomRows = nil 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 { // Adapt sqlc-generated rows into the internal struct, then apply
out := make([]discoverTrack, len(rows)) // per-album / per-artist caps.
for i, r := range rows { dormantPool := capByAlbumAndArtist(dormantRowsToTracks(dormantRows))
out[i] = discoverTrack{ crossUserPool := capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows))
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID, randomPool := capByAlbumAndArtist(randomRowsToTracks(randomRows))
PickKind: pickKindTasteUnheard,
} // 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 { func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindDormant,
}
} }
return out return out
} }
@@ -160,10 +107,7 @@ func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []dis
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack { func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindCrossUser,
}
} }
return out return out
} }
@@ -171,10 +115,7 @@ func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack { func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindRandom,
}
} }
return out 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(2, 11, 101, 0.9),
makeCand(3, 12, 102, 0.8), 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 { if len(got) != 3 {
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got)) 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++ { for i := 0; i < 7; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(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 { if len(got) != 7 {
t.Errorf("len = %d, want 7 (pool == total)", len(got)) 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++ { for i := 0; i < 100; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(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 { if len(got) != 25 {
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got)) 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))) in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
} }
now := time.Now() now := time.Now()
got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5) got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5) got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
if len(got1) != len(got2) { if len(got1) != len(got2) {
t.Fatalf("len mismatch: %d vs %d", 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))) in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
} }
now := time.Now() now := time.Now()
day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5) day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
day2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-08", now, 20, 5) day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5)
if len(day1) != 25 || len(day2) != 25 { if len(day1) != 25 || len(day2) != 25 {
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2)) 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) sim := float64(50 - i)
in = append(in, makeCand(i+1, i+1, i+1, sim)) 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 { if len(got) != 8 {
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got)) 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)) 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 // 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. // 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) { 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", s.logger.Warn("scheduler: taste profile rebuild failed",
"user_id", uuidStringPL(userID), "err", err) "user_id", uuidStringPL(userID), "err", err)
} }
+63 -86
View File
@@ -1,7 +1,6 @@
package playlists package playlists
import ( import (
"sort"
"testing" "testing"
"github.com/jackc/pgx/v5/pgtype" "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- // userIDHash is the per-user, per-day hash that drives the daily-
// determinism RNGs. Same family as tieBreakHash, just keyed on user // determinism RNGs. Same family as tieBreakHash, just keyed on user
// ID instead of track ID. // ID instead of track ID.
@@ -97,100 +76,81 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
} }
} }
// pickDailySeeds shuffles the candidate pool daily-deterministically // pickForYouSeedForDay rotates the chosen For-You seed across the
// and takes up to n — For-You uses n=forYouSeedCount for its // user's top-played candidates using userIDHash. Verifies the picker
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via // is deterministic within a day, varies across days, and degrades
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day, // gracefully when fewer than 5 candidates exist.
// variation across days, and graceful degradation on small pools.
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) { func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := []pgtype.UUID{ seeds := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true}, {Bytes: [16]byte{50}, Valid: true},
} }
a := pickDailySeeds(pool, u, "2026-05-04", 3) a := pickForYouSeedForDay(seeds, u, "2026-05-04")
b := pickDailySeeds(pool, u, "2026-05-04", 3) b := pickForYouSeedForDay(seeds, u, "2026-05-04")
if len(a) != 3 || len(b) != 3 { if a != b {
t.Fatalf("lens = %d, %d; want 3, 3", len(a), len(b)) t.Fatalf("same day should pick same seed; got %v then %v", a, b)
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("same day should pick same seeds; 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} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := []pgtype.UUID{ seeds := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true}, {Bytes: [16]byte{50}, Valid: true},
} }
firstPicks := map[[16]byte]bool{} picks := map[[16]byte]bool{}
for i := 1; i <= 30; i++ { for i := 1; i <= 30; i++ {
date := "2026-05-" + twoDigits(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 { if len(picks) < 2 {
t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks)) 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} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true} only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true}
got := pickDailySeeds([]pgtype.UUID{only}, u, "2026-05-04", 3) got := pickForYouSeedForDay([]pgtype.UUID{only}, u, "2026-05-04")
if len(got) != 1 || got[0] != only { if got != only {
t.Errorf("single-entry pool should return just that entry; got %v", got) 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} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 { got := pickForYouSeedForDay(nil, u, "2026-05-04")
t.Errorf("empty pool should return nothing; got %v", got) if got.Valid {
t.Errorf("empty pool should return zero UUID; got %v", got)
} }
} }
// pickSeedArtistsForDay takes the user's top-12 candidate artists and // pickSeedArtistsForDay takes the user's top-5 candidate artists and
// returns songsLikeSeedCount of them via daily-deterministic shuffle. // returns 3 of them via daily-deterministic shuffle. Verifies the
// Verifies the picker is deterministic within a day, varies across days, // picker is deterministic within a day, varies across days, and
// and degrades gracefully when fewer than songsLikeSeedCount candidates // degrades gracefully when fewer than 3 or 5 candidates exist.
// 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)
}
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) { func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} 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") a := pickSeedArtistsForDay(pool, u, "2026-05-04")
b := pickSeedArtistsForDay(pool, u, "2026-05-04") b := pickSeedArtistsForDay(pool, u, "2026-05-04")
if len(a) != songsLikeSeedCount || len(b) != songsLikeSeedCount { if len(a) != 3 || len(b) != 3 {
t.Fatalf("expected %d seeds; got %d / %d", songsLikeSeedCount, len(a), len(b)) t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b))
} }
for i := range a { for i := range a {
if a[i] != b[i] { if a[i] != b[i] {
@@ -201,23 +161,40 @@ func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) { func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := bigSeedPool(10) pool := []pgtype.UUID{
// Collect the seed set (order-independent) across many dates. With {Bytes: [16]byte{10}, Valid: true},
// C(10, songsLikeSeedCount) combinations, 30 dates yield >=2 distinct sets. {Bytes: [16]byte{20}, Valid: true},
seen := map[string]bool{} {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++ { for i := 1; i <= 30; i++ {
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i)) got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
if len(got) != songsLikeSeedCount { if len(got) != 3 {
t.Fatalf("expected %d seeds; got %d", songsLikeSeedCount, len(got)) 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 { 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} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := []pgtype.UUID{ pool := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{10}, Valid: true},
+89 -352
View File
@@ -12,10 +12,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"math/rand" "math/rand"
"sort" "sort"
"sync"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -24,7 +22,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
) )
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape. // seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
@@ -35,7 +32,7 @@ type seedArtistRow struct {
} }
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The // 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 // just a column projection — but pulling it into a function keeps the
// call-site readable and makes the post-fetch path testable without // call-site readable and makes the post-fetch path testable without
// a database. // a database.
@@ -86,15 +83,32 @@ func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
return binary.BigEndian.Uint64(sum[:8]) return binary.BigEndian.Uint64(sum[:8])
} }
// pickDailySeeds takes a candidate pool of UUIDs and returns up to n // pickForYouSeedForDay picks one of the user's top-played candidate
// of them, daily-deterministically shuffled. Each day gets a different // tracks as today's For-You seed. With 5 candidates and SHA-256-based
// ordering / selection, but within-day stability is preserved (same // rotation, each candidate gets picked roughly 1 day in 5; the chosen
// inputs always produce the same output). // 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. // 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. // 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 { if len(pool) == 0 {
return nil 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) { rng.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i] shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
}) })
n := 3
if len(shuffled) < n { if len(shuffled) < n {
n = len(shuffled) n = len(shuffled)
} }
return shuffled[:n] 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 // rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from // sorting before insert into playlist_tracks. T5 fills these from
// recommendation.Candidate scores. PickKind is the track's provenance // recommendation.Candidate scores.
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
type rankedCandidate struct { type rankedCandidate struct {
TrackID pgtype.UUID TrackID pgtype.UUID
Score float64 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
}
} }
const systemMixLength = 25 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 // JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
// RNG (see scoreAndSortCandidates) — same (user, day) produces same // RNG (see scoreAndSortCandidates) — same (user, day) produces same
// scores within a day, but near-tied candidates reshuffle across days // scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen. // so the playlist doesn't feel frozen.
// var systemMixWeights = recommendation.ScoringWeights{
// DB-tunable since #1250: the recsettings service pushes the current BaseWeight: 1.0,
// daily_mix profile via SetSystemMixWeights at boot and on every admin LikeBoost: 2.0,
// change (coverart Configure() pattern — no signature threading, live RecencyWeight: 1.0,
// effect without restart). The literal here is only the pre-push SkipPenalty: 2.0,
// value; shipped defaults live in recsettings.ShippedDailyMixWeights, JitterMagnitude: 0.1,
// which must stay in sync with it. ContextWeight: 0.5,
var ( SimilarityWeight: 1.5,
systemTuningMu sync.RWMutex // Taste profile (#796 phase 2): the daily mixes are the primary
systemMixWeights = recommendation.ScoringWeights{ // taste-driven surface, so they lean on it. TasteMatchScore is in
BaseWeight: 1.0, // [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
LikeBoost: 2.0, // while passive avoidance (negative) gently demotes.
RecencyWeight: 1.0, TasteWeight: 1.5,
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
} }
// forYouHeadN is the number of top-scored tracks that anchor the For-You // forYouHeadN is the number of top-scored tracks that anchor the For-You
@@ -268,122 +175,6 @@ const (
forYouTailN = 50 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 // scoreAndSortCandidates scores every candidate with recommendation.Score
// and returns a new slice sorted by score DESC (ties broken by // and returns a new slice sorted by score DESC (ties broken by
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is // 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 { sort.SliceStable(ordered, func(i, j int) bool {
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID) return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
}) })
weights := currentSystemMixWeights()
pairs := make([]scored, len(ordered)) pairs := make([]scored, len(ordered))
for i, c := range 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 { sort.SliceStable(pairs, func(i, j int) bool {
if pairs[i].score != pairs[j].score { if pairs[i].score != pairs[j].score {
@@ -546,11 +336,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
} }
} }
// produceForYou: blend candidate pools from up to forYouSeedCount of // produceForYou: today's seed from the user's top-5 played tracks
// the user's top-5 played tracks (rotating daily via pickDailySeeds), // (rotates daily via userIDHash), similarity candidate pool, head+
// then head+tail composition with per-seed head quotas (#1269). The // tail composition. The base seed query failing is fatal; a
// base seed query failing is fatal; a per-seed candidate-load failure // candidate-load failure is logged and yields no For-You.
// is logged and that seed just contributes nothing.
func produceForYou( func produceForYou(
ctx context.Context, q *dbq.Queries, logger *slog.Logger, ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time, userID pgtype.UUID, dateStr string, now time.Time,
@@ -559,52 +348,33 @@ func produceForYou(
if err != nil { if err != nil {
return nil, fmt.Errorf("pick for-you seed candidates: %w", err) return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
} }
seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount) forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
if len(seeds) == 0 { if !forYouSeed.Valid {
return nil, nil return nil, nil
} }
zeroVec := recommendation.SessionVector{Seed: true} zeroVec := recommendation.SessionVector{Seed: true}
// Merge per-seed pools; first-seen wins on dedup, and seedOf cands, cerr := recommendation.LoadCandidatesFromSimilarity(
// remembers which seed sourced each track for the head quotas. ctx, q, userID, forYouSeed,
var merged []recommendation.Candidate 1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
seedOf := map[pgtype.UUID]int{} zeroVec,
for i, seed := range seeds { []pgtype.UUID{forYouSeed},
cands, cerr := recommendation.LoadCandidatesFromSimilarity( systemForYouSourceLimits(),
ctx, q, userID, seed, )
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood if cerr != nil {
zeroVec, logger.Warn("system playlist: for-you candidates load failed; skipping",
seeds, "user_id", uuidStringPL(userID), "err", cerr)
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 {
return nil, nil 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 { if len(tracks) == 0 {
return nil, nil return nil, nil
} }
return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
} }
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}" // produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
// mixes. Seed artists rotate daily-deterministically; the seed query falls back // artists rotate daily-deterministically. The base seed-artist
// through widening engagement windows (#1255) and every returned row // query failing is fatal; per-artist failures are logged + skipped.
// 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.
func produceSeedMixes( func produceSeedMixes(
ctx context.Context, q *dbq.Queries, logger *slog.Logger, ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time, userID pgtype.UUID, dateStr string, now time.Time,
@@ -613,10 +383,6 @@ func produceSeedMixes(
if err != nil { if err != nil {
return nil, fmt.Errorf("pick seed artists: %w", err) 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)) seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
for _, r := range seedRows { for _, r := range seedRows {
seedRowsLocal = append(seedRowsLocal, seedArtistRow{ seedRowsLocal = append(seedRowsLocal, seedArtistRow{
@@ -661,9 +427,6 @@ func produceSeedMixes(
if len(tracks) == 0 { if len(tracks) == 0 {
continue continue
} }
for i := range tracks {
tracks[i].PickKind = seedTierKind
}
out = append(out, builtPlaylist{ out = append(out, builtPlaylist{
Name: fmt.Sprintf("Songs like %s", artistRow.Name), Name: fmt.Sprintf("Songs like %s", artistRow.Name),
Variant: "songs_like_artist", Variant: "songs_like_artist",
@@ -695,7 +458,7 @@ func produceDiscover(
} }
// BuildSystemPlaylists builds the user's daily system mixes (one For-You + // 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 // concurrency-guarded via system_playlist_runs.in_flight; deterministic
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")). // 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] capped = capped[:n]
} }
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
out := make([]rankedCandidate, len(capped)) out := make([]rankedCandidate, len(capped))
for i, c := range capped { for i, c := range capped {
out[i] = rankedCandidate{ out[i] = rankedCandidate{
TrackID: c.Track.ID, TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64), Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
} }
} }
return out return out
} }
// pickHeadAndTail picks headN taste anchors from the score-sorted pool // pickHeadAndTail picks headN from the score-sorted head plus tailN from
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed // positions 2*headN onward (the tail), with the tail sampled
// blend, #1269) plus tailN freshness picks sampled rank-weighted from // daily-deterministically via tieBreakHash. Caps applied before the
// positions 2*headN onward. Caps applied before the head/tail split. // head/tail split. Used by For-You only.
// Used by For-You only.
// //
// The "tail" — candidates ranked beyond 2*headN — is still similarity- // The "tail" — candidates ranked beyond 2*headN — is still similarity-
// related (every candidate passed the similarity filter) but isn't among // 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 // Falls back to standard pickTopN behavior when the candidate pool is too
// small to support a meaningful head/tail split (capped pool <= // small to support a meaningful head/tail split (capped pool <=
// headN+tailN, or no candidates at or beyond position 2*headN). // headN+tailN, or no candidates at or beyond position 2*headN).
func pickHeadAndTail( func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
) []rankedCandidate {
sorted := scoreAndSortCandidates(cands, userID, dateStr, now) sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted) capped := capCandidatesByAlbumAndArtist(sorted)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
total := headN + tailN total := headN + tailN
if len(capped) <= total { if len(capped) <= total {
// Pool too small for a head/tail split — return up to total entries. // 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 { if len(capped) < total {
total = len(capped) total = len(capped)
} }
out := make([]rankedCandidate, total) out := make([]rankedCandidate, total)
for i := 0; i < total; i++ { for i := 0; i < total; i++ {
out[i] = rankedCandidate{ out[i] = rankedCandidate{
TrackID: capped[i].Track.ID, TrackID: capped[i].Track.ID,
Score: recommendation.Score(capped[i].Inputs, weights, now, rng.Float64), Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
PickKind: pickKindTaste,
} }
} }
return out return out
} }
head := pickQuotaHead(capped, seedOf, numSeeds, headN) head := capped[:headN]
inHead := make(map[pgtype.UUID]bool, len(head))
for _, c := range head {
inHead[c.Track.ID] = true
}
tailStart := 2 * headN tailStart := 2 * headN
if tailStart >= len(capped) { if tailStart >= len(capped) {
tailStart = headN tailStart = headN
} }
// The tail pool keeps its rank order (position drives the sampling // Defensive copy so that sorting the tail pool does not mutate capped.
// weight); head members are excluded — a quota walk can reach past tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...)
// 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)
// Combine: head first (score-sorted under quotas), then the fresh // Sort tail pool by tieBreakHash (daily-deterministic), take tailN.
// sample. "First similar, then surprise" reads naturally in // Sample is stable across requests within a day but varies across days.
// playback. Head entries are the taste picks; tail entries are the sort.SliceStable(tailPool, func(i, j int) bool {
// freshness injection (#1249) — the split the metrics page return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr)
// attributes skips to. })
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 := make([]recommendation.Candidate, 0, len(head)+len(tail))
combined = append(combined, head...) combined = append(combined, head...)
combined = append(combined, tail...) combined = append(combined, tail...)
out := make([]rankedCandidate, len(combined)) out := make([]rankedCandidate, len(combined))
for i, c := range combined { for i, c := range combined {
kind := pickKindTaste
if i >= len(head) {
kind = pickKindFresh
}
out[i] = rankedCandidate{ out[i] = rankedCandidate{
TrackID: c.Track.ID, TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64), Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
PickKind: kind,
} }
} }
return out return out
@@ -966,15 +709,9 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
} }
for _, t := range tracks { for _, t := range tracks {
var pickKind *string
if t.PickKind != "" {
k := t.PickKind
pickKind = &k
}
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{ if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
PlaylistID: p.ID, PlaylistID: p.ID,
TrackID: t.TrackID, TrackID: t.TrackID,
PickKind: pickKind,
}); err != nil { }); err != nil {
// Track may have been deleted between candidate-load and insert; // Track may have been deleted between candidate-load and insert;
// skip silently rather than failing the whole build. // 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. // Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100 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 // discoveryMixSpec describes one discovery mix. The unified producer
// reads the spec and runs a single code path for all variants. // reads the spec and runs a single code path for all variants.
type discoveryMixSpec struct { type discoveryMixSpec struct {
@@ -66,11 +55,6 @@ type discoveryMixSpec struct {
// or when day-over-day stability is the intended UX (NewForYou). // or when day-over-day stability is the intended UX (NewForYou).
dailyRotate bool 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 // fetch returns the raw ranked rows. dateStr is supplied for
// queries that accept it (passed as the second positional arg // queries that accept it (passed as the second positional arg
// historically); queries that don't accept it ignore the param. // historically); queries that don't accept it ignore the param.
@@ -95,49 +79,25 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
if spec.dailyRotate { if spec.dailyRotate {
pool = rotateForDay(pool, userID, dateStr) pool = rotateForDay(pool, userID, dateStr)
} }
tracks := finishMix(pool, spec.diversify) return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil
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
} }
} }
// rotateForDay rotates the pool left by a daily-deterministic offset // rotateForDay rotates pool left by a daily-deterministic offset so
// so each day's downstream truncate-to-N surfaces a different slice // each day's downstream truncate-to-N surfaces a different slice of
// while contiguous-block ordering inside the slice is preserved. // the pool while contiguous-block ordering inside the slice is
// // preserved. Empty / single-element pools pass through unchanged.
// 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.
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack { func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
n := len(pool) n := len(pool)
if n <= 1 { if n <= 1 {
return pool return pool
} }
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
out := make([]discoverTrack, 0, n) offset := rng.Intn(n)
for start := 0; start < n; { rotated := make([]discoverTrack, 0, n)
end := start + 1 rotated = append(rotated, pool[offset:]...)
for end < n && pool[end].PickKind == pool[start].PickKind { rotated = append(rotated, pool[:offset]...)
end++ return rotated
}
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
} }
// discoveryMixSpecs is the concrete spec list used by the registry in // discoveryMixSpecs is the concrete spec list used by the registry in
@@ -149,7 +109,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "Deep Cuts", variant: "deep_cuts", name: "Deep Cuts", variant: "deep_cuts",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2) 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) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{ rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
UserID: uid, Column2: ds, UserID: uid, Column2: ds,
@@ -167,7 +126,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "Rediscover", variant: "rediscover", name: "Rediscover", variant: "rediscover",
diversify: true, dailyRotate: true, // SQL has no date arg 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) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListRediscoverTracks(ctx, uid) rows, err := q.ListRediscoverTracks(ctx, uid)
if err != nil { if err != nil {
@@ -183,7 +141,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "New for you", variant: "new_for_you", name: "New for you", variant: "new_for_you",
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes 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) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListNewForYouTracks(ctx, uid) rows, err := q.ListNewForYouTracks(ctx, uid)
if err != nil { if err != nil {
@@ -191,10 +148,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
} }
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindForMixTier(r.Tier),
}
} }
return out, nil return out, nil
}, },
@@ -202,7 +156,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "On this day", variant: "on_this_day", name: "On this day", variant: "on_this_day",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2) 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) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{ rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
UserID: uid, Column2: ds, UserID: uid, Column2: ds,
@@ -220,7 +173,6 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "First listens", variant: "first_listens", name: "First listens", variant: "first_listens",
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up 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) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListFirstListensTracks(ctx, uid) rows, err := q.ListFirstListensTracks(ctx, uid)
if err != nil { if err != nil {
@@ -228,10 +180,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
} }
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindForMixTier(r.Tier),
}
} }
return out, nil return out, nil
}, },
@@ -268,7 +217,7 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
} }
tracks := make([]rankedCandidate, len(pool)) tracks := make([]rankedCandidate, len(pool))
for i, t := range pool { for i, t := range pool {
tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind} tracks[i] = rankedCandidate{TrackID: t.ID}
} }
return tracks 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) { func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) {
pool := newPool(t) pool := newPool(t)
logger := discardLogger() logger := discardLogger()
+3 -8
View File
@@ -83,14 +83,10 @@ func buildYouMightLike(
"user_id", uuidStringPL(userID), "err", err) "user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false} return youMightLikeResult{built: false}
} }
// One rotating seed is right here (unlike For-You's multi-seed seed := pickForYouSeedForDay(seeds, userID, dateStr)
// blend, #1269): the row is a short shelf, not a mix, and a single if !seed.Valid {
// neighborhood per day keeps it coherent.
daily := pickDailySeeds(seeds, userID, dateStr, 1)
if len(daily) == 0 {
return youMightLikeResult{built: true} return youMightLikeResult{built: true}
} }
seed := daily[0]
zeroVec := recommendation.SessionVector{Seed: true} zeroVec := recommendation.SessionVector{Seed: true}
// You-might-like surfaces in-library artists the user does NOT actively // 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, cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
) (albumIDs, artistIDs []pgtype.UUID) { ) (albumIDs, artistIDs []pgtype.UUID) {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
albumScores := map[pgtype.UUID][]float64{} albumScores := map[pgtype.UUID][]float64{}
artistScores := map[pgtype.UUID][]float64{} artistScores := map[pgtype.UUID][]float64{}
albumArtist := map[pgtype.UUID]pgtype.UUID{} albumArtist := map[pgtype.UUID]pgtype.UUID{}
for _, c := range cands { 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 { if c.Track.AlbumID.Valid {
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s) albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
albumArtist[c.Track.AlbumID] = c.Track.ArtistID 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/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "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/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/web" "git.fabledsword.com/bvandeusen/minstrel/web"
) )
@@ -78,7 +76,6 @@ type Server struct {
BrandingCfg config.BrandingConfig BrandingCfg config.BrandingConfig
CoverEnricher *coverart.Enricher CoverEnricher *coverart.Enricher
CoverSettings *coverart.SettingsService CoverSettings *coverart.SettingsService
TagSettings *tags.SettingsService
LibraryScanner *library.Scanner LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig ScanCfg library.RunScanConfig
// Bus is the live-event bus shared with background workers (the // 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 // PUT /api/me/timezone and POST /api/auth/register can call
// Refresh synchronously. // Refresh synchronously.
PlaylistScheduler *playlists.Scheduler 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 // StreamSecret is the HMAC key used by /api/cast/stream-token to
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream // mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
// to verify them. Sourced from config.Config.StreamSecret. Tests that // to verify them. Sourced from config.Config.StreamSecret. Tests that
@@ -153,18 +143,7 @@ func (s *Server) Router() http.Handler {
if bus == nil { if bus == nil {
bus = eventbus.New() bus = eventbus.New()
} }
recSettings := s.RecSettings 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)
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/admin/scan is the only admin route owned by the server package // /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware // (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second // route — using r.Route("/api/admin", ...) here would create a second

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