Compare commits

..
69 Commits
Author SHA1 Message Date
bvandeusen 3e258507bb Merge pull request 'fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening' (#98) from dev into main
android / Build + lint + test (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 15s
2026-06-12 21:25:25 -04:00
bvandeusen 9550d8daaf Merge pull request 'fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation)' (#97) from dev into main
android / Build + lint + test (push) Successful in 4m5s
release / Build signed APK (tag releases only) (push) Successful in 4m4s
release / Build + push container image (push) Successful in 13s
2026-06-12 19:45:44 -04:00
bvandeusen db393bbe65 Merge PR #96: recommendation batch (You-might-like fallback + taste 2b + observability)
test-go / test (push) Successful in 41s
test-web / test (push) Successful in 43s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-12 01:10:09 -04:00
bvandeusen 7b7bd0c3e8 Merge PR #95: You-might-like liked-entity fallback (#790)
test-go / test (push) Successful in 30s
release / Build signed APK (tag releases only) (push) Successful in 3m55s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-11 23:33:25 -04:00
bvandeusen 8019537b02 Merge PR #94: "You might like" web client row (#790)
test-web / test (push) Successful in 35s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 1m56s
2026-06-11 23:01:38 -04:00
bvandeusen e41d603c12 Merge PR #93: move "You might like" under system-playlists row
android / Build + lint + test (push) Successful in 4m24s
release / Build signed APK (tag releases only) (push) Successful in 4m29s
release / Build + push container image (push) Successful in 14s
2026-06-11 22:55:52 -04:00
bvandeusen a62f07bd3a Merge PR #92: "You might like" Android client row (#790)
release / Build signed APK (tag releases only) (push) Successful in 4m12s
release / Build + push container image (push) Successful in 15s
android / Build + lint + test (push) Successful in 4m19s
2026-06-11 22:36:22 -04:00
bvandeusen 3c646c6974 Merge PR #91: "You might like" rows + taste profile (learn + apply)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m34s
release / Build signed APK (tag releases only) (push) Successful in 4m5s
release / Build + push container image (push) Successful in 15s
2026-06-11 21:41:17 -04:00
bvandeusen 9a57dc4bec Merge pull request 'Offline/playback recording robustness (contract-audit follow-ups)' (#90) from dev into main
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 4m25s
release / Build signed APK (tag releases only) (push) Successful in 4m20s
release / Build + push container image (push) Successful in 1m53s
2026-06-11 14:19:27 -04:00
bvandeusen f167ddfbfb Merge pull request 'fix: restore native Android play-event recording (History was empty)' (#89) from dev into main
test-go / test (push) Successful in 38s
test-go / integration (push) Successful in 4m29s
android / Build + lint + test (push) Successful in 4m42s
release / Build signed APK (tag releases only) (push) Successful in 3m59s
release / Build + push container image (push) Successful in 14s
2026-06-11 09:08:20 -04:00
bvandeusen 962b4dbc8c Merge pull request 'v2026.06.07 — Sonos: one SOAP failure no longer drops to local' (#88) from dev into main
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 15s
2026-06-07 19:14:45 -04:00
bvandeusen aa4089118e chore: configure Renovate (tuned)
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 1m34s
Activate Renovate with a tuned config: target dev, ignore retired
flutter_client/**, auto-merge GREEN patch/minor bumps, hold majors behind
dependency-dashboard approval, and group go/CI/docker/gradle/npm updates.
Throttled to a weekend schedule with prHourlyLimit 2.
2026-06-07 12:00:44 -04:00
bvandeusen 7c791dc8e4 v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 14s
2026-06-06 23:31:27 -04:00
bvandeusen 11466e1525 Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
release / Build + push container image (push) Successful in 13s
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
2026-06-05 13:27:36 -04:00
bvandeusen 301c3bfb86 Merge pull request 'fix(android): don't flip offline on WAN-validation flicker — trust /healthz' (#85) from dev into main
android / Build + lint + test (push) Successful in 5m6s
release / Build signed APK (tag releases only) (push) Successful in 4m42s
release / Build + push container image (push) Successful in 14s
2026-06-04 23:02:28 -04:00
bvandeusen 4d8c7d6566 Merge pull request 'fix(android): notification art uses album cover, not embedded stream tags' (#84) from dev into main
android / Build + lint + test (push) Successful in 4m1s
release / Build signed APK (tag releases only) (push) Successful in 6m12s
release / Build + push container image (push) Successful in 15s
2026-06-04 22:35:21 -04:00
bvandeusen d6e6caa223 Merge pull request 'Sonos queue resync + cold-start prefetcher gate' (#83) from dev into main
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 13s
2026-06-04 17:36:31 -04:00
bvandeusen 8b08482d13 Merge pull request 'fix(android): hysteresis on /healthz reachable signal' (#82) from dev into main
android / Build + lint + test (push) Successful in 4m46s
release / Build signed APK (tag releases only) (push) Successful in 4m26s
release / Build + push container image (push) Successful in 13s
2026-06-04 14:23:49 -04:00
bvandeusen 7cf04fe24b Merge pull request 'Android #618 offline-mode UX + Sonos polish + server DRY' (#81) from dev into main
android / Build + lint + test (push) Successful in 4m35s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 13s
2026-06-04 12:53:59 -04:00
bvandeusen 222a0ff636 Merge pull request 'dev → main: collage center-crop, server DRY, CI durability-off' (#80) from dev into main
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m27s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 12s
2026-06-04 08:42:36 -04:00
bvandeusen d75c1ae37f Merge pull request 'dev → main: Android UPnP/Sonos transport parity + server stream URL extension' (#79) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 30s
release / Build + push container image (push) Successful in 1m24s
android / Build + lint + test (push) Successful in 4m12s
test-go / integration (push) Successful in 9m16s
2026-06-04 08:15:15 -04:00
bvandeusen 3c4c27fb08 Merge pull request 'v2026.06.03 hotfix — Sonos cast URL + UPnP picker polish' (#78) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m49s
test-go / integration (push) Successful in 10m56s
release / Build signed APK (tag releases only) (push) Successful in 4m15s
release / Build + push container image (push) Successful in 17s
2026-06-03 15:30:09 -04:00
bvandeusen a62a20b599 Merge pull request 'v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation' (#77) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 4m22s
test-go / integration (push) Successful in 11m14s
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m59s
release / Build + push container image (push) Successful in 2m6s
2026-06-03 14:09:23 -04:00
bvandeusen d9b2dd957c Merge pull request 'fix(android): interceptor order — auth before baseUrl (hotfix for v2026.06.02)' (#76) from dev into main
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m28s
release / Build + push container image (push) Successful in 2m1s
2026-06-02 22:07:32 -04:00
bvandeusen 46dcd38fd8 Merge pull request 'Drift audit 2026-06-02 — 26 findings shipped' (#75) from dev into main
test-go / test (push) Successful in 43s
test-web / test (push) Successful in 56s
release / Build signed APK (tag releases only) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 24s
android / Build + lint + test (push) Successful in 5m47s
test-go / integration (push) Failing after 12m37s
2026-06-02 19:21:53 -04:00
bvandeusen 7838038047 Merge pull request 'Playback errors slice + scrubber polish + various polish' (#74) from dev into main
test-go / test (push) Successful in 39s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 4m45s
release / Build signed APK (tag releases only) (push) Successful in 5m12s
release / Build + push container image (push) Successful in 22s
test-go / integration (push) Successful in 12m7s
2026-06-02 14:14:11 -04:00
bvandeusen b64965b38d Merge pull request 'Discover artwork + Library icon + notification tap routing' (#73) from dev into main
android / Build + lint + test (push) Successful in 5m55s
release / Build signed APK (tag releases only) (push) Successful in 5m26s
release / Build + push container image (push) Successful in 16s
2026-06-02 09:58:10 -04:00
bvandeusen bf2f9f3811 Merge pull request 'Lock Android MainActivity to portrait' (#72) from dev into main
android / Build + lint + test (push) Successful in 4m2s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 15s
2026-06-02 08:19:07 -04:00
bvandeusen deb726a285 Merge pull request 'Keep onPostScroll under detekt ReturnCount limit' (#71) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 10s
android / Build + lint + test (push) Successful in 4m9s
2026-06-02 08:10:12 -04:00
bvandeusen 7ede83a586 Merge pull request 'Alphabet rail page-chasing + Songs Like fix + scrubber polish' (#70) from dev into main
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Failing after 1m29s
release / Build signed APK (tag releases only) (push) Successful in 3m51s
release / Build + push container image (push) Successful in 12s
2026-06-01 23:36:07 -04:00
bvandeusen 1d7b91333f Merge pull request 'Web Library: alphabet rail always shows #/A-Z/&' (#69) from dev into main
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 19s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
2026-06-01 21:55:49 -04:00
bvandeusen 883d416d26 Merge pull request 'Web Library: continuous grid + sticky alphabet rail' (#68) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 28s
test-web / test (push) Successful in 35s
2026-06-01 21:36:55 -04:00
bvandeusen 09471a8f5c Merge pull request 'Web: Most Played horizontal tiles + nav centering + Library link + Search refinements' (#67) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 25s
test-web / test (push) Successful in 34s
2026-06-01 21:06:03 -04:00
bvandeusen 7de238e91e Merge pull request 'Web Home: drop hero row + compact Rediscover tiles' (#66) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 26s
test-web / test (push) Successful in 35s
2026-06-01 20:43:53 -04:00
bvandeusen 9440c5860b Merge pull request 'Android v1 polish + Web UI flavor pass' (#65) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 40s
release / Build + push container image (push) Successful in 41s
android / Build + lint + test (push) Successful in 4m29s
2026-06-01 20:17:49 -04:00
bvandeusen 082d31bfa9 Merge pull request 'ci: chain APK build → image build via needs (fix race, kill polling)' (#64) from dev into main
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m47s
release / Build + push container image (push) Successful in 14s
2026-06-01 18:31:12 -04:00
bvandeusen 8847b43d9e Merge pull request 'ci(android): make APK attach failures visible (debug v2026.06.01 missing asset)' (#63) from dev into main
release / release (push) Successful in 11s
android / Build + lint + test (push) Successful in 4m16s
android / Build signed release APK (push) Successful in 3m9s
2026-06-01 18:13:43 -04:00
bvandeusen 09cc810e5a Merge pull request 'ci: pin upload-artifact to v3 (Gitea GHES-mode incompatible with v4)' (#62) from dev into main
release / release (push) Successful in 11s
android / Build signed release APK (push) Has been skipped
android / Build + lint + test (push) Successful in 4m34s
2026-06-01 17:38:24 -04:00
bvandeusen 2534384ed1 Merge pull request 'fix(android): remove WorkManager auto-initializer (lintVitalRelease)' (#61) from dev into main
android / Build + lint + test (push) Successful in 3m42s
android / Build signed release APK (push) Successful in 3m22s
release / release (push) Successful in 7m46s
2026-06-01 16:55:52 -04:00
bvandeusen 9ff1b30e3f Merge pull request 'fix(docker): bump builder to go 1.25 to match go.mod' (#60) from dev into main
android / Build + lint + test (push) Successful in 3m36s
android / Build signed release APK (push) Failing after 3m21s
release / release (push) Successful in 15m17s
2026-06-01 16:13:43 -04:00
bvandeusen c01853577b Merge pull request 'Release: web UX overhaul + Android native port + server polish' (#59) from dev into main
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 44s
release / release (push) Failing after 15m9s
android / Build + lint + test (push) Successful in 4m49s
android / Build signed release APK (push) Failing after 3m32s
test-go / integration (push) Successful in 9m33s
2026-06-01 16:11:21 -04:00
bvandeusen 747ed4134b Merge pull request 'v2026.05.21.0 — Wear OS dispatch fix, playlist-load feedback, drift CI' (#58) from dev into main 2026-05-21 15:42:06 -04:00
bvandeusen ccbd3b62a0 Merge pull request 'v2026.05.19.3 — playback stall resilience + legacy home cleanup' (#56) from dev into main 2026-05-19 15:48:08 -04:00
bvandeusen 19de0c2874 Merge pull request 'v2026.05.19.2 — hotfix: restore media notification (remove broken custom favorite)' (#55) from dev into main
Merge v2026.05.19.2 hotfix — restore media notification (PR #55)
2026-05-19 07:48:10 -04:00
bvandeusen 22a4649bfc Merge pull request 'v2026.05.19.1 — hotfix: notification permission + full-player auto-minimize' (#54) from dev into main
Merge v2026.05.19.1 hotfix — notification permission + full-player auto-minimize (PR #54)
2026-05-18 23:08:33 -04:00
bvandeusen 8e7660c05e Merge pull request 'fix(ci): scope integration Postgres discovery to this job's network' (#53) from dev into main
Merge CI fix: scope integration Postgres discovery to this job's network (PR #53)
2026-05-18 22:50:36 -04:00
bvandeusen 53be834e89 Merge pull request 'v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration' (#52) from dev into main
Merge v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration (PR #52)
2026-05-18 21:42:48 -04:00
bvandeusen 0d410630a2 Merge pull request 'Release v2026.05.18.0 — integration-tests-in-CI + recommendations/cover-art/discover batch' (#51) from dev into main 2026-05-17 22:41:20 -04:00
bvandeusen 62db8edcdb Merge pull request 'Release v2026.05.16.0 — recommendations working end-to-end + cover-art uncap' (#50) from dev into main 2026-05-16 18:58:55 -04:00
bvandeusen ec0cc37bc9 Merge pull request 'Hotfix v2026.05.15.1 — allow discovery-mix variants in playlists CHECK constraints' (#49) from dev into main 2026-05-16 00:32:25 -04:00
bvandeusen e772938a3b Merge pull request 'Release v2026.05.15.0 — system playlists v2, offline cache rework, CI speedup' (#48) from dev into main 2026-05-15 23:15:48 -04:00
bvandeusen 29fee5aa37 Merge pull request 'Release v2026.05.14.0 — player polish, CacheFiller, offline mutation queue' (#47) from dev into main 2026-05-15 01:17:44 +00:00
bvandeusen e5ab471ce1 Merge pull request 'release v2026.05.13.3: full-player seed + MediaSession expansion (Wear)' (#46) from dev into main 2026-05-14 18:19:39 +00:00
bvandeusen 573aa4226d Merge pull request 'release v2026.05.13.2: artist covers + load-then-swap player transitions' (#45) from dev into main 2026-05-14 16:31:43 +00:00
bvandeusen 7339815ea9 Merge pull request 'release v2026.05.13.1: player + Discover hotfix' (#44) from dev into main 2026-05-14 15:17:45 +00:00
bvandeusen baa601765e Merge pull request 'release v2026.05.13.0: SSE live updates + offline cache + per-item rendering' (#43) from dev into main 2026-05-14 02:36:47 +00:00
bvandeusen 0d009b34e2 Merge pull request 'release v2026.05.12.1: Discover surface + nav fixes + cache hygiene' (#42) from dev into main 2026-05-12 03:58:43 +00:00
bvandeusen f1b4652c77 Merge pull request 'release v2026.05.11.3: caching, perf, playlist polish' (#41) from dev into main 2026-05-12 00:35:48 +00:00
bvandeusen e610948307 Merge pull request 'release v2026.05.11.2: signing key + library infinite scroll' (#40) from dev into main 2026-05-11 19:51:41 +00:00
bvandeusen fb811804d2 Merge pull request 'release v2026.05.11.1: Flutter caching, navigation, and player polish' (#39) from dev into main 2026-05-11 17:47:41 +00:00
bvandeusen 37134950a5 Merge pull request 'release: M5-M7 server + web + Flutter, plus Flutter v1 polish' (#38) from dev into main 2026-05-11 15:21:58 +00:00
bvandeusen fcded9294c Merge pull request 'fix(web): download button + server version display' (#37) from dev into main 2026-05-11 03:20:39 +00:00
bvandeusen 42abb7adff Merge pull request 'ci: unify REGISTRY_TOKEN + RELEASE_TOKEN into CI_TOKEN' (#36) from dev into main 2026-05-11 01:45:01 +00:00
bvandeusen 04933f2d9f Merge pull request 'M5–M7 sweep + offline cache + in-app updates + DRY pass' (#35) from dev into main 2026-05-11 01:09:36 +00:00
bvandeusen 626fc7502c Merge pull request 'refactor(server): remove bootstrap admin path' (#34) from dev into main 2026-05-09 02:19:40 +00:00
bvandeusen 9bf3b8a2f2 Merge pull request 'feat(flutter): admin parity slice — requests, quarantine, users, invites' (#33) from dev into main 2026-05-09 02:07:59 +00:00
bvandeusen 492460cf4a Merge pull request 'fix(web): /register reachable for bootstrap admin (closes #376)' (#32) from dev into main 2026-05-08 22:01:37 +00:00
bvandeusen 1c775905d7 Release v2026.05.08.1 — DRY pass + cover-art HTTP base (#31)
Merges ~50 commits from dev: full DRY pass round 1 + round 2, plus PR3 cover-art HTTP base.
2026-05-08 17:45:41 +00:00
bvandeusen cd2ff648d0 Merge pull request 'M5a frontend + M5b quarantine + M5c suggestions' (#30) from dev into main 2026-05-01 11:14:18 +00:00
200 changed files with 1378 additions and 12631 deletions
+5 -54
View File
@@ -2,8 +2,8 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry.
#
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# push to main → :main and :latest (no APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref)
#
# Release model: per-day CalVer tags (no trailing patch digit). The day's
@@ -20,14 +20,6 @@ name: release
# happens in the same android-release job, so the Release-page download
# link and the in-image bundled APK are both populated atomically.
#
# :latest always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# and reconstructs its exact versionName (tag + commit-count, the same
# formula android-release bakes in) for the version sidecar — no rebuild,
# just rebundle. Tag builds keep bundling their own freshly-built APK.
#
# Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push.
@@ -189,12 +181,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags so non-tag :latest builds can resolve the
# latest release tag's commit count and reconstruct the bundled
# APK's exact versionName (see "Bundle latest release APK" below).
fetch-depth: 0
fetch-tags: true
- name: Detect buildable project
id: guard
@@ -235,8 +221,9 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
# Tag pushes only — android-release just produced this. Main
# pushes skip and the image ships with empty client/ (the
# /api/client/version endpoint then returns 404 by design).
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
uses: actions/download-artifact@v3
with:
@@ -260,42 +247,6 @@ jobs:
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release / APK asset / tag-count can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -eu
REPO="${GITHUB_REPOSITORY}"
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
fi
VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
ls -lh client/
- name: Build and push
if: steps.guard.outputs.ready == 'true'
run: |
+7 -44
View File
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
> State and intelligence belong on the server, not the client.
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
<!-- TODO: screenshot of the home page -->
## Highlights
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
## Quickstart
@@ -24,18 +24,10 @@ services:
image: git.fabledsword.com/bvandeusen/minstrel:latest
ports: ['4533:4533']
volumes:
# Your music library. Point ./music at wherever your audio files
# live. Mounted read-only — Minstrel never writes to your library.
- ./music:/music:ro
# Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
# sets to /app/data — keep this mount on /app/data or your cache
# won't survive a container recreate.
- minstrel-data:/app/data
- minstrel-data:/data
environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container
# path of the read-only music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db]
@@ -45,8 +37,6 @@ services:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel
# Postgres data dir — users, likes, play history, sessions, settings.
# The one volume you must never lose; back it up with pg_dump.
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
@@ -58,29 +48,7 @@ volumes:
docker compose up -d
```
## First run
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The &quot;Install the Android app&quot; download card in Settings"></a>
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
@@ -89,7 +57,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
- `MINSTREL_STORAGE_DATA_DIR`where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
- `MINSTREL_STORAGE_DATA_DIR`defaults to `./data`. Holds playlist cover collages and other generated artefacts.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
@@ -98,13 +66,8 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
## Updating
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
## Specs
@@ -10,8 +10,6 @@ import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.diagnostics.DiagnosticsReporter
import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
@@ -157,11 +155,6 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
// Device diagnostics (M9). The reporter gates itself on the account's
// debug flag; the uploader drains its buffer on a tick / recovery.
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
@@ -27,7 +25,6 @@ data class AdminInvitesUiState(
@HiltViewModel
class AdminInvitesViewModel @Inject constructor(
private val repository: AdminInvitesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow(AdminInvitesUiState())
@@ -39,13 +36,6 @@ class AdminInvitesViewModel @Inject constructor(
init {
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() {
@@ -29,7 +29,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
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
@@ -63,10 +62,9 @@ fun AdminQuarantineScreen(
body = "When users flag tracks as bad rips, wrong tags, or " +
"duplicates, their reports get aggregated and surfaced here.",
)
is AdminQuarantineUiState.Error -> ErrorRetry(
is AdminQuarantineUiState.Error -> EmptyState(
title = "Couldn't load queue",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is AdminQuarantineUiState.Success -> QueueList(
rows = s.rows,
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
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.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -28,7 +26,6 @@ sealed interface AdminQuarantineUiState {
class AdminQuarantineViewModel @Inject constructor(
private val repository: AdminQuarantineRepository,
private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
@@ -41,13 +38,6 @@ class AdminQuarantineViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") }
.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 {
@@ -28,7 +28,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
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
@@ -62,10 +61,9 @@ fun AdminRequestsScreen(
body = "When users ask Lidarr for new music, their pending " +
"requests show up here for approval.",
)
is AdminRequestsUiState.Error -> ErrorRetry(
is AdminRequestsUiState.Error -> EmptyState(
title = "Couldn't load requests",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is AdminRequestsUiState.Success -> RequestList(
rows = s.rows,
@@ -5,8 +5,6 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
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.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -37,7 +35,6 @@ class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
@@ -50,13 +47,6 @@ class AdminRequestsViewModel @Inject constructor(
.filter { it.kind in RELEVANT_EVENT_KINDS }
.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 {
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -25,7 +23,6 @@ sealed interface AdminUsersUiState {
@HiltViewModel
class AdminUsersViewModel @Inject constructor(
private val repository: AdminUsersRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
@@ -33,13 +30,6 @@ class AdminUsersViewModel @Inject constructor(
init {
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 {
@@ -1,36 +0,0 @@
package com.fabledsword.minstrel.api.endpoints
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for device diagnostics ingest (M9). One call
* uploads a batch of buffered events. The server stores them only when
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
* which the uploader treats as success so it can drop the batch).
*/
interface DiagnosticsApi {
@POST("api/diagnostics")
suspend fun report(@Body body: DiagnosticsReportRequest)
}
@Serializable
data class DiagnosticsReportRequest(
@SerialName("client_id") val clientId: String,
@SerialName("app_version") val appVersion: String? = null,
@SerialName("os_version") val osVersion: String? = null,
val events: List<DiagnosticEventWire>,
)
@Serializable
data class DiagnosticEventWire(
val kind: String,
// Device-clock epoch milliseconds. The server stamps its own
// received_at; both are stored so a skewed device clock is visible.
@SerialName("occurred_at") val occurredAt: Long,
// Opaque structured payload carrying the event sub-type + fields.
val payload: JsonElement,
)
@@ -1,7 +1,6 @@
package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.api.endpoints.AuthApi
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UserRef
import com.fabledsword.minstrel.models.wire.LoginRequestBody
@@ -36,7 +35,6 @@ class AuthController @Inject constructor(
retrofit: Retrofit,
) {
private val api: AuthApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val currentUserState = MutableStateFlow<UserRef?>(null)
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
@@ -57,10 +55,6 @@ class AuthController @Inject constructor(
currentUserState.value = raw?.let { decodeUser(it) }
}
}
// Refresh from /api/me on startup so a remotely-changed account
// flag (e.g. admin enabling debug mode, M9) reaches the device
// without a re-login. Best-effort; failures keep the cached value.
scope.launch { refreshProfile() }
}
/**
@@ -78,32 +72,9 @@ class AuthController @Inject constructor(
)
currentUserState.value = user
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
// Pull the fuller /me shape (carries debug_mode_enabled) right
// after login so the diagnostics gate is correct without waiting
// for the next startup refresh.
scope.launch { refreshProfile() }
return user
}
/**
* Re-fetch the caller's profile from /api/me and update currentUser
* + persisted userJson. Carries account-level flags (debug mode)
* that aren't in the login response. Best-effort: a network failure
* leaves the cached identity untouched. No-op when signed out.
*/
suspend fun refreshProfile() {
if (!isSignedIn) return
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
val user = UserRef(
id = p.id,
username = p.username,
isAdmin = p.isAdmin,
debugModeEnabled = p.debugModeEnabled,
)
currentUserState.value = user
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
}
/**
* Clears the local session immediately and best-effort hits
* `/api/auth/logout` so the server can drop its session row.
@@ -59,9 +59,6 @@ class AuthStore @Inject constructor(
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
init {
@@ -73,7 +70,6 @@ class AuthStore @Inject constructor(
themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
}
}
}
@@ -116,11 +112,6 @@ class AuthStore @Inject constructor(
scope.launch { persistCacheSettings(encoded) }
}
fun setDiagnosticsOptOut(value: Boolean) {
diagnosticsOptOutState.value = value
scope.launch { persistDiagnosticsOptOut(value) }
}
private suspend fun persistCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -169,14 +160,6 @@ class AuthStore @Inject constructor(
}
}
private suspend fun persistDiagnosticsOptOut(value: Boolean) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(diagnosticsOptOut = value))
} else {
dao.setDiagnosticsOptOut(value)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
sessionCookie = sessionCookieState.value,
@@ -188,7 +171,6 @@ class AuthStore @Inject constructor(
CacheSettings.serializer(),
cacheSettingsState.value,
),
diagnosticsOptOut = diagnosticsOptOutState.value,
)
companion object {
@@ -16,7 +16,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
@@ -31,7 +30,6 @@ import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/**
@@ -63,11 +61,8 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHomeIndexEntity::class,
CachedHistorySnapshotEntity::class,
AuthSessionEntity::class,
DiagnosticEventEntity::class,
],
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
version = 7,
version = 6,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -86,5 +81,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
}
@@ -10,7 +10,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
@@ -106,10 +105,5 @@ object DatabaseModule {
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
db.audioCacheIndexDao()
@Provides
@Singleton
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
db.diagnosticEventDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -42,8 +42,4 @@ interface AuthSessionDao {
/** Partial update: change only the serialized cache settings. */
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
suspend fun setCacheSettingsJson(json: String?)
/** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean)
}
@@ -1,35 +0,0 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
@Dao
interface DiagnosticEventDao {
@Insert
suspend fun insert(row: DiagnosticEventEntity): Long
/** FIFO drain order so the uploader sends oldest-first. */
@Query("SELECT * FROM diagnostic_events ORDER BY id ASC LIMIT :limit")
suspend fun takeBatch(limit: Int): List<DiagnosticEventEntity>
@Query("SELECT COUNT(*) FROM diagnostic_events")
suspend fun count(): Int
@Query("DELETE FROM diagnostic_events WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<Long>)
/**
* Ring-buffer trim: drop the oldest rows beyond [keep]. Called after
* insert so a long offline stretch can't grow the buffer unbounded.
*/
@Query(
"DELETE FROM diagnostic_events WHERE id NOT IN " +
"(SELECT id FROM diagnostic_events ORDER BY id DESC LIMIT :keep)",
)
suspend fun trimToNewest(keep: Int)
@Query("DELETE FROM diagnostic_events")
suspend fun clear()
}
@@ -36,11 +36,4 @@ data class AuthSessionEntity(
* CacheSettings shape evolves.
*/
val cacheSettingsJson: String? = null,
/**
* Per-device opt-out of diagnostics reporting (M9). When the
* account's debug mode is enabled by an admin, the user can still
* turn reporting OFF on this device (battery/privacy) — that local
* choice lives here. Default false = honor the account flag.
*/
val diagnosticsOptOut: Boolean = false,
)
@@ -1,28 +0,0 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
* writes rows here when the account's debug mode is on; the
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
* on success.
*
* This is a deliberate ring buffer that is NOT routed through the offline
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
* offline — so events must persist locally through the dead zone and
* upload on recovery, without clogging the user-data mutation replay.
*
* `occurredAtMillis` is the device-clock epoch-ms when the event happened
* (the server also stamps its own received_at). `payloadJson` is an
* opaque JSON object carrying the event sub-type + fields.
*/
@Entity(tableName = "diagnostic_events")
data class DiagnosticEventEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val kind: String,
val occurredAtMillis: Long,
val payloadJson: String,
)
@@ -1,6 +1,5 @@
package com.fabledsword.minstrel.cache.sync
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.api.endpoints.SyncApi
import com.fabledsword.minstrel.auth.AuthStore
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.SyncTrackWire
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.filter
import kotlinx.coroutines.flow.filterNotNull
@@ -68,17 +64,6 @@ class SyncController @Inject constructor(
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
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 {
scope.launch {
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() {
if (!mutex.tryLock()) return
try {
runCatching { sync() }
.onSuccess { lastSyncErrorInternal.value = null }
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
} finally {
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 { }
@@ -1,328 +0,0 @@
package com.fabledsword.minstrel.diagnostics
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.PowerManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.core.content.ContextCompat
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.output.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton
/**
* On-device diagnostics collector (M9). Active only while the account's
* debug mode is enabled (server flag, read via AuthController.currentUser)
* AND the user hasn't opted out locally. When active it taps existing
* connectivity / player / UPnP / power seams READ-ONLY and writes a
* timeseries of events into the [DiagnosticEventDao] ring buffer; the
* [DiagnosticsUploader] drains that buffer to the server.
*
* The aim is to explain two failure classes after the fact: roaming /
* poor-data recovery (connectivity + server-health transitions) and
* locked-phone UPnP desync (power/Doze transitions correlated with the
* Sonos-vs-local position deltas captured in the heartbeat).
*/
@Suppress("TooManyFunctions") // one collector + one (un)register pair per signal; cohesive
@Singleton
class DiagnosticsReporter @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val dao: DiagnosticEventDao,
private val authController: AuthController,
private val authStore: AuthStore,
private val connectivity: ConnectivityObserver,
private val networkStatus: NetworkStatusController,
private val playerController: PlayerController,
private val outputPicker: OutputPickerController,
private val remoteState: RemotePlayerState,
private val json: Json,
) {
@Volatile private var enabled = false
private var collectorJob: Job? = null
private var powerReceiver: BroadcastReceiver? = null
private var lifecycleObserver: DefaultLifecycleObserver? = null
init {
// Gate = account flag (currentUser.debugModeEnabled) AND not the
// per-device local opt-out.
scope.launch {
combine(authController.currentUser, authStore.diagnosticsOptOut) { user, optOut ->
(user?.debugModeEnabled == true) && !optOut
}.distinctUntilChanged().collect { setEnabled(it) }
}
// Periodically re-pull /me so a remote admin flip propagates
// without a re-login or app restart.
scope.launch {
while (true) {
delay(GATE_REFRESH_MS)
authController.refreshProfile()
}
}
}
private fun setEnabled(value: Boolean) {
if (value == enabled) return
enabled = value
if (value) start() else stop()
}
private fun start() {
registerPowerReceiver()
registerLifecycleObserver()
collectorJob = scope.launch {
record("lifecycle", buildJsonObject { put("event", "reporting_started") })
launch { collectConnectivity() }
launch { collectServerHealth() }
launch { collectUpnpDrops() }
launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectRoutes() }
launch { heartbeatLoop() }
}
}
private fun stop() {
collectorJob?.cancel()
collectorJob = null
unregisterPowerReceiver()
unregisterLifecycleObserver()
// Off means off: drop any unsent buffer (honors the user OFF
// intent and avoids leaking data captured before disable).
scope.launch { runCatching { dao.clear() } }
}
private suspend fun collectConnectivity() {
connectivity.online.distinctUntilChanged().collect { online ->
record("connectivity", buildJsonObject {
put("event", "online_changed")
put("online", online)
})
}
}
private suspend fun collectServerHealth() {
// state is a StateFlow — already conflated/distinct, so no
// distinctUntilChanged (it's a deprecation warning = build error).
networkStatus.state.collect { s ->
record("connectivity", buildJsonObject {
put("event", "server_health")
put("state", s.name)
})
}
}
private suspend fun collectUpnpDrops() {
playerController.dropEvents.collect { msg ->
record("upnp_sync", buildJsonObject {
put("event", "drop")
put("message", msg)
})
}
}
private suspend fun collectPlayerState() {
// 'playback', not 'upnp_sync' — player state applies to every output
// route (phone speaker, Bluetooth, UPnP), not just casting.
playerController.uiState
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
.distinctUntilChanged()
.collect { (source, upnpLoading, err) ->
record("playback", buildJsonObject {
put("event", "player_state")
put("source", source ?: "")
put("upnp_loading", upnpLoading)
if (err != null) put("error", err)
})
}
}
// 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() {
// '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 ->
record("playback", buildJsonObject {
put("event", "route")
put("id", r.id)
put("name", r.name)
put("kind", r.kind.name)
put("protocol", r.protocol.name)
})
}
}
private suspend fun heartbeatLoop() {
while (true) {
delay(HEARTBEAT_MS)
val ui = playerController.uiState.value
val route = outputPicker.routesState.value.current
val routeActive = route.protocol != OutputRoute.Protocol.SYSTEM
// Idle = nothing worth sampling. Skip to keep the buffer lean.
if (!ui.isPlaying && !routeActive) continue
record("heartbeat", buildJsonObject {
put("server_health", networkStatus.state.value.name)
put("is_playing", ui.isPlaying)
put("source", ui.currentSource ?: "")
put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
put("route", route.name)
put("route_protocol", route.protocol.name)
putSonos(this, routeActive)
addPowerFields(this)
})
}
}
// --- power + lifecycle taps -------------------------------------------
private fun registerPowerReceiver() {
if (powerReceiver != null) return
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
val action = intent?.action ?: return
if (!enabled) return
scope.launch { record("power", powerEvent(action)) }
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
}
// System-protected broadcasts, but pass NOT_EXPORTED explicitly so
// the API 34+ registerReceiver flag requirement is satisfied.
ContextCompat.registerReceiver(
context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED,
)
powerReceiver = receiver
}
private fun unregisterPowerReceiver() {
powerReceiver?.let { runCatching { context.unregisterReceiver(it) } }
powerReceiver = null
}
private fun registerLifecycleObserver() {
if (lifecycleObserver != null) return
val obs = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = logLifecycle("app_foreground")
override fun onStop(owner: LifecycleOwner) = logLifecycle("app_background")
}
lifecycleObserver = obs
scope.launch(Dispatchers.Main) {
ProcessLifecycleOwner.get().lifecycle.addObserver(obs)
}
}
private fun unregisterLifecycleObserver() {
val obs = lifecycleObserver ?: return
lifecycleObserver = null
scope.launch(Dispatchers.Main) {
ProcessLifecycleOwner.get().lifecycle.removeObserver(obs)
}
}
private fun logLifecycle(event: String) {
if (!enabled) return
scope.launch { record("lifecycle", buildJsonObject { put("event", event) }) }
}
private fun powerEvent(action: String): JsonObject = buildJsonObject {
put("event", action.substringAfterLast('.'))
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) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
builder.put("doze", pm.isDeviceIdleMode)
builder.put("screen_on", pm.isInteractive)
builder.put("battery_opt_ignored", pm.isIgnoringBatteryOptimizations(context.packageName))
}
private suspend fun record(kind: String, payload: JsonObject) {
runCatching {
val id = dao.insert(
DiagnosticEventEntity(
kind = kind,
occurredAtMillis = System.currentTimeMillis(),
payloadJson = json.encodeToString(JsonObject.serializer(), payload),
),
)
if (id % TRIM_INTERVAL == 0L) dao.trimToNewest(MAX_BUFFER)
}
}
private companion object {
const val HEARTBEAT_MS = 45_000L
const val GATE_REFRESH_MS = 300_000L // re-pull /me every 5 min
const val MAX_BUFFER = 5_000 // ring-buffer ceiling, oldest dropped
const val TRIM_INTERVAL = 50L // trim every N inserts, not every one
}
}
@@ -1,118 +0,0 @@
package com.fabledsword.minstrel.diagnostics
import android.os.Build
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.endpoints.DiagnosticEventWire
import com.fabledsword.minstrel.api.endpoints.DiagnosticsApi
import com.fabledsword.minstrel.api.endpoints.DiagnosticsReportRequest
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.serialization.json.Json
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
* upload the moment the link comes back.
*
* Deliberately NOT the MutationQueue: diagnostics are high-volume,
* best-effort telemetry. A failed upload just retries on the next tick;
* the server no-ops (204) when the account's debug flag is off, which the
* uploader treats as success so the buffer drains either way.
*/
@Singleton
class DiagnosticsUploader @Inject constructor(
private val dao: DiagnosticEventDao,
private val authStore: AuthStore,
private val json: Json,
@ApplicationScope private val scope: CoroutineScope,
networkStatus: NetworkStatusController,
retrofit: Retrofit,
) {
private val api: DiagnosticsApi = retrofit.create()
private val mutex = Mutex()
init {
scope.launch {
while (true) {
delay(UPLOAD_INTERVAL_MS)
drainSafe()
}
}
scope.launch {
authStore.sessionCookie.filterNotNull().distinctUntilChanged().collect { drainSafe() }
}
scope.launch {
networkStatus.state
.map { it == ServerHealth.Healthy }
.distinctUntilChanged()
.filter { it }
.collect { drainSafe() }
}
}
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
private suspend fun drainSafe() {
val clientId = authStore.clientId.value
// Signed out / no client id yet → nothing to do. Single guard keeps
// the return count within the detekt gate.
if (authStore.sessionCookie.value.isNullOrEmpty() || clientId == null) return
if (!mutex.tryLock()) return
try {
drain(clientId)
} finally {
mutex.unlock()
}
}
private suspend fun drain(clientId: String) {
var more = true
while (more) {
val batch = runCatching { dao.takeBatch(BATCH_SIZE) }.getOrNull().orEmpty()
val sent = batch.isNotEmpty() && runCatching { upload(clientId, batch) }.isSuccess
if (sent) runCatching { dao.deleteByIds(batch.map { it.id }) }
// Keep going only while a full batch sent cleanly; otherwise stop
// (empty buffer, or a failure to retry on the next trigger).
more = sent && batch.size >= BATCH_SIZE
}
}
private suspend fun upload(clientId: String, batch: List<DiagnosticEventEntity>) {
api.report(
DiagnosticsReportRequest(
clientId = clientId,
appVersion = BuildConfig.VERSION_NAME,
osVersion = "Android ${Build.VERSION.RELEASE} (${Build.MODEL})",
events = batch.map { row ->
DiagnosticEventWire(
kind = row.kind,
occurredAt = row.occurredAtMillis,
payload = json.parseToJsonElement(row.payloadJson),
)
},
),
)
}
private companion object {
const val UPLOAD_INTERVAL_MS = 60_000L
const val BATCH_SIZE = 100
}
}
@@ -41,7 +41,6 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
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.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -110,7 +109,6 @@ private fun DiscoverBody(
snackbar.showSnackbar(snackbarFor(outcome, s.name))
}
},
onRetry = { viewModel.loadSuggestions() },
)
ResultsState.Loading -> LoadingCentered()
is ResultsState.Loaded -> ResultsList(
@@ -123,11 +121,7 @@ private fun DiscoverBody(
}
},
)
is ResultsState.Error -> ErrorRetry(
title = "Search failed",
message = r.message,
onRetry = viewModel::runSearch,
)
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
}
}
}
@@ -183,15 +177,10 @@ private fun SuggestionsPane(
state: SuggestionState,
locallyRequestedMbids: Set<String>,
onRequest: (ArtistSuggestionRef) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
SuggestionState.Loading -> LoadingCentered()
is SuggestionState.Error -> ErrorRetry(
title = "Couldn't load suggestions",
message = state.message,
onRetry = onRetry,
)
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
is SuggestionState.Loaded -> SuggestionsList(
items = state.items.filter { it.mbid !in locallyRequestedMbids },
onRequest = onRequest,
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.discover.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
@@ -48,7 +46,6 @@ sealed interface ResultsState {
@HiltViewModel
class DiscoverViewModel @Inject constructor(
private val repository: DiscoverRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow(DiscoverState())
@@ -56,15 +53,6 @@ class DiscoverViewModel @Inject constructor(
init {
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) {
@@ -19,8 +19,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
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.HistoryRepository
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.widgets.TrackRow
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.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -59,7 +56,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
class HistoryTabViewModel @Inject constructor(
private val repository: HistoryRepository,
private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val refreshError = MutableStateFlow<String?>(null)
@@ -86,11 +82,6 @@ class HistoryTabViewModel @Inject constructor(
init {
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 {
@@ -125,10 +116,9 @@ fun HistoryTab(
title = "No listening history yet",
body = "Play something — your recent plays will show up here.",
)
is UiState.Error -> ErrorRetry(
is UiState.Error -> EmptyState(
title = "Couldn't load history",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is UiState.Success -> HistoryList(
entries = s.data,
@@ -2,19 +2,11 @@
package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -35,7 +27,6 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -64,7 +55,6 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository
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.asCacheFirstStateFlow
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.MinstrelTopAppBar
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 kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -107,7 +95,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -120,16 +107,6 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil (automatic refresh). Held through the pull
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_SETTLE_MS = 500L
private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f
private const val VEIL_SPINNER_DP = 22
private const val VEIL_SPINNER_STROKE_DP = 2
private const val VEIL_LABEL_GAP_DP = 12
// ─── State ───────────────────────────────────────────────────────────
data class HomeSections(
@@ -162,7 +139,6 @@ class HomeViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) : ViewModel() {
@@ -189,45 +165,8 @@ class HomeViewModel @Inject constructor(
/** Transient snackbar messages from offline-pool taps. */
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 {
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
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
// rebuild churn hides behind "Updating your mixes…".
viewModelScope.launch {
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" }
.collect { refreshBehindVeil() }
}
}
/**
@@ -339,14 +278,7 @@ class HomeViewModel @Inject constructor(
* actual completion before hiding the indicator.
*/
fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
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 home = launch { runCatching { homeRepository.refreshIndex() } }
val lists = launch { runCatching { playlistsRepository.refreshList() } }
val status = launch {
runCatching { homeRepository.getSystemPlaylistsStatus() }
@@ -357,26 +289,6 @@ class HomeViewModel @Inject constructor(
status.join()
}
/**
* Automatic background refresh with the "Updating your mixes…" veil
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
* paths where Home is already on screen. Holds the veil through the
* pull plus a short settle so per-tile hydration lands behind it, then
* lets it wipe off. Overlapping automatic refreshes are rare enough
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
*/
private fun refreshBehindVeil() {
viewModelScope.launch {
updatingInternal.value = true
try {
refresh().join()
delay(VEIL_SETTLE_MS)
} finally {
updatingInternal.value = false
}
}
}
val uiState: StateFlow<UiState<HomeSections>> =
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
@@ -413,17 +325,9 @@ class HomeViewModel @Inject constructor(
}
private fun combineHomeFlows() =
combine(
observeHomeSections(),
playlistsRepository.observeAll(),
refreshError,
) { sections, playlists, err ->
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
val merged = sections.copy(playlists = playlists)
when {
!merged.isAllEmpty -> UiState.Success(merged)
err != null -> UiState.Error(err)
else -> UiState.Empty
}
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
}
}
@@ -452,108 +356,46 @@ fun HomeScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Box(Modifier.fillMaxSize()) {
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
// Automatic-refresh veil: the daily rebuild / reconnect
// churn hides behind an "Updating your mixes…" wipe. Manual
// pull owns the PullToRefreshBox spinner instead.
UpdatingVeil(visible = updating)
}
}
}
}
/**
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
*
* Key the Crossfade on the state CLASS, not the instance. Each section
* emission produces a new UiState.Success(data); if we keyed on `state`
* directly, every per-section hydration tick would re-run the 300ms
* crossfade, and first-sign-in (six sections cascading in) reads as
* continuous flicker. Keying on the class restricts the animation to
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
* recompositions update the LazyColumn without a fade.
*/
@Composable
private fun HomeStateCrossfade(
state: UiState<HomeSections>,
systemStatus: SystemPlaylistsStatus,
offline: Boolean,
navController: NavHostController,
viewModel: HomeViewModel,
) {
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.refresh() },
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
}
}
}
/**
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
* holds while an automatic refresh repopulates Home, then wipes off.
* Near-opaque so the section churn underneath never shows; swallows taps
* while raised so a mid-hydration tile can't be hit.
*/
@Composable
private fun BoxScope.UpdatingVeil(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {},
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
Text(
text = "Updating your mixes…",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// Key Crossfade on the state CLASS, not the instance. Each
// section emission produces a new UiState.Success(data); if
// we keyed on `state` directly, every per-section
// hydration tick would re-run the 300ms crossfade, and
// first-sign-in (six sections cascading in) reads as
// continuous flicker. Keying on the class restricts the
// animation to Loading↔Success↔Empty↔Error transitions and
// lets normal Success→Success recompositions update the
// LazyColumn without a fade.
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> EmptyState(
title = "Couldn't load home",
body = s.message,
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
}
}
}
}
@@ -623,12 +465,10 @@ private fun HomeSuccessContent(
) {
item {
// Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover slots
// that haven't generated yet. When offline, the cache-backed
// pool cards lead the row. Songs-like now lives in its own
// dedicated row below (#1491), no longer inside this carousel.
// placeholder cards filling the For You / Discover /
// 3× Songs-like slots that haven't generated yet. When
// offline, the cache-backed pool cards lead the row.
PlaylistsRow(
title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
offline = offline,
onPlaylistClick = onPlaylistClick,
@@ -636,18 +476,6 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist,
)
}
// Songs-like is the best-performing surface (#1491) — promoted out
// of the Playlists carousel into its own row so it shows a wider
// spread of "Songs like {artist}" mixes. Hidden when there's
// nothing to show (offline with none cached).
songsLikeSection(
playlists = sections.playlists,
status = systemStatus,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
youMightLikeSection(
albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists,
@@ -671,35 +499,6 @@ private fun HomeSuccessContent(
}
}
/**
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
* rendering with a distinct title; hidden entirely when there's nothing
* to show (offline with no cached mixes). Online with none generated yet
* still shows a few placeholders so the building / seed-needed state is
* visible, matching the pre-promotion carousel behavior.
*/
private fun LazyListScope.songsLikeSection(
playlists: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
val rowItems = buildSongsLikeRow(playlists, status, offline)
if (rowItems.isEmpty()) return
item {
PlaylistsRow(
title = "Songs like…",
rowItems = rowItems,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
}
}
private fun LazyListScope.recentlyAddedSection(
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
@@ -969,14 +768,13 @@ private fun AlbumsRow(
@Composable
private fun PlaylistsRow(
title: String,
rowItems: List<PlaylistRowItem>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
HorizontalScrollRow(title = title) {
HorizontalScrollRow(title = "Playlists") {
itemsIndexed(items = rowItems) { _, item ->
when (item) {
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
@@ -1027,12 +825,11 @@ enum class OfflinePoolKind(val label: String) {
/**
* Builds the Home Playlists row.
*
* Online: For You + Discover fixed slots (real card when generated,
* placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they exist —
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
* / rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
* [buildSongsLikeRow] and no longer appears in this carousel.
* user-owned playlists.
*
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached
@@ -1059,7 +856,7 @@ internal fun buildPlaylistsRow(
return out
}
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
/** The online layout: fixed system slots (with placeholders), secondary, user. */
private fun buildOnlineRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
@@ -1071,7 +868,12 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
// Songs-like is no longer here — it has its own dedicated row (#1491).
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
}
@@ -1079,44 +881,16 @@ private fun buildOnlineRow(
return out
}
/**
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
* mixes the server generated, no longer capped to the 3 carousel slots.
*
* Online: every generated mix as a real card; when none exist yet, a few
* placeholders so the building / seed-needed state stays visible.
* Offline: the cached mixes only (fully-cached first, greyed after), and
* an empty list — hiding the whole section — when nothing is cached.
*/
internal fun buildSongsLikeRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
): List<PlaylistRowItem> {
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
return when {
offline -> {
val (available, greyed) = mixes.partition { !it.unavailableOffline }
(available + greyed).map { PlaylistRowItem.Real(it) }
}
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
}
}
/**
* Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first.
* Songs-like is excluded here too — it renders in its own row via
* [buildSongsLikeRow] in both online and offline modes (#1491).
*/
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>()
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
}
@@ -1131,10 +905,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
else -> "pending"
}
// How many "Songs like…" placeholder cards the dedicated row shows while
// the mixes haven't generated yet (building / seed-needed). Real mixes,
// once generated, are shown in full and no longer capped by this (#1491).
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
private const val SONGS_LIKE_SLOTS = 3
/**
* The 5 system playlist kinds the server generates that aren't pinned
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
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.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage
@@ -116,10 +116,9 @@ private fun AlbumDetailStateContent(
when (val s = state) {
is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> ErrorRetry(
is AlbumDetailUiState.Error -> EmptyState(
title = "Couldn't load album",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is AlbumDetailUiState.Success -> {
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
@@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
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.likes.data.LikesRepository
import com.fabledsword.minstrel.models.AlbumDetailRef
@@ -38,7 +36,6 @@ class AlbumDetailViewModel @Inject constructor(
private val likes: LikesRepository,
private val player: PlayerController,
private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -67,14 +64,6 @@ class AlbumDetailViewModel @Inject constructor(
init {
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 {
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
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.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -107,10 +107,9 @@ fun ArtistDetailScreen(
} else {
SkeletonArtistAlbumsGrid()
}
is ArtistDetailUiState.Error -> ErrorRetry(
is ArtistDetailUiState.Error -> EmptyState(
title = "Couldn't load artist",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is ArtistDetailUiState.Success ->
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
@@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
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.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef
@@ -47,7 +45,6 @@ class ArtistDetailViewModel @Inject constructor(
private val likes: LikesRepository,
private val player: PlayerController,
private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -72,19 +69,6 @@ class ArtistDetailViewModel @Inject constructor(
init {
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() {
@@ -24,17 +24,11 @@ class LibraryViewModel @Inject constructor(
combine(
repository.observeArtists(),
repository.observeAlbums(),
syncController.lastSyncError,
) { artists, albums, syncError ->
when {
artists.isNotEmpty() || albums.isNotEmpty() ->
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
) { artists, albums ->
if (artists.isEmpty() && albums.isEmpty()) {
UiState.Empty
} else {
UiState.Success(LibraryData(artists, albums))
}
}
.asCacheFirstStateFlow(viewModelScope)
@@ -25,9 +25,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
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.library.widgets.AlbumCard
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.widgets.TrackRow
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.LikeButton
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@@ -73,25 +68,14 @@ data class LikedSections(
class LikedTabViewModel @Inject constructor(
private val repository: LikesRepository,
private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() {
/** Copy for the most recent refresh failure; null once one succeeds. */
private val refreshError = MutableStateFlow<String?>(null)
init {
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 {
refreshError.value = null
runCatching { repository.refreshIds() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
}
/**
@@ -108,16 +92,9 @@ class LikedTabViewModel @Inject constructor(
repository.observeLikedArtists(),
repository.observeLikedAlbums(),
repository.observeLikedTracks(),
refreshError,
) { artists, albums, tracks, err ->
) { artists, albums, tracks ->
val sections = LikedSections(artists, albums, tracks)
when {
!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
}
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
}.asCacheFirstStateFlow(viewModelScope)
/**
@@ -153,10 +130,9 @@ fun LikedTab(
body = "Tap the heart on an artist, album, or track to start " +
"building your liked collection.",
)
is UiState.Error -> ErrorRetry(
is UiState.Error -> EmptyState(
title = "Couldn't load likes",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is UiState.Success -> LikedContent(
sections = s.data,
@@ -16,11 +16,4 @@ data class UserRef(
val id: String,
val username: String,
val isAdmin: Boolean = false,
/**
* Account-level diagnostics opt-in (M9). NOT present in the login
* response (that's the narrow UserView) — populated by the /api/me
* refresh in AuthController so a remote admin flip reaches the
* device. Default false until the first refresh.
*/
val debugModeEnabled: Boolean = false,
)
@@ -18,5 +18,4 @@ data class MyProfileWire(
@SerialName("display_name") val displayName: String? = null,
val email: String? = null,
@SerialName("is_admin") val isAdmin: Boolean = false,
@SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false,
)
@@ -14,8 +14,6 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.resolveServerUrl
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
@@ -65,7 +63,6 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController,
private val playlists: PlaylistsRepository,
private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
@@ -114,13 +111,6 @@ class PlayerController @Inject constructor(
*/
private var lastEvaluatedItemIndex: Int = -1
/**
* #968: self-heal budget — re-pulls of a stale refreshable queue since
* the last successful play. Capped so a still-broken refresh can't loop;
* reset when any track loads with real audio.
*/
private var selfHealAttempts = 0
/**
* Stable queue snapshot kept in sync with the player's MediaItems —
* the player's own getMediaItem(index) returns Media3 types; we keep
@@ -361,12 +351,7 @@ class PlayerController @Inject constructor(
if (current == null || upnpEngaged) return
val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) {
// A track loaded with real audio — clear the self-heal budget so
// a later stale queue can recover again.
selfHealAttempts = 0
return
}
if (!isZeroDuration) return
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = current.id,
@@ -382,58 +367,6 @@ class PlayerController @Inject constructor(
}
}
/**
* #968: the queue is fully unplayable (every track failed to load). If it
* came from a refreshable source — a system playlist (bare-variant source)
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
* open across the daily rebuild); re-pull it and resume instead of stopping.
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
* sources have nothing to refresh — stop.
*/
private fun selfHealOrStop(controller: MediaController) {
val source = controller.currentMediaItem
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
controller.stop()
return
}
when {
source.startsWith("radio:") -> {
selfHealAttempts++
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
}
!source.contains(':') -> { // bare variant = refreshable system playlist
selfHealAttempts++
scope.launch { selfHealSystem(source) }
}
else -> controller.stop()
}
}
private suspend fun selfHealSystem(variant: String) {
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
.getOrDefault(emptyList())
if (refs.isEmpty()) {
stopOnControllerThread()
return
}
setQueue(refs, initialIndex = 0, source = variant)
}
private suspend fun selfHealRadio(seedTrackId: String) {
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
if (refs.isEmpty()) {
stopOnControllerThread()
return
}
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
}
private fun stopOnControllerThread() {
val controller = mediaController ?: return
runOnControllerThread(controller) { controller.stop() }
}
// ── Internal: async connect + Listener-driven UI state sync ──────────
private suspend fun connectAndObserve() {
@@ -484,17 +417,6 @@ class PlayerController @Inject constructor(
detail = error.message,
),
)
// A failed load leaves the player IDLE; advance past the bad
// track and re-prepare so one unplayable item doesn't strand
// playback (mirrors the zero_duration skip). At the end of a
// fully-unplayable queue, try to self-heal a stale refreshable
// source before stopping.
if (controller.hasNextMediaItem()) {
controller.seekToNextMediaItem()
controller.prepare()
} else {
selfHealOrStop(controller)
}
}
override fun onMediaItemTransition(
@@ -841,10 +763,6 @@ class PlayerController @Inject constructor(
// they don't need this poll.
private const val POSITION_POLL_INTERVAL_MS = 500L
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
// the next successful play) so a still-broken refresh can't loop.
private const val MAX_SELF_HEAL_ATTEMPTS = 1
/**
* Structured playback failure event for [PlaybackErrorReporter]. Drives
* both the user-facing snackbar ("Couldn't play X — skipping") and the
@@ -133,7 +133,7 @@ private fun RouteRow(
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
val subtitle = route.description ?: defaultSubtitle(route)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
@@ -198,13 +198,10 @@ private fun MulticastHintRow() {
}
}
// isSelected (route == the active/selected route) drives the Bluetooth
// "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) {
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
OutputRoute.Kind.BuiltIn -> "Phone speaker"
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.Other -> "Available"
}
@@ -21,6 +21,7 @@ data class OutputRoute(
val description: String?,
val kind: Kind,
val protocol: Protocol,
val isConnected: Boolean,
) {
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
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. "Active" is NOT an
* intrinsic of the route — the caller derives it by comparing to
* the selected route (RouteSnapshot.current), because
* RouteInfo.connectionState only reflects remote-route handshakes
* and stays DISCONNECTED for local SYSTEM routes even when in use.
* device types fall through to [Kind.Other]. The
* `connectionState` proxy is good enough for the chip's
* "Connected"/"Available" subtitle.
*/
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) {
@@ -59,12 +58,15 @@ data class OutputRoute(
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other
}
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute(
id = route.id,
name = route.name,
description = route.description,
kind = kind,
protocol = Protocol.SYSTEM,
isConnected = connected,
)
}
@@ -74,10 +76,10 @@ data class OutputRoute(
* network speakers into the same `OutputPickerController`
* routes stream the system routes come through.
*
* UPnP devices have no MediaRouter connection-state concept —
* they're always "available" on the LAN, and the picker's
* selected-route rendering handles the "currently playing"
* indicator.
* `isConnected = false` because UPnP devices have no
* MediaRouter connection-state concept — they're always
* "available" on the LAN, and the picker's selected-route
* rendering handles the "currently playing" indicator.
*
* Subtitle is `manufacturer modelName` joined by a single
* space, falling back to "Network speaker" when both fields
@@ -94,6 +96,7 @@ data class OutputRoute(
description = description,
kind = Kind.Other,
protocol = Protocol.UPNP,
isConnected = false,
)
}
}
@@ -28,17 +28,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.draw.clip
@@ -58,8 +56,6 @@ import com.composables.icons.lucide.Play
import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Shuffle
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.LiveEvent
import com.fabledsword.minstrel.models.PlaylistRef
@@ -76,7 +72,6 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.formatDuration
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.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
@@ -118,7 +113,6 @@ class PlaylistDetailViewModel @Inject constructor(
private val player: PlayerController,
private val eventsStream: EventsStream,
private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -145,15 +139,6 @@ class PlaylistDetailViewModel @Inject constructor(
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
/**
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
* its list is now stale and the uuid has rotated. Drives a "this mix was
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
* re-resolves the variant via [reloadRebuilt].
*/
private val staleInternal = MutableStateFlow(false)
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTrackIds()
.stateIn(
@@ -169,14 +154,6 @@ class PlaylistDetailViewModel @Inject constructor(
.filter { it.kind.startsWith("playlist.") }
.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()
}
}
}
/**
@@ -186,12 +163,6 @@ class PlaylistDetailViewModel @Inject constructor(
* ignored — the playlists-list screen handles those.
*/
private fun handlePlaylistEvent(event: LiveEvent) {
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
// user's system mixes at once) — handle it before the per-id filter.
if (event.kind == "playlist.system_rebuilt") {
markStaleIfSystem()
return
}
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
if (eventPlaylistId != playlistId) return
when (event.kind) {
@@ -200,14 +171,6 @@ class PlaylistDetailViewModel @Inject constructor(
}
}
private fun markStaleIfSystem() {
val playlist = (internal.value as? PlaylistDetailUiState.Success)
?.detail?.playlist ?: return
if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
staleInternal.value = true
}
}
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
@@ -257,30 +220,6 @@ class PlaylistDetailViewModel @Inject constructor(
}
}
/**
* #980: user tapped Refresh on the stale-view snackbar. The rebuild
* already ran server-side (uuid rotated) — re-resolve the variant to the
* fresh playlist id and hand it to [regenerated] for navigate-replace.
* Unlike [regenerate] this does NOT trigger another rebuild.
*/
fun reloadRebuilt() {
val playlist = (internal.value as? PlaylistDetailUiState.Success)
?.detail?.playlist ?: return
val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
staleInternal.value = false
viewModelScope.launch {
runCatching { repository.systemShuffle(variant).playlist.id }
.onSuccess { newId ->
if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
}
}
}
/** #980: user dismissed the stale-view snackbar without refreshing. */
fun dismissStale() {
staleInternal.value = false
}
/** Play the available tracks starting at [startTrackId] (or first available). */
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
val refs = tracks.toPlayableTrackRefs()
@@ -322,25 +261,8 @@ fun PlaylistDetailScreen(
}
}
}
// #980: a rebuild landed while this system-playlist screen is open. Offer a
// non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
// rotated uuid (via the regenerated navigate-replace flow).
val snackbarHostState = remember { SnackbarHostState() }
val stale by viewModel.stale.collectAsState()
LaunchedEffect(stale) {
if (stale) {
val result = snackbarHostState.showSnackbar(
message = "This mix was refreshed",
actionLabel = "Refresh",
duration = SnackbarDuration.Indefinite,
)
if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
else viewModel.dismissStale()
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(currentTitle(state)) },
@@ -379,11 +301,7 @@ private fun PlaylistDetailContent(
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
is PlaylistDetailUiState.Loading ->
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorRetry(
title = "Couldn't load playlist",
message = s.message,
onRetry = { viewModel.refresh() },
)
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
val playerState by playerViewModel.uiState.collectAsState()
@@ -667,6 +585,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 ─────────────────────────────────────────────────────────
private const val UNAVAILABLE_ALPHA = 0.4f
@@ -27,10 +27,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
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.playlists.widgets.PlaylistCard
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
@@ -50,10 +47,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
@@ -89,9 +84,6 @@ class PlaylistsListViewModel @Inject constructor(
initialValue = false,
)
/** Copy for the most recent list-refresh failure; null once one succeeds. */
private val refreshError = MutableStateFlow<String?>(null)
init {
refresh()
// Live updates: a playlist created/updated/deleted from another
@@ -101,18 +93,11 @@ class PlaylistsListViewModel @Inject constructor(
.filter { it.kind.startsWith("playlist.") }
.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. */
fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
runCatching { repository.refreshList() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
}
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
@@ -125,15 +110,14 @@ class PlaylistsListViewModel @Inject constructor(
}
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
combine(repository.observeAll(), refreshError) { list, err ->
when {
list.isNotEmpty() -> UiState.Success(list)
// Empty cache + failed refresh is a load failure, not "no
// playlists yet" — surface Error so the user gets a Retry.
err != null -> UiState.Error(err)
else -> UiState.Empty
repository.observeAll()
.map { list ->
if (list.isEmpty()) {
UiState.Empty
} else {
UiState.Success(list)
}
}
}
.asCacheFirstStateFlow(viewModelScope)
}
@@ -173,10 +157,9 @@ fun PlaylistsListScreen(
"appear once your library has enough plays. Create your " +
"own playlist from any album or track in the meantime.",
)
is UiState.Error -> ErrorRetry(
is UiState.Error -> EmptyState(
title = "Couldn't load playlists",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is UiState.Success -> PlaylistsGrid(
playlists = s.data,
@@ -27,7 +27,6 @@ import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState
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.PullToRefreshScaffold
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 " +
"playlists until you unhide it.",
)
is UiState.Error -> ErrorRetry(
is UiState.Error -> EmptyState(
title = "Couldn't load hidden tracks",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is UiState.Success -> HiddenList(
rows = s.data,
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.quarantine.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState
@@ -26,7 +24,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
class HiddenTabViewModel @Inject constructor(
private val repository: QuarantineRepository,
private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val refreshError = MutableStateFlow<String?>(null)
@@ -64,11 +61,6 @@ class HiddenTabViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") }
.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 {
@@ -46,7 +46,6 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
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
@@ -79,10 +78,9 @@ fun RequestsScreen(
body = "Use Discover to ask Lidarr for new music; your " +
"requests show up here.",
)
is UiState.Error -> ErrorRetry(
is UiState.Error -> EmptyState(
title = "Couldn't load requests",
message = s.message,
onRetry = { viewModel.refresh() },
body = s.message,
)
is UiState.Success -> RequestList(
rows = s.data,
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.requests.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
@@ -32,7 +30,6 @@ private const val POLL_INTERVAL_MS = 12_000L
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository,
private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
@@ -48,14 +45,6 @@ class RequestsViewModel @Inject constructor(
.collect { silentReload() }
}
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 {
@@ -82,11 +71,7 @@ class RequestsViewModel @Inject constructor(
*/
private suspend fun pollWhileInFlight() {
while (true) {
// Also retry from a failed load (issue #1245): from UiState.Error
// 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) {
if (hasInFlightRequest()) {
silentReload()
}
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.Search as SearchRoute
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.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -114,7 +113,6 @@ fun SearchScreen(
onTrackPlay = viewModel::playTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
onRetry = viewModel::retry,
)
}
}
@@ -153,16 +151,11 @@ private fun ResultsPane(
onTrackPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
SearchResultsState.Loading -> LoadingCentered()
is SearchResultsState.Error -> ErrorRetry(
title = "Search failed",
message = state.message,
onRetry = onRetry,
)
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
is SearchResultsState.Loaded -> {
if (state.response.isEmpty) {
CenteredHint(
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.search.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.TrackRef
import com.fabledsword.minstrel.player.PlayerController
@@ -45,7 +43,6 @@ data class SearchState(
class SearchViewModel @Inject constructor(
private val repository: SearchRepository,
private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val queryFlow = MutableStateFlow("")
@@ -72,14 +69,6 @@ class SearchViewModel @Inject constructor(
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) {
@@ -91,18 +80,6 @@ class SearchViewModel @Inject constructor(
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
* track-results list starting at the tapped entry, matching Flutter.
@@ -28,7 +28,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -95,7 +94,6 @@ fun SettingsScreen(
onPickTheme = themeVm::setThemeMode,
onNavToRequests = { navController.navigate(Requests) },
onNavToAdmin = { navController.navigate(Admin) },
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
onSignOutClick = { showSignOutConfirm = true },
)
}
@@ -118,7 +116,6 @@ private fun SettingsList(
onPickTheme: (ThemeMode) -> Unit,
onNavToRequests: () -> Unit,
onNavToAdmin: () -> Unit,
onToggleDiagnostics: (Boolean) -> Unit,
onSignOutClick: () -> Unit,
) {
Column(
@@ -151,12 +148,6 @@ private fun SettingsList(
ProfileCard()
PasswordCard()
ListenBrainzCard()
if (state.diagnosticsEnabledByAdmin) {
DiagnosticsCard(
optOut = state.diagnosticsOptOut,
onToggle = onToggleDiagnostics,
)
}
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
StorageCard()
AboutCard()
@@ -271,46 +262,6 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
}
}
// Shown only while an admin has enabled debug mode on this account. Lets
// the user turn reporting OFF on this device (battery/privacy) without
// touching the account flag. Checked = reporting on = NOT opted out.
@Composable
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Diagnostics",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Send diagnostic reports",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Your admin enabled debug mode to investigate an " +
"issue. This device streams connectivity and playback " +
"diagnostics. Turn off any time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.size(12.dp))
Switch(
checked = !optOut,
onCheckedChange = { checked -> onToggle(!checked) },
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
@@ -19,15 +19,12 @@ data class SettingsState(
val isAdmin: Boolean = false,
val isSigningOut: Boolean = false,
val signedOut: Boolean = false,
// M9: account debug flag (admin-set) + this device's local opt-out.
val diagnosticsEnabledByAdmin: Boolean = false,
val diagnosticsOptOut: Boolean = false,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val authController: AuthController,
private val authStore: AuthStore,
authStore: AuthStore,
) : ViewModel() {
private val transient = MutableStateFlow(TransientState())
@@ -44,27 +41,19 @@ class SettingsViewModel @Inject constructor(
combine(
authStore.baseUrl,
authController.currentUser,
authStore.diagnosticsOptOut,
transient,
) { url, user, optOut, t ->
) { url, user, t ->
SettingsState(
serverUrl = url,
username = user?.username.orEmpty(),
isAdmin = user?.isAdmin == true,
isSigningOut = t.isSigningOut,
signedOut = t.signedOut,
diagnosticsEnabledByAdmin = user?.debugModeEnabled == true,
diagnosticsOptOut = optOut,
)
}.collect { internal.value = it }
}
}
/** Per-device opt-out of diagnostics while the account flag is on. */
fun setDiagnosticsOptOut(optOut: Boolean) {
authStore.setDiagnosticsOptOut(optOut)
}
fun signOut() {
if (transient.value.isSigningOut) return
viewModelScope.launch {
@@ -23,12 +23,8 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* ServerImage + fallback structure was identical at all three sites.
*
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
* (e.g. the `VariantPill` system-playlist label, the play button) —
* callers can use `Modifier.align(...)` inside it. The overlay sits on
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
* the square's corner, outside the circle, and would otherwise be clipped
* away. Only the artwork + background are clipped to [shape].
* (e.g. the `VariantPill` system-playlist label) — callers can use
* `Modifier.align(...)` inside it.
*/
@Composable
fun CoverTile(
@@ -42,23 +38,18 @@ fun CoverTile(
overlay: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier.size(size),
modifier = modifier
.size(size)
.clip(shape)
.background(background),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(shape)
.background(background),
contentAlignment = Alignment.Center,
ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
fallback()
}
fallback()
}
overlay()
}
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
@@ -24,63 +23,44 @@ import com.fabledsword.minstrel.theme.LocalActionColors
* Shared error-with-retry widget. Surfaces a brief message and a
* primary action button. Per the design system, the button uses
* `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
fun ErrorRetry(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
retryLabel: String = "Retry",
) {
val actionColors = LocalActionColors.current
LazyColumn(modifier = modifier.fillMaxSize()) {
item {
Column(
modifier = Modifier
.fillParentMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Lucide.TriangleAlert,
contentDescription = null,
modifier = Modifier.size(48.dp),
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 = message,
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = actionColors.primary,
contentColor = actionColors.onAction,
),
) {
Text(retryLabel)
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Lucide.TriangleAlert,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error,
)
Text(
text = message,
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = actionColors.primary,
contentColor = actionColors.onAction,
),
) {
Text(retryLabel)
}
}
}
@@ -1,25 +1,15 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.fabledsword.minstrel.shared.resolveServerUrl
/**
* Renders a server-hosted image, resolving relative URLs centrally so
* every cover surface loads consistently. Shows [fallback] when the URL
* is blank/unresolvable, while the image is still loading, and when the
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
* which the "You might like" row hits often).
* is blank or unresolvable.
*/
@Composable
fun ServerImage(
@@ -32,26 +22,12 @@ fun ServerImage(
val resolved = resolveServerUrl(url)
if (resolved == null) {
fallback()
return
}
// Track Coil's load state so the fallback doubles as a placeholder
// (loading) and an error state (404 / unreachable) — not just a
// null-URL guard, which left present-but-failing URLs blank.
var state by remember(resolved) {
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
} else {
AsyncImage(
model = resolved,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
modifier = modifier,
contentScale = contentScale,
onState = { state = it },
)
if (state is AsyncImagePainter.State.Loading ||
state is AsyncImagePainter.State.Error
) {
fallback()
}
}
}
@@ -56,51 +56,4 @@ class BuildPlaylistsRowTest {
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
}
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
id = id,
userId = "u",
name = "Songs like $id",
systemVariant = "songs_like_artist",
trackCount = 25,
fullyCached = cached,
)
@Test
fun `songs-like no longer appears in the main playlists carousel`() {
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
assertTrue("u1" in reals)
}
@Test
fun `online songs-like row shows every generated mix uncapped`() {
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
val owned = (1..6).map { songsLike("a$it", cached = true) }
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals((1..6).map { "a$it" }, reals)
}
@Test
fun `online songs-like row shows placeholders when none generated`() {
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
assertTrue(row.isNotEmpty())
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
}
@Test
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
// Fully-cached songs-like mix (not refreshable) is available; the
// un-cached one greys and sorts after. No placeholders offline.
assertEquals(listOf("full", "partial"), reals)
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
}
}
@@ -11,7 +11,6 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@@ -22,13 +21,6 @@ import kotlin.test.assertEquals
@ExtendWith(MainDispatcherExtension::class)
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
fun `initial state is Loading before any DAO emission`() = runTest {
val repo = mockk<LibraryRepository>()
@@ -37,7 +29,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
syncControllerMock(),
mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true),
)
@@ -59,7 +51,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
syncControllerMock(),
mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true),
)
@@ -79,7 +71,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
syncControllerMock(),
mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true),
)
@@ -102,7 +94,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel(
repo,
syncControllerMock(),
mockk<SyncController>(relaxed = true),
mockk<PlayerController>(relaxed = true),
)
+8 -18
View File
@@ -6,27 +6,17 @@ in-app update flow (#397).
## Production
CI populates this directory in `release.yml` (no Flutter — the client is
native Android, built by the `android-release` job):
- **Tag release (`v*`):** the `android-release` job builds + signs the
APK, uploads it as a workflow artifact, and attaches it to the Gitea
release as `minstrel-<TAG>.apk`. The `image-release` job
(`needs: android-release`) downloads that artifact into this directory
as `minstrel.apk` and writes the computed versionName to
`minstrel.apk.version`.
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the
image still moves `:latest`, so `image-release` pulls the most-recent
release's signed APK from the Gitea API and reconstructs its exact
versionName — keeping the in-app update channel alive on `:latest`
instead of stripping it until the next tag.
Either way, `docker buildx build` bakes both files into the image via
`COPY client/ /app/client/`.
CI populates this directory on tag releases:
1. `flutter.yml` builds `app-release.apk` and attaches it to the
Gitea release as `minstrel-<TAG>.apk`.
2. `release.yml` waits for that asset to appear, downloads it into
this directory as `minstrel.apk`, writes the tag string to
`minstrel.apk.version`, and `docker buildx build` includes both
via `COPY client/ /app/client/`.
## Development
Empty directory works fine — the endpoints return 404 and the Android
Empty directory works fine — the endpoints return 404 and the Flutter
update banner stays hidden ("no update channel" graceful degradation).
To smoke-test the update flow locally, drop a real APK + a version
+1 -39
View File
@@ -25,14 +25,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
func main() {
@@ -148,16 +146,6 @@ func run() error {
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
coverEnricher.DataDir = cfg.Storage.DataDir
// Recommendation tuning lab (#1250): seeds shipped defaults on first
// boot and pushes the daily_mix weights + taste config into package
// playlists — must precede the scheduler so the first builds score
// with the operator's tuned values, not the pre-push literals.
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
if err != nil {
logger.Error("recommendation settings service init failed", "err", err)
os.Exit(1)
}
// One unified scan chain: library walk → MBID backfill → cover enrich.
// Boot-time scan and manual-trigger scans share this path; results land
// in scan_runs for the admin overview.
@@ -206,25 +194,6 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if
// the provider set changed (re-opening settled rows), then drains tracks
// needing folksonomy tags on a periodic tick. Standalone (not in the file
// scan chain) because tag lookups need only DB fields — MBID / artist /
// title — that a scan has already imported.
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
if err != nil {
logger.Error("tag settings service init failed", "err", err)
os.Exit(1)
}
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
logger.Warn("tags: provider-hash boot check failed", "err", berr)
} else if bumped {
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
}
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
// that have no writer-side close path or retention policy:
// orphan play_events, stale play_sessions, expired
@@ -273,12 +242,7 @@ func run() error {
// active user's daily build at 03:00 in their stored timezone.
// Replaces the 24h-anchored cron loop (removed in the next commit
// of this arc).
playlistScheduler, err := playlists.NewScheduler(
pool,
logger.With("component", "playlist_scheduler"),
cfg.Storage.DataDir,
bus,
)
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
if err != nil {
return fmt.Errorf("init playlist scheduler: %w", err)
}
@@ -325,8 +289,6 @@ func run() error {
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
srv.RecSettings = recSettings
srv.TagSettings = tagSettings
srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{
Addr: cfg.Server.Address,
+2 -7
View File
@@ -26,13 +26,8 @@ library:
# Filesystem roots to scan for music. Each path is walked recursively.
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
scan_paths: []
# Kick off a scan on server startup. On by default so a fresh install
# populates its library on first boot with no manual step. Scans are
# incremental (unchanged files are skipped by mtime), so the cost on
# every later restart is just a directory walk, not a full re-index.
# Set false if you'd rather scan only on a schedule / via the admin UI.
# Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
scan_on_startup: true
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
scan_on_startup: false
# Per-instance branding. Operators can rename their instance ("Family
# Jukebox", "Office Music", etc.) and override the OG share-preview
-19
View File
@@ -1,19 +0,0 @@
# README screenshots
Drop the files below into this directory with these **exact names** — the
root `README.md` already references them, so no Markdown editing is needed.
Export as PNG. Capture while logged in as admin and *after* the first
library scan so nothing looks empty; keep the same browser width and theme
across all shots for a consistent look.
| Filename | Route | What to capture |
|---|---|---|
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
| `register.png` | `/register` | The account-creation form on a fresh instance |
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
| `android-download.png` | `/settings` | The "Install the Android app" download card |
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
Tip: to control display width in the README, you can swap the Markdown
`![alt](path)` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

-202
View File
@@ -1,202 +0,0 @@
// Admin recommendation-trends endpoint (#1251): weekly per-surface
// outcome series with tuning-audit markers — the verify half of the
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
// all users because the knobs are global; rows carry rates only.
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
trendsDefaultWeeks = 12
trendsMaxWeeks = 52
// trendsAuditFetchCap bounds the audit fetch; markers older than
// the window are dropped in Go. Far above any real knob-turn count
// inside a year.
trendsAuditFetchCap = 500
)
// trendPoint is one week of one surface family's outcomes.
type trendPoint struct {
WeekStart string `json:"week_start"` // ISO date (Monday)
Plays int64 `json:"plays"`
Skips int64 `json:"skips"`
SkipRate float64 `json:"skip_rate"`
AvgCompletion float64 `json:"avg_completion"`
// TasteHitRate is the share of plays whose track's artist carries a
// positive weight in the player's current taste profile — a drifted
// but retroactive read on whether the surface feeds taste-fitting
// tracks.
TasteHitRate float64 `json:"taste_hit_rate"`
// completionN carries the completion sample count through same-week
// merges so avg_completion stays sample-weighted; not serialized.
completionN int64
}
// trendSeries is one surface family's weekly series.
type trendSeries struct {
Key string `json:"key"`
Label string `json:"label"`
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
Points []trendPoint `json:"points"`
}
// trendMarker is one tuning-audit event annotated on the timeline.
type trendMarker struct {
ChangedAt string `json:"changed_at"`
Scope string `json:"scope"`
Action string `json:"action"`
Changes json.RawMessage `json:"changes"`
}
type trendsResp struct {
Weeks int `json:"weeks"`
Series []trendSeries `json:"series"`
Markers []trendMarker `json:"markers"`
}
// handleGetRecommendationTrends implements
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
weeks := trendsDefaultWeeks
if v := r.URL.Query().Get("weeks"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
return
}
if n > trendsMaxWeeks {
n = trendsMaxWeeks
}
weeks = n
}
q := dbq.New(h.pool)
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
if err != nil {
h.logger.Error("api: recommendation trends", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
if err != nil {
h.logger.Error("api: recommendation trends audit", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
}
// buildTrendsResponse folds weekly rows into per-family series and
// windows the audit markers. Split from the handler for pure-unit
// testability.
func buildTrendsResponse(
weeks int, now time.Time,
rows []dbq.RecommendationWeeklyTrendsRow,
audits []dbq.RecommendationTuningAudit,
) trendsResp {
type accum struct {
fam recFamily
plays int64
points []trendPoint
}
families := map[string]*accum{}
for _, row := range rows {
fam := recFamily{key: "manual", label: "Manual library plays"}
if row.Source != nil && *row.Source != "" {
fam = bucketRecSource(*row.Source)
}
acc, ok := families[fam.key]
if !ok {
acc = &accum{fam: fam}
families[fam.key] = acc
}
acc.plays += row.Plays
p := trendPoint{
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
Plays: row.Plays,
Skips: row.Skips,
AvgCompletion: row.AvgCompletion,
completionN: row.CompletionN,
}
if row.Plays > 0 {
p.SkipRate = float64(row.Skips) / float64(row.Plays)
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
}
// Same family can arrive as several raw sources (radio:<uuid>);
// merge same-week points play-weighted.
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
} else {
acc.points = append(acc.points, p)
}
}
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
for _, acc := range families {
resp.Series = append(resp.Series, trendSeries{
Key: acc.fam.key,
Label: acc.fam.label,
Intent: acc.fam.intent,
Plays: acc.plays,
Points: acc.points,
})
}
sort.Slice(resp.Series, func(i, j int) bool {
if resp.Series[i].Plays != resp.Series[j].Plays {
return resp.Series[i].Plays > resp.Series[j].Plays
}
return resp.Series[i].Key < resp.Series[j].Key
})
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
for _, a := range audits {
if a.ChangedAt.Time.Before(cutoff) {
continue
}
resp.Markers = append(resp.Markers, trendMarker{
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
Scope: a.Scope,
Action: a.Action,
Changes: json.RawMessage(a.Changes),
})
}
// ListTuningAudit returns newest-first; the timeline reads better
// oldest-first.
sort.Slice(resp.Markers, func(i, j int) bool {
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
})
return resp
}
// mergeTrendPoints combines two same-week points of one family:
// counts add, skip/taste rates re-derive from the merged counts, and
// avg_completion is weighted by each side's completion sample count.
func mergeTrendPoints(a, b trendPoint) trendPoint {
out := trendPoint{
WeekStart: a.WeekStart,
Plays: a.Plays + b.Plays,
Skips: a.Skips + b.Skips,
completionN: a.completionN + b.completionN,
}
if out.Plays > 0 {
out.SkipRate = float64(out.Skips) / float64(out.Plays)
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
float64(out.Plays)
}
if out.completionN > 0 {
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
float64(out.completionN)
}
return out
}
@@ -1,152 +0,0 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func date(s string) pgtype.Date {
t, _ := time.Parse("2006-01-02", s)
return pgtype.Date{Time: t, Valid: true}
}
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
src := func(s string) *string { return &s }
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
rows := []dbq.RecommendationWeeklyTrendsRow{
// Two radio session sources in the same week collapse into one
// family point; completion weighting by sample count.
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
// NULL source = manual baseline family.
{WeekStart: date("2026-06-29"), Source: nil,
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
}
audits := []dbq.RecommendationTuningAudit{
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
// Older than the window → dropped.
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
}
resp := buildTrendsResponse(12, now, rows, audits)
if len(resp.Series) != 2 {
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
}
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
if radio.Key != "radio" || radio.Plays != 6 {
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
}
if len(radio.Points) != 2 {
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
}
wk1 := radio.Points[0]
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
}
if wk1.SkipRate != 0.5 {
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
}
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
}
// Taste hits: 3 of 4 plays.
if wk1.TasteHitRate != 0.75 {
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
}
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
}
if len(resp.Markers) != 1 {
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
}
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
}
}
func newTrendsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
return r
}
func TestRecommendationTrends_EndToEnd(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "trends", "pw", true)
artist := seedArtist(t, pool, "TrendArtist")
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
session := seedPlaySession(t, pool, user.ID, time.Now())
// Positive taste weight for the artist → plays count as taste hits.
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
user.ID, artist.ID); err != nil {
t.Fatalf("seed taste profile: %v", err)
}
radio := "radio"
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
// A knob turn to mark the timeline.
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
map[string]float64{"taste_weight": 2}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
var resp trendsResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Weeks != trendsDefaultWeeks {
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
}
var radioSeries *trendSeries
for i := range resp.Series {
if resp.Series[i].Key == "radio" {
radioSeries = &resp.Series[i]
}
}
if radioSeries == nil || len(radioSeries.Points) != 1 {
t.Fatalf("radio series = %+v, want one point", radioSeries)
}
if radioSeries.Points[0].TasteHitRate != 1.0 {
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
radioSeries.Points[0].TasteHitRate)
}
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
}
}
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
-152
View File
@@ -1,152 +0,0 @@
// Admin recommendation-tuning endpoints (#1250): the defaults-
// discovery lab. GET returns current values + shipped defaults for
// every scope; PATCH applies a partial update to one scope; reset
// restores a scope to shipped defaults. Every change writes an audit
// row (consumed by the metrics trend view, #1251).
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
}
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
return weightsResp{
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
}
}
type tasteTuningResp struct {
HalfLifeDays float64 `json:"half_life_days"`
EngagementHardSkip float64 `json:"engagement_hard_skip"`
EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
return tasteTuningResp{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
}
}
// tuningSnapshot is both the GET response and the post-mutation echo:
// current values alongside shipped defaults so the card can mark
// which knobs deviate.
type tuningSnapshot struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
Shipped struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
} `json:"shipped"`
}
func (h *handlers) tuningSnapshot() tuningSnapshot {
var out tuningSnapshot
out.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
}
out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
}
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
return out
}
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// patchTuningReq carries the partial update: field name → new value,
// using the same snake_case names the GET response emits.
type patchTuningReq struct {
Values map[string]float64 `json:"values"`
}
// handlePatchRecommendationTuning implements
// PATCH /api/admin/recommendation-tuning/{scope}.
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
var body patchTuningReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if len(body.Values) == 0 {
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
return
}
var err error
if scope == recsettings.ScopeTaste {
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
} else {
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
}
if err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// handleResetRecommendationTuning implements
// POST /api/admin/recommendation-tuning/{scope}/reset.
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// writeTuningErr maps recsettings validation errors to 400s and
// everything else to a logged 500.
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
switch {
case errors.Is(err, recsettings.ErrUnknownScope):
writeErr(w, &apierror.Error{
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
})
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
default:
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
writeErr(w, apierror.InternalMsg("tuning update failed", err))
}
}
@@ -1,110 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
func newTuningRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
return r
}
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
t.Helper()
var snap tuningSnapshot
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
t.Fatalf("decode: %v", err)
}
return snap
}
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
rec := httptest.NewRecorder()
newTuningRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
}
if snap.Taste.HalfLifeDays != 75 {
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
}
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
t.Error("untouched values must equal shipped defaults")
}
}
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 2.5 {
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
}
// The change is live for the radio scoring path.
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
}
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200", rec.Code)
}
snap = decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 {
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
}
}
func TestRecommendationTuning_PatchErrors(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
cases := []struct {
name, path, body string
want int
}{
{"unknown scope", "/api/admin/recommendation-tuning/banana",
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
{"unknown field", "/api/admin/recommendation-tuning/radio",
`{"values":{"vibes":1}}`, http.StatusBadRequest},
{"out of range", "/api/admin/recommendation-tuning/taste",
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
{"empty values", "/api/admin/recommendation-tuning/radio",
`{"values":{}}`, http.StatusBadRequest},
{"bad json", "/api/admin/recommendation-tuning/radio",
`{`, http.StatusBadRequest},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
}
}
}
-153
View File
@@ -1,153 +0,0 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// tagSourceResp is the wire shape for one tag-enrichment provider in the
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
// (admin_cover_sources.go) — a separate, independent settings card so a new
// tag source is added without touching art settings (#1490).
type tagSourceResp struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
RequiresAPIKey bool `json:"requires_api_key"`
Supports []string `json:"supports"`
Enabled bool `json:"enabled"`
APIKeySet bool `json:"api_key_set"`
DisplayOrder int32 `json:"display_order"`
Testable bool `json:"testable"`
}
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
supports := info.Supports
if supports == nil {
supports = []string{}
}
return tagSourceResp{
ID: info.ID,
DisplayName: info.DisplayName,
RequiresAPIKey: info.RequiresAPIKey,
Supports: supports,
Enabled: info.Enabled,
APIKeySet: info.APIKeySet,
DisplayOrder: info.DisplayOrder,
Testable: info.Testable,
}
}
type tagSourcesListResp struct {
Providers []tagSourceResp `json:"providers"`
SourcesVersion int32 `json:"sources_version"`
}
// handleListTagSources implements GET /api/admin/tag-sources.
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
infos := h.tagSettings.ListProviderInfo()
out := tagSourcesListResp{
Providers: make([]tagSourceResp, 0, len(infos)),
SourcesVersion: h.tagSettings.CurrentVersion(),
}
for _, info := range infos {
out.Providers = append(out.Providers, tagSourceRespFrom(info))
}
writeJSON(w, http.StatusOK, out)
}
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
// unchanged"; non-nil sets (empty api_key clears).
type updateTagSourceReq struct {
Enabled *bool `json:"enabled,omitempty"`
APIKey *string `json:"api_key,omitempty"`
}
type updateTagSourceResp struct {
tagSourceResp
VersionBumped bool `json:"version_bumped"`
}
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
var body updateTagSourceReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
Enabled: body.Enabled,
APIKey: body.APIKey,
})
if err != nil {
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
h.logger.Error("admin: update tag source", "id", id, "err", err)
writeErr(w, apierror.InternalMsg("update failed", err))
return
}
var found tagSourceResp
for _, info := range h.tagSettings.ListProviderInfo() {
if info.ID == id {
found = tagSourceRespFrom(info)
break
}
}
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
}
type testTagSourceResp struct {
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
start := time.Now()
err := h.tagSettings.TestProvider(r.Context(), id)
duration := time.Since(start).Milliseconds()
if err == nil {
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
return
}
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
// Not testable, or the test failed — both surface as ok=false with a
// 200 (the operation completed; the result is data, not an error).
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
}
type researchTagsResp struct {
SourcesVersion int32 `json:"sources_version"`
}
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
// the sources version so every settled ('none') track becomes eligible for
// a re-enrichment pass, then the background worker re-processes them.
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
newVer, err := h.tagSettings.BumpVersion(r.Context())
if err != nil {
h.logger.Error("admin: research tags", "err", err)
writeErr(w, apierror.InternalMsg("research failed", err))
return
}
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
}
-243
View File
@@ -1,243 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
// Does not implement TestableProvider.
type apiTestTagProvider struct {
id string
display string
}
func (p *apiTestTagProvider) ID() string { return p.id }
func (p *apiTestTagProvider) DisplayName() string { return p.display }
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
return []tags.Tag{{Name: "x", Weight: 1}}, nil
}
// apiTestTestableTagProvider also implements TestableProvider.
type apiTestTestableTagProvider struct {
apiTestTagProvider
}
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
func newAdminTagSourcesRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/tag-sources", h.handleListTagSources)
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
})
return r
}
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
// test DB. Register the needed fake providers BEFORE calling this, and pair
// with tags.ResetRegistryForTests in t.Cleanup.
func testHandlersWithTagSettings(t *testing.T) *handlers {
t.Helper()
h, pool := testHandlers(t)
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
if err != nil {
t.Fatalf("NewSettingsService: %v", err)
}
h.tagSettings = s
return h
}
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tslist", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp tagSourcesListResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
}
found := false
for _, s := range resp.Providers[0].Supports {
if s == "track_tags" {
found = true
}
}
if !found {
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
}
if resp.Providers[0].Testable {
t.Error("testable = true, want false (no TestConnection)")
}
}
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsflip", "pw", true)
disabled := false
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.VersionBumped {
t.Error("version_bumped = false, want true after flipping enabled")
}
}
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tskey", "pw", true)
newKey := "newkey"
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VersionBumped {
t.Error("version_bumped = true, want false for key-only change")
}
if !resp.APIKeySet {
t.Error("api_key_set = false, want true after setting key")
}
}
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "ts404", "pw", true)
body, _ := json.Marshal(updateTagSourceReq{})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
h := testHandlersWithTagSettings(t)
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
req = withUser(req, nonAdmin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsntest", "pw", true)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp testTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.OK {
t.Error("ok = true, want false for non-testable provider")
}
if resp.Error == "" {
t.Error("error string empty, want a message")
}
}
-5
View File
@@ -22,7 +22,6 @@ type adminUserView struct {
DisplayName *string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
AutoApproveRequests bool `json:"auto_approve_requests"`
DebugModeEnabled bool `json:"debug_mode_enabled"`
CreatedAt string `json:"created_at"`
}
@@ -45,7 +44,6 @@ func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request)
DisplayName: row.DisplayName,
IsAdmin: row.IsAdmin,
AutoApproveRequests: row.AutoApproveRequests,
DebugModeEnabled: row.DebugModeEnabled,
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
}
out = append(out, v)
@@ -122,7 +120,6 @@ func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request)
DisplayName: updated.DisplayName,
IsAdmin: updated.IsAdmin,
AutoApproveRequests: updated.AutoApproveRequests,
DebugModeEnabled: updated.DebugModeEnabled,
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
})
}
@@ -192,7 +189,6 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
DisplayName: user.DisplayName,
IsAdmin: user.IsAdmin,
AutoApproveRequests: user.AutoApproveRequests,
DebugModeEnabled: user.DebugModeEnabled,
CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339),
})
}
@@ -333,7 +329,6 @@ func (h *handlers) handleAdminAutoApproveToggle(w http.ResponseWriter, r *http.R
DisplayName: updated.DisplayName,
IsAdmin: updated.IsAdmin,
AutoApproveRequests: updated.AutoApproveRequests,
DebugModeEnabled: updated.DebugModeEnabled,
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
})
}
+1 -31
View File
@@ -22,19 +22,16 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs,
@@ -43,7 +40,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
playlists: playlistsSvc,
coverart: coverEnricher,
coverSettings: coverSettings,
tagSettings: tagSettings,
scanner: scanner,
scanCfg: scanCfg,
dataDir: dataDir,
@@ -139,12 +135,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// load failures). Admin-only inbox; any user can report.
authed.Post("/playback-errors", h.handleReportPlaybackError)
// Device diagnostics ingest (M9). Any signed-in user can
// POST a batch, but events are only stored when the
// account's debug_mode_enabled flag is on (handler no-ops
// otherwise). Admin views live under /admin/diagnostics.
authed.Post("/diagnostics", h.handleReportDiagnostics)
// Self-hosted in-app update channel (#397). Auth-gated to
// prevent anonymous bandwidth abuse on the APK stream;
// /apk additionally per-user rate-limited.
@@ -191,33 +181,15 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle)
// Device diagnostics timeline + device overview (M9).
admin.Get("/diagnostics", h.handleListAdminDiagnostics)
admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices)
admin.Get("/cover-sources", h.handleListCoverSources)
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
admin.Get("/tag-sources", h.handleListTagSources)
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
admin.Post("/tag-sources/research", h.handleResearchTags)
admin.Get("/smtp-config", h.handleGetSMTPConfig)
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
// Recommendation tuning lab (#1250): scoring-weight
// profiles + taste-build knobs, DB-backed, live effect.
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
// Weekly outcome trends + knob-turn markers (#1251).
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
})
authed.Get("/playlists", h.handleListPlaylists)
@@ -240,7 +212,6 @@ type handlers struct {
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
recSettings *recsettings.Service
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
@@ -249,7 +220,6 @@ type handlers struct {
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
+4 -6
View File
@@ -29,7 +29,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
@@ -57,12 +56,11 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dbtest.ResetDB(t, pool)
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
recCfg := config.RecommendationConfig{
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
SkipPenalty: 1.0, JitterMagnitude: 0.1,
ContextWeight: 2.0, SimilarityWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
}
recSettings, err := recsettings.New(context.Background(), pool, logger)
if err != nil {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
@@ -72,7 +70,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
return h, pool
}
-346
View File
@@ -1,346 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// validDiagnosticKinds is the COARSE category whitelist. Mirrors the
// CHECK constraint in migration 0036 — keep both in sync (per the
// enum-CHECK-whitelist rule). The finer event discriminator + all
// event-specific fields live inside each event's payload, so new event
// variants do NOT require a new category here.
var validDiagnosticKinds = map[string]struct{}{
"connectivity": {},
"upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
"power": {},
"lifecycle": {},
"heartbeat": {},
"http": {},
}
// maxDiagnosticBatch caps a single ingest call. The client buffers and
// flushes in batches of ~100; 500 leaves generous headroom while
// bounding a misbehaving client's per-request work.
const maxDiagnosticBatch = 500
// Diagnostics list paging. Larger than the generic parsePaging caps
// because the admin timeline is meant to be exported as a slice and
// handed off for analysis — a heartbeat-dense window has many rows.
const (
defaultDiagnosticPageSize = 500
maxDiagnosticPageSize = 5000
)
// diagnosticEventIn is one event in a POST /api/diagnostics batch.
// OccurredAt is the client device clock as epoch milliseconds (may be
// skewed — the server also stamps received_at). Payload is opaque JSON
// carrying the event's sub-type + fields.
type diagnosticEventIn struct {
Kind string `json:"kind"`
OccurredAt int64 `json:"occurred_at"`
Payload json.RawMessage `json:"payload"`
}
// reportDiagnosticsRequest is the body of POST /api/diagnostics — one
// batch from one device.
type reportDiagnosticsRequest struct {
ClientID string `json:"client_id"`
AppVersion *string `json:"app_version,omitempty"`
OsVersion *string `json:"os_version,omitempty"`
Events []diagnosticEventIn `json:"events"`
}
// handleReportDiagnostics implements POST /api/diagnostics. Any signed-in
// user can call it, but events are only stored when the account's
// debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the
// client can safely drop the batch (it gates locally on /api/me, this is
// the race-safe backstop for "admin just disabled debug").
func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
if !user.DebugModeEnabled {
w.WriteHeader(http.StatusNoContent)
return
}
var req reportDiagnosticsRequest
if !decodeBody(w, r, &req) {
return
}
if strings.TrimSpace(req.ClientID) == "" {
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
return
}
if len(req.Events) == 0 {
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0})
return
}
if len(req.Events) > maxDiagnosticBatch {
writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch"))
return
}
for i := range req.Events {
if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid {
writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind"))
return
}
}
err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
for i := range req.Events {
ev := req.Events[i]
payload := ev.Payload
if len(payload) == 0 {
payload = json.RawMessage("{}")
}
if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{
UserID: user.ID,
ClientID: req.ClientID,
AppVersion: req.AppVersion,
OsVersion: req.OsVersion,
Kind: ev.Kind,
Payload: payload,
OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err))
return
}
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)})
}
// adminDiagnosticView is one row in the admin timeline response. Payload
// is passed through verbatim as raw JSON so the SPA can render / export
// the structured event without the server re-shaping it.
type adminDiagnosticView struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Username string `json:"username"`
ClientID string `json:"client_id"`
AppVersion *string `json:"app_version,omitempty"`
OsVersion *string `json:"os_version,omitempty"`
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
OccurredAt string `json:"occurred_at"`
ReceivedAt string `json:"received_at"`
}
// handleListAdminDiagnostics implements GET /api/admin/diagnostics with
// optional filters user_id, client_id, kind, from, to (RFC3339 or epoch
// millis) plus limit/offset. Newest-first; the SPA reverses for a
// chronological timeline + export.
func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
params, ok := h.buildDiagnosticsFilter(w, q)
if !ok {
return
}
rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params)
if err != nil {
writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err))
return
}
out := make([]adminDiagnosticView, 0, len(rows))
for _, row := range rows {
out = append(out, adminDiagnosticView{
ID: uuidToString(row.ID),
UserID: uuidToString(row.UserID),
Username: row.Username,
ClientID: row.ClientID,
AppVersion: row.AppVersion,
OsVersion: row.OsVersion,
Kind: row.Kind,
Payload: json.RawMessage(row.Payload),
OccurredAt: formatTimestamp(row.OccurredAt),
ReceivedAt: formatTimestamp(row.ReceivedAt),
})
}
writeJSON(w, http.StatusOK, out)
}
// buildDiagnosticsFilter parses the query string into a query param
// struct, writing a 400 and returning ok=false on a malformed time.
func (h *handlers) buildDiagnosticsFilter(
w http.ResponseWriter, q map[string][]string,
) (dbq.ListAdminDiagnosticsParams, bool) {
get := func(k string) string {
if v, present := q[k]; present && len(v) > 0 {
return v[0]
}
return ""
}
limit, offset, err := parseDiagPaging(get("limit"), get("offset"))
if err != nil {
writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset"))
return dbq.ListAdminDiagnosticsParams{}, false
}
from, okFrom := parseDiagTime(get("from"))
to, okTo := parseDiagTime(get("to"))
if !okFrom || !okTo {
writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp"))
return dbq.ListAdminDiagnosticsParams{}, false
}
params := dbq.ListAdminDiagnosticsParams{
ClientID: optionalQuery(get("client_id")),
Kind: optionalQuery(get("kind")),
FromTs: from,
ToTs: to,
Off: int32(offset),
Lim: int32(limit),
}
if uid, valid := parseUUID(get("user_id")); valid {
params.UserID = uid
}
return params, true
}
// adminDiagnosticDeviceView is one device in the "who is reporting"
// overview / device filter dropdown.
type adminDiagnosticDeviceView struct {
ClientID string `json:"client_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
LastSeen string `json:"last_seen"`
EventCount int64 `json:"event_count"`
}
// handleListAdminDiagnosticDevices implements GET
// /api/admin/diagnostics/devices?user_id=. Lists distinct reporting
// devices so the admin UI can populate its device filter.
func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) {
var userID pgtype.UUID
if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid {
userID = uid
}
rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID)
if err != nil {
writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err))
return
}
out := make([]adminDiagnosticDeviceView, 0, len(rows))
for _, row := range rows {
out = append(out, adminDiagnosticDeviceView{
ClientID: row.ClientID,
UserID: uuidToString(row.UserID),
Username: row.Username,
AppVersion: row.AppVersion,
OsVersion: row.OsVersion,
LastSeen: formatTimestamp(row.LastSeen),
EventCount: row.EventCount,
})
}
writeJSON(w, http.StatusOK, out)
}
// adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode.
type adminDebugModeReq struct {
Enabled bool `json:"enabled"`
}
// handleAdminDebugModeToggle implements PUT
// /api/admin/users/{id}/debug-mode — flip an account's diagnostics
// opt-in remotely while a bug is live. Echoes the updated user.
func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) {
targetID, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var req adminDebugModeReq
if !decodeBody(w, r, &req) {
return
}
updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{
ID: targetID,
DebugModeEnabled: req.Enabled,
})
if err != nil {
writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err))
return
}
h.logger.Info("admin: debug mode toggled",
"target_user", uuidToString(targetID), "enabled", req.Enabled)
writeJSON(w, http.StatusOK, adminUserView{
ID: uuidToString(updated.ID),
Username: updated.Username,
DisplayName: updated.DisplayName,
IsAdmin: updated.IsAdmin,
AutoApproveRequests: updated.AutoApproveRequests,
DebugModeEnabled: updated.DebugModeEnabled,
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
})
}
// parseDiagPaging reads limit/offset with diagnostics-specific caps.
// Blank → defaults; out-of-range clamps; non-numeric → error.
func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) {
limit = defaultDiagnosticPageSize
if s := strings.TrimSpace(rawLimit); s != "" {
n, perr := strconv.Atoi(s)
if perr != nil {
return 0, 0, perr
}
if n < 1 {
n = 1
}
if n > maxDiagnosticPageSize {
n = maxDiagnosticPageSize
}
limit = n
}
if s := strings.TrimSpace(rawOffset); s != "" {
n, perr := strconv.Atoi(s)
if perr != nil {
return 0, 0, perr
}
if n > 0 {
offset = n
}
}
return limit, offset, nil
}
// optionalQuery returns nil for an absent/blank query value so the sqlc
// NULL-or-equals filter treats it as "no filter."
func optionalQuery(raw string) *string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
return &raw
}
// parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL
// filter, ok=true). Returns ok=false only on a non-empty unparseable value.
func parseDiagTime(raw string) (pgtype.Timestamptz, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return pgtype.Timestamptz{}, true
}
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true
}
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true
}
return pgtype.Timestamptz{}, false
}
+1 -21
View File
@@ -31,26 +31,6 @@ type playStartedResponse struct {
SessionID string `json:"session_id"`
}
// eventFutureSkew is the tolerated client-clock drift into the future
// before a timestamp is treated as bogus and replaced with now.
const eventFutureSkew = 5 * time.Minute
// clampEventTime bounds a client-supplied event timestamp to sanity:
// no earlier than the account's creation (offline replays can
// legitimately be days old, but no play can predate the user) and no
// later than now + a small skew allowance. Unbounded client clocks
// previously let a skewed device write arbitrarily old plays, which
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
if at.Before(userCreatedAt) {
return userCreatedAt
}
if at.After(now.Add(eventFutureSkew)) {
return now
}
return at
}
type okResponse struct {
OK bool `json:"ok"`
}
@@ -72,7 +52,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
return
}
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
at = parsed
}
clientID := ""
if req.ClientID != nil {
-15
View File
@@ -88,21 +88,6 @@ func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.
})
}
// publishSystemRebuilt notifies the owner's clients that their system
// playlists (and You-might-like rows) were regenerated, so they invalidate
// the home / system-playlist providers and a stale active queue can re-pull.
// Mirrors the daily scheduler's event; fired here from the manual refresh.
func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
if h.eventbus == nil {
return
}
h.eventbus.Publish(eventbus.Event{
Kind: "playlist.system_rebuilt",
UserID: uuidToString(userID),
Data: map[string]any{},
})
}
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
// the request's original requester so their /requests page reflects the
// new state without manual refresh. Admin actors (approve / reject) still
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
paths := []string{
"/api/artists",
+5 -10
View File
@@ -35,10 +35,6 @@ type meProfileResp struct {
DisplayName *string `json:"display_name"`
Email *string `json:"email"`
IsAdmin bool `json:"is_admin"`
// DebugModeEnabled is the account's diagnostics opt-in. The client
// reads it here to decide whether to run the on-device diagnostics
// reporter (M9). Admin-set; the client also has a local OFF switch.
DebugModeEnabled bool `json:"debug_mode_enabled"`
}
func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) {
@@ -124,11 +120,10 @@ func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request)
func profileViewFromUser(u dbq.User) meProfileResp {
return meProfileResp{
ID: uuidToString(u.ID),
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
IsAdmin: u.IsAdmin,
DebugModeEnabled: u.DebugModeEnabled,
ID: uuidToString(u.ID),
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
IsAdmin: u.IsAdmin,
}
}
+27 -257
View File
@@ -2,9 +2,7 @@ package api
import (
"net/http"
"sort"
"strconv"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -13,142 +11,28 @@ import (
const (
recMetricsDefaultDays = 30
recMetricsMaxDays = 365
// recMetricsLowVolume marks a family as low-confidence rather than
// hiding it: with fewer plays than this a skip rate is anecdote, not
// signal, but silently dropping the row would misread as "surface
// unused". The web renders low-confidence rows dimmed.
recMetricsLowVolume = 20
)
// Surface intents (milestone #127): each family is judged against its
// job, not one global bar — discovery mixes are EXPECTED to run higher
// skip rates than the go-to surfaces.
const (
intentGoTo = "go_to"
intentDiscovery = "discovery"
intentDirect = "direct"
)
// surfaceMetric is one bucketed surface family's outcomes.
type surfaceMetric struct {
Key string `json:"key"` // stable family key ("for_you", "radio", …)
Label string `json:"label"` // display label
Plays int64 `json:"plays"` // plays launched from this family
// recommendationMetric is one recommendation surface's outcomes.
type recommendationMetric struct {
Source string `json:"source"` // 'for_you' | 'discover' | mixes
Plays int64 `json:"plays"` // plays launched from this surface
Skips int64 `json:"skips"` // of those, marked skipped
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
// Breakdown splits the family into the pick-kind populations its
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
// that predate attribution. Present only when the family has at
// least one attributed play; the parent row remains the sum of its
// breakdown.
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
}
// surfaceGroup is one intent band of surface families.
type surfaceGroup struct {
Intent string `json:"intent"` // go_to | discovery | direct
Label string `json:"label"`
Surfaces []surfaceMetric `json:"surfaces"`
}
type recommendationMetricsResp struct {
WindowDays int `json:"window_days"`
// Baseline is the control group: plays the user picked manually
// (source IS NULL). Surfaces are judged as deltas against it; nil
// when the window holds no manual plays.
Baseline *surfaceMetric `json:"baseline"`
Groups []surfaceGroup `json:"groups"`
}
// recFamily is the bucketing target for a raw play_events.source value.
type recFamily struct {
key string
label string
intent string
}
// bucketRecSource maps a raw client-stamped source string to its stable
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
// their family so the table stays readable at any library size.
func bucketRecSource(src string) recFamily {
switch {
case src == "for_you":
return recFamily{"for_you", "For You", intentGoTo}
case src == "songs_like_artist":
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
case src == "radio" || strings.HasPrefix(src, "radio:"):
return recFamily{"radio", "Radio", intentGoTo}
case src == "discover":
return recFamily{"discover", "Discover", intentDiscovery}
case src == "deep_cuts":
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
case src == "rediscover":
return recFamily{"rediscover", "Rediscover", intentDiscovery}
case src == "new_for_you":
return recFamily{"new_for_you", "New for you", intentDiscovery}
case src == "on_this_day":
return recFamily{"on_this_day", "On this day", intentDiscovery}
case src == "first_listens":
return recFamily{"first_listens", "First listens", intentDiscovery}
case strings.HasPrefix(src, "album:"):
return recFamily{"direct_album", "Album plays", intentDirect}
case strings.HasPrefix(src, "artist:"):
return recFamily{"direct_artist", "Artist plays", intentDirect}
case strings.HasPrefix(src, "offline:"):
return recFamily{"offline", "Offline pools", intentDirect}
case strings.HasPrefix(src, "home:"):
return recFamily{"home", "Home sections", intentDirect}
case src == "history":
return recFamily{"history", "History", intentDirect}
default:
return recFamily{"other", "Other", intentDirect}
}
}
// familyAccum merges raw source rows into one family, carrying the
// completion sample count so the merged average stays play-weighted.
type familyAccum struct {
fam recFamily
plays int64
skips int64
completionN int64
// completionSum is avg*count re-expanded, so merging N raw rows
// reduces to a single weighted division at the end.
completionSum float64
}
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
a.plays += row.Plays
a.skips += row.Skips
a.completionN += row.CompletionN
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
}
func (a *familyAccum) metric() surfaceMetric {
m := surfaceMetric{
Key: a.fam.key,
Label: a.fam.label,
Plays: a.plays,
Skips: a.skips,
LowConfidence: a.plays < recMetricsLowVolume,
}
if a.plays > 0 {
m.SkipRate = float64(a.skips) / float64(a.plays)
}
if a.completionN > 0 {
m.AvgCompletion = a.completionSum / float64(a.completionN)
}
return m
WindowDays int `json:"window_days"`
Sources []recommendationMetric `json:"sources"`
}
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
// Bucketed per-surface-family outcomes for the caller over the last `days`
// (default 30, capped at 365), grouped by surface intent and anchored by the
// manual-plays baseline so the numbers are judgeable, not just observable.
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
// caller over the last `days` (default 30, capped at 365), so the operator can
// see which recommendation surfaces are landing and tune the taste weights.
// Only plays tagged with a system-playlist source count; library/radio plays
// (no source) are excluded.
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
@@ -167,142 +51,28 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
return
}
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
}
// pickKindLabels is the display vocabulary for play_events.pick_kind
// values (mirrors the CHECK in migration 0041). Every system mix that
// stamps provenance gets its breakdown from this one map — adding a
// stamping mix needs no metrics change.
var pickKindLabels = map[string]string{
"taste": "Taste picks",
"fresh": "Fresh picks",
"dormant": "Dormant artists",
"taste_unheard": "Taste-matched",
"cross_user": "Liked by others",
"random": "Random unheard",
"tier1": "Tier 1 (exact)",
"tier2": "Tier 2 (relaxed)",
"tier3": "Tier 3 (stretched)",
}
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
// pick_kind — plays recorded before the mix stamped provenance, or
// whose track had rotated out of the snapshot at ingestion) renders
// last, kept visible so the parent row's sums stay transparent instead
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
// vocabulary, so iterating the list is exhaustive.
var pickKindOrder = []string{
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
"tier1", "tier2", "tier3", "",
}
// pickKindFamily derives the sub-family for one (family, pick_kind)
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
func pickKindFamily(parent recFamily, kind string) recFamily {
if kind == "" {
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
out := recommendationMetricsResp{
WindowDays: days,
Sources: make([]recommendationMetric, 0, len(rows)),
}
label, ok := pickKindLabels[kind]
if !ok {
label = kind
}
return recFamily{parent.key + "_" + kind, label, parent.intent}
}
// pickKindBreakdown folds a family's per-pick-kind accums into its
// Breakdown rows. Attached only when at least one attributed play
// exists — an all-unattributed breakdown would just repeat the parent
// row, and families that never stamp (radio, direct plays) stay flat.
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
attributed := int64(0)
for kind, acc := range picks {
if kind != "" {
attributed += acc.plays
}
}
if attributed == 0 {
return nil
}
out := make([]surfaceMetric, 0, len(picks))
for _, kind := range pickKindOrder {
if acc, ok := picks[kind]; ok && acc.plays > 0 {
out = append(out, acc.metric())
}
}
return out
}
// bucketMetricsResponse folds the raw per-source rows into the grouped,
// baseline-anchored response shape. Split from the handler for pure-unit
// testability.
func bucketMetricsResponse(
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
) recommendationMetricsResp {
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
families := map[string]*familyAccum{}
picks := map[string]map[string]*familyAccum{}
for _, row := range rows {
if row.Source == nil || *row.Source == "" {
baseline.add(row)
continue
source := ""
if row.Source != nil {
source = *row.Source
}
fam := bucketRecSource(*row.Source)
acc, exists := families[fam.key]
if !exists {
acc = &familyAccum{fam: fam}
families[fam.key] = acc
var skipRate float64
if row.Plays > 0 {
skipRate = float64(row.Skips) / float64(row.Plays)
}
acc.add(row)
// Accumulate the pick-kind population unconditionally; families
// that never stamp end up all-unattributed and get no breakdown.
kind := ""
if row.PickKind != nil {
kind = *row.PickKind
}
byKind, ok := picks[fam.key]
if !ok {
byKind = map[string]*familyAccum{}
picks[fam.key] = byKind
}
pick, ok := byKind[kind]
if !ok {
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
byKind[kind] = pick
}
pick.add(row)
}
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
if baseline.plays > 0 {
m := baseline.metric()
resp.Baseline = &m
}
for _, g := range []struct{ intent, label string }{
{intentGoTo, "Go-to surfaces"},
{intentDiscovery, "Discovery mixes"},
{intentDirect, "Direct plays"},
} {
group := surfaceGroup{Intent: g.intent, Label: g.label}
for _, acc := range families {
if acc.fam.intent == g.intent {
m := acc.metric()
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
group.Surfaces = append(group.Surfaces, m)
}
}
if len(group.Surfaces) == 0 {
continue
}
sort.Slice(group.Surfaces, func(i, j int) bool {
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
}
return group.Surfaces[i].Key < group.Surfaces[j].Key
out.Sources = append(out.Sources, recommendationMetric{
Source: source,
Plays: row.Plays,
Skips: row.Skips,
SkipRate: skipRate,
AvgCompletion: row.AvgCompletion,
})
resp.Groups = append(resp.Groups, group)
}
return resp
writeJSON(w, http.StatusOK, out)
}
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
+24 -197
View File
@@ -11,8 +11,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func newMetricsRouter(h *handlers) chi.Router {
@@ -22,19 +20,17 @@ func newMetricsRouter(h *handlers) chi.Router {
}
// seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (manual library play → baseline).
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
// except attributed for_you plays.
// skip flag. A nil source inserts NULL (library/radio play).
func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
source, pickKind *string, completion float64, skipped bool,
source *string, completion float64, skipped bool,
) {
t.Helper()
if _, err := h.pool.Exec(context.Background(),
`INSERT INTO play_events
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
userID, trackID, sessionID, source, completion, skipped); err != nil {
t.Fatalf("seed sourced play: %v", err)
}
}
@@ -49,168 +45,7 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
}
}
// findSurface returns the named family from any group, or nil.
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
for _, g := range resp.Groups {
for i := range g.Surfaces {
if g.Surfaces[i].Key == key {
return &g.Surfaces[i]
}
}
}
return nil
}
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
src := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
// Two radio sessions collapse into one "radio" family; weighted
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
// NULL source = manual plays → baseline, not a group row.
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
if resp.Baseline == nil {
t.Fatal("baseline missing")
}
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
}
if resp.Baseline.LowConfidence {
t.Error("baseline with 25 plays should not be low-confidence")
}
radio := findSurface(resp, "radio")
if radio == nil {
t.Fatal("radio family missing")
}
if radio.Plays != 2 || radio.Skips != 1 {
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
}
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
}
if !radio.LowConfidence {
t.Error("radio with 2 plays should be low-confidence")
}
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
t.Errorf("direct_album = %+v, want plays=1", s)
}
if s := findSurface(resp, ""); s != nil {
t.Error("NULL source must not appear as a surface family")
}
// No attributed (taste/fresh) plays in this fixture → no breakdown;
// an all-unattributed breakdown would just repeat the parent row.
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
}
// Group ordering is intent-banded: go_to before discovery before direct.
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
if len(resp.Groups) != len(wantOrder) {
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
}
for i, g := range resp.Groups {
if g.Intent != wantOrder[i] {
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
}
}
}
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
CompletionN: 30, AvgCompletion: 0.9},
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
CompletionN: 10, AvgCompletion: 0.5},
// NULL pick_kind = plays that predate attribution.
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
fy := findSurface(resp, "for_you")
if fy == nil {
t.Fatal("for_you family missing")
}
// The parent row stays the sum of its breakdown.
if fy.Plays != 45 || fy.Skips != 8 {
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
}
if len(fy.Breakdown) != 3 {
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
}
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
for i, k := range wantKeys {
if fy.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
}
}
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
}
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
}
// Breakdown rows never appear as their own surface families.
if s := findSurface(resp, "for_you_taste"); s != nil {
t.Error("for_you_taste must not be a top-level surface")
}
}
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
// Provenance is standard (#1270): Discover's bucket stamps surface as
// a breakdown exactly like For You's taste/fresh — this is what makes
// the bucket allocation judgeable instead of a guess.
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
CompletionN: 8, AvgCompletion: 0.8},
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
CompletionN: 6, AvgCompletion: 0.6},
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
CompletionN: 4, AvgCompletion: 0.4},
// NULL pick_kind = plays that predate bucket stamping.
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
d := findSurface(resp, "discover")
if d == nil {
t.Fatal("discover family missing")
}
if d.Plays != 20 || d.Skips != 8 {
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
}
wantKeys := []string{
"discover_dormant", "discover_cross_user", "discover_random",
"discover_unattributed",
}
if len(d.Breakdown) != len(wantKeys) {
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
}
for i, k := range wantKeys {
if d.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
}
}
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
}
}
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
@@ -222,17 +57,15 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
session := seedPlaySession(t, pool, user.ID, time.Now())
forYou := "for_you"
radioA := "radio:11111111-1111-1111-1111-111111111111"
taste, fresh := "taste", "fresh"
discover := "discover"
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
// A radio session play collapses into the "radio" family.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
// Manual play (NULL source) — the baseline row.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
// discover: 1 play.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
// library play (NULL source) — must be excluded.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
req = withUser(req, user)
@@ -249,11 +82,15 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if resp.WindowDays != recMetricsDefaultDays {
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
}
if resp.Baseline == nil || resp.Baseline.Plays != 1 {
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
bySource := map[string]recommendationMetric{}
for _, m := range resp.Sources {
bySource[m.Source] = m
}
fy := findSurface(resp, "for_you")
if fy == nil {
if _, present := bySource[""]; present {
t.Error("NULL-source (library) plays should be excluded")
}
fy, ok := bySource["for_you"]
if !ok {
t.Fatal("for_you metrics missing")
}
if fy.Plays != 3 || fy.Skips != 1 {
@@ -265,17 +102,7 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
}
// Pick-kind attribution surfaces as the For You breakdown (#1249).
if len(fy.Breakdown) != 2 {
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
}
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
}
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
}
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
}
}
-2
View File
@@ -73,8 +73,6 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
writeErr(w, apierror.InternalMsg("build failed", err))
return
}
// #968: announce the rebuild so the user's other clients refresh.
h.publishSystemRebuilt(user.ID)
q := dbq.New(h.pool)
v := kind
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
+10 -4
View File
@@ -15,7 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
// RadioResponse is the body of GET /api/radio.
@@ -101,9 +100,16 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
}
}
// Scoring weights come from the DB-backed tuning lab (#1250) —
// read per request so an admin change takes effect live.
weights := h.recSettings.Weights(recsettings.ScopeRadio)
weights := recommendation.ScoringWeights{
BaseWeight: h.recCfg.BaseWeight,
LikeBoost: h.recCfg.LikeBoost,
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
SimilarityWeight: h.recCfg.SimilarityWeight,
TasteWeight: h.recCfg.TasteWeight,
}
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
out := make([]TrackRef, 0, len(picks)+1)
+23 -12
View File
@@ -88,14 +88,20 @@ type EventsConfig struct {
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
}
// RecommendationConfig holds the radio path's operational knobs. The
// scoring WEIGHTS moved to DB-backed admin settings (#1250,
// internal/recsettings) — YAML is bootstrap-only; anything an operator
// tunes lives in the UI with live effect.
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
// All weights are operator-tunable; defaults match the spec recommendations.
type RecommendationConfig struct {
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"`
SimilarityWeight float64 `yaml:"similarity_weight"`
TasteWeight float64 `yaml:"taste_weight"`
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
}
func Default() Config {
@@ -114,16 +120,21 @@ func Default() Config {
SkipMaxDurationPlayedMs: 30000,
},
Recommendation: RecommendationConfig{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
// Radio is seed-directed (the user picked a direction), so taste
// is a lighter nudge here than in the daily mixes (1.5).
TasteWeight: 1.0,
RecentlyPlayedHours: 1,
RadioSize: 50,
RadioSizeMax: 200,
},
Library: LibraryConfig{
// On by default so a fresh install populates its library on
// first boot without the operator hunting for a "scan now"
// button. Scans are incremental (unchanged files skipped by
// mtime), so every later restart is just a cheap directory walk.
ScanOnStartup: true,
CoverArtFromMBCAA: true,
},
}
+3 -4
View File
@@ -97,8 +97,7 @@ func TestEnvOverrides(t *testing.T) {
func TestLibraryEnvOverrides(t *testing.T) {
stubStreamSecret(t)
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
// Default is true; set false so the override path is actually exercised.
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
cfg, err := Load("")
if err != nil {
@@ -113,8 +112,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
}
}
if cfg.Library.ScanOnStartup {
t.Error("scan_on_startup = true, want false (env override of the true default)")
if !cfg.Library.ScanOnStartup {
t.Error("scan_on_startup = false, want true")
}
}
-221
View File
@@ -1,221 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: diagnostics.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const gcPruneDiagnostics = `-- name: GcPruneDiagnostics :execrows
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days'
`
// Retention sweep for the gc worker. Deletes diagnostic_events older
// than 30 days by server clock (received_at) so a skewed client clock
// can't keep rows alive. Matches the other gc lifecycle sweeps'
// hardcoded-threshold style; diagnostics are debug telemetry, not user
// data, and 30 days is ample to investigate a live incident after the
// fact. Returns the affected row count for the sweep log line.
func (q *Queries) GcPruneDiagnostics(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcPruneDiagnostics)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const insertDiagnosticEvent = `-- name: InsertDiagnosticEvent :one
INSERT INTO diagnostic_events (
user_id, client_id, app_version, os_version, kind, payload, occurred_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, received_at
`
type InsertDiagnosticEventParams struct {
UserID pgtype.UUID
ClientID string
AppVersion *string
OsVersion *string
Kind string
Payload []byte
OccurredAt pgtype.Timestamptz
}
type InsertDiagnosticEventRow struct {
ID pgtype.UUID
ReceivedAt pgtype.Timestamptz
}
// Records one client-reported diagnostic event. The writer validates the
// `kind` category against its whitelist before this runs; the CHECK
// constraint is the belt-and-braces guard. payload carries the finer
// event discriminator + all event-specific fields. Returns received_at
// so the caller can confirm ingest.
func (q *Queries) InsertDiagnosticEvent(ctx context.Context, arg InsertDiagnosticEventParams) (InsertDiagnosticEventRow, error) {
row := q.db.QueryRow(ctx, insertDiagnosticEvent,
arg.UserID,
arg.ClientID,
arg.AppVersion,
arg.OsVersion,
arg.Kind,
arg.Payload,
arg.OccurredAt,
)
var i InsertDiagnosticEventRow
err := row.Scan(&i.ID, &i.ReceivedAt)
return i, err
}
const listAdminDiagnostics = `-- name: ListAdminDiagnostics :many
SELECT
de.id AS id,
de.user_id AS user_id,
u.username AS username,
de.client_id AS client_id,
de.app_version AS app_version,
de.os_version AS os_version,
de.kind AS kind,
de.payload AS payload,
de.occurred_at AS occurred_at,
de.received_at AS received_at
FROM diagnostic_events de
JOIN users u ON u.id = de.user_id
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
AND ($2::text IS NULL OR de.client_id = $2::text)
AND ($3::text IS NULL OR de.kind = $3::text)
AND ($4::timestamptz IS NULL
OR de.occurred_at >= $4::timestamptz)
AND ($5::timestamptz IS NULL
OR de.occurred_at <= $5::timestamptz)
ORDER BY de.occurred_at DESC
LIMIT $7::int OFFSET $6::int
`
type ListAdminDiagnosticsParams struct {
UserID pgtype.UUID
ClientID *string
Kind *string
FromTs pgtype.Timestamptz
ToTs pgtype.Timestamptz
Off int32
Lim int32
}
type ListAdminDiagnosticsRow struct {
ID pgtype.UUID
UserID pgtype.UUID
Username string
ClientID string
AppVersion *string
OsVersion *string
Kind string
Payload []byte
OccurredAt pgtype.Timestamptz
ReceivedAt pgtype.Timestamptz
}
// Admin timeline query. All filters are optional (NULL = no filter):
// account (user_id), device (client_id), category (kind), and the
// occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
// a chronological timeline / export. Joined with users for the username
// so the view renders without a second round-trip.
func (q *Queries) ListAdminDiagnostics(ctx context.Context, arg ListAdminDiagnosticsParams) ([]ListAdminDiagnosticsRow, error) {
rows, err := q.db.Query(ctx, listAdminDiagnostics,
arg.UserID,
arg.ClientID,
arg.Kind,
arg.FromTs,
arg.ToTs,
arg.Off,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAdminDiagnosticsRow
for rows.Next() {
var i ListAdminDiagnosticsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Username,
&i.ClientID,
&i.AppVersion,
&i.OsVersion,
&i.Kind,
&i.Payload,
&i.OccurredAt,
&i.ReceivedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDiagnosticDevices = `-- name: ListDiagnosticDevices :many
SELECT
de.client_id AS client_id,
de.user_id AS user_id,
u.username AS username,
max(de.app_version)::text AS app_version,
max(de.os_version)::text AS os_version,
max(de.occurred_at)::timestamptz AS last_seen,
count(*) AS event_count
FROM diagnostic_events de
JOIN users u ON u.id = de.user_id
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
GROUP BY de.client_id, de.user_id, u.username
ORDER BY last_seen DESC
`
type ListDiagnosticDevicesRow struct {
ClientID string
UserID pgtype.UUID
Username string
AppVersion string
OsVersion string
LastSeen pgtype.Timestamptz
EventCount int64
}
// Distinct devices that have reported, for the admin device filter +
// "who is currently reporting" overview. Optionally scoped to one account.
func (q *Queries) ListDiagnosticDevices(ctx context.Context, userID pgtype.UUID) ([]ListDiagnosticDevicesRow, error) {
rows, err := q.db.Query(ctx, listDiagnosticDevices, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDiagnosticDevicesRow
for rows.Next() {
var i ListDiagnosticDevicesRow
if err := rows.Scan(
&i.ClientID,
&i.UserID,
&i.Username,
&i.AppVersion,
&i.OsVersion,
&i.LastSeen,
&i.EventCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
-66
View File
@@ -211,69 +211,3 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
}
return items, nil
}
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
LIMIT 120
`
type ListTasteUnheardTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
}
type ListTasteUnheardTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
// weight — "new to you, but your vibe" rather than the crude random arm.
// Genres live inline on tracks.genre as a delimited string, split the
// same way the radio tag_overlap arm does (regexp_split_to_table on
// [;,]). Same exclusion filters as the other buckets. Returns nothing
// when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteUnheardTracksForDiscoverRow
for rows.Next() {
var i ListTasteUnheardTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+7 -50
View File
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
}
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events
WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
@@ -79,13 +79,12 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1
`
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
@@ -105,48 +104,15 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
SELECT pt.pick_kind
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1
AND p.system_variant = $2
AND pt.track_id = $3
LIMIT 1
`
type GetSystemPickKindForTrackParams struct {
UserID pgtype.UUID
SystemVariant *string
TrackID pgtype.UUID
}
// Looks a track up in the user's CURRENT snapshot of the given system
// variant and returns its pick_kind. Used at play-ingestion time to
// freeze provenance onto the play_event — snapshots rebuild daily, so
// attribution can't be reconstructed at read time (#1249/#1270). No
// row = track not in today's snapshot (caller stores NULL); a row with
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
// is non-singleton (up to 3 mixes/user); a track in two of them takes
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
// same eligibility ladder.
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
var pick_kind *string
err := row.Scan(&pick_kind)
return pick_kind, err
}
const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
user_id, track_id, session_id, started_at, client_id, source
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
`
type InsertPlayEventParams struct {
@@ -156,12 +122,8 @@ type InsertPlayEventParams struct {
StartedAt pgtype.Timestamptz
ClientID *string
Source *string
PickKind *string
}
// pick_kind is non-NULL only for system-playlist plays whose track was
// found (with a stamped kind) in the user's live snapshot for that
// variant at ingestion time (#1249, generalized in #1270).
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID,
@@ -170,7 +132,6 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.StartedAt,
arg.ClientID,
arg.Source,
arg.PickKind,
)
var i PlayEvent
err := row.Scan(
@@ -187,7 +148,6 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
@@ -254,7 +214,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
}
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1
AND pe.started_at < $2
@@ -296,8 +256,6 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -333,7 +291,7 @@ SET ended_at = $2,
completion_ratio = $4,
was_skipped = $5
WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
`
type UpdatePlayEventEndedParams struct {
@@ -370,7 +328,6 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
)
return i, err
}
+1 -3
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id,
pe.started_at,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM play_events pe
@@ -77,8 +77,6 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
+1 -3
View File
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
}
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
JOIN general_likes l ON l.track_id = t.id
WHERE l.user_id = $1
ORDER BY l.liked_at DESC
@@ -297,8 +297,6 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
+15 -83
View File
@@ -265,18 +265,6 @@ type CoverArtSourcesMetum struct {
LastRegisteredProvidersHash string
}
type DiagnosticEvent struct {
ID pgtype.UUID
UserID pgtype.UUID
ClientID string
AppVersion *string
OsVersion *string
Kind string
Payload []byte
OccurredAt pgtype.Timestamptz
ReceivedAt pgtype.Timestamptz
}
type GeneralLike struct {
UserID pgtype.UUID
TrackID pgtype.UUID
@@ -383,7 +371,6 @@ type PlayEvent struct {
SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz
Source *string
PickKind *string
}
type PlaySession struct {
@@ -434,28 +421,6 @@ type PlaylistTrack struct {
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
PickKind *string
}
type RecommendationTuningAudit struct {
ID int64
ChangedAt pgtype.Timestamptz
Scope string
Action string
Changes []byte
}
type RecommendationWeightProfile struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
}
type RegistrationSetting struct {
@@ -532,21 +497,6 @@ type SystemPlaylistRun struct {
LastError *string
}
type TagProviderSetting struct {
ProviderID string
Enabled bool
ApiKey *string
DisplayOrder int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type TagSourcesMetum struct {
ID bool
CurrentVersion int32
LastRegisteredProvidersHash string
}
type TasteProfileArtist struct {
UserID pgtype.UUID
ArtistID pgtype.UUID
@@ -561,33 +511,22 @@ type TasteProfileTag struct {
UpdatedAt pgtype.Timestamptz
}
type TasteTuning struct {
Singleton bool
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
UpdatedAt pgtype.Timestamptz
}
type Track struct {
ID pgtype.UUID
Title string
AlbumID pgtype.UUID
ArtistID pgtype.UUID
TrackNumber *int32
DiscNumber *int32
DurationMs int32
FilePath string
FileSize int64
FileFormat string
Bitrate *int32
Mbid *string
Genre *string
AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
TagSource *string
TagSourcesVersion int32
ID pgtype.UUID
Title string
AlbumID pgtype.UUID
ArtistID pgtype.UUID
TrackNumber *int32
DiscNumber *int32
DurationMs int32
FilePath string
FileSize int64
FileFormat string
Bitrate *int32
Mbid *string
Genre *string
AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type TrackSimilarity struct {
@@ -598,12 +537,6 @@ type TrackSimilarity struct {
FetchedAt pgtype.Timestamptz
}
type TrackTag struct {
TrackID pgtype.UUID
Tag string
Weight float64
}
type User struct {
ID pgtype.UUID
Username string
@@ -619,7 +552,6 @@ type User struct {
Email *string
Timezone string
TimezoneUpdatedAt pgtype.Timestamptz
DebugModeEnabled bool
}
type UserInvite struct {
+7 -13
View File
@@ -12,7 +12,7 @@ import (
)
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
SELECT
$1::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
@@ -20,27 +20,24 @@ SELECT
t.title,
artists.name,
albums.title,
(t.duration_ms / 1000)::integer,
$2::text
(t.duration_ms / 1000)::integer
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.id = $3::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
WHERE t.id = $2::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
`
type AppendPlaylistTrackParams struct {
PlaylistID pgtype.UUID
PickKind *string
TrackID pgtype.UUID
}
// Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms
// is converted to seconds for the snapshot. pick_kind is NULL for manual
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
// is converted to seconds for the snapshot.
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID)
var i PlaylistTrack
err := row.Scan(
&i.PlaylistID,
@@ -51,7 +48,6 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
&i.PickKind,
)
return i, err
}
@@ -261,7 +257,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
}
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at,
t.id AS live_track_id,
albums.id AS album_id,
artists.id AS artist_id
@@ -282,7 +278,6 @@ type ListPlaylistTracksRow struct {
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
PickKind *string
LiveTrackID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
@@ -310,7 +305,6 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
&i.PickKind,
&i.LiveTrackID,
&i.AlbumID,
&i.ArtistID,
+4 -12
View File
@@ -104,7 +104,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -161,8 +161,6 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -183,7 +181,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -242,8 +240,6 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -581,7 +577,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -654,8 +650,6 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
@@ -770,7 +764,7 @@ random_fill AS (
LIMIT $9
)
SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -868,8 +862,6 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
+12 -91
View File
@@ -12,17 +12,19 @@ import (
)
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
SELECT
pe.source,
pe.pick_kind,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
COALESCE(
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
0)::float8 AS avg_completion
FROM play_events pe
WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source, pe.pick_kind
GROUP BY pe.source
ORDER BY plays DESC
`
@@ -33,17 +35,18 @@ type RecommendationSourceMetricsForUserParams struct {
type RecommendationSourceMetricsForUserRow struct {
Source *string
PickKind *string
Plays int64
Skips int64
CompletionN int64
AvgCompletion float64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
// NULL for library / radio / user-playlist plays, which are excluded here.
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
// mean completion ratio over the completion_n plays that recorded one.
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
// it is NULL for every other source, so those still group to one row.
// mean completion ratio over plays that recorded one (0 when none did).
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
if err != nil {
@@ -55,10 +58,8 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
var i RecommendationSourceMetricsForUserRow
if err := rows.Scan(
&i.Source,
&i.PickKind,
&i.Plays,
&i.Skips,
&i.CompletionN,
&i.AvgCompletion,
); err != nil {
return nil, err
@@ -70,83 +71,3 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
}
return items, nil
}
const recommendationWeeklyTrends = `-- name: RecommendationWeeklyTrends :many
SELECT
date_trunc('week', pe.started_at)::date AS week_start,
pe.source,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
LEFT JOIN taste_profile_artists tpa
ON tpa.user_id = pe.user_id
AND tpa.artist_id = t.artist_id
AND tpa.weight > 0
WHERE pe.started_at > now() - ($1::int * INTERVAL '1 week')
GROUP BY 1, 2
ORDER BY 1, 2
`
type RecommendationWeeklyTrendsRow struct {
WeekStart pgtype.Date
Source *string
Plays int64
Skips int64
CompletionN int64
AvgCompletion float64
TasteHits int64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a recommendation surface; NULL means the user picked the track manually —
// those rows are INCLUDED here as the baseline control group the surfaces are
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
// actionable). Raw source strings are bucketed into stable surface families in
// the Go handler; completion_n is carried so family merges can weight
// avg_completion correctly.
// Weekly per-source outcome series for the tuning lab's trend view
// (#1251). Aggregated across ALL users: the tuning knobs are global,
// so judging a knob turn needs global outcomes — rows carry rates
// only, no track or user identity. NULL-source (manual) rows are
// included as the baseline family.
//
// taste_hits counts plays whose track's artist has a positive weight
// in that user's CURRENT taste profile — the "cheap recompute" option:
// retroactive over the whole window, at the cost of drift (the profile
// is today's, the play may be weeks old). Good enough to read whether
// a surface is feeding taste-fitting tracks.
// $1 window in weeks.
func (q *Queries) RecommendationWeeklyTrends(ctx context.Context, weeks int32) ([]RecommendationWeeklyTrendsRow, error) {
rows, err := q.db.Query(ctx, recommendationWeeklyTrends, weeks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeeklyTrendsRow
for rows.Next() {
var i RecommendationWeeklyTrendsRow
if err := rows.Scan(
&i.WeekStart,
&i.Source,
&i.Plays,
&i.Skips,
&i.CompletionN,
&i.AvgCompletion,
&i.TasteHits,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -1,272 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: recommendation_tuning.sql
package dbq
import (
"context"
)
const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
`
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
row := q.db.QueryRow(ctx, getTasteTuning)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
)
return i, err
}
const insertTuningAudit = `-- name: InsertTuningAudit :exec
INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3)
`
type InsertTuningAuditParams struct {
Scope string
Action string
Changes []byte
}
// changes is a jsonb array of {field, old, new} objects.
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
return err
}
const listTuningAudit = `-- name: ListTuningAudit :many
SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit
ORDER BY changed_at DESC, id DESC
LIMIT $1
`
// Newest first; consumed by the metrics trend view (#1251) to annotate
// knob turns on the timeline.
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
rows, err := q.db.Query(ctx, listTuningAudit, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationTuningAudit
for rows.Next() {
var i RecommendationTuningAudit
if err := rows.Scan(
&i.ID,
&i.ChangedAt,
&i.Scope,
&i.Action,
&i.Changes,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWeightProfiles = `-- name: ListWeightProfiles :many
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
`
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
rows, err := q.db.Query(ctx, listWeightProfiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeightProfile
for rows.Next() {
var i RecommendationWeightProfile
if err := rows.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTasteTuning = `-- name: UpdateTasteTuning :one
UPDATE taste_tuning
SET half_life_days = $1,
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
`
type UpdateTasteTuningParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
row := q.db.QueryRow(ctx, updateTasteTuning,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
)
return i, err
}
const updateWeightProfile = `-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
WHERE profile = $1
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
`
type UpdateWeightProfileParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
}
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
row := q.db.QueryRow(ctx, updateWeightProfile,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
)
var i RecommendationWeightProfile
err := row.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
)
return i, err
}
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
ON CONFLICT (singleton) DO NOTHING
`
type UpsertTasteTuningDefaultsParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
)
return err
}
const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (profile) DO NOTHING
`
type UpsertWeightProfileDefaultsParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
}
// Recommendation tuning lab queries (#1250). Seeding happens via the
// recsettings boot reconcile; shipped defaults live in Go only.
// Boot reconcile: insert the shipped defaults for a profile if the row
// doesn't exist yet. Never overwrites operator-tuned values.
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
)
return err
}
+70 -170
View File
@@ -27,20 +27,12 @@ play_counts AS (
FROM play_events
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
),
skip_counts AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = true
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.track_id = t.id
WHERE COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -68,11 +60,6 @@ type ListDeepCutsTracksRow struct {
// gives per-play variety on top.
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
// has liked OR played heavily (>=5 non-skip plays across the artist).
// Tracks the user has skipped twice or more don't qualify (#1257):
// eligibility used to count only unskipped plays, so the most actively
// rejected tracks read as "barely heard" and kept being re-offered.
// Threshold 2 so a single accidental skip doesn't banish a track;
// passive-signal only (no dislike UI, rule #101).
// $1 user_id, $2 date string.
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
@@ -95,49 +82,31 @@ func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracks
}
const listFirstListensTracks = `-- name: ListFirstListensTracks :many
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
WITH heard_albums AS (
SELECT DISTINCT t.album_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
),
album_attempts AS (
SELECT t.album_id,
COUNT(a.track_id) AS attempted_count,
COUNT(*) AS track_count
FROM tracks t
LEFT JOIN attempted a ON a.track_id = t.id
GROUP BY t.album_id
),
attempted_artists AS (
played_artists AS (
SELECT DISTINCT t.artist_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
albums_tiered AS (
SELECT aa.album_id,
CASE
WHEN aa.attempted_count = 0 THEN 1
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
END AS tier
FROM album_attempts aa
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
)
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN albums_tiered alt ON alt.album_id = al.id
WHERE alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY
alt.tier,
(CASE
WHEN EXISTS (
SELECT 1 FROM general_likes_artists gla
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
) THEN 0
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
ELSE 2
END),
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
@@ -148,25 +117,12 @@ type ListFirstListensTracksRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
Tier int32
}
// #423 First Listens: songs the user has never heard or even
// attempted. "Attempted" is track-level with a >=30s listen threshold
// (#1268, operator decision 2026-07-03) — the old version disqualified
// a whole album on ANY play_event, so a 2-second accidental brush
// banished it permanently.
//
// tier 1 the exact desire: albums with ZERO attempted tracks
// tier 2 step back: barely-attempted albums (<=25% of tracks
// reached 30s) — brushed, never explored. The already-
// attempted tracks themselves are excluded; they're not
// first listens.
//
// Within each tier, affinity-ordered as before: liked artist, then
// attempted-played artist (>=30s — skip-only contact isn't trust),
// then the rest; album-coherent. The tier lands on
// playlist_tracks.pick_kind via the producer. $1 user_id.
// #423 First Listens: albums the user has never played any track of.
// Tiered: liked-artist albums first, then played-artist albums, then
// the rest — album-coherent within each tier. Not diversity-capped
// (whole-album discovery). $1 user_id.
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
if err != nil {
@@ -176,12 +132,7 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
var items []ListFirstListensTracksRow
for rows.Next() {
var i ListFirstListensTracksRow
if err := rows.Scan(
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
@@ -193,76 +144,39 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
}
const listNewForYouTracks = `-- name: ListNewForYouTracks :many
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
),
consumed_albums AS (
SELECT DISTINCT t.album_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
affinity_artists AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
UNION
SELECT t.artist_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
HAVING COUNT(*) >= 3
),
albums_tiered AS (
SELECT al.id AS album_id, al.created_at,
CASE
WHEN al.created_at >= now() - interval '30 days'
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
ELSE 3
END AS tier
FROM albums al
WHERE al.created_at >= now() - interval '90 days'
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
)
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN albums_tiered alt ON alt.album_id = t.album_id
WHERE NOT EXISTS (
JOIN albums al ON al.id = t.album_id
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
WHERE al.created_at >= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 300
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 200
`
type ListNewForYouTracksRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
Tier int32
}
// #421 New for you, tiered per project rule #131 (#1267). The old
// single hard rule (added <30d AND direct affinity) had two failure
// modes with one root: no notion of consumption — the album you
// devoured in week one crowded the mix for three more weeks, and
// after a quiet month nothing qualified at all.
//
// "Consumed" = any track attempted for >=30s (the First Listens
// threshold, #1268): once you've meaningfully engaged, the album is
// no longer news. Applies to every tier — played albums leave the
// mix the next build.
//
// tier 1 the exact desire: unconsumed albums added <30d by
// direct-affinity artists (liked, or >=3 attempted plays)
// tier 2 step back a little: unconsumed affinity albums from the
// wider 30-90d window — added while you weren't looking
// tier 3 step back more: any unconsumed album added <90d
// regardless of affinity, newest first
//
// Album-coherent within tiers (newest album first, then disc/track);
// the producer fills top-down and rotates within tier blocks. The
// tier lands on playlist_tracks.pick_kind so metrics can price what
// each step-back trades away. $1 user_id.
// #421 New for you: tracks from albums added in the last 30 days
// whose artist the user has liked OR played (>=3 non-skip). Album-
// coherent (newest album first, then disc/track) — the producer does
// NOT diversity-cap these; they're meant as whole-album discovery.
// $1 user_id.
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
if err != nil {
@@ -272,12 +186,7 @@ func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) (
var items []ListNewForYouTracksRow
for rows.Next() {
var i ListNewForYouTracksRow
if err := rows.Scan(
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
@@ -294,10 +203,7 @@ WITH windowed AS (
FROM play_events
WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '30 days'
AND LEAST(
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
) <= 10
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
@@ -329,13 +235,6 @@ type ListOnThisDayTracksRow struct {
// and window ±7→±10 so it surfaces on a months-old library instead
// of needing a full year of history; still skips cleanly (no rows →
// no playlist) when there's no qualifying history yet.
// Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
// Jan 3 read as 359 days apart, silently gutting the window for ~3
// weeks around every New Year — precisely when holiday nostalgia is
// the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
// ±1 is absorbed by the ±10 window. The build date ($2, already here
// for the md5 rotation) anchors "today" instead of now() so the
// window is testable and consistent with the mix's daily determinism.
// $1 user_id, $2 date string.
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
@@ -363,32 +262,37 @@ WITH stats AS (
FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id
),
deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 3
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
-- rediscover-sourced play is the user explicitly saying "I've moved
-- on" to the exact invitation this mix extends — such tracks sit
-- out ~90 days instead of re-qualifying the next day. Passive
-- signal only (rule #101).
AND NOT EXISTS (
SELECT 1 FROM play_events rp
WHERE rp.user_id = $1
AND rp.track_id = t.id
AND rp.source = 'rediscover'
AND rp.was_skipped = true
AND rp.started_at > now() - interval '90 days'
)
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
(s.c >= 5) DESC,
s.c DESC, t.id
SELECT id, album_id, artist_id
FROM (
SELECT id, album_id, artist_id, c, tier FROM deep
UNION ALL
SELECT id, album_id, artist_id, c, tier FROM shallow
WHERE NOT EXISTS (SELECT 1 FROM deep)
) u
ORDER BY tier, c DESC, id
LIMIT 200
`
@@ -398,17 +302,13 @@ type ListRediscoverTracksRow struct {
ArtistID pgtype.UUID
}
// #420 Rediscover: tracks the user played a lot but has drifted away
// from. One blended pool (issue #1246: the old two-tier UNION was
// all-or-nothing — a single ">=6 months cold" row suppressed the whole
// ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
// tier was also a strict subset of the 30d tier). Eligibility is >=3
// non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
// so a weeks-old library still fields a pool — on young histories the
// >=5-play tracks are precisely the ones still in rotation. Ordering
// prefers true rediscoveries (>=6 months cold), then strong affection
// (>=5 plays), then raw play count; the producer's minimum floor
// decides whether the pool is big enough to ship at all. $1 user_id.
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
// has drifted away from. Tiered so a young library still gets a mix:
//
// tier 0 not played in the last 6 months (true rediscovery)
// tier 1 (only if tier 0 empty) not played in the last 30 days
//
// Ordered by historical affection. $1 user_id.
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
if err != nil {
+20 -77
View File
@@ -290,13 +290,9 @@ func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlayli
}
const pickSeedArtists = `-- name: PickSeedArtists :many
WITH liked AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
),
recent7 AS (
WITH plays AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
0 AS tier
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
@@ -304,80 +300,27 @@ recent7 AS (
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
recent30 AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
alltime AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
2 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
likedonly AS (
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
FROM liked l
),
chosen AS (
SELECT artist_id, play_count, tier FROM recent7
UNION ALL
SELECT artist_id, play_count, tier FROM recent30
WHERE NOT EXISTS (SELECT 1 FROM recent7)
UNION ALL
SELECT artist_id, play_count, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
UNION ALL
SELECT artist_id, play_count, tier FROM likedonly
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
AND NOT EXISTS (SELECT 1 FROM alltime)
liked AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
)
SELECT c.artist_id,
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
c.tier::int AS tier
FROM chosen c
LEFT JOIN liked l ON l.artist_id = c.artist_id
ORDER BY score DESC, c.artist_id
LIMIT 12
SELECT p.artist_id,
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
FROM plays p
LEFT JOIN liked l ON l.artist_id = p.artist_id
ORDER BY score DESC, p.artist_id
LIMIT 5
`
type PickSeedArtistsRow struct {
ArtistID pgtype.UUID
Score int64
Tier int32
}
// Top-12 most-engaged distinct artist candidates, tiered so the
// "Songs like X" mixes never silently vanish (#1255): the old hard
// 7-day window emptied the seed pool after a quiet week, and the
// daily atomic-replace build then deleted every existing mix until
// the user played something again. Same fallback shape as
// PickTopPlayedTracksForUser (For You's seeds):
//
// tier 0 engagement in the last 7 days
// tier 1 (only if tier 0 empty) last 30 days
// tier 2 (only if tiers 0+1 empty) all-time
// tier 3 (only if 0-2 empty) liked artists with no play history
//
// All returned rows share one tier; produceSeedMixes maps it onto the
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
// can compare mixes seeded from fresh vs stale engagement.
// Score = unskipped-play count + 5 if user has liked the artist. The
// Go-side picker (pickSeedArtistsForDay) shuffles the 12
// daily-deterministically and takes songsLikeSeedCount (6) so the mix
// set both rotates day-to-day and fills the dedicated Home row (#1491).
// Top-5 most-engaged distinct artist candidates in the user's last 7
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these
// daily-deterministically and takes the first 3 so the set of
// "Songs like X" mixes rotates day-to-day.
// Score = unskipped-play count + 5 if user has liked the artist.
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
if err != nil {
@@ -387,7 +330,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi
var items []PickSeedArtistsRow
for rows.Next() {
var i PickSeedArtistsRow
if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil {
if err := rows.Scan(&i.ArtistID, &i.Score); err != nil {
return nil, err
}
items = append(items, i)
@@ -514,10 +457,10 @@ SELECT id
// tier 2 (only if tiers 0+1 empty) liked tracks
//
// Returns up to 5 ids; tie-break by track_id for determinism. The
// Go-side picker (pickDailySeeds) draws the day's seeds from these.
// Widened from a hard 7-day window, which made For-You disappear
// after a week of not listening and never recover on a self-hosted
// library with sparse history.
// Go-side picker (pickForYouSeedForDay) rotates one per day via
// userIDHash. Widened from a hard 7-day window, which made For-You
// disappear after a week of not listening and never recover on a
// self-hosted library with sparse history.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil {
-157
View File
@@ -1,157 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: tag_settings.sql
package dbq
import (
"context"
)
const bumpTagSourcesVersion = `-- name: BumpTagSourcesVersion :one
UPDATE tag_sources_meta
SET current_version = current_version + 1
WHERE id = true
RETURNING current_version
`
// Increment + return the new version. Called only when the enabled set
// changes (not on key edits).
func (q *Queries) BumpTagSourcesVersion(ctx context.Context) (int32, error) {
row := q.db.QueryRow(ctx, bumpTagSourcesVersion)
var current_version int32
err := row.Scan(&current_version)
return current_version, err
}
const bumpTagVersionAndSetProvidersHash = `-- name: BumpTagVersionAndSetProvidersHash :one
UPDATE tag_sources_meta
SET current_version = current_version + 1,
last_registered_providers_hash = $1
WHERE id = true
RETURNING current_version
`
// Atomically increment the version and store the new registered-provider
// hash. Called at boot when the compiled-in provider set changed, so
// 'none' rows become eligible for a retry through the new chain.
func (q *Queries) BumpTagVersionAndSetProvidersHash(ctx context.Context, lastRegisteredProvidersHash string) (int32, error) {
row := q.db.QueryRow(ctx, bumpTagVersionAndSetProvidersHash, lastRegisteredProvidersHash)
var current_version int32
err := row.Scan(&current_version)
return current_version, err
}
const getCurrentTagSourcesVersion = `-- name: GetCurrentTagSourcesVersion :one
SELECT current_version FROM tag_sources_meta WHERE id = true
`
func (q *Queries) GetCurrentTagSourcesVersion(ctx context.Context) (int32, error) {
row := q.db.QueryRow(ctx, getCurrentTagSourcesVersion)
var current_version int32
err := row.Scan(&current_version)
return current_version, err
}
const getTagProvidersHash = `-- name: GetTagProvidersHash :one
SELECT last_registered_providers_hash
FROM tag_sources_meta
WHERE id = true
`
func (q *Queries) GetTagProvidersHash(ctx context.Context) (string, error) {
row := q.db.QueryRow(ctx, getTagProvidersHash)
var last_registered_providers_hash string
err := row.Scan(&last_registered_providers_hash)
return last_registered_providers_hash, err
}
const listTagProviderSettings = `-- name: ListTagProviderSettings :many
SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
FROM tag_provider_settings
ORDER BY display_order, provider_id
`
// Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
// The tags.SettingsService consumes these — parallel to the cover-art
// SettingsService (coverart_settings.sql). Keeping the two independent
// means a new tag source is added without touching the art settings.
// All rows, for boot reconciliation and the admin GET handler.
func (q *Queries) ListTagProviderSettings(ctx context.Context) ([]TagProviderSetting, error) {
rows, err := q.db.Query(ctx, listTagProviderSettings)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TagProviderSetting
for rows.Next() {
var i TagProviderSetting
if err := rows.Scan(
&i.ProviderID,
&i.Enabled,
&i.ApiKey,
&i.DisplayOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTagProviderSettings = `-- name: UpdateTagProviderSettings :exec
UPDATE tag_provider_settings
SET enabled = COALESCE($2, enabled),
api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END,
updated_at = now()
WHERE provider_id = $1
`
type UpdateTagProviderSettingsParams struct {
ProviderID string
Enabled bool
Column3 bool
ApiKey *string
}
// Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag
// to leave api_key untouched; TRUE with empty string clears it, TRUE with
// a value sets it.
func (q *Queries) UpdateTagProviderSettings(ctx context.Context, arg UpdateTagProviderSettingsParams) error {
_, err := q.db.Exec(ctx, updateTagProviderSettings,
arg.ProviderID,
arg.Enabled,
arg.Column3,
arg.ApiKey,
)
return err
}
const upsertTagProviderSettings = `-- name: UpsertTagProviderSettings :exec
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
VALUES ($1, $2, $3)
ON CONFLICT (provider_id) DO UPDATE
SET display_order = EXCLUDED.display_order,
updated_at = now()
`
type UpsertTagProviderSettingsParams struct {
ProviderID string
Enabled bool
DisplayOrder int32
}
// INSERT a default row for a newly-registered provider, or refresh an
// existing row's display_order on boot. Does NOT overwrite enabled /
// api_key on conflict — the operator's settings persist across restarts.
func (q *Queries) UpsertTagProviderSettings(ctx context.Context, arg UpsertTagProviderSettingsParams) error {
_, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder)
return err
}
+4 -9
View File
@@ -87,21 +87,19 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
}
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
SELECT t.id AS track_id, t.artist_id, t.genre
SELECT t.artist_id, t.genre
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1
`
type ListLikedTrackTasteInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
}
// (track_id, artist_id, genre) for each track the user has explicitly
// liked. Feeds the track-like bonus into the liked track's artist and
// tags; track_id keys the enriched track_tags lookup (#1490).
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
// track-like bonus into the liked track's artist and tags.
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil {
@@ -111,7 +109,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
var items []ListLikedTrackTasteInputsForUserRow
for rows.Next() {
var i ListLikedTrackTasteInputsForUserRow
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
return nil, err
}
items = append(items, i)
@@ -125,7 +123,6 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
SELECT
t.id AS track_id,
t.artist_id,
t.genre,
LEAST(GREATEST(
@@ -149,7 +146,6 @@ type ListPlayEngagementInputsForUserParams struct {
}
type ListPlayEngagementInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
Completion float64
@@ -173,7 +169,6 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
for rows.Next() {
var i ListPlayEngagementInputsForUserRow
if err := rows.Scan(
&i.TrackID,
&i.ArtistID,
&i.Genre,
&i.Completion,
-185
View File
@@ -1,185 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: track_tags.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteTrackTags = `-- name: DeleteTrackTags :exec
DELETE FROM track_tags WHERE track_id = $1
`
// Clear a track's cached tags before rewriting (atomic replace by the caller).
func (q *Queries) DeleteTrackTags(ctx context.Context, trackID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTrackTags, trackID)
return err
}
const insertTrackTag = `-- name: InsertTrackTag :exec
INSERT INTO track_tags (track_id, tag, weight)
VALUES ($1, $2, $3)
ON CONFLICT (track_id, tag)
DO UPDATE SET weight = GREATEST(track_tags.weight, EXCLUDED.weight)
`
type InsertTrackTagParams struct {
TrackID pgtype.UUID
Tag string
Weight float64
}
// Upsert one (track, tag); keep the stronger weight when two providers
// agree on a tag with different folksonomy strengths.
func (q *Queries) InsertTrackTag(ctx context.Context, arg InsertTrackTagParams) error {
_, err := q.db.Exec(ctx, insertTrackTag, arg.TrackID, arg.Tag, arg.Weight)
return err
}
const listLikedTrackTagsForUser = `-- name: ListLikedTrackTagsForUser :many
SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
JOIN general_likes gl ON gl.track_id = tt.track_id
WHERE gl.user_id = $1
`
// Enriched tags for the user's liked tracks — the like-bonus path in the
// recompute mirrors the play path, so it needs the same tag lookup.
func (q *Queries) ListLikedTrackTagsForUser(ctx context.Context, userID pgtype.UUID) ([]TrackTag, error) {
rows, err := q.db.Query(ctx, listLikedTrackTagsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackTag
for rows.Next() {
var i TrackTag
if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlayedTrackTagsForUser = `-- name: ListPlayedTrackTagsForUser :many
SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
WHERE tt.track_id IN (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - make_interval(days => $2::int)
)
`
type ListPlayedTrackTagsForUserParams struct {
UserID pgtype.UUID
Column2 int32
}
// The enriched tags for every track this user played inside the taste
// window, so the recompute can union them into the tag facet alongside
// ID3 genre. $1 = user_id, $2 = window_days. One row per (track, tag).
func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayedTrackTagsForUserParams) ([]TrackTag, error) {
rows, err := q.db.Query(ctx, listPlayedTrackTagsForUser, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackTag
for rows.Next() {
var i TrackTag
if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
SELECT t.id, t.mbid, t.title, a.name AS artist_name
FROM tracks t
JOIN artists a ON a.id = t.artist_id
WHERE t.tag_source IS NULL
OR (t.tag_source = 'none' AND t.tag_sources_version < $1)
ORDER BY t.id
LIMIT $2
`
type ListTracksMissingTagsParams struct {
TagSourcesVersion int32
Limit int32
}
type ListTracksMissingTagsRow struct {
ID pgtype.UUID
Mbid *string
Title string
ArtistName string
}
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
// drains ListTracksMissingTags, fetches from the provider chain, and
// writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
// stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
// Tracks eligible for tag enrichment: never processed (tag_source NULL)
// or previously settled 'none' under an older provider version. Returns
// the fields the provider chain needs — recording MBID (nullable) for
// keyed lookups, plus title + artist name for name-based fallback.
// $1 = current tag_sources_version, $2 = limit.
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksMissingTagsRow
for rows.Next() {
var i ListTracksMissingTagsRow
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Title,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setTrackTagSource = `-- name: SetTrackTagSource :exec
UPDATE tracks SET tag_source = $2, tag_sources_version = $3 WHERE id = $1
`
type SetTrackTagSourceParams struct {
ID pgtype.UUID
TagSource *string
TagSourcesVersion int32
}
// Stamp the enrichment outcome so the batch drainer skips settled rows.
// $2 = 'lastfm' | 'musicbrainz' | 'mixed' | 'none', $3 = current version.
func (q *Queries) SetTrackTagSource(ctx context.Context, arg SetTrackTagSourceParams) error {
_, err := q.db.Exec(ctx, setTrackTagSource, arg.ID, arg.TagSource, arg.TagSourcesVersion)
return err
}
+8 -24
View File
@@ -90,7 +90,7 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
}
const getTrackByID = `-- name: GetTrackByID :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = $1
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1
`
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
@@ -112,14 +112,12 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
const getTrackByPath = `-- name: GetTrackByPath :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE file_path = $1
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE file_path = $1
`
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
@@ -141,14 +139,12 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
const getTracksByIDs = `-- name: GetTracksByIDs :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = ANY($1::uuid[])
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
@@ -178,8 +174,6 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -192,7 +186,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
}
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -248,8 +242,6 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -264,7 +256,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
}
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -317,8 +309,6 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -333,7 +323,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
}
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -375,8 +365,6 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -424,7 +412,7 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
}
const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -473,8 +461,6 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -522,7 +508,7 @@ ON CONFLICT (file_path) DO UPDATE SET
mbid = EXCLUDED.mbid,
genre = EXCLUDED.genre,
updated_at = now()
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at
`
type UpsertTrackParams struct {
@@ -573,8 +559,6 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
+12 -64
View File
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type CreateUserParams struct {
@@ -89,7 +89,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -97,7 +96,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
const createUserAdmin = `-- name: CreateUserAdmin :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type CreateUserAdminParams struct {
@@ -135,7 +134,6 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -147,7 +145,7 @@ VALUES (
(SELECT NOT EXISTS (SELECT 1 FROM users)),
$4
)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type CreateUserFirstAdminRaceParams struct {
@@ -193,7 +191,6 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -240,7 +237,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
}
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1
`
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
@@ -261,13 +258,12 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1)
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1)
`
// Used by forgot-password lookup. Lowercase comparison both sides
@@ -291,13 +287,12 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
@@ -318,13 +313,12 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -345,7 +339,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -389,8 +382,7 @@ func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiv
}
const listUsers = `-- name: ListUsers :many
SELECT id, username, display_name, is_admin, auto_approve_requests,
debug_mode_enabled, created_at
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
FROM users
ORDER BY created_at DESC
`
@@ -401,7 +393,6 @@ type ListUsersRow struct {
DisplayName *string
IsAdmin bool
AutoApproveRequests bool
DebugModeEnabled bool
CreatedAt pgtype.Timestamptz
}
@@ -421,7 +412,6 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
&i.DisplayName,
&i.IsAdmin,
&i.AutoApproveRequests,
&i.DebugModeEnabled,
&i.CreatedAt,
); err != nil {
return nil, err
@@ -436,7 +426,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
const regenerateApiToken = `-- name: RegenerateApiToken :one
UPDATE users SET api_token = $2 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type RegenerateApiTokenParams struct {
@@ -464,7 +454,6 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -489,44 +478,6 @@ func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordPa
return err
}
const setDebugMode = `-- name: SetDebugMode :one
UPDATE users
SET debug_mode_enabled = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type SetDebugModeParams struct {
ID pgtype.UUID
DebugModeEnabled bool
}
// Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
// from /admin (flip remotely while a bug is live) or self-driven OFF from
// the client. Returns the updated row so the handler can echo it.
func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (User, error) {
row := q.db.QueryRow(ctx, setDebugMode, arg.ID, arg.DebugModeEnabled)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
UPDATE users
SET listenbrainz_enabled = $2
@@ -580,7 +531,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
UPDATE users
SET is_admin = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type UpdateUserAdminParams struct {
@@ -608,7 +559,6 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -617,7 +567,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
UPDATE users
SET auto_approve_requests = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type UpdateUserAutoApproveParams struct {
@@ -644,7 +594,6 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -654,7 +603,7 @@ UPDATE users
SET display_name = $2,
email = $3
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type UpdateUserProfileParams struct {
@@ -683,7 +632,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -1,2 +0,0 @@
ALTER TABLE users DROP COLUMN IF EXISTS debug_mode_enabled;
DROP TABLE IF EXISTS diagnostic_events;

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