Compare commits

..

75 Commits

Author SHA1 Message Date
bvandeusen 23a82fb38d Merge pull request 'M9 — Device diagnostics & debug reporting (connectivity + UPnP desync)' (#104) from dev into main
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m6s
test-go / integration (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 1m38s
2026-06-29 19:24:16 -04:00
bvandeusen 0de2437689 Merge pull request 'Image rendering + player resilience (#968, #980)' (#103) from dev into main
test-go / test (push) Successful in 46s
test-web / test (push) Successful in 55s
android / Build + lint + test (push) Successful in 4m11s
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)
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m12s
release / Build + push container image (push) Successful in 15s
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)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
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
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
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 13s
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
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m59s
test-go / integration (push) Successful in 11m14s
release / Build signed APK (tag releases only) (push) Successful in 4m22s
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
android / Build + lint + test (push) Successful in 5m47s
release / Build signed APK (tag releases only) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 24s
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 signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 19s
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 + lint + test (push) Successful in 4m34s
android / Build signed release APK (push) Has been skipped
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
android / Build + lint + test (push) Successful in 4m49s
android / Build signed release APK (push) Failing after 3m32s
test-go / integration (push) Successful in 9m33s
release / release (push) Failing after 15m9s
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
183 changed files with 1145 additions and 12693 deletions
@@ -1,48 +0,0 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the tag-enrichment provider settings (#1521).
* No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
*/
@Singleton
class AdminTagSourcesRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminTagSourcesApi = retrofit.create()
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
}
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
id = id,
displayName = displayName,
requiresApiKey = requiresApiKey,
supports = supports,
enabled = enabled,
apiKeySet = apiKeySet,
testable = testable,
)
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
@@ -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() {
@@ -28,18 +28,15 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.Inbox import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.TriangleAlert import com.composables.icons.lucide.TriangleAlert
import com.composables.icons.lucide.Users import com.composables.icons.lucide.Users
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
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.nav.Admin import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -57,7 +54,7 @@ import javax.inject.Inject
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int) data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
sealed interface AdminLandingUiState { sealed interface AdminLandingUiState {
data object Loading : AdminLandingUiState data object Loading : AdminLandingUiState
@@ -72,7 +69,6 @@ class AdminLandingViewModel @Inject constructor(
private val requestsRepo: AdminRequestsRepository, private val requestsRepo: AdminRequestsRepository,
private val quarantineRepo: AdminQuarantineRepository, private val quarantineRepo: AdminQuarantineRepository,
private val usersRepo: AdminUsersRepository, private val usersRepo: AdminUsersRepository,
private val tagSourcesRepo: AdminTagSourcesRepository,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading) private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
@@ -89,8 +85,7 @@ class AdminLandingViewModel @Inject constructor(
val req = async { requestsRepo.list().size } val req = async { requestsRepo.list().size }
val qua = async { quarantineRepo.list().size } val qua = async { quarantineRepo.list().size }
val usr = async { usersRepo.list().size } val usr = async { usersRepo.list().size }
val tag = async { tagSourcesRepo.list().count { it.enabled } } AdminCounts(req.await(), qua.await(), usr.await())
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
} }
internal.value = AdminLandingUiState.Success(counts) internal.value = AdminLandingUiState.Success(counts)
} catch ( } catch (
@@ -175,15 +170,6 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
onClick = { navController.navigate(AdminUsers) }, onClick = { navController.navigate(AdminUsers) },
) )
} }
item {
SectionCard(
icon = Lucide.Music,
title = "Tag sources",
subtitle = "Metadata enrichment providers",
count = counts.tagSources,
onClick = { navController.navigate(AdminTagSources) },
)
}
} }
} }
@@ -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 {
@@ -1,220 +0,0 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
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.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminTagSourcesScreen(
navController: NavHostController,
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Tag sources",
navController = navController,
currentRouteName = AdminTagSources::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminTagSourcesUiState.Loading -> LoadingCentered()
AdminTagSourcesUiState.Empty -> EmptyState(
title = "No tag sources",
body = "Tag-enrichment providers register on the server; none are available.",
)
is AdminTagSourcesUiState.Error -> ErrorRetry(
title = "Couldn't load tag sources",
message = s.message,
onRetry = { viewModel.refresh() },
)
is AdminTagSourcesUiState.Success -> TagSourceList(
providers = s.providers,
testResults = s.testResults,
onToggle = viewModel::setEnabled,
onSaveKey = viewModel::saveApiKey,
onTest = viewModel::test,
)
}
}
}
}
@Composable
private fun TagSourceList(
providers: List<AdminTagSourceRef>,
testResults: Map<String, TagSourceTestResult>,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = providers, key = { it.id }) { provider ->
TagSourceCard(
provider = provider,
testResult = testResults[provider.id],
onToggle = onToggle,
onSaveKey = onSaveKey,
onTest = onTest,
)
}
}
}
@Composable
private fun TagSourceCard(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
Text(
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = provider.enabled,
onCheckedChange = { onToggle(provider.id, it) },
)
}
if (provider.requiresApiKey) {
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
}
if (provider.testable) {
TestRow(provider = provider, testResult = testResult, onTest = onTest)
}
}
}
}
@Composable
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
var key by remember(provider.id) { mutableStateOf("") }
OutlinedTextField(
value = key,
onValueChange = { key = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("API key") },
placeholder = {
Text(
if (provider.apiKeySet) {
"••• saved — leave blank to keep"
} else {
"Paste your API key to enable this source"
},
)
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
onSaveKey(provider.id, key)
key = ""
},
enabled = key.isNotBlank(),
) { Text("Save key") }
if (provider.apiKeySet) {
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun TestRow(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onTest: (String) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
testResult?.let { result ->
if (result.ok) {
Text(
text = "OK (${result.durationMs}ms)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = "Failed — ${result.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -1,116 +0,0 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminTagSourcesUiState {
data object Loading : AdminTagSourcesUiState
data object Empty : AdminTagSourcesUiState
data class Success(
val providers: List<AdminTagSourceRef>,
val testResults: Map<String, TagSourceTestResult>,
) : AdminTagSourcesUiState
data class Error(val message: String) : AdminTagSourcesUiState
}
@HiltViewModel
class AdminTagSourcesViewModel @Inject constructor(
private val repository: AdminTagSourcesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245).
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh()
}
}
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminTagSourcesUiState.Loading
try {
val providers = repository.list()
internal.value = if (providers.isEmpty()) {
AdminTagSourcesUiState.Empty
} else {
AdminTagSourcesUiState.Success(providers, emptyMap())
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
}
}
fun setEnabled(id: String, enabled: Boolean) {
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
// Optimistic toggle; reconcile via refresh() if the server rejects.
internal.value = before.copy(
providers = before.providers.map {
if (it.id == id) it.copy(enabled = enabled) else it
},
)
viewModelScope.launch {
try {
repository.setEnabled(id, enabled)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun saveApiKey(id: String, apiKey: String) {
viewModelScope.launch {
try {
replaceProvider(repository.setApiKey(id, apiKey))
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun test(id: String) {
viewModelScope.launch {
val result = try {
repository.test(id)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
}
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
internal.value = current.copy(testResults = current.testResults + (id to result))
}
}
private fun replaceProvider(updated: AdminTagSourceRef) {
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
internal.value = current.copy(
providers = current.providers.map { if (it.id == updated.id) updated else it },
)
}
}
@@ -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,41 +0,0 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
* surface for the tag-enrichment provider settings (#1490). Mirrors the
* web integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings.
*/
interface AdminTagSourcesApi {
@GET("api/admin/tag-sources")
suspend fun list(): AdminTagSourcesListWire
@PATCH("api/admin/tag-sources/{id}")
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
@POST("api/admin/tag-sources/{id}/test")
suspend fun test(@Path("id") id: String): TestTagSourceWire
}
/**
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
* untouched one (the server reads a missing/null field as "leave
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
*/
@Serializable
data class UpdateTagSourceBody(
val enabled: Boolean? = null,
@SerialName("api_key") val apiKey: String? = null,
)
@@ -239,9 +239,6 @@ data class PlayOfflinePayload(
val atIso: String, val atIso: String,
val durationPlayedMs: Long, val durationPlayedMs: Long,
val source: String? = null, val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null,
) )
/** /**
@@ -254,7 +254,6 @@ class MutationReplayer @Inject constructor(
at = decoded.atIso, at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs, durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source, source = decoded.source,
deviceClass = decoded.deviceClass,
), ),
) )
return Outcome.SENT return Outcome.SENT
@@ -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 { }
@@ -102,7 +102,6 @@ class DiagnosticsReporter @Inject constructor(
launch { collectServerHealth() } launch { collectServerHealth() }
launch { collectUpnpDrops() } launch { collectUpnpDrops() }
launch { collectPlayerState() } launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectRoutes() } launch { collectRoutes() }
launch { heartbeatLoop() } launch { heartbeatLoop() }
} }
@@ -148,13 +147,11 @@ class DiagnosticsReporter @Inject constructor(
} }
private suspend fun collectPlayerState() { private suspend fun collectPlayerState() {
// 'playback', not 'upnp_sync' — player state applies to every output
// route (phone speaker, Bluetooth, UPnP), not just casting.
playerController.uiState playerController.uiState
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) } .map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
.distinctUntilChanged() .distinctUntilChanged()
.collect { (source, upnpLoading, err) -> .collect { (source, upnpLoading, err) ->
record("playback", buildJsonObject { record("upnp_sync", buildJsonObject {
put("event", "player_state") put("event", "player_state")
put("source", source ?: "") put("source", source ?: "")
put("upnp_loading", upnpLoading) put("upnp_loading", upnpLoading)
@@ -163,44 +160,15 @@ class DiagnosticsReporter @Inject constructor(
} }
} }
// Emit a snapshot at each track/queue-index change — captures a
// skip-induced local↔Sonos desync at the INSTANT it happens, which the
// 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
// very rapid skip burst may coalesce intermediate indices (we still get
// the boundaries + the local-vs-Sonos snapshot).
private suspend fun collectTrackChanges() {
playerController.uiState
.map { it.queueIndex to it.currentTrack?.id }
.distinctUntilChanged()
.collect { (index, _) ->
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
record("playback", buildJsonObject {
put("event", "track_change")
put("local_index", index)
// Track IDENTITY, not just index — indices wobble across
// re-casts, so the id + Sonos URI make a desync unambiguous.
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
})
}
}
private suspend fun collectRoutes() { private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag.
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r -> outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
record("playback", buildJsonObject { record("upnp_sync", buildJsonObject {
put("event", "route") put("event", "route")
put("id", r.id) put("id", r.id)
put("name", r.name) put("name", r.name)
put("kind", r.kind.name) put("kind", r.kind.name)
put("protocol", r.protocol.name) put("protocol", r.protocol.name)
put("connected", r.isConnected)
}) })
} }
} }
@@ -218,11 +186,13 @@ class DiagnosticsReporter @Inject constructor(
put("is_playing", ui.isPlaying) put("is_playing", ui.isPlaying)
put("source", ui.currentSource ?: "") put("source", ui.currentSource ?: "")
put("local_index", ui.queueIndex) put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs) put("local_pos_ms", ui.positionMs)
put("route", route.name) put("route", route.name)
put("route_protocol", route.protocol.name) put("route_protocol", route.protocol.name)
putSonos(this, routeActive) // UPnP/Sonos remote-vs-local — the desync signal.
put("sonos_track", remoteState.trackNumber)
put("sonos_pos_ms", remoteState.positionMs)
put("sonos_playing", remoteState.isPlaying)
addPowerFields(this) addPowerFields(this)
}) })
} }
@@ -287,18 +257,6 @@ class DiagnosticsReporter @Inject constructor(
addPowerFields(this) addPowerFields(this)
} }
// Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
// remote route is active; zeroed otherwise so a stale RemotePlayerState
// from a just-ended cast can't masquerade as live Sonos data (the
// cast→phone handoff artifact). currentTrackUri is the desync ground
// truth — it carries the track the speaker is actually streaming.
private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
builder.put("sonos_playing", casting && remoteState.isPlaying)
if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
}
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) { private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
builder.put("doze", pm.isDeviceIdleMode) builder.put("doze", pm.isDeviceIdleMode)
@@ -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,41 +367,19 @@ 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.
}
/**
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
*
* Key the Crossfade on the state CLASS, not the instance. Each section
* emission produces a new UiState.Success(data); if we keyed on `state`
* directly, every per-section hydration tick would re-run the 300ms
* crossfade, and first-sign-in (six sections cascading in) reads as
* continuous flicker. Keying on the class restricts the animation to
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
* recompositions update the LazyColumn without a fade.
*/
@Composable
private fun HomeStateCrossfade(
state: UiState<HomeSections>,
systemStatus: SystemPlaylistsStatus,
offline: Boolean,
navController: NavHostController,
viewModel: HomeViewModel,
) {
Crossfade(targetState = state::class, label = "home-state") { _ -> Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) { when (val s = state) {
UiState.Loading -> HomeSkeletonContent() UiState.Loading -> HomeSkeletonContent()
@@ -496,10 +389,9 @@ private fun HomeStateCrossfade(
"settings, then come back here for system playlists " + "settings, then come back here for system playlists " +
"and recommendations.", "and recommendations.",
) )
is UiState.Error -> ErrorRetry( is UiState.Error -> EmptyState(
title = "Couldn't load home", title = "Couldn't load home",
message = s.message, body = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> HomeSuccessContent( is UiState.Success -> HomeSuccessContent(
sections = s.data, sections = s.data,
@@ -516,45 +408,6 @@ private fun HomeStateCrossfade(
) )
} }
} }
}
/**
* 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,
@@ -1,19 +0,0 @@
package com.fabledsword.minstrel.models
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
data class AdminTagSourceRef(
val id: String,
val displayName: String,
val requiresApiKey: Boolean,
val supports: List<String>,
val enabled: Boolean,
val apiKeySet: Boolean,
val testable: Boolean,
)
/** Result of a provider "test connection" call. */
data class TagSourceTestResult(
val ok: Boolean,
val durationMs: Long = 0,
val error: String = "",
)
@@ -1,35 +0,0 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
* Every field defaults defensively so a missing JSON key never crashes
* deserialization. `versionBumped` is present only on the PATCH response.
*/
@Serializable
data class AdminTagSourceWire(
val id: String = "",
@SerialName("display_name") val displayName: String = "",
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
val supports: List<String> = emptyList(),
val enabled: Boolean = false,
@SerialName("api_key_set") val apiKeySet: Boolean = false,
@SerialName("display_order") val displayOrder: Int = 0,
val testable: Boolean = false,
@SerialName("version_bumped") val versionBumped: Boolean = false,
)
@Serializable
data class AdminTagSourcesListWire(
val providers: List<AdminTagSourceWire> = emptyList(),
@SerialName("sources_version") val sourcesVersion: Int = 0,
)
@Serializable
data class TestTagSourceWire(
val ok: Boolean = false,
@SerialName("duration_ms") val durationMs: Long = 0,
val error: String = "",
)
@@ -27,8 +27,6 @@ data class PlayStartedRequest(
@SerialName("track_id") val trackId: String, @SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String, @SerialName("client_id") val clientId: String,
val source: String? = null, val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
) )
@Serializable @Serializable
@@ -58,6 +56,4 @@ data class PlayOfflineRequest(
val at: String, val at: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long, @SerialName("duration_played_ms") val durationPlayedMs: Long,
val source: String? = null, val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
) )
@@ -16,7 +16,6 @@ import androidx.navigation.compose.composable
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
import com.fabledsword.minstrel.auth.ui.LoginScreen import com.fabledsword.minstrel.auth.ui.LoginScreen
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
@@ -191,13 +190,6 @@ private fun NavGraphBuilder.inShellDetail(
} }
} }
} }
composable<AdminTagSources> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
AdminTagSourcesScreen(navController = navController)
}
}
}
} }
private fun NavGraphBuilder.outsideShell(navController: NavHostController) { private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
@@ -22,7 +22,6 @@ import kotlinx.serialization.Serializable
@Serializable data object AdminRequests @Serializable data object AdminRequests
@Serializable data object AdminQuarantine @Serializable data object AdminQuarantine
@Serializable data object AdminUsers @Serializable data object AdminUsers
@Serializable data object AdminTagSources
// ── Outside-shell full-screen routes ────────────────────────────────── // ── Outside-shell full-screen routes ──────────────────────────────────
@@ -17,7 +17,6 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException import java.io.IOException
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -59,10 +58,8 @@ import timber.log.Timber
* native queue via ClearQueue + AddURIToQueue, then points the * native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to * transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free * AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop keeps the local cursor aligned to the * advance natively. PollLoop syncs the local cursor by comparing the
* track the renderer is actually playing, matched by track identity * 1-based Track index from GetPositionInfo.
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
* index drift -- see [syncLocalCursorToRemote].
*/ */
class MinstrelForwardingPlayer( class MinstrelForwardingPlayer(
private val delegate: Player, private val delegate: Player,
@@ -342,23 +339,6 @@ class MinstrelForwardingPlayer(
} }
} }
// The system media notification / lock-screen / Android Auto / Wear 'next'
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
// base ForwardingPlayer forwards these to the paused local delegate -- so
// the notification next/prev only nudged the local cursor (which the poll
// then re-synced back to Sonos), reading as dead buttons while casting.
// Route them through the same Sonos path as the media-item variants when a
// UPnP route is engaged; plain local playback keeps the default behaviour.
override fun seekToNext() {
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
}
override fun seekToPrevious() {
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
}
override fun getCurrentPosition(): Long = override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition() if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
@@ -546,8 +526,18 @@ class MinstrelForwardingPlayer(
/** /**
* One poll tick: read position + transport state from Sonos, apply to * One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and sync the local cursor to the track the renderer is * [remoteState], and forward-sync the local cursor to Sonos's Track
* actually playing (see [syncLocalCursorToRemote]). * index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
*/ */
private suspend fun pollOnce(active: ActiveUpnp) { private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo() val info = active.avTransport.getPositionInfo()
@@ -563,7 +553,7 @@ class MinstrelForwardingPlayer(
trackUri = info.trackUri, trackUri = info.trackUri,
trackNumber = info.track, trackNumber = info.track,
) )
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri) maybeSyncLocalCursor(info.track)
val transport = active.avTransport.getTransportInfo() val transport = active.avTransport.getTransportInfo()
when (transport.state) { when (transport.state) {
TransportState.PLAYING -> { TransportState.PLAYING -> {
@@ -587,95 +577,23 @@ class MinstrelForwardingPlayer(
notifyRemoteStateChanged() notifyRemoteStateChanged()
} }
/** private fun maybeSyncLocalCursor(sonosTrack: Int) {
* Align the paused local delegate cursor to the track the renderer is if (holder.target.value != null) return
* actually playing, so the un-overridden current-item getters if (sonosTrack <= 0) return
* (getCurrentMediaItem/Index -- what both the in-app player UI via val sonosIdx = sonosTrack - 1
* PlayerController.onEvents AND the MediaSession notification read) always
* resolve to that track. This is THE single source of truth for "what's
* playing" during a cast.
*
* Identity-first: match the track-id embedded in the renderer's current URI
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
* can never park the cursor on a stale (pre-cast) track. Falls back to the
* 1-based Sonos Track index only when the URI can't be resolved to a queue
* item. Unlike the old forward-only sync this moves the cursor in BOTH
* directions -- a renderer Previous / re-cast to a lower track pulls it back
* too. Runs on the application looper (delegate access contract).
*
* Suppressed while:
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
* (re)uploaded, so its Track index is meaningless; and
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
* the override already advanced the delegate optimistically, and Sonos
* still reports the OLD track for a beat, so syncing now would undo the
* user's press. The pending deadline is honoured directly so a missed
* clear can't wedge the sync.
*/
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
if (isLoadingUpnp()) return
val pendingDeadline = remoteState.pendingTransportDeadlineMs
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
handler.post { handler.post {
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post val localIdx = delegate.currentMediaItemIndex
if (target != delegate.currentMediaItemIndex) { if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
Timber.w( Timber.w(
"UPnP cursor sync: local=%d -> %d", "UPnP cursor catch-up: local=%d -> sonos=%d",
delegate.currentMediaItemIndex, target, localIdx, sonosIdx,
) )
delegate.seekTo(target, 0L) delegate.seekTo(sonosIdx, 0L)
} }
} }
} }
/**
* Most-authoritative -> most-degraded (project rule #48): the track-id
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
* index; else null (leave the cursor put rather than surface a wrong track).
* Must run on the application looper -- reads the delegate timeline.
*/
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
}
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
}
/**
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
* occurrence nearest [preferNear] so a track that appears twice in the queue
* doesn't snap the cursor across the queue. null if the id isn't present.
*/
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
var best: Int? = null
var bestDist = Int.MAX_VALUE
for (i in 0 until delegate.mediaItemCount) {
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
val dist = abs(i - preferNear)
if (dist < bestDist) {
best = i
bestDist = dist
}
}
}
return best
}
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
private fun trackIdFromStreamUri(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private companion object { private companion object {
// Path segment that precedes the track-id in a stream URI
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
// back to a delegate MediaItem by identity.
const val TRACKS_PATH_MARKER = "/api/tracks/"
const val POLL_INTERVAL_MS = 1_000L const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2 const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -155,7 +155,6 @@ class PlayEventsReporter @Inject constructor(
trackId = trackId, trackId = trackId,
clientId = cid, clientId = cid,
source = source.takeIf { !it.isNullOrEmpty() }, source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
), ),
) )
if (curTrackId == trackId) { if (curTrackId == trackId) {
@@ -249,7 +248,6 @@ class PlayEventsReporter @Inject constructor(
atIso = startedAt.toString(), atIso = startedAt.toString(),
durationPlayedMs = durationPlayedMs, durationPlayedMs = durationPlayedMs,
source = source.takeIf { !it.isNullOrEmpty() }, source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
), ),
) )
} }
@@ -270,12 +268,6 @@ class PlayEventsReporter @Inject constructor(
// ── Lifecycle: durable-close on app background / detach ──────── // ── Lifecycle: durable-close on app background / detach ────────
companion object {
// #1551: Android is a mobile client. Refine to tablet/tv via device
// configuration later if the metrics trend view shows it matters.
private const val DEVICE_CLASS = "mobile"
}
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
// App backgrounded. If the tracked play is still PLAYING, leave it // App backgrounded. If the tracked play is still PLAYING, leave it
// alone: the foreground media service keeps the process — and this // alone: the foreground media service keeps the process — and this
@@ -598,19 +598,18 @@ class PlayerController @Inject constructor(
} }
/** /**
* One position-polling tick. Patches ONLY the smoothly-changing transport * One position-polling tick. Owns track-change detection too: when UPnP
* fields (position / duration / play-pause / buffer) via `.copy()`. The * is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
* queue index + current track are owned solely by [onEvents], which reads * `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
* the authoritative delegate cursor: during a cast, * uiState.queueIndex stuck on the old track even after Sonos has
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on * advanced. So the tick reads Sonos's reported Track as the source of
* the track the renderer is actually playing (matched by identity), so there * truth, rebuilds the index/title fields itself, and force-syncs the
* is exactly one writer of "what's playing" and a stale tick can never * wrapped player as defense in depth.
* revert it to the old track. (Previously the tick was a second index writer
* that fought onEvents — the flicker-to-stale-track bug.)
*/ */
private fun tickPositionPoll(controller: MediaController) { private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive) resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying = val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) { val effectivePosition = if (upnpActive) {
@@ -618,30 +617,28 @@ class PlayerController @Inject constructor(
} else { } else {
controller.currentPosition controller.currentPosition
} }
val idx = controller.currentMediaItemIndex val desiredIdx = desiredQueueIndex(controller, upnpActive)
val current = uiStateInternal.value val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0) val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration( val newDur = effectiveDuration(
upnpActive, upnpActive,
remoteState.durationMs, remoteState.durationMs,
controller.duration, controller.duration,
desiredIdx = idx, desiredIdx = desiredIdx,
controllerIdx = idx, controllerIdx = controller.currentMediaItemIndex,
) )
val newBuf = controller.bufferedPosition.coerceAtLeast(0) val newBuf = controller.bufferedPosition.coerceAtLeast(0)
val somethingChanged = current.isPlaying != effectiveIsPlaying || // Track adjustments are forward-only AND suppressed while a user
current.positionMs != newPos || // transport press is pending Sonos confirmation. Together those keep
current.durationMs != newDur || // either direction of user input from being undone by a stale poll.
current.bufferedPositionMs != newBuf val trackChanged = !pendingTransport &&
if (somethingChanged) { desiredIdx > current.queueIndex &&
uiStateInternal.value = current.copy( desiredIdx in queueRefs.indices
isPlaying = effectiveIsPlaying, publishTickIfChanged(
positionMs = newPos, current, trackChanged, desiredIdx,
durationMs = newDur, effectiveIsPlaying, newPos, newDur, newBuf,
bufferedPositionMs = newBuf,
) )
} }
}
/** /**
* Event-driven primary path: clear pending the moment Sonos's reported * Event-driven primary path: clear pending the moment Sonos's reported
@@ -657,6 +654,48 @@ class PlayerController @Inject constructor(
} }
} }
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
private fun publishTickIfChanged(
current: PlayerUiState,
trackChanged: Boolean,
desiredIdx: Int,
effectiveIsPlaying: Boolean,
newPos: Long,
newDur: Long,
newBuf: Long,
) {
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur
if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
// Intentionally do NOT call controller.seekTo here. That would route
// through MinstrelForwardingPlayer's seekTo override and re-issue
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
// start of the same track it's already playing, restarting the song.
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
// delegate.seekTo (which bypasses the override). If it lags briefly,
// the next pollOnce catches up; the uiState above already reflects
// Sonos's truth for the user.
}
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
if (upnpActive) {
(remoteState.trackNumber - 1).coerceAtLeast(0)
} else {
controller.currentMediaItemIndex
}
// ── Remote position interpolation state ────────────────────────────── // ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz // remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi // SOAP poll (and only when the round-trip completes -- screen-off WiFi
@@ -133,7 +133,7 @@ private fun RouteRow(
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
val subtitle = route.description ?: defaultSubtitle(route, isSelected) val subtitle = route.description ?: defaultSubtitle(route)
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -198,13 +198,10 @@ private fun MulticastHintRow() {
} }
} }
// isSelected (route == the active/selected route) drives the Bluetooth private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
// "Connected" subtitle. RouteInfo.connectionState can't — it stays
// DISCONNECTED for local SYSTEM routes even when they're in use.
private fun defaultSubtitle(route: OutputRoute, isSelected: Boolean): String = when (route.kind) {
OutputRoute.Kind.BuiltIn -> "Phone speaker" OutputRoute.Kind.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired" OutputRoute.Kind.Wired -> "Wired"
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available" OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
OutputRoute.Kind.Cast -> "Cast" OutputRoute.Kind.Cast -> "Cast"
OutputRoute.Kind.Other -> "Available" OutputRoute.Kind.Other -> "Available"
} }
@@ -21,6 +21,7 @@ data class OutputRoute(
val description: String?, val description: String?,
val kind: Kind, val kind: Kind,
val protocol: Protocol, val protocol: Protocol,
val isConnected: Boolean,
) { ) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other } enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
@@ -42,11 +43,9 @@ data class OutputRoute(
/** /**
* Lift a MediaRouter [route] into the domain model. Kind is * Lift a MediaRouter [route] into the domain model. Kind is
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown * inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. "Active" is NOT an * device types fall through to [Kind.Other]. The
* intrinsic of the route — the caller derives it by comparing to * `connectionState` proxy is good enough for the chip's
* the selected route (RouteSnapshot.current), because * "Connected"/"Available" subtitle.
* RouteInfo.connectionState only reflects remote-route handshakes
* and stays DISCONNECTED for local SYSTEM routes even when in use.
*/ */
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute { fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) { val kind = when (route.deviceType) {
@@ -59,12 +58,15 @@ data class OutputRoute(
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other else -> Kind.Other
} }
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute( return OutputRoute(
id = route.id, id = route.id,
name = route.name, name = route.name,
description = route.description, description = route.description,
kind = kind, kind = kind,
protocol = Protocol.SYSTEM, protocol = Protocol.SYSTEM,
isConnected = connected,
) )
} }
@@ -74,10 +76,10 @@ data class OutputRoute(
* network speakers into the same `OutputPickerController` * network speakers into the same `OutputPickerController`
* routes stream the system routes come through. * routes stream the system routes come through.
* *
* UPnP devices have no MediaRouter connection-state concept — * `isConnected = false` because UPnP devices have no
* they're always "available" on the LAN, and the picker's * MediaRouter connection-state concept — they're always
* selected-route rendering handles the "currently playing" * "available" on the LAN, and the picker's selected-route
* indicator. * rendering handles the "currently playing" indicator.
* *
* Subtitle is `manufacturer modelName` joined by a single * Subtitle is `manufacturer modelName` joined by a single
* space, falling back to "Network speaker" when both fields * space, falling back to "Network speaker" when both fields
@@ -94,6 +96,7 @@ data class OutputRoute(
description = description, description = description,
kind = Kind.Other, kind = Kind.Other,
protocol = Protocol.UPNP, protocol = Protocol.UPNP,
isConnected = false,
) )
} }
} }
@@ -1,16 +1,11 @@
package com.fabledsword.minstrel.player.ui package com.fabledsword.minstrel.player.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -27,26 +22,11 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PlayerViewModel @Inject constructor( class PlayerViewModel @Inject constructor(
private val controller: PlayerController, private val controller: PlayerController,
private val likes: LikesRepository,
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents val dropEvents: SharedFlow<String> = controller.dropEvents
/**
* Reactive set of liked track ids — the set-based pattern the queue
* (and playlist/album/artist detail) uses to color each row's
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
* per-row Flow subscription.
*/
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = emptySet(),
)
fun play() = controller.play() fun play() = controller.play()
fun pause() = controller.pause() fun pause() = controller.pause()
fun seekTo(positionMs: Long) = controller.seekTo(positionMs) fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
@@ -55,13 +35,4 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index) fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle() fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat() fun cycleRepeat() = controller.cycleRepeat()
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
}
}
} }
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -36,7 +36,6 @@ import com.composables.icons.lucide.Volume2
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -45,7 +44,6 @@ fun QueueScreen(
viewModel: PlayerViewModel = hiltViewModel(), viewModel: PlayerViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
@@ -69,9 +67,7 @@ fun QueueScreen(
QueueList( QueueList(
tracks = state.queue, tracks = state.queue,
currentIndex = state.queueIndex, currentIndex = state.queueIndex,
likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex, onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack,
) )
} }
} }
@@ -82,18 +78,14 @@ fun QueueScreen(
private fun QueueList( private fun QueueList(
tracks: List<TrackRef>, tracks: List<TrackRef>,
currentIndex: Int, currentIndex: Int,
likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit, onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track -> itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow( QueueRow(
track = track, track = track,
isCurrent = index == currentIndex, isCurrent = index == currentIndex,
liked = track.id in likedTrackIds,
onClick = { onJumpTo(index) }, onClick = { onJumpTo(index) },
onToggleLike = { onToggleLike(track.id) },
) )
HorizontalDivider() HorizontalDivider()
} }
@@ -101,13 +93,7 @@ private fun QueueList(
} }
@Composable @Composable
private fun QueueRow( private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
track: TrackRef,
isCurrent: Boolean,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
) {
val highlight = if (isCurrent) { val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA) MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else { } else {
@@ -156,7 +142,6 @@ private fun QueueRow(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
LikeButton(liked = liked, onToggle = onToggleLike)
} }
} }
@@ -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,13 +110,12 @@ 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,12 +38,8 @@ fun CoverTile(
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
) { ) {
Box( Box(
modifier = modifier.size(size), modifier = modifier
contentAlignment = Alignment.Center, .size(size)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(shape) .clip(shape)
.background(background), .background(background),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -59,7 +51,6 @@ fun CoverTile(
) { ) {
fallback() 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,26 +23,18 @@ 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()) {
item {
Column( Column(
modifier = Modifier modifier = modifier
.fillParentMaxSize() .fillMaxSize()
.padding(32.dp), .padding(32.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -54,18 +45,9 @@ fun ErrorRetry(
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
if (title != null) {
Text(
text = title,
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
Text( Text(
text = message, text = message,
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp), modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -81,6 +63,4 @@ fun ErrorRetry(
Text(retryLabel) 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),
) )
-40
View File
@@ -15,7 +15,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -26,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() {
@@ -149,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.
@@ -207,31 +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 household co-play worker (#1533). Recomputes artistartist
// co-occurrence edges (source='user_cooccurrence') from play_events every
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
// SQL, no external calls; empty on single-user servers.
go coplay.NewWorker(pool, logger.With("component", "coplay")).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
@@ -332,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)
}
}
-160
View File
@@ -1,160 +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"`
ContextTimeWeight float64 `json:"context_time_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,
ContextTimeWeight: w.ContextTimeWeight,
}
}
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"`
EnrichedTagScale float64 `json:"enriched_tag_scale"`
EraScale float64 `json:"era_scale"`
MoodScale float64 `json:"mood_scale"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
return tasteTuningResp{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}
}
// 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 -2
View File
@@ -21,8 +21,7 @@ import (
// variants do NOT require a new category here. // variants do NOT require a new category here.
var validDiagnosticKinds = map[string]struct{}{ var validDiagnosticKinds = map[string]struct{}{
"connectivity": {}, "connectivity": {},
"upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync) "upnp_sync": {},
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
"power": {}, "power": {},
"lifecycle": {}, "lifecycle": {},
"heartbeat": {}, "heartbeat": {},
+3 -54
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -25,33 +24,6 @@ type eventRequest struct {
// / "discover" feed the system-playlist rotation dedup (#415); // / "discover" feed the system-playlist rotation dedup (#415);
// absent / "" for library, user-playlist, radio, Subsonic. // absent / "" for library, user-playlist, radio, Subsonic.
Source *string `json:"source"` Source *string `json:"source"`
// DeviceClass is the client's self-reported device class (#1551),
// normalized + stored per play to condition radio on the current device.
DeviceClass *string `json:"device_class"`
}
// knownDeviceClasses whitelists the device classes the context-affinity facet
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
// "other", and empty/absent to "" (stored NULL → excluded from the device
// dimension). Kept permissive (no DB CHECK) so a new client class is one
// whitelist entry, not a migration.
var knownDeviceClasses = map[string]bool{
"mobile": true, "tablet": true, "desktop": true,
"web": true, "tv": true, "watch": true,
}
func normalizeDeviceClass(dc *string) string {
if dc == nil {
return ""
}
s := strings.ToLower(strings.TrimSpace(*dc))
if s == "" {
return ""
}
if knownDeviceClasses[s] {
return s
}
return "other"
} }
type playStartedResponse struct { type playStartedResponse struct {
@@ -59,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"`
} }
@@ -100,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 {
@@ -142,9 +94,7 @@ func (h *handlers) handleEventPlayStarted(
if req.Source != nil { if req.Source != nil {
source = *req.Source source = *req.Source
} }
deviceClass := normalizeDeviceClass(req.DeviceClass) res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
res, err := h.events.RecordPlayStartedWithSource(
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
if err != nil { if err != nil {
h.logger.Error("api: events: play_started", "err", err) h.logger.Error("api: events: play_started", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err)) writeErr(w, apierror.InternalMsg("record failed", err))
@@ -189,8 +139,7 @@ func (h *handlers) handleEventPlayOffline(
source = *req.Source source = *req.Source
} }
if err := h.events.RecordOfflinePlay( if err := h.events.RecordOfflinePlay(
r.Context(), user.ID, trackID, clientID, source, r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
); err != nil { ); err != nil {
h.logger.Error("api: events: play_offline", "err", err) h.logger.Error("api: events: play_offline", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err)) writeErr(w, apierror.InternalMsg("record failed", err))
+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",
+26 -256
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 -26
View File
@@ -1,7 +1,6 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "log/slog"
@@ -16,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.
@@ -82,9 +80,6 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
} }
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
// Condition on the current device (#1551): the latest play's device is a
// cheap, request-free proxy for what the user is on right now.
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
exclude := parseExcludeParam(r.URL.Query().Get("exclude")) exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
limits := recommendation.DefaultCandidateSourceLimits() limits := recommendation.DefaultCandidateSourceLimits()
@@ -105,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)
@@ -154,24 +156,6 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
return v return v
} }
// latestDeviceClass returns the device_class of the user's most recent play as
// the "current device" for context conditioning (#1551), or "" when unknown
// (no plays yet, or the latest play predates device capture). Best-effort: a
// lookup failure yields a device-agnostic ("") affinity cell.
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
logger.Warn("api: radio: latest device class", "err", err)
}
return ""
}
if dc == nil {
return ""
}
return *dc
}
// parseExcludeParam parses a comma-separated list of UUIDs from the // parseExcludeParam parses a comma-separated list of UUIDs from the
// `exclude` query string, silently dropping malformed entries. Returns // `exclude` query string, silently dropping malformed entries. Returns
// nil for empty or all-malformed input. // nil for empty or all-malformed input.
+20 -4
View File
@@ -88,11 +88,17 @@ 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 {
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"`
SimilarityWeight float64 `yaml:"similarity_weight"`
TasteWeight float64 `yaml:"taste_weight"`
RecentlyPlayedHours int `yaml:"recently_played_hours"` RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"` RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"` RadioSizeMax int `yaml:"radio_size_max"`
@@ -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,
-86
View File
@@ -1,86 +0,0 @@
// Package coplay builds household co-play similarity edges (#1533, milestone
// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no
// per-user library ACL), so the "household" is the whole instance's user set.
// A periodic worker recomputes artistartist co-occurrence from play_events
// entirely in SQL (no external API) and stores the edges in artist_similarity
// under source='user_cooccurrence' — the pre-provisioned co-occurrence source
// from the 0009 schema — which the radio/mix candidate pool consumes as a
// collaborative arm. Single-user servers produce no edges (the >= 2 co-player
// gate), so the feature is a graceful no-op there.
package coplay
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// minJaccard is the co-play edge floor: prune weak pairs and self-limit hub
// artists, whose large combined-audience denominators pull their Jaccard down.
// 0.1 keeps only pairs whose co-player overlap is a real fraction of their
// combined audience.
const minJaccard = 0.1
// Worker periodically rebuilds the household co-play edge set. Pure local SQL,
// so there are no rate limits — but the pair self-join is roughly
// O(users × artists_per_user²), so it ticks slowly: co-listening shifts over
// days, not minutes, and a frequent rebuild would burn CPU for no fresher
// signal.
type Worker struct {
pool *pgxpool.Pool
logger *slog.Logger
tick time.Duration
}
// NewWorker constructs a worker with the production default 6h tick.
func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker {
return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour}
}
// Run rebuilds once at startup (so edges exist without waiting a full tick),
// then every w.tick until ctx is cancelled. Runs in its own goroutine, so a
// slow initial rebuild never blocks boot.
func (w *Worker) Run(ctx context.Context) {
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: initial rebuild failed", "err", err)
}
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: rebuild failed", "err", err)
}
}
}
}
// rebuild atomic-replaces the user_cooccurrence edge set inside one
// transaction — delete the old edges, recompute from current play history — so
// the candidate pool never observes a half-built set.
func (w *Worker) rebuild(ctx context.Context) error {
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbq.New(tx)
if err := q.DeleteArtistCoplayEdges(ctx); err != nil {
return err
}
if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
w.logger.Debug("coplay: edges rebuilt")
return nil
}
-162
View File
@@ -1,162 +0,0 @@
package coplay
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); 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 discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
}
return u.ID
}
// seedArtistTrack creates an artist + album + one track, returning both ids.
func seedArtistTrack(t *testing.T, q *dbq.Queries, name string) (pgtype.UUID, pgtype.UUID) {
t.Helper()
ctx := context.Background()
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name})
if err != nil {
t.Fatalf("seed artist: %v", err)
}
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: a.ID})
if err != nil {
t.Fatalf("seed album: %v", err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: al.ID, ArtistID: a.ID,
DurationMs: 1000, FilePath: "/tmp/coplay-" + name + ".mp3",
FileSize: 1, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track: %v", err)
}
return a.ID, tr.ID
}
func play(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
ctx := context.Background()
at := time.Now()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'coplay-test') RETURNING id`,
userID, at).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, $3, $4, false)`,
userID, trackID, sessionID, at); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
func coplayEdgeCount(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
var n int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM artist_similarity WHERE source='user_cooccurrence'`).Scan(&n); err != nil {
t.Fatalf("count edges: %v", err)
}
return n
}
// TestCoplayWorker_BuildsEdges: two users who both play two artists produce a
// perfect-overlap co-play edge (Jaccard 1.0) in each direction.
func TestCoplayWorker_BuildsEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u1 := seedUser(t, q, "cop1")
u2 := seedUser(t, q, "cop2")
aA, tA := seedArtistTrack(t, q, "ArtA")
aB, tB := seedArtistTrack(t, q, "ArtB")
for _, u := range []pgtype.UUID{u1, u2} {
play(t, pool, u, tA)
play(t, pool, u, tB)
}
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
var score float64
if err := pool.QueryRow(ctx,
`SELECT score FROM artist_similarity
WHERE artist_a_id=$1 AND artist_b_id=$2 AND source='user_cooccurrence'`,
aA, aB).Scan(&score); err != nil {
t.Fatalf("expected co-play edge ArtA→ArtB: %v", err)
}
// Jaccard of perfectly-overlapping player sets (both users play both):
// coplayers=2, players each=2 → 2/(2+2-2) = 1.0.
if score != 1.0 {
t.Errorf("edge score = %v, want 1.0 (perfect overlap)", score)
}
if got := coplayEdgeCount(t, pool); got != 2 {
t.Errorf("edge count = %d, want 2 (A→B and B→A)", got)
}
}
// TestCoplayWorker_SingleUserNoEdges: one user's own co-listening yields no
// edges — the >= 2 distinct-co-player gate keeps single-user servers empty.
func TestCoplayWorker_SingleUserNoEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u := seedUser(t, q, "solo")
_, tA := seedArtistTrack(t, q, "SoloA")
_, tB := seedArtistTrack(t, q, "SoloB")
play(t, pool, u, tA)
play(t, pool, u, tB)
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
if got := coplayEdgeCount(t, pool); got != 0 {
t.Errorf("single-user edge count = %d, want 0 (>= 2 co-player gate)", got)
}
}
-78
View File
@@ -1,78 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: coplay.sql
package dbq
import (
"context"
)
const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence'
`
// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
// single shared-library, multi-user server (no per-user library ACL), so the
// "household" is the whole instance's user set. These queries recompute
// artistartist co-occurrence edges from play_events and store them in
// artist_similarity under the pre-provisioned source = 'user_cooccurrence'
// (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
// re-INSERTs so edges that fall below threshold disappear. Single-user servers
// produce no edges (the >= 2 co-player gate), so the arm is empty there.
func (q *Queries) DeleteArtistCoplayEdges(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteArtistCoplayEdges)
return err
}
const insertArtistCoplayEdges = `-- name: InsertArtistCoplayEdges :exec
WITH user_artists AS (
SELECT DISTINCT pe.user_id, t.artist_id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
),
artist_players AS (
SELECT artist_id, count(*)::float8 AS players
FROM user_artists
GROUP BY artist_id
),
pairs AS (
SELECT ua.artist_id AS a_id,
ub.artist_id AS b_id,
count(*)::float8 AS coplayers
FROM user_artists ua
JOIN user_artists ub
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
GROUP BY ua.artist_id, ub.artist_id
HAVING count(*) >= 2
),
scored AS (
SELECT p.a_id, p.b_id,
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
FROM pairs p
JOIN artist_players pa ON pa.artist_id = p.a_id
JOIN artist_players pb ON pb.artist_id = p.b_id
)
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
SELECT a_id, b_id, score, 'user_cooccurrence', now()
FROM scored
WHERE score >= $1
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at
`
// Two artists are "co-played" when the same users play both. score is the
// Jaccard of their distinct-player sets — coplayers / (playersA + playersB
// coplayers) in (0,1] — which controls for globally-popular artists (an artist
// everyone plays would otherwise co-occur with everything). Only pairs with
// >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
// edges and self-limits hub artists, whose large denominators drag their
// Jaccard down) are kept. Completed plays only, 365-day window.
func (q *Queries) InsertArtistCoplayEdges(ctx context.Context, score float64) error {
_, err := q.db.Exec(ctx, insertArtistCoplayEdges, score)
return err
}
-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 -58
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, device_class 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,14 +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,
&i.DeviceClass,
) )
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, device_class 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) {
@@ -106,51 +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,
&i.DeviceClass,
) )
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
device_class ) VALUES ($1, $2, $3, $4, $5, $6)
) VALUES ($1, $2, $3, $4, $5, $6, $7::text, RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
$8::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
` `
type InsertPlayEventParams struct { type InsertPlayEventParams struct {
@@ -160,13 +122,8 @@ type InsertPlayEventParams struct {
StartedAt pgtype.Timestamptz StartedAt pgtype.Timestamptz
ClientID *string ClientID *string
Source *string Source *string
PickKind *string
DeviceClass *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,
@@ -175,8 +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,
arg.DeviceClass,
) )
var i PlayEvent var i PlayEvent
err := row.Scan( err := row.Scan(
@@ -193,8 +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,
&i.DeviceClass,
) )
return i, err return i, err
} }
@@ -261,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
@@ -303,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
} }
@@ -340,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, device_class 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 {
@@ -377,8 +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,
&i.DeviceClass,
) )
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
} }
-74
View File
@@ -383,8 +383,6 @@ type PlayEvent struct {
SessionVectorAtPlay []byte SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz ScrobbledAt pgtype.Timestamptz
Source *string Source *string
PickKind *string
DeviceClass *string
} }
type PlaySession struct { type PlaySession struct {
@@ -435,29 +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
ContextTimeWeight float64
} }
type RegistrationSetting struct { type RegistrationSetting struct {
@@ -534,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
@@ -556,20 +516,6 @@ type TasteProfileArtist struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type TasteProfileEra struct {
UserID pgtype.UUID
Era string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileMood struct {
UserID pgtype.UUID
Mood string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileTag struct { type TasteProfileTag struct {
UserID pgtype.UUID UserID pgtype.UUID
Tag string Tag string
@@ -577,18 +523,6 @@ 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
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
type Track struct { type Track struct {
ID pgtype.UUID ID pgtype.UUID
Title string Title string
@@ -605,8 +539,6 @@ type Track struct {
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 {
@@ -617,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,
+7 -150
View File
@@ -11,110 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
SELECT device_class
FROM play_events
WHERE user_id = $1
ORDER BY started_at DESC
LIMIT 1
`
// The device_class of the user's most recent play (#1551), used as the
// "current device" for radio context conditioning. NULL when the latest play
// predates device capture or came from a client that didn't send one.
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
var device_class *string
err := row.Scan(&device_class)
return device_class, err
}
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
WITH tz AS (
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
FROM users u WHERE u.id = $1
),
now_cell AS (
SELECT
CASE
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM tz
),
plays AS (
SELECT t.artist_id,
pe.device_class,
CASE
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
CROSS JOIN tz
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
)
SELECT p.artist_id,
count(*) AS total_plays,
count(*) FILTER (
WHERE p.daypart = (SELECT daypart FROM now_cell)
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
AND ($2::text = '' OR p.device_class = $2::text)
) AS cell_plays
FROM plays p
GROUP BY p.artist_id
`
type ListArtistContextPlayCountsForUserParams struct {
ID pgtype.UUID
Column2 string
}
type ListArtistContextPlayCountsForUserRow struct {
ArtistID pgtype.UUID
TotalPlays int64
CellPlays int64
}
// Per-artist completed-play counts split by whether each play falls in the
// CURRENT context cell, in the user's local timezone (#1531). The cell is
// daypart × weekday-type, optionally narrowed by device class (#1551): when
// $2 (the current device) is ”, the device dimension is ignored (identical to
// the time-only #1531 behaviour, used by the daily mixes which have no device);
// when $2 is set (radio), a play only counts in the cell if its device_class
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
// afternoon [12,17) evening [17,22). Weekend = ISO days 67 (Sat/Sun).
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListArtistContextPlayCountsForUserRow
for rows.Next() {
var i ListArtistContextPlayCountsForUserRow
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
WITH user_plays AS ( WITH user_plays AS (
SELECT t.artist_id, max(pe.started_at) AS last_started SELECT t.artist_id, max(pe.started_at) AS last_started
@@ -208,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
@@ -265,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 {
@@ -287,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
@@ -346,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 {
@@ -685,14 +577,12 @@ 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,
pe.skip_count, pe.skip_count
al.release_date AS release_date
FROM tracks t FROM tracks t
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
@@ -726,7 +616,6 @@ type LoadRadioCandidatesRow struct {
LastPlayedAt pgtype.Timestamptz LastPlayedAt pgtype.Timestamptz
PlayCount int64 PlayCount int64
SkipCount int64 SkipCount int64
ReleaseDate pgtype.Date
} }
// Returns all tracks except the seed and any played by the user within // Returns all tracks except the seed and any played by the user within
@@ -761,13 +650,10 @@ 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,
&i.SkipCount, &i.SkipCount,
&i.ReleaseDate,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -862,22 +748,6 @@ taste_overlap AS (
ORDER BY tpa.weight DESC, t.id ORDER BY tpa.weight DESC, t.id
LIMIT $10 LIMIT $10
), ),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
-- similar_artists since it's artist-level.
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS ( random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t FROM tracks t
@@ -889,18 +759,16 @@ random_fill AS (
UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
) )
ORDER BY random() ORDER BY random()
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,
pe.skip_count, pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM ( FROM (
SELECT track_id, sim_score FROM lb_similar SELECT track_id, sim_score FROM lb_similar
@@ -908,11 +776,9 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill UNION ALL SELECT track_id, sim_score FROM random_fill
) u ) u
JOIN tracks t ON t.id = u.track_id JOIN tracks t ON t.id = u.track_id
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at, SELECT max(started_at) AS last_played_at,
@@ -924,8 +790,7 @@ LEFT JOIN LATERAL (
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at, t.mbid, t.genre, t.added_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count, l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
al.release_date
` `
type LoadRadioCandidatesV2Params struct { type LoadRadioCandidatesV2Params struct {
@@ -939,7 +804,6 @@ type LoadRadioCandidatesV2Params struct {
Limit_4 int32 Limit_4 int32
Limit_5 int32 Limit_5 int32
Limit_6 int32 Limit_6 int32
Limit_7 int32
} }
type LoadRadioCandidatesV2Row struct { type LoadRadioCandidatesV2Row struct {
@@ -948,7 +812,6 @@ type LoadRadioCandidatesV2Row struct {
LastPlayedAt pgtype.Timestamptz LastPlayedAt pgtype.Timestamptz
PlayCount int64 PlayCount int64
SkipCount int64 SkipCount int64
ReleaseDate pgtype.Date
SimilarityScore interface{} SimilarityScore interface{}
} }
@@ -960,9 +823,7 @@ type LoadRadioCandidatesV2Row struct {
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top // $10 taste_overlap K (#796 phase 2b — tracks by the user's top
// positively-weighted taste-profile artists, so taste-relevant tracks // positively-weighted taste-profile artists, so taste-relevant tracks
// enter the pool even when the similarity/random arms miss them; scored // enter the pool even when the similarity/random arms miss them; scored
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion), // in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
// instance with the seed's artist; source='user_cooccurrence').
// //
// Returns same shape as LoadRadioCandidates plus similarity_score column. // Returns same shape as LoadRadioCandidates plus similarity_score column.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
@@ -977,7 +838,6 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
arg.Limit_4, arg.Limit_4,
arg.Limit_5, arg.Limit_5,
arg.Limit_6, arg.Limit_6,
arg.Limit_7,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1002,13 +862,10 @@ 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,
&i.SkipCount, &i.SkipCount,
&i.ReleaseDate,
&i.SimilarityScore, &i.SimilarityScore,
); err != nil { ); err != nil {
return nil, err return nil, err
+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,302 +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, enriched_tag_scale, era_scale, mood_scale 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,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
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, context_time_weight 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,
&i.ContextTimeWeight,
); 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,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
`
type UpdateTasteTuningParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale 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,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
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,
context_time_weight = $10,
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, context_time_weight
`
type UpdateWeightProfileParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight 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,
arg.ContextTimeWeight,
)
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,
&i.ContextTimeWeight,
)
return i, err
}
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING
`
type UpsertTasteTuningDefaultsParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale 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,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
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,
context_time_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
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
ContextTimeWeight 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,
arg.ContextTimeWeight,
)
return err
}
+64 -164
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
) ),
SELECT t.id, t.album_id, t.artist_id deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t FROM tracks t
JOIN stats s ON s.track_id = t.id JOIN stats s ON s.track_id = t.id
WHERE s.c >= 3 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 s.last_at <= now() - interval '30 days'
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
) )
-- Declined-resurfacing cooldown (#1258): a skip on a )
-- rediscover-sourced play is the user explicitly saying "I've moved SELECT id, album_id, artist_id
-- on" to the exact invitation this mix extends — such tracks sit FROM (
-- out ~90 days instead of re-qualifying the next day. Passive SELECT id, album_id, artist_id, c, tier FROM deep
-- signal only (rule #101). UNION ALL
AND NOT EXISTS ( SELECT id, album_id, artist_id, c, tier FROM shallow
SELECT 1 FROM play_events rp WHERE NOT EXISTS (SELECT 1 FROM deep)
WHERE rp.user_id = $1 ) u
AND rp.track_id = t.id ORDER BY tier, c DESC, 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
}
+5 -150
View File
@@ -20,24 +20,6 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
return err return err
} }
const deleteTasteProfileErasForUser = `-- name: DeleteTasteProfileErasForUser :exec
DELETE FROM taste_profile_eras WHERE user_id = $1
`
func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTasteProfileErasForUser, userID)
return err
}
const deleteTasteProfileMoodsForUser = `-- name: DeleteTasteProfileMoodsForUser :exec
DELETE FROM taste_profile_moods WHERE user_id = $1
`
func (q *Queries) DeleteTasteProfileMoodsForUser(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTasteProfileMoodsForUser, userID)
return err
}
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
DELETE FROM taste_profile_tags WHERE user_id = $1 DELETE FROM taste_profile_tags WHERE user_id = $1
` `
@@ -63,38 +45,6 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
return err return err
} }
const insertTasteProfileEra = `-- name: InsertTasteProfileEra :exec
INSERT INTO taste_profile_eras (user_id, era, weight)
VALUES ($1, $2, $3)
`
type InsertTasteProfileEraParams struct {
UserID pgtype.UUID
Era string
Weight float64
}
func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProfileEraParams) error {
_, err := q.db.Exec(ctx, insertTasteProfileEra, arg.UserID, arg.Era, arg.Weight)
return err
}
const insertTasteProfileMood = `-- name: InsertTasteProfileMood :exec
INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3)
`
type InsertTasteProfileMoodParams struct {
UserID pgtype.UUID
Mood string
Weight float64
}
func (q *Queries) InsertTasteProfileMood(ctx context.Context, arg InsertTasteProfileMoodParams) error {
_, err := q.db.Exec(ctx, insertTasteProfileMood, arg.UserID, arg.Mood, arg.Weight)
return err
}
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
INSERT INTO taste_profile_tags (user_id, tag, weight) INSERT INTO taste_profile_tags (user_id, tag, weight)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
@@ -137,23 +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, a.release_date 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
JOIN albums a ON a.id = t.album_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
ReleaseDate pgtype.Date
} }
// (track_id, artist_id, genre, release_date) for each track the user has // (artist_id, genre) for each track the user has explicitly liked. Feeds the
// explicitly liked. Feeds the track-like bonus into the liked track's artist, // track-like bonus into the liked track's artist and tags.
// tags, and era (#1530); 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 {
@@ -163,12 +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( if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
&i.TrackID,
&i.ArtistID,
&i.Genre,
&i.ReleaseDate,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -182,10 +123,8 @@ 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,
a.release_date,
LEAST(GREATEST( LEAST(GREATEST(
COALESCE(pe.completion_ratio, COALESCE(pe.completion_ratio,
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0), pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
@@ -193,7 +132,6 @@ SELECT
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days (EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
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
JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS ( AND NOT EXISTS (
@@ -208,10 +146,8 @@ type ListPlayEngagementInputsForUserParams struct {
} }
type ListPlayEngagementInputsForUserRow struct { type ListPlayEngagementInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Genre *string Genre *string
ReleaseDate pgtype.Date
Completion float64 Completion float64
AgeDays float64 AgeDays float64
} }
@@ -222,8 +158,7 @@ type ListPlayEngagementInputsForUserRow struct {
// One row per play in the decay-relevant window. completion is the play's // One row per play in the decay-relevant window. completion is the play's
// completion ratio (precomputed column when present, else duration_played / // completion ratio (precomputed column when present, else duration_played /
// 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; release_date derives the decade for the era // is split into tags in Go. Quarantined tracks are excluded.
// facet (#1530). Quarantined tracks are excluded.
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) { func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
if err != nil { if err != nil {
@@ -234,10 +169,8 @@ 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.ReleaseDate,
&i.Completion, &i.Completion,
&i.AgeDays, &i.AgeDays,
); err != nil { ); err != nil {
@@ -290,84 +223,6 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
return items, nil return items, nil
} }
const listTasteProfileErasForUser = `-- name: ListTasteProfileErasForUser :many
SELECT era, weight
FROM taste_profile_eras
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2
`
type ListTasteProfileErasForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListTasteProfileErasForUserRow struct {
Era string
Weight float64
}
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteProfileErasForUserRow
for rows.Next() {
var i ListTasteProfileErasForUserRow
if err := rows.Scan(&i.Era, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteProfileMoodsForUser = `-- name: ListTasteProfileMoodsForUser :many
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2
`
type ListTasteProfileMoodsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListTasteProfileMoodsForUserRow struct {
Mood string
Weight float64
}
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteProfileMoodsForUserRow
for rows.Next() {
var i ListTasteProfileMoodsForUserRow
if err := rows.Scan(&i.Mood, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
SELECT tag, weight SELECT tag, weight
FROM taste_profile_tags FROM taste_profile_tags
-222
View File
@@ -1,222 +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 listTrackTagsForTracks = `-- name: ListTrackTagsForTracks :many
SELECT tt.track_id, tt.tag
FROM track_tags tt
WHERE tt.track_id = ANY($1::uuid[])
`
type ListTrackTagsForTracksRow struct {
TrackID pgtype.UUID
Tag string
}
// Enriched tags for a set of candidate tracks (#1534), so the scorer can
// derive each candidate's mood buckets at scoring time. One row per (track,
// tag); the caller filters to mood words via the internal/mood vocabulary.
func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
rows, err := q.db.Query(ctx, listTrackTagsForTracks, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTrackTagsForTracksRow
for rows.Next() {
var i ListTrackTagsForTracksRow
if err := rows.Scan(&i.TrackID, &i.Tag); 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, a.mbid AS artist_mbid
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
ArtistMbid *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, title + artist name for name-based fallback, and the
// artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
// $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,
&i.ArtistMbid,
); 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,9 +0,0 @@
-- Drop rows using the new value first so they don't violate the restored
-- (narrower) constraint, then revert the whitelist.
DELETE FROM diagnostic_events WHERE kind = 'playback';
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http'
));
@@ -1,19 +0,0 @@
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
--
-- `route` and `player_state` events fire for EVERY output route (phone
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
-- (drops, and future resync/desync); general playback + route telemetry
-- moves to the new 'playback' kind.
--
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
-- re-adding the constraint in the same change. Existing rows tagged
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http', 'playback'
));
@@ -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);
@@ -1,2 +0,0 @@
-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql.
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale;
@@ -1,6 +0,0 @@
-- Expose EnrichedTagScale in the tuning lab (#1520). Adds the enriched-tag
-- weight knob to the taste_tuning singleton so operators can dial how much
-- folksonomy tags (#1490) count vs raw ID3 genre. DEFAULT 0.5 backfills the
-- existing row and matches taste.DefaultConfig().EnrichedTagScale.
ALTER TABLE taste_tuning
ADD COLUMN enriched_tag_scale double precision NOT NULL DEFAULT 0.5;

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