Compare commits

..
57 Commits
Author SHA1 Message Date
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
274 changed files with 1560 additions and 19476 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() {
@@ -1,48 +0,0 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the tag-enrichment provider settings (#1521).
* No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
*/
@Singleton
class AdminTagSourcesRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminTagSourcesApi = retrofit.create()
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
}
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
id = id,
displayName = displayName,
requiresApiKey = requiresApiKey,
supports = supports,
enabled = enabled,
apiKeySet = apiKeySet,
testable = testable,
)
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import 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() {
@@ -28,18 +28,15 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.TriangleAlert
import com.composables.icons.lucide.Users
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -57,7 +54,7 @@ import javax.inject.Inject
// ─── State ───────────────────────────────────────────────────────────
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
sealed interface AdminLandingUiState {
data object Loading : AdminLandingUiState
@@ -72,7 +69,6 @@ class AdminLandingViewModel @Inject constructor(
private val requestsRepo: AdminRequestsRepository,
private val quarantineRepo: AdminQuarantineRepository,
private val usersRepo: AdminUsersRepository,
private val tagSourcesRepo: AdminTagSourcesRepository,
) : ViewModel() {
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
@@ -89,8 +85,7 @@ class AdminLandingViewModel @Inject constructor(
val req = async { requestsRepo.list().size }
val qua = async { quarantineRepo.list().size }
val usr = async { usersRepo.list().size }
val tag = async { tagSourcesRepo.list().count { it.enabled } }
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
AdminCounts(req.await(), qua.await(), usr.await())
}
internal.value = AdminLandingUiState.Success(counts)
} catch (
@@ -175,15 +170,6 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
onClick = { navController.navigate(AdminUsers) },
)
}
item {
SectionCard(
icon = Lucide.Music,
title = "Tag sources",
subtitle = "Metadata enrichment providers",
count = counts.tagSources,
onClick = { navController.navigate(AdminTagSources) },
)
}
}
}
@@ -29,7 +29,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.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 {
@@ -1,220 +0,0 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminTagSourcesScreen(
navController: NavHostController,
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Tag sources",
navController = navController,
currentRouteName = AdminTagSources::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminTagSourcesUiState.Loading -> LoadingCentered()
AdminTagSourcesUiState.Empty -> EmptyState(
title = "No tag sources",
body = "Tag-enrichment providers register on the server; none are available.",
)
is AdminTagSourcesUiState.Error -> ErrorRetry(
title = "Couldn't load tag sources",
message = s.message,
onRetry = { viewModel.refresh() },
)
is AdminTagSourcesUiState.Success -> TagSourceList(
providers = s.providers,
testResults = s.testResults,
onToggle = viewModel::setEnabled,
onSaveKey = viewModel::saveApiKey,
onTest = viewModel::test,
)
}
}
}
}
@Composable
private fun TagSourceList(
providers: List<AdminTagSourceRef>,
testResults: Map<String, TagSourceTestResult>,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = providers, key = { it.id }) { provider ->
TagSourceCard(
provider = provider,
testResult = testResults[provider.id],
onToggle = onToggle,
onSaveKey = onSaveKey,
onTest = onTest,
)
}
}
}
@Composable
private fun TagSourceCard(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
Text(
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = provider.enabled,
onCheckedChange = { onToggle(provider.id, it) },
)
}
if (provider.requiresApiKey) {
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
}
if (provider.testable) {
TestRow(provider = provider, testResult = testResult, onTest = onTest)
}
}
}
}
@Composable
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
var key by remember(provider.id) { mutableStateOf("") }
OutlinedTextField(
value = key,
onValueChange = { key = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("API key") },
placeholder = {
Text(
if (provider.apiKeySet) {
"••• saved — leave blank to keep"
} else {
"Paste your API key to enable this source"
},
)
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
onSaveKey(provider.id, key)
key = ""
},
enabled = key.isNotBlank(),
) { Text("Save key") }
if (provider.apiKeySet) {
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun TestRow(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onTest: (String) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
testResult?.let { result ->
if (result.ok) {
Text(
text = "OK (${result.durationMs}ms)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = "Failed — ${result.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -1,116 +0,0 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminTagSourcesUiState {
data object Loading : AdminTagSourcesUiState
data object Empty : AdminTagSourcesUiState
data class Success(
val providers: List<AdminTagSourceRef>,
val testResults: Map<String, TagSourceTestResult>,
) : AdminTagSourcesUiState
data class Error(val message: String) : AdminTagSourcesUiState
}
@HiltViewModel
class AdminTagSourcesViewModel @Inject constructor(
private val repository: AdminTagSourcesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245).
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh()
}
}
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminTagSourcesUiState.Loading
try {
val providers = repository.list()
internal.value = if (providers.isEmpty()) {
AdminTagSourcesUiState.Empty
} else {
AdminTagSourcesUiState.Success(providers, emptyMap())
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
}
}
fun setEnabled(id: String, enabled: Boolean) {
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
// Optimistic toggle; reconcile via refresh() if the server rejects.
internal.value = before.copy(
providers = before.providers.map {
if (it.id == id) it.copy(enabled = enabled) else it
},
)
viewModelScope.launch {
try {
repository.setEnabled(id, enabled)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun saveApiKey(id: String, apiKey: String) {
viewModelScope.launch {
try {
replaceProvider(repository.setApiKey(id, apiKey))
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun test(id: String) {
viewModelScope.launch {
val result = try {
repository.test(id)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
}
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
internal.value = current.copy(testResults = current.testResults + (id to result))
}
}
private fun replaceProvider(updated: AdminTagSourceRef) {
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
internal.value = current.copy(
providers = current.providers.map { if (it.id == updated.id) updated else it },
)
}
}
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import 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,41 +0,0 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
* surface for the tag-enrichment provider settings (#1490). Mirrors the
* web integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings.
*/
interface AdminTagSourcesApi {
@GET("api/admin/tag-sources")
suspend fun list(): AdminTagSourcesListWire
@PATCH("api/admin/tag-sources/{id}")
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
@POST("api/admin/tag-sources/{id}/test")
suspend fun test(@Path("id") id: String): TestTagSourceWire
}
/**
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
* untouched one (the server reads a missing/null field as "leave
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
*/
@Serializable
data class UpdateTagSourceBody(
val enabled: Boolean? = null,
@SerialName("api_key") val apiKey: String? = null,
)
@@ -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,
)
@@ -14,11 +14,6 @@ import kotlinx.datetime.Instant
* - "rediscover_artists"
* - "most_played_tracks"
* - "last_played_artists"
* - "you_might_like_albums"
* - "you_might_like_artists"
*
* New `section` values need no schema change — the column stores the
* string verbatim and the DAO keys on it.
*
* `entityType` is "album" | "artist" | "track" — dispatches hydration
* to the right per-entity endpoint when a tile is rendered.
@@ -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,
)
@@ -28,13 +28,6 @@ object MutationKind {
const val PLAY_OFFLINE: String = "play_offline"
const val REQUEST_CANCEL: String = "request_cancel"
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
// Durable close-by-id for a play whose server row is already open
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
// a *whole* play when no server row exists. Using close-by-id on
// background avoids the duplicate + orphan row the old offline-on-stop
// path produced (see 2026-06-11 contract audit).
const val PLAY_ENDED: String = "play_ended"
}
/**
@@ -174,17 +167,6 @@ class MutationQueue @Inject constructor(
),
)
/**
* Durable close-by-id for an already-open server play row. Background
* path only (no user hint) — same as [enqueuePlayOffline].
*/
suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAY_ENDED,
payload = json.encodeToString(PlayEndedPayload.serializer(), payload),
),
)
private suspend fun insertUserDriven(kind: String, payload: String): Long {
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
@@ -239,22 +221,6 @@ data class PlayOfflinePayload(
val atIso: String,
val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null,
)
/**
* Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
* `POST /api/events` with type=play_ended, closing the already-open server
* row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
* idempotent (re-ending updates the same row and skips a duplicate
* skip_events insert), so a lost-response retry can't duplicate history.
*/
@Serializable
data class PlayEndedPayload(
val playEventId: String,
val durationPlayedMs: Long,
)
/**
@@ -1,5 +1,3 @@
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
@@ -12,9 +10,6 @@ import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.api.endpoints.RequestsApi
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -24,16 +19,12 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
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.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -43,24 +34,25 @@ import javax.inject.Singleton
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
* re-fires each call against the raw Retrofit API (NOT the high-level
* Repository, which would re-enqueue on failure → infinite loop), and
* resolves each to an [Outcome]:
* - SENT — server accepted (2xx) → delete the row.
* - DROP — permanently un-sendable (4xx, unknown kind/variant, corrupt
* payload) → delete the row so it can't wedge the queue forever.
* - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
* attempts, retry on the next trigger.
* deletes the row on 2xx success.
*
* Triggered by:
* - app start (MinstrelApplication @Inject forces the singleton's init)
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
* start with a persisted cookie)
* - NetworkStatusController recovering to Healthy (server reachable again
* mid-session — covers offline→online without needing a cold start)
* - app start (MinstrelApplication @Inject forces the singleton's
* init block to subscribe)
* - every time AuthStore.sessionCookie transitions to a non-null
* value (sign-in, app launch with persisted cookie)
*
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
* drain pass. No exponential backoff / max-attempts: the rows are few for
* typical use, and permanent failures are now dropped rather than retried
* forever.
* Single in-flight via the `mutex` — back-to-back triggers collapse
* into one drain pass. A proper connectivity-listener / WorkManager
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
* sign-in + app-open triggers cover the common offline → online
* transition (operator unlocks phone, opens app).
*
* Failed rows stay in the queue with their `attempts` and
* `lastAttemptAt` updated. They get another chance on the next
* trigger; no exponential backoff or max-attempts yet — the rows are
* small (low single-digit count for typical use) so retry-forever is
* cheap.
*/
@Singleton
class MutationReplayer @Inject constructor(
@@ -68,7 +60,6 @@ class MutationReplayer @Inject constructor(
private val authStore: AuthStore,
private val json: Json,
@ApplicationScope private val scope: CoroutineScope,
networkStatus: NetworkStatusController,
retrofit: Retrofit,
) {
private val likesApi: LikesApi = retrofit.create()
@@ -81,8 +72,6 @@ class MutationReplayer @Inject constructor(
private val mutex = Mutex()
private enum class Outcome { SENT, DROP, RETRY }
init {
scope.launch {
authStore.sessionCookie
@@ -90,16 +79,6 @@ class MutationReplayer @Inject constructor(
.distinctUntilChanged()
.collect { drainSafe() }
}
// Server-reachable-again: replay queued writes the moment the
// health signal recovers, so an offline→online transition mid-
// session doesn't wait for the next cold start / re-auth.
scope.launch {
networkStatus.state
.map { it == ServerHealth.Healthy }
.distinctUntilChanged()
.filter { it }
.collect { drainSafe() }
}
}
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
@@ -114,186 +93,193 @@ class MutationReplayer @Inject constructor(
private suspend fun drain() {
val rows = dao.getAll()
// Collapse superseded like-toggles: only the latest desired state per
// (entity) is replayed; older toggles for the same entity are dropped
// unsent. Without this, partial-failure + differential retry could
// replay an older toggle last and invert the final like state.
val superseded = supersededLikeToggleIds(rows)
for (row in rows) {
if (row.id in superseded) {
val sent = runCatching { dispatch(row) }.getOrDefault(false)
if (sent) {
dao.delete(row.id)
continue
}
when (outcomeFor(row)) {
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
} else {
dao.recordAttempt(row.id, Clock.System.now())
}
}
}
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
val latestByEntity = HashMap<String, Long>()
val superseded = HashSet<Long>()
rows.asSequence()
.filter { it.kind == MutationKind.LIKE_TOGGLE }
.forEach { row ->
val decoded = runCatching {
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
}.getOrNull()
if (decoded != null) {
val key = "${decoded.entityType}:${decoded.entityId}"
// `rows` is ascending by id, so a prior entry is always older.
latestByEntity.put(key, row.id)?.let(superseded::add)
}
}
return superseded
}
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
dispatch(row)
} catch (e: HttpException) {
if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
} catch (@Suppress("SwallowedException") e: SerializationException) {
// Corrupt / schema-incompatible payload — permanent, drop it.
Outcome.DROP
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
// Transport / unknown — leave queued for the next pass.
Outcome.RETRY
}
/** 4xx are permanent except the two "retry me" statuses. */
private fun isPermanent(code: Int): Boolean =
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
/**
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
* unknown kind/variant. Network / HTTP failures THROW and are classified
* by [outcomeFor] — dispatchers no longer swallow.
* Returns true if the server accepted the call, false on any
* failure (transport, server error, decode). The replayer treats
* false as "leave the row for next pass" — it doesn't try to
* distinguish permanent rejections from transient ones; a row
* that consistently fails will accumulate attempt count and
* lastAttemptAt for diagnostics.
*/
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
else -> {
// Unknown kind — drop the row by claiming success so a
// stale schema entry can't wedge the queue forever.
true
}
}
private suspend fun dispatchLikeToggle(payload: String): Outcome {
private suspend fun dispatchLikeToggle(payload: String): Boolean {
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
val kindPath = when (decoded.entityType) {
LikesRepository.ENTITY_ARTIST -> "artists"
LikesRepository.ENTITY_ALBUM -> "albums"
LikesRepository.ENTITY_TRACK -> "tracks"
else -> return Outcome.DROP
else -> return true // unknown variant — drop.
}
if (decoded.desiredState) {
likesApi.like(kindPath, decoded.entityId)
} else {
likesApi.unlike(kindPath, decoded.entityId)
return try {
if (decoded.desiredState) {
likesApi.like(kindPath, decoded.entityId)
} else {
likesApi.unlike(kindPath, decoded.entityId)
}
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: the row stays in the queue with
// attempts + lastAttemptAt incremented; next drain pass
// retries. Per-attempt diagnostic logging lands when we
// wire up Timber at the replayer level.
false
}
return Outcome.SENT
}
private suspend fun dispatchRequestCreate(payload: String): Outcome {
private suspend fun dispatchRequestCreate(payload: String): Boolean {
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
discoverApi.createRequest(
CreateRequestBody(
kind = decoded.kind,
artistMbid = decoded.artistMbid,
artistName = decoded.artistName,
albumMbid = decoded.albumMbid,
albumTitle = decoded.albumTitle,
trackMbid = decoded.trackMbid,
trackTitle = decoded.trackTitle,
),
val body = CreateRequestBody(
kind = decoded.kind,
artistMbid = decoded.artistMbid,
artistName = decoded.artistName,
albumMbid = decoded.albumMbid,
albumTitle = decoded.albumTitle,
trackMbid = decoded.trackMbid,
trackTitle = decoded.trackTitle,
)
return Outcome.SENT
return try {
discoverApi.createRequest(body)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: the row stays in the queue with
// attempts + lastAttemptAt incremented; next drain pass
// retries. Per-attempt diagnostic logging lands when we
// wire up Timber at the replayer level.
false
}
}
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
quarantineApi.unflag(decoded.trackId)
return Outcome.SENT
return try {
quarantineApi.unflag(decoded.trackId)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: the row stays in the queue with
// attempts + lastAttemptAt incremented; next drain pass
// retries. Per-attempt diagnostic logging lands when we
// wire up Timber at the replayer level.
false
}
}
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
playlistsApi.appendTracks(
playlistId = decoded.playlistId,
body = AppendTracksRequest(trackIds = decoded.trackIds),
)
return Outcome.SENT
return try {
playlistsApi.appendTracks(
playlistId = decoded.playlistId,
body = AppendTracksRequest(trackIds = decoded.trackIds),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
quarantineApi.flag(
FlagRequest(
trackId = decoded.trackId,
reason = decoded.reason,
notes = decoded.notes,
),
)
return Outcome.SENT
return try {
quarantineApi.flag(
FlagRequest(
trackId = decoded.trackId,
reason = decoded.reason,
notes = decoded.notes,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
private suspend fun dispatchPlayOffline(payload: String): Outcome {
private suspend fun dispatchPlayOffline(payload: String): Boolean {
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
eventsApi.playOffline(
PlayOfflineRequest(
trackId = decoded.trackId,
clientId = decoded.clientId,
at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source,
deviceClass = decoded.deviceClass,
),
)
return Outcome.SENT
return try {
eventsApi.playOffline(
PlayOfflineRequest(
trackId = decoded.trackId,
clientId = decoded.clientId,
at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
private suspend fun dispatchPlayEnded(payload: String): Outcome {
val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
eventsApi.playEnded(
PlayEndedRequest(
playEventId = decoded.playEventId,
durationPlayedMs = decoded.durationPlayedMs,
),
)
return Outcome.SENT
}
private suspend fun dispatchRequestCancel(payload: String): Outcome {
private suspend fun dispatchRequestCancel(payload: String): Boolean {
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
requestsApi.cancel(decoded.requestId)
return Outcome.SENT
return try {
requestsApi.cancel(decoded.requestId)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
PlaybackErrorReportRequest(
trackId = decoded.trackId,
kind = decoded.kind,
detail = decoded.detail,
clientId = decoded.clientId,
),
)
return Outcome.SENT
}
private companion object {
const val HTTP_CLIENT_ERR_MIN = 400
const val HTTP_CLIENT_ERR_MAX = 499
const val HTTP_TIMEOUT = 408
const val HTTP_TOO_MANY = 429
return try {
playbackErrorsApi.report(
PlaybackErrorReportRequest(
trackId = decoded.trackId,
kind = decoded.kind,
detail = decoded.detail,
clientId = decoded.clientId,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
}
@@ -1,10 +1,7 @@
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
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
@@ -18,15 +15,9 @@ 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
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
@@ -59,26 +50,10 @@ class SyncController @Inject constructor(
private val trackDao: CachedTrackDao,
private val authStore: AuthStore,
@ApplicationScope private val scope: CoroutineScope,
networkStatus: NetworkStatusController,
retrofit: Retrofit,
) {
private val api: SyncApi = retrofit.create()
// Single in-flight guard: the cookie + health-recovery triggers (and a
// 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
@@ -86,27 +61,11 @@ class SyncController @Inject constructor(
.distinctUntilChanged()
.collect { syncSafe() }
}
// Re-sync when the server becomes reachable again mid-session, so a
// delta missed while offline lands without waiting for a cold start.
scope.launch {
networkStatus.state
.map { it == ServerHealth.Healthy }
.distinctUntilChanged()
.filter { it }
.collect { syncSafe() }
}
}
/** 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()
}
runCatching { sync() }
}
private suspend fun sync() {
@@ -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,
@@ -39,9 +39,6 @@ import javax.inject.Singleton
* pre-warms the top artists via [HomeArtistPrewarmer].
*/
@Singleton
// Per-section observe accessors (one per Home row) inflate the function
// count past detekt's 11 default — accessor density, not complexity.
@Suppress("TooManyFunctions")
class HomeRepository @Inject constructor(
private val homeIndexDao: CachedHomeIndexDao,
private val metadataProvider: MetadataProvider,
@@ -79,12 +76,6 @@ class HomeRepository @Inject constructor(
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
/**
* Pulls /api/home/index, replaces each cached_home_index section,
* and pre-warms the top artists. The section Flows re-fire on the
@@ -97,11 +88,7 @@ class HomeRepository @Inject constructor(
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
prewarmer.warm(
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
)
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
}
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
@@ -161,7 +148,5 @@ class HomeRepository @Inject constructor(
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
}
}
@@ -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(
@@ -139,8 +116,6 @@ data class HomeSections(
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
) {
val isAllEmpty: Boolean
get() = playlists.isEmpty() &&
@@ -148,9 +123,7 @@ data class HomeSections(
rediscoverAlbums.isEmpty() &&
rediscoverArtists.isEmpty() &&
mostPlayedTracks.isEmpty() &&
lastPlayedArtists.isEmpty() &&
youMightLikeAlbums.isEmpty() &&
youMightLikeArtists.isEmpty()
lastPlayedArtists.isEmpty()
}
// ─── ViewModel ───────────────────────────────────────────────────────
@@ -162,7 +135,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 +161,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 +274,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,38 +285,17 @@ 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)
/**
* The core five section flows merged into the sections struct. Split
* out so each combine call stays inside the coroutines built-in arity
* limit (5) and avoids the typed-vararg acrobatics a wider combine
* across heterogeneous types would need. [observeHomeSections] then
* folds in the two you-might-like flows, and [combineHomeFlows] the
* playlists flow.
* Five home-section flows merged into the sections struct without
* playlists; then merged with the playlists flow in [combineHomeFlows].
* Splitting it like this keeps each combine call inside the
* coroutines built-in arity limit (5) and avoids the typed-vararg
* acrobatics needed for a 6-flow combine across heterogeneous types.
*/
private fun observeCoreSections() = combine(
private fun observeHomeSections() = combine(
homeRepository.observeRecentlyAddedAlbums(),
homeRepository.observeRediscoverAlbums(),
homeRepository.observeRediscoverArtists(),
@@ -404,26 +311,10 @@ class HomeViewModel @Inject constructor(
)
}
private fun observeHomeSections() = combine(
observeCoreSections(),
homeRepository.observeYouMightLikeAlbums(),
homeRepository.observeYouMightLikeArtists(),
) { core, ymlAlbums, ymlArtists ->
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
}
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 +343,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 +452,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,26 +463,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,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
rediscoverSection(
albums = sections.rediscoverAlbums,
@@ -671,35 +478,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,
@@ -889,61 +667,6 @@ private fun RediscoverBlock(
}
}
/**
* "You might like" — in-library albums/artists predicted from the user's
* taste that they don't actively spin (server section, daily-built, cold-
* start gated). Sits directly under the system-playlists row. Same
* albums-then-artists block shape as Rediscover; the artist sub-row drops
* its title when an album sub-row precedes it so the two read as one section.
*/
private fun LazyListScope.youMightLikeSection(
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
item {
if (albums.isNotEmpty() || artists.isNotEmpty()) {
YouMightLikeBlock(
albums = albums,
artists = artists,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
} else {
EmptySection(
title = "You might like",
body = "We're still learning your taste — keep listening and " +
"picks you might like will show up here.",
)
}
}
}
@Composable
private fun YouMightLikeBlock(
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
Column {
if (albums.isNotEmpty()) {
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
}
if (artists.isNotEmpty()) {
val title = if (albums.isEmpty()) "You might like" else ""
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
}
}
}
@Composable
private fun AlbumsRow(
title: String,
@@ -969,14 +692,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 +749,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 +780,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 +792,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 +805,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 +829,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,
@@ -1,19 +0,0 @@
package com.fabledsword.minstrel.models
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
data class AdminTagSourceRef(
val id: String,
val displayName: String,
val requiresApiKey: Boolean,
val supports: List<String>,
val enabled: Boolean,
val apiKeySet: Boolean,
val testable: Boolean,
)
/** Result of a provider "test connection" call. */
data class TagSourceTestResult(
val ok: Boolean,
val durationMs: Long = 0,
val error: String = "",
)
@@ -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,
)
@@ -1,35 +0,0 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
* Every field defaults defensively so a missing JSON key never crashes
* deserialization. `versionBumped` is present only on the PATCH response.
*/
@Serializable
data class AdminTagSourceWire(
val id: String = "",
@SerialName("display_name") val displayName: String = "",
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
val supports: List<String> = emptyList(),
val enabled: Boolean = false,
@SerialName("api_key_set") val apiKeySet: Boolean = false,
@SerialName("display_order") val displayOrder: Int = 0,
val testable: Boolean = false,
@SerialName("version_bumped") val versionBumped: Boolean = false,
)
@Serializable
data class AdminTagSourcesListWire(
val providers: List<AdminTagSourceWire> = emptyList(),
@SerialName("sources_version") val sourcesVersion: Int = 0,
)
@Serializable
data class TestTagSourceWire(
val ok: Boolean = false,
@SerialName("duration_ms") val durationMs: Long = 0,
val error: String = "",
)
@@ -1,9 +1,5 @@
@file:OptIn(ExperimentalSerializationApi::class)
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -19,16 +15,10 @@ import kotlinx.serialization.Serializable
@Serializable
data class PlayStartedRequest(
// Discriminator MUST stay on the wire. The app's Json has
// encodeDefaults=false, so without this a `type` left at its
// default is omitted entirely and the server 400s the event as
// "unknown event type" — silently dropping every native play.
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
val type: String = "play_started",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
)
@Serializable
@@ -38,26 +28,24 @@ data class PlayStartedResponse(
@Serializable
data class PlayEndedRequest(
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
val type: String = "play_ended",
@SerialName("play_event_id") val playEventId: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
)
@Serializable
data class PlaySkippedRequest(
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
val type: String = "play_skipped",
@SerialName("play_event_id") val playEventId: String,
@SerialName("position_ms") val positionMs: Long,
)
@Serializable
data class PlayOfflineRequest(
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
val type: String = "play_offline",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val at: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
)
@@ -10,17 +10,13 @@ import kotlinx.serialization.Serializable
* `internal/api/types.go HomeIndexPayload`).
*
* Section name implies entity type; no per-entry type tag is needed:
* - recentlyAddedAlbums → album
* - rediscoverAlbums → album
* - rediscoverArtists → artist
* - mostPlayedTracks → track
* - lastPlayedArtists → artist
* - youMightLikeAlbums → album
* - youMightLikeArtists → artist
* - recentlyAddedAlbums → album
* - rediscoverAlbums → album
* - rediscoverArtists → artist
* - mostPlayedTracks → track
* - lastPlayedArtists → artist
*
* All slices default to empty so missing keys don't throw on decode
* which also keeps the client safe against older servers that don't
* emit the you-might-like sections yet.
* All slices default to empty so missing keys don't throw on decode.
*/
@Serializable
data class HomeIndexWire(
@@ -29,6 +25,4 @@ data class HomeIndexWire(
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
)
@@ -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,
)
@@ -16,7 +16,6 @@ import androidx.navigation.compose.composable
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
import com.fabledsword.minstrel.auth.ui.LoginScreen
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
@@ -191,13 +190,6 @@ private fun NavGraphBuilder.inShellDetail(
}
}
}
composable<AdminTagSources> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
AdminTagSourcesScreen(navController = navController)
}
}
}
}
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
@@ -22,7 +22,6 @@ import kotlinx.serialization.Serializable
@Serializable data object AdminRequests
@Serializable data object AdminQuarantine
@Serializable data object AdminUsers
@Serializable data object AdminTagSources
// ── Outside-shell full-screen routes ──────────────────────────────────
@@ -1,77 +0,0 @@
package com.fabledsword.minstrel.player
import android.content.Context
import android.net.wifi.WifiManager
import android.os.Build
import android.os.PowerManager
import timber.log.Timber
/**
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
*
* While casting, the wrapped ExoPlayer is paused, so its automatic
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
* awake. On a locked, on-battery phone the WiFi enters power-save within
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
* renderer and the queue-extend calls to the server. The poll then trips the
* drop threshold and playback falls back to a phone that *also* has no
* network — silence, while the renderer was streaming fine the whole time.
*
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
* coroutine scheduled. Both are acquired when a route goes active and released
* the moment it drops or the user switches back to the phone.
*
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
* synchronized, so they are safe to call from any thread.
*/
class CastNetworkLock(context: Context) {
private val appContext = context.applicationContext
private val wifiLock: WifiManager.WifiLock? =
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
?.createWifiLock(wifiLockMode(), LOCK_TAG)
?.apply { setReferenceCounted(false) }
private val wakeLock: PowerManager.WakeLock? =
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
?.apply { setReferenceCounted(false) }
@Synchronized
fun acquire() {
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
Timber.w(
"CastNetworkLock acquired (wifi=%s wake=%s)",
wifiLock?.isHeld, wakeLock?.isHeld,
)
}
@Synchronized
fun release() {
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
Timber.w("CastNetworkLock released")
}
private companion object {
const val LOCK_TAG = "minstrel:upnp-cast"
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
// the radio fully awake.
fun wifiLockMode(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
@Suppress("DEPRECATION")
WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
}
}
@@ -10,20 +10,14 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
@@ -45,31 +39,21 @@ import timber.log.Timber
* wrapped ExoPlayer stays paused at the position it had when the
* route was selected.
*
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
* factory wraps that callback into a SharedFlow consumed by the
* NowPlaying surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop keeps the local cursor aligned to the
* track the renderer is actually playing, matched by track identity
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
* index drift -- see [syncLocalCursorToRemote].
* advance natively. PollLoop syncs the local cursor by comparing the
* 1-based Track index from GetPositionInfo.
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController,
private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) {
@@ -123,20 +107,6 @@ class MinstrelForwardingPlayer(
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
}
scope.launch {
// When the phone's own network recovers, reconcile a held cast
// session immediately instead of waiting up to POLL_INTERVAL_MS:
// the renderer kept playing, so one round-trip re-confirms it and
// resyncs the cursor/position. Only a transition *into* Healthy
// while a route is active matters.
var prev = networkStatus.state.value
networkStatus.state.collect { next ->
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
pollTrigger.trySend(Unit)
}
prev = next
}
}
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
// addObserver requires it. The observer just trySend's to the channel.
handler.post {
@@ -204,12 +174,12 @@ class MinstrelForwardingPlayer(
} else {
remoteState.setPlayIntent(true)
scope.launch {
runCatching { retryTransport { active.avTransport.play() } }
runCatching { active.avTransport.play() }
.onSuccess {
remoteState.applyTransportPlaying()
notifyRemoteStateChanged()
}
.onFailure { handleTransportFailure(active, it) }
.onFailure { handleSoapFailure(active, it) }
}
}
}
@@ -226,12 +196,12 @@ class MinstrelForwardingPlayer(
} else {
remoteState.setPlayIntent(false)
scope.launch {
runCatching { retryTransport { active.avTransport.pause() } }
runCatching { active.avTransport.pause() }
.onSuccess {
remoteState.applyTransportPaused()
notifyRemoteStateChanged()
}
.onFailure { handleTransportFailure(active, it) }
.onFailure { handleSoapFailure(active, it) }
}
}
}
@@ -254,8 +224,8 @@ class MinstrelForwardingPlayer(
trackNumber = remoteState.trackNumber,
)
scope.launch {
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
.onFailure { handleTransportFailure(active, it) }
runCatching { active.avTransport.seek(positionMs) }
.onFailure { handleSoapFailure(active, it) }
}
}
}
@@ -285,13 +255,11 @@ class MinstrelForwardingPlayer(
)
scope.launch {
runCatching {
retryTransport {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}.onFailure { handleTransportFailure(active, it) }
}.onFailure { handleSoapFailure(active, it) }
}
}
@@ -314,8 +282,8 @@ class MinstrelForwardingPlayer(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.next() } }
.onFailure { handleTransportFailure(active, it) }
runCatching { active.avTransport.next() }
.onFailure { handleSoapFailure(active, it) }
}
}
@@ -337,28 +305,11 @@ class MinstrelForwardingPlayer(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.previous() } }
.onFailure { handleTransportFailure(active, it) }
runCatching { active.avTransport.previous() }
.onFailure { handleSoapFailure(active, it) }
}
}
// The system media notification / lock-screen / Android Auto / Wear 'next'
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
// base ForwardingPlayer forwards these to the paused local delegate -- so
// the notification next/prev only nudged the local cursor (which the poll
// then re-synced back to Sonos), reading as dead buttons while casting.
// Route them through the same Sonos path as the media-item variants when a
// UPnP route is engaged; plain local playback keeps the default behaviour.
override fun seekToNext() {
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
}
override fun seekToPrevious() {
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
}
override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
@@ -436,34 +387,11 @@ class MinstrelForwardingPlayer(
super.release()
}
/**
* Run a transport SOAP block, retrying transport-level (IO) failures a
* few times with backoff. A locked phone's WiFi power-save can stall the
* first socket I/O for a second or two; a retry lets the command land once
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
* renderer answered and rejected the action) is NOT retried -- the device
* is alive and retrying won't change its verdict.
*/
private suspend fun <T> retryTransport(block: suspend () -> T): T =
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
/**
* A transport SOAP command failed even after retries. Deliberately does
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
* WiFi power-save can fail one command while the renderer is perfectly
* reachable; dropping on a single command falsely kicked playback back to
* the phone. We log, leave the poll loop running, and nudge an immediate
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
* truly gone, the poll loop trips the drop on its own.
*/
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
if (t is SoapFaultException) {
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
} else {
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
}
pollTrigger.trySend(Unit)
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
remoteState.applyError(t)
handler.post { onDrop(active.routeName) }
}
private fun onActiveChanged(active: ActiveUpnp?) {
@@ -476,11 +404,6 @@ class MinstrelForwardingPlayer(
lastNotifiedTrackIdx = -1
if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
// without this the radio power-saves on a locked screen and the
// poll below starves -- see [CastNetworkLock].
castNetworkLock.acquire()
// Pause the wrapped ExoPlayer so we are not playing local audio
// simultaneously with the remote renderer. handler.post targets the
// application looper, so this runs on the same thread that processes
@@ -490,26 +413,20 @@ class MinstrelForwardingPlayer(
handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) }
} else {
castNetworkLock.release()
remoteState.reset()
}
}
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
private suspend fun pollLoop(active: ActiveUpnp) {
var networkDropSuppressed = false
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching { pollOnce(active) }
if (outcome.isSuccess) {
remoteState.recordPollSuccess()
networkDropSuppressed = false
} else if (remoteState.recordPollFailure()) {
if (networkStatus.state.value == ServerHealth.Healthy) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
return
}
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
return
}
// Race the normal cadence against any external wake (activity
// resume). Whichever wins continues to the next pollOnce.
@@ -520,34 +437,20 @@ class MinstrelForwardingPlayer(
}
}
/**
* The poll-failure threshold tripped, but the phone's *own* network is down
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
* renderer right now," not "the renderer died." A UPnP renderer streams
* autonomously and is almost certainly still playing; meanwhile a local
* fallback couldn't reach the server either, so dropping would only blast
* local audio out of a pocketed phone while the speaker keeps going. Hold
* the route and keep polling -- a successful poll once the network returns
* reconciles to the renderer's real state. Clear the failure streak so
* recovery re-evaluates the renderer from scratch instead of re-dropping on
* the first post-recovery hiccup. Returns the (latched) suppression flag so
* the rationale logs once per outage, not every tick.
*/
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
if (!alreadySuppressed) {
Timber.w(
"UPnP drop suppressed for %s -- phone network %s, holding route",
active.routeName, networkStatus.state.value,
)
}
remoteState.recordPollSuccess() // clear streak; not a renderer failure
return true
}
/**
* One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and sync the local cursor to the track the renderer is
* actually playing (see [syncLocalCursorToRemote]).
* [remoteState], and forward-sync the local cursor to Sonos's Track
* index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
@@ -563,7 +466,7 @@ class MinstrelForwardingPlayer(
trackUri = info.trackUri,
trackNumber = info.track,
)
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
maybeSyncLocalCursor(info.track)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> {
@@ -587,95 +490,23 @@ class MinstrelForwardingPlayer(
notifyRemoteStateChanged()
}
/**
* Align the paused local delegate cursor to the track the renderer is
* actually playing, so the un-overridden current-item getters
* (getCurrentMediaItem/Index -- what both the in-app player UI via
* PlayerController.onEvents AND the MediaSession notification read) always
* resolve to that track. This is THE single source of truth for "what's
* playing" during a cast.
*
* Identity-first: match the track-id embedded in the renderer's current URI
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
* can never park the cursor on a stale (pre-cast) track. Falls back to the
* 1-based Sonos Track index only when the URI can't be resolved to a queue
* item. Unlike the old forward-only sync this moves the cursor in BOTH
* directions -- a renderer Previous / re-cast to a lower track pulls it back
* too. Runs on the application looper (delegate access contract).
*
* Suppressed while:
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
* (re)uploaded, so its Track index is meaningless; and
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
* the override already advanced the delegate optimistically, and Sonos
* still reports the OLD track for a beat, so syncing now would undo the
* user's press. The pending deadline is honoured directly so a missed
* clear can't wedge the sync.
*/
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
if (isLoadingUpnp()) return
val pendingDeadline = remoteState.pendingTransportDeadlineMs
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
private fun maybeSyncLocalCursor(sonosTrack: Int) {
if (holder.target.value != null) return
if (sonosTrack <= 0) return
val sonosIdx = sonosTrack - 1
handler.post {
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
if (target != delegate.currentMediaItemIndex) {
val localIdx = delegate.currentMediaItemIndex
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
Timber.w(
"UPnP cursor sync: local=%d -> %d",
delegate.currentMediaItemIndex, target,
"UPnP cursor catch-up: local=%d -> sonos=%d",
localIdx, sonosIdx,
)
delegate.seekTo(target, 0L)
delegate.seekTo(sonosIdx, 0L)
}
}
}
/**
* Most-authoritative -> most-degraded (project rule #48): the track-id
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
* index; else null (leave the cursor put rather than surface a wrong track).
* Must run on the application looper -- reads the delegate timeline.
*/
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
}
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
}
/**
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
* occurrence nearest [preferNear] so a track that appears twice in the queue
* doesn't snap the cursor across the queue. null if the id isn't present.
*/
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
var best: Int? = null
var bestDist = Int.MAX_VALUE
for (i in 0 until delegate.mediaItemCount) {
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
val dist = abs(i - preferNear)
if (dist < bestDist) {
best = i
bestDist = dist
}
}
}
return best
}
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
private fun trackIdFromStreamUri(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private companion object {
// Path segment that precedes the track-id in a stream URI
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
// back to a delegate MediaItem by identity.
const val TRACKS_PATH_MARKER = "/api/tracks/"
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -684,39 +515,5 @@ class MinstrelForwardingPlayer(
// when Sonos's reported Track matches the wrapped player; this only
// kicks in if SOAP fails or Sonos drops the ack entirely.
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
// Transport commands retry transient IO failures so a single WiFi
// power-save stall (locked phone) doesn't abandon the command. 3
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
// worst case at ~7s; a still-failing command then defers to the poll.
const val TRANSPORT_RETRY_ATTEMPTS = 3
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
}
}
/**
* Retry [block] on transient transport-level ([IOException]) failures, with
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
* an [IOException] -- notably [SoapFaultException], where the renderer
* answered and rejected the action -- propagates immediately: the device is
* alive, so retrying won't change its verdict. Extracted from
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
* abandon a single transport command (the bug that falsely reverted Sonos
* playback to local audio).
*/
internal suspend fun <T> retryTransientIo(
attempts: Int,
backoffMs: Long,
block: suspend () -> T,
): T {
var attempt = 0
while (true) {
try {
return block()
} catch (io: IOException) {
attempt += 1
if (attempt >= attempts) throw io
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
delay(backoffMs)
}
}
}
@@ -6,7 +6,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
@@ -65,24 +64,11 @@ class PlayEventsReporter @Inject constructor(
private var curLastPositionMs: Long = 0
private var curDurationMs: Long = 0
// Whether the currently-tracked play is actively playing (vs paused).
// onStop reads this: a backgrounded play that's still playing is left
// to the live path (the foreground service keeps us alive), while a
// paused one is durably closed so a kill can't orphan its open row.
private var curPlaying: Boolean = false
// Server play_event_id when the live play_started succeeded;
// null if start failed/offline — then the close is captured into
// the offline mutation queue instead of a live ended/skipped.
private var openPlayEventId: String? = null
// True from beginTrack until startLive's play_started resolves. When a
// close races an in-flight start (id still null), the server row is
// being created but we've lost its id — we must NOT also enqueue an
// offline play (that would duplicate); the server auto-closes the
// orphan on the next play_started instead.
private var startInFlight: Boolean = false
private var prevTrackId: String? = null
init {
@@ -109,7 +95,7 @@ class PlayEventsReporter @Inject constructor(
) {
// Track-change → close the prior tracked play.
if (trackId != prevTrackId && curTrackId != null) {
closeCurrent()
closeCurrent(viaOffline = false)
}
// Entered playing for a new track → begin tracking it.
@@ -124,7 +110,6 @@ class PlayEventsReporter @Inject constructor(
if (curTrackId != null && trackId == curTrackId) {
curLastPositionMs = positionMs
if (durationMs > 0) curDurationMs = durationMs
curPlaying = playing
}
prevTrackId = trackId
@@ -136,18 +121,12 @@ class PlayEventsReporter @Inject constructor(
curSource = source
curLastPositionMs = 0
curDurationMs = durationMs
curPlaying = true
openPlayEventId = null
startInFlight = true
startLive(trackId = trackId, source = source)
}
private fun startLive(trackId: String, source: String?) {
val cid = resolveClientId()
if (cid == null) {
startInFlight = false
return
}
val cid = resolveClientId() ?: return
scope.launch {
try {
val response = api.playStarted(
@@ -155,7 +134,6 @@ class PlayEventsReporter @Inject constructor(
trackId = trackId,
clientId = cid,
source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
),
)
if (curTrackId == trackId) {
@@ -166,72 +144,56 @@ class PlayEventsReporter @Inject constructor(
) {
// Offline / flaky — openPlayEventId stays null; close
// path will enqueue the completed play for replay.
} finally {
startInFlight = false
}
}
}
/**
* Closes the tracked play. Routes by whether the server already has an
* open row, NOT by caller context — this is what keeps background /
* track-change / failure paths from each minting a second row:
* - open server row (id) → close it by id; on failure enqueue a durable
* close-by-id (never an offline play).
* - start in flight (no id yet) → leave it; the server auto-closes the
* orphan on the next play_started. Enqueuing offline here would
* duplicate the in-flight row.
* - no server row at all → record the whole play offline.
*
* Always uses the actual position played; the server's skip rule
* (50%/30s defaults) classifies was_skipped from it.
*/
private fun closeCurrent() {
private fun closeCurrent(viaOffline: Boolean) {
val trackId = curTrackId
val startedAt = curStartedAt
if (trackId == null || startedAt == null) {
resetCurrent()
return
}
// Always send the actual position played to; the server's
// skip rule (50%/30s defaults, configurable) classifies
// was_skipped from this. If the track ran to completion the
// last observed position is ~= duration, which gives ratio ~1
// and the server records a non-skip. The COMPLETION_TOLERANCE
// rounding the prior shape did is no longer needed — the
// server's threshold is the source of truth.
val durationMs = curLastPositionMs
val source = curSource
val id = openPlayEventId
val inFlight = startInFlight
when {
id != null -> closeById(id, durationMs)
inFlight -> Unit
else -> enqueueOffline(trackId, startedAt, source, durationMs)
if (!viaOffline && id != null) {
scope.launch {
try {
api.playEnded(
PlayEndedRequest(
playEventId = id,
durationPlayedMs = durationMs,
),
)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
enqueueOffline(trackId, startedAt, source, durationMs)
}
}
} else {
enqueueOffline(trackId, startedAt, source, durationMs)
}
resetCurrent()
}
private fun closeById(id: String, durationMs: Long) {
scope.launch {
try {
api.playEnded(
PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Durable close-by-id (idempotent server-side) — survives a
// process kill without orphaning the open row or duplicating.
mutationQueue.enqueuePlayEnded(
PlayEndedPayload(playEventId = id, durationPlayedMs = durationMs),
)
}
}
}
private fun resetCurrent() {
curTrackId = null
curStartedAt = null
curSource = null
curLastPositionMs = 0
curDurationMs = 0
curPlaying = false
openPlayEventId = null
startInFlight = false
}
private fun enqueueOffline(
@@ -249,7 +211,6 @@ class PlayEventsReporter @Inject constructor(
atIso = startedAt.toString(),
durationPlayedMs = durationPlayedMs,
source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
),
)
}
@@ -270,24 +231,9 @@ class PlayEventsReporter @Inject constructor(
// ── Lifecycle: durable-close on app background / detach ────────
companion object {
// #1551: Android is a mobile client. Refine to tablet/tv via device
// configuration later if the metrics trend view shows it matters.
private const val DEVICE_CLASS = "mobile"
}
override fun onStop(owner: LifecycleOwner) {
// App backgrounded. If the tracked play is still PLAYING, leave it
// alone: the foreground media service keeps the process — and this
// app-scope uiState collector — alive, so the live state machine
// closes the play accurately on the next real track change. Closing
// here (the old always-offline behavior) raced the collector's
// re-begin and minted a duplicate, skip-misclassified row on every
// screen-lock. Only durably close a PAUSED play, where no further
// progress will happen and a process kill would otherwise orphan the
// open server row. See docs/superpowers/2026-06-11 contract audit.
if (curTrackId != null && !curPlaying) {
closeCurrent()
if (curTrackId != null) {
closeCurrent(viaOffline = true)
}
}
}
@@ -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
@@ -202,20 +192,6 @@ class PlayerController @Inject constructor(
controller.play()
}
/**
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
* Used by the UPnP adopt path: when attaching to a renderer that's already
* playing our queue, we sync the local cursor to the renderer's current
* track/position so skip/seek and the UI map onto its live session — the
* renderer keeps the audio, the local player stays paused. Unlike
* [seekToIndex] this issues no play().
*/
fun moveCursorTo(index: Int, positionMs: Long) {
val controller = mediaController ?: return
if (index !in queueRefs.indices) return
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
}
/**
* Replace the queue with [tracks] starting at [initialIndex].
* [source] tags the queue with its origin (e.g. "for_you") so the
@@ -361,12 +337,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 +353,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 +403,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(
@@ -598,19 +506,18 @@ class PlayerController @Inject constructor(
}
/**
* One position-polling tick. Patches ONLY the smoothly-changing transport
* fields (position / duration / play-pause / buffer) via `.copy()`. The
* queue index + current track are owned solely by [onEvents], which reads
* the authoritative delegate cursor: during a cast,
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
* the track the renderer is actually playing (matched by identity), so there
* is exactly one writer of "what's playing" and a stale tick can never
* revert it to the old track. (Previously the tick was a second index writer
* that fought onEvents — the flicker-to-stale-track bug.)
* One position-polling tick. Owns track-change detection too: when UPnP
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
* uiState.queueIndex stuck on the old track even after Sonos has
* advanced. So the tick reads Sonos's reported Track as the source of
* truth, rebuilds the index/title fields itself, and force-syncs the
* wrapped player as defense in depth.
*/
private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) {
@@ -618,29 +525,27 @@ class PlayerController @Inject constructor(
} else {
controller.currentPosition
}
val idx = controller.currentMediaItemIndex
val desiredIdx = desiredQueueIndex(controller, upnpActive)
val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration(
upnpActive,
remoteState.durationMs,
controller.duration,
desiredIdx = idx,
controllerIdx = idx,
desiredIdx = desiredIdx,
controllerIdx = controller.currentMediaItemIndex,
)
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur ||
current.bufferedPositionMs != newBuf
if (somethingChanged) {
uiStateInternal.value = current.copy(
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
}
// Track adjustments are forward-only AND suppressed while a user
// transport press is pending Sonos confirmation. Together those keep
// either direction of user input from being undone by a stale poll.
val trackChanged = !pendingTransport &&
desiredIdx > current.queueIndex &&
desiredIdx in queueRefs.indices
publishTickIfChanged(
current, trackChanged, desiredIdx,
effectiveIsPlaying, newPos, newDur, newBuf,
)
}
/**
@@ -657,6 +562,48 @@ class PlayerController @Inject constructor(
}
}
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
private fun publishTickIfChanged(
current: PlayerUiState,
trackChanged: Boolean,
desiredIdx: Int,
effectiveIsPlaying: Boolean,
newPos: Long,
newDur: Long,
newBuf: Long,
) {
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur
if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
// Intentionally do NOT call controller.seekTo here. That would route
// through MinstrelForwardingPlayer's seekTo override and re-issue
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
// start of the same track it's already playing, restarting the song.
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
// delegate.seekTo (which bypasses the override). If it lags briefly,
// the next pollOnce catches up; the uiState above already reflects
// Sonos's truth for the user.
}
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
if (upnpActive) {
(remoteState.trackNumber - 1).coerceAtLeast(0)
} else {
controller.currentMediaItemIndex
}
// ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
@@ -802,10 +749,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
@@ -81,8 +81,6 @@ class PlayerFactory @Inject constructor(
delegate = exo,
holder = activeUpnpHolder,
remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth,
onDrop = { name -> emitDrop(name) },
)
}
@@ -11,13 +11,11 @@ import javax.inject.Singleton
* Updates flow in from:
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
* - Poll failures -> recordPollFailure()
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
*
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
* The poll-failure counter implements the rolling-3 drop heuristic: 3
* consecutive poll failures = remote considered dropped (returns true
* from recordPollFailure for the caller to surface). Any success resets it.
* Failed transport commands no longer drop the route -- they retry and
* otherwise defer to this counter (see MinstrelForwardingPlayer).
* from recordPollFailure for the caller to surface). Success resets it.
*/
@Singleton
class RemotePlayerState @Inject constructor() {
@@ -15,7 +15,6 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -30,7 +29,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@@ -164,8 +162,7 @@ class OutputPickerController @Inject constructor(
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback)
// When MinstrelForwardingPlayer reports the active UPnP route has
// dropped (the poll loop's consecutive-failure threshold tripping --
// the sole drop arbiter; a failed transport command no longer drops),
// dropped (3+ consecutive poll failures or a transport SOAP exception),
// clear the UPnP selection state and fall back to local ExoPlayer at
// the last-known remote position. This mirrors selectSystem's disconnect
// path but skips the Stop SOAP since the device is already unreachable.
@@ -174,31 +171,6 @@ class OutputPickerController @Inject constructor(
}
scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeIdleRevertWhileUpnp() }
scope.launch { observeSelectedRouteDisappearance() }
}
/**
* Revert the UPnP selection to the phone when the selected renderer leaves
* discovery (powered off / off the LAN) while we are NOT actively casting
* to it. Without this the selection could pin to a ghost route, so a later
* "play" would target a speaker that's gone. An *active* cast is left alone
* -- its own poll loop arbitrates liveness and won't drop on a mere
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
* post-picker-scan prune; this reacts to the resulting routes change.
*/
private suspend fun observeSelectedRouteDisappearance() {
upnpDiscovery.routes.collect { routes ->
val selected = selectedUpnpRouteIdInternal.value
val present = routes.any { it.id == selected }
val casting = activeUpnpHolder.active.value?.routeId == selected
if (selected != null && !present && !casting) {
Timber.w(
"Output: selected UPnP route %s left discovery; reverting to phone",
selected,
)
selectedUpnpRouteIdInternal.value = null
}
}
}
/**
@@ -448,9 +420,9 @@ class OutputPickerController @Inject constructor(
}
/**
* Called when the active UPnP route drops unexpectedly (the poll loop's
* consecutive-failure threshold tripping). Captures the last remote
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
* Called when the active UPnP route drops unexpectedly (poll-failure
* threshold or SOAP exception). Captures the last remote position +
* play state, clears UPnP selection, and resumes local ExoPlayer at
* the same point. Skips the Stop SOAP (device already unreachable).
* The snackbar is handled independently by the NowPlaying surface
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
@@ -568,13 +540,6 @@ class OutputPickerController @Inject constructor(
// local audio while we queue up Sonos.
playerController.pause()
selectedUpnpRouteIdInternal.value = effectiveRoute.id
// If the renderer is already playing this exact queue (a "stray"
// session we got disconnected from), attach to it in place instead of
// wiping + reloading -- no jarring restart, and skip/seek land on its
// live queue. Falls through to the clear+reload path when it isn't ours.
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
return@withLock
}
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
// silently while target is set but active is null -- the user's premature
// taps don't hit Sonos's stale state from a prior session.
@@ -601,102 +566,6 @@ class OutputPickerController @Inject constructor(
}
}
/**
* The renderer's current position + transport state, validated as an
* adoptable continuation of our local queue: same track id at the same
* 1-based index. Null means "not ours / not playing" -> caller reloads.
*/
private data class AdoptPlan(
val localIndex: Int,
val positionMs: Long,
val durationMs: Long,
val trackUri: String,
val trackNumber: Int,
val playing: Boolean,
)
/**
* Attach to a renderer that is *already* playing our queue (a session the
* phone got disconnected from but the speaker kept streaming) instead of
* clearing + reloading it. Probes the renderer's live state, and if it's
* mid-playback on the same track id at the same queue index, syncs the
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
* skips a beat, and skip/seek now drive its live queue. Returns false (and
* the caller does the full clear+reload) when the renderer isn't playing
* our queue. The local player is already paused by the caller.
*/
private suspend fun tryAdoptRunningSession(
transport: AVTransportClient,
rendering: RenderingControlClient?,
route: OutputRoute,
localQueue: List<TrackRef>,
): Boolean {
val plan = probeAdoptable(transport, route, localQueue) ?: return false
Timber.w(
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
route.name, plan.trackNumber, plan.positionMs,
)
// active is still null here, so this aligns the *local* cursor without
// emitting SOAP; the holder.set below then starts the poll loop, which
// reconciles to the renderer's advancing position from here.
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
remoteState.applyPositionInfo(
positionMs = plan.positionMs,
durationMs = plan.durationMs,
trackUri = plan.trackUri,
trackNumber = plan.trackNumber,
)
remoteState.setPlayIntent(plan.playing)
activeUpnpHolder.set(
ActiveUpnp(
routeId = route.id,
routeName = route.name,
avTransport = transport,
rendering = rendering,
),
)
return true
}
private suspend fun probeAdoptable(
transport: AVTransportClient,
route: OutputRoute,
localQueue: List<TrackRef>,
): AdoptPlan? {
val probe = runCatching {
transport.getTransportInfo() to transport.getPositionInfo()
}.getOrElse {
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
return null
}
val (info, pos) = probe
val idx0 = pos.track - 1
val playingId = trackIdFromStreamUrl(pos.trackUri)
val midPlayback =
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
val aligned = midPlayback && playingId != null &&
idx0 in localQueue.indices && localQueue[idx0].id == playingId
return if (aligned) {
AdoptPlan(
localIndex = idx0,
positionMs = pos.relTimeMs,
durationMs = pos.trackDurationMs,
trackUri = pos.trackUri,
trackNumber = pos.track,
playing = info.state == TransportState.PLAYING,
)
} else {
null
}
}
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
private fun trackIdFromStreamUrl(url: String): String? {
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
val i = segments.indexOf("tracks")
return if (i >= 0) segments.getOrNull(i + 1) else null
}
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
@@ -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,
)
}
}
@@ -8,7 +8,6 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -18,7 +17,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -60,12 +58,6 @@ class UpnpDiscoveryController @Inject constructor(
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
// post-active-scan pruning so a powered-off renderer leaves the picker
// (and can't pin a stale selection) instead of lingering for the process
// lifetime -- upsert only ever added before.
private val lastSeenMs = ConcurrentHashMap<String, Long>()
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
@@ -86,31 +78,7 @@ class UpnpDiscoveryController @Inject constructor(
* that just opened).
*/
fun upgradeDiscovery() {
appScope.launch {
val scanStartMs = System.currentTimeMillis()
ssdp.requestActiveScan()
// Give present devices time to answer the M-SEARCH (+ our follow-up
// description fetch), then drop any route that didn't re-announce.
// Pruning is tied to picker-open scans only -- no background timer,
// so route rows never flicker while the user isn't looking.
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
pruneRoutesNotSeenSince(scanStartMs)
}
}
/**
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
* -- i.e. they didn't answer the active scan that just ran, so they're
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
*/
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
val before = routesInternal.value
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
if (survivors.size == before.size) return
val removed = before.filterNot { it in survivors }.map { it.id }
removed.forEach { lastSeenMs.remove(it) }
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
routesInternal.value = survivors
appScope.launch { ssdp.requestActiveScan() }
}
/**
@@ -147,7 +115,6 @@ class UpnpDiscoveryController @Inject constructor(
}
private fun upsertRoute(route: UpnpRoute) {
lastSeenMs[route.id] = System.currentTimeMillis()
val current = routesInternal.value
val idx = current.indexOfFirst { it.id == route.id }
routesInternal.value = if (idx < 0) {
@@ -273,11 +240,6 @@ class UpnpDiscoveryController @Inject constructor(
// trips the drop-recovery quickly. Conservative enough not to false-drop
// a slow-but-alive LAN; read timeout still inherits the shared client.
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
// Grace window after an M-SEARCH burst before pruning non-responders.
// Covers the SSDP round-trip plus our per-device description fetch;
// generous enough that a present-but-slow renderer isn't false-pruned.
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
}
}
@@ -1,16 +1,11 @@
package com.fabledsword.minstrel.player.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -27,26 +22,11 @@ import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val controller: PlayerController,
private val likes: LikesRepository,
) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
/**
* Reactive set of liked track ids — the set-based pattern the queue
* (and playlist/album/artist detail) uses to color each row's
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
* per-row Flow subscription.
*/
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = emptySet(),
)
fun play() = controller.play()
fun pause() = controller.pause()
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
@@ -55,13 +35,4 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat()
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
}
}
}
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -37,7 +36,6 @@ import com.composables.icons.lucide.Volume2
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -46,7 +44,6 @@ fun QueueScreen(
viewModel: PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -70,9 +67,7 @@ fun QueueScreen(
QueueList(
tracks = state.queue,
currentIndex = state.queueIndex,
likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack,
)
}
}
@@ -83,25 +78,14 @@ fun QueueScreen(
private fun QueueList(
tracks: List<TrackRef>,
currentIndex: Int,
likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit,
) {
// Open scrolled to the now-playing track so it's in view immediately.
// Seeding the initial index (rather than animating post-layout) avoids a
// flash of the list top; it's captured once per entry, so the view stays
// put as the track later auto-advances — matching "show me where I am now."
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
)
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
isCurrent = index == currentIndex,
liked = track.id in likedTrackIds,
onClick = { onJumpTo(index) },
onToggleLike = { onToggleLike(track.id) },
)
HorizontalDivider()
}
@@ -109,13 +93,7 @@ private fun QueueList(
}
@Composable
private fun QueueRow(
track: TrackRef,
isCurrent: Boolean,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
) {
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else {
@@ -164,7 +142,6 @@ private fun QueueRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LikeButton(liked = liked, onToggle = onToggleLike)
}
}
@@ -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),
)
@@ -1,65 +0,0 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* Locks in the transport-command retry semantics behind the locked-phone
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
* SOAP fault from a responding renderer must NOT be retried, and an
* exhausted retry must rethrow rather than loop forever. Without this, a
* single failed command falsely reverted Sonos playback to the phone.
*/
class RetryTransientIoTest {
@Test
fun `returns immediately on first success`() = runTest {
var calls = 0
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
"ok"
}
assertEquals("ok", result)
assertEquals(1, calls)
}
@Test
fun `retries transient IO failure then succeeds`() = runTest {
var calls = 0
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
if (calls < 2) throw IOException("connect timeout")
"ok"
}
assertEquals("ok", result)
assertEquals(2, calls)
}
@Test
fun `rethrows IO failure after exhausting attempts`() = runTest {
var calls = 0
assertFailsWith<IOException> {
retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
throw IOException("still asleep")
}
}
assertEquals(3, calls)
}
@Test
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
var calls = 0
assertFailsWith<SoapFaultException> {
retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
throw SoapFaultException("718", "Invalid InstanceID")
}
}
assertEquals(1, calls)
}
}
+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 -46
View File
@@ -15,7 +15,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -26,14 +25,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/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() {
@@ -149,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.
@@ -207,31 +194,6 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
// Start the household co-play worker (#1533). Recomputes artistartist
// co-occurrence edges (source='user_cooccurrence') from play_events every
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
// SQL, no external calls; empty on single-user servers.
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if
// the provider set changed (re-opening settled rows), then drains tracks
// needing folksonomy tags on a periodic tick. Standalone (not in the file
// scan chain) because tag lookups need only DB fields — MBID / artist /
// title — that a scan has already imported.
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
if err != nil {
logger.Error("tag settings service init failed", "err", err)
os.Exit(1)
}
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
logger.Warn("tags: provider-hash boot check failed", "err", berr)
} else if bumped {
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
}
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
// that have no writer-side close path or retention policy:
// orphan play_events, stale play_sessions, expired
@@ -280,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)
}
@@ -332,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)
}
}
-160
View File
@@ -1,160 +0,0 @@
// Admin recommendation-tuning endpoints (#1250): the defaults-
// discovery lab. GET returns current values + shipped defaults for
// every scope; PATCH applies a partial update to one scope; reset
// restores a scope to shipped defaults. Every change writes an audit
// row (consumed by the metrics trend view, #1251).
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
ContextTimeWeight float64 `json:"context_time_weight"`
}
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
return weightsResp{
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
type tasteTuningResp struct {
HalfLifeDays float64 `json:"half_life_days"`
EngagementHardSkip float64 `json:"engagement_hard_skip"`
EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"`
EnrichedTagScale float64 `json:"enriched_tag_scale"`
EraScale float64 `json:"era_scale"`
MoodScale float64 `json:"mood_scale"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
return tasteTuningResp{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}
}
// tuningSnapshot is both the GET response and the post-mutation echo:
// current values alongside shipped defaults so the card can mark
// which knobs deviate.
type tuningSnapshot struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
Shipped struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
} `json:"shipped"`
}
func (h *handlers) tuningSnapshot() tuningSnapshot {
var out tuningSnapshot
out.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
}
out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
}
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
return out
}
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// patchTuningReq carries the partial update: field name → new value,
// using the same snake_case names the GET response emits.
type patchTuningReq struct {
Values map[string]float64 `json:"values"`
}
// handlePatchRecommendationTuning implements
// PATCH /api/admin/recommendation-tuning/{scope}.
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
var body patchTuningReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if len(body.Values) == 0 {
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
return
}
var err error
if scope == recsettings.ScopeTaste {
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
} else {
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
}
if err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// handleResetRecommendationTuning implements
// POST /api/admin/recommendation-tuning/{scope}/reset.
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// writeTuningErr maps recsettings validation errors to 400s and
// everything else to a logged 500.
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
switch {
case errors.Is(err, recsettings.ErrUnknownScope):
writeErr(w, &apierror.Error{
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
})
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
default:
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
writeErr(w, apierror.InternalMsg("tuning update failed", err))
}
}
@@ -1,110 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
func newTuningRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
return r
}
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
t.Helper()
var snap tuningSnapshot
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
t.Fatalf("decode: %v", err)
}
return snap
}
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
rec := httptest.NewRecorder()
newTuningRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
}
if snap.Taste.HalfLifeDays != 75 {
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
}
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
t.Error("untouched values must equal shipped defaults")
}
}
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 2.5 {
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
}
// The change is live for the radio scoring path.
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
}
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200", rec.Code)
}
snap = decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 {
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
}
}
func TestRecommendationTuning_PatchErrors(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
cases := []struct {
name, path, body string
want int
}{
{"unknown scope", "/api/admin/recommendation-tuning/banana",
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
{"unknown field", "/api/admin/recommendation-tuning/radio",
`{"values":{"vibes":1}}`, http.StatusBadRequest},
{"out of range", "/api/admin/recommendation-tuning/taste",
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
{"empty values", "/api/admin/recommendation-tuning/radio",
`{"values":{}}`, http.StatusBadRequest},
{"bad json", "/api/admin/recommendation-tuning/radio",
`{`, http.StatusBadRequest},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
}
}
}
-153
View File
@@ -1,153 +0,0 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// tagSourceResp is the wire shape for one tag-enrichment provider in the
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
// (admin_cover_sources.go) — a separate, independent settings card so a new
// tag source is added without touching art settings (#1490).
type tagSourceResp struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
RequiresAPIKey bool `json:"requires_api_key"`
Supports []string `json:"supports"`
Enabled bool `json:"enabled"`
APIKeySet bool `json:"api_key_set"`
DisplayOrder int32 `json:"display_order"`
Testable bool `json:"testable"`
}
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
supports := info.Supports
if supports == nil {
supports = []string{}
}
return tagSourceResp{
ID: info.ID,
DisplayName: info.DisplayName,
RequiresAPIKey: info.RequiresAPIKey,
Supports: supports,
Enabled: info.Enabled,
APIKeySet: info.APIKeySet,
DisplayOrder: info.DisplayOrder,
Testable: info.Testable,
}
}
type tagSourcesListResp struct {
Providers []tagSourceResp `json:"providers"`
SourcesVersion int32 `json:"sources_version"`
}
// handleListTagSources implements GET /api/admin/tag-sources.
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
infos := h.tagSettings.ListProviderInfo()
out := tagSourcesListResp{
Providers: make([]tagSourceResp, 0, len(infos)),
SourcesVersion: h.tagSettings.CurrentVersion(),
}
for _, info := range infos {
out.Providers = append(out.Providers, tagSourceRespFrom(info))
}
writeJSON(w, http.StatusOK, out)
}
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
// unchanged"; non-nil sets (empty api_key clears).
type updateTagSourceReq struct {
Enabled *bool `json:"enabled,omitempty"`
APIKey *string `json:"api_key,omitempty"`
}
type updateTagSourceResp struct {
tagSourceResp
VersionBumped bool `json:"version_bumped"`
}
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
var body updateTagSourceReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
Enabled: body.Enabled,
APIKey: body.APIKey,
})
if err != nil {
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
h.logger.Error("admin: update tag source", "id", id, "err", err)
writeErr(w, apierror.InternalMsg("update failed", err))
return
}
var found tagSourceResp
for _, info := range h.tagSettings.ListProviderInfo() {
if info.ID == id {
found = tagSourceRespFrom(info)
break
}
}
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
}
type testTagSourceResp struct {
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
start := time.Now()
err := h.tagSettings.TestProvider(r.Context(), id)
duration := time.Since(start).Milliseconds()
if err == nil {
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
return
}
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
// Not testable, or the test failed — both surface as ok=false with a
// 200 (the operation completed; the result is data, not an error).
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
}
type researchTagsResp struct {
SourcesVersion int32 `json:"sources_version"`
}
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
// the sources version so every settled ('none') track becomes eligible for
// a re-enrichment pass, then the background worker re-processes them.
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
newVer, err := h.tagSettings.BumpVersion(r.Context())
if err != nil {
h.logger.Error("admin: research tags", "err", err)
writeErr(w, apierror.InternalMsg("research failed", err))
return
}
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
}
-243
View File
@@ -1,243 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
// Does not implement TestableProvider.
type apiTestTagProvider struct {
id string
display string
}
func (p *apiTestTagProvider) ID() string { return p.id }
func (p *apiTestTagProvider) DisplayName() string { return p.display }
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
return []tags.Tag{{Name: "x", Weight: 1}}, nil
}
// apiTestTestableTagProvider also implements TestableProvider.
type apiTestTestableTagProvider struct {
apiTestTagProvider
}
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
func newAdminTagSourcesRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/tag-sources", h.handleListTagSources)
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
})
return r
}
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
// test DB. Register the needed fake providers BEFORE calling this, and pair
// with tags.ResetRegistryForTests in t.Cleanup.
func testHandlersWithTagSettings(t *testing.T) *handlers {
t.Helper()
h, pool := testHandlers(t)
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
if err != nil {
t.Fatalf("NewSettingsService: %v", err)
}
h.tagSettings = s
return h
}
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tslist", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp tagSourcesListResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
}
found := false
for _, s := range resp.Providers[0].Supports {
if s == "track_tags" {
found = true
}
}
if !found {
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
}
if resp.Providers[0].Testable {
t.Error("testable = true, want false (no TestConnection)")
}
}
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsflip", "pw", true)
disabled := false
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.VersionBumped {
t.Error("version_bumped = false, want true after flipping enabled")
}
}
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tskey", "pw", true)
newKey := "newkey"
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VersionBumped {
t.Error("version_bumped = true, want false for key-only change")
}
if !resp.APIKeySet {
t.Error("api_key_set = false, want true after setting key")
}
}
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "ts404", "pw", true)
body, _ := json.Marshal(updateTagSourceReq{})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
h := testHandlersWithTagSettings(t)
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
req = withUser(req, nonAdmin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsntest", "pw", true)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp testTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.OK {
t.Error("ok = true, want false for non-testable provider")
}
if resp.Error == "" {
t.Error("error string empty, want a message")
}
}
-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 -32
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,
@@ -78,7 +74,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Post("/auth/logout", h.handleLogout)
authed.Get("/me", h.handleGetMe)
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
authed.Get("/me/history", h.handleGetMyHistory)
@@ -139,12 +134,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 +180,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 +211,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 +219,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
}
+3 -54
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -25,33 +24,6 @@ type eventRequest struct {
// / "discover" feed the system-playlist rotation dedup (#415);
// absent / "" for library, user-playlist, radio, Subsonic.
Source *string `json:"source"`
// DeviceClass is the client's self-reported device class (#1551),
// normalized + stored per play to condition radio on the current device.
DeviceClass *string `json:"device_class"`
}
// knownDeviceClasses whitelists the device classes the context-affinity facet
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
// "other", and empty/absent to "" (stored NULL → excluded from the device
// dimension). Kept permissive (no DB CHECK) so a new client class is one
// whitelist entry, not a migration.
var knownDeviceClasses = map[string]bool{
"mobile": true, "tablet": true, "desktop": true,
"web": true, "tv": true, "watch": true,
}
func normalizeDeviceClass(dc *string) string {
if dc == nil {
return ""
}
s := strings.ToLower(strings.TrimSpace(*dc))
if s == "" {
return ""
}
if knownDeviceClasses[s] {
return s
}
return "other"
}
type playStartedResponse struct {
@@ -59,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"`
}
@@ -100,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 {
@@ -142,9 +94,7 @@ func (h *handlers) handleEventPlayStarted(
if req.Source != nil {
source = *req.Source
}
deviceClass := normalizeDeviceClass(req.DeviceClass)
res, err := h.events.RecordPlayStartedWithSource(
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
if err != nil {
h.logger.Error("api: events: play_started", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
@@ -189,8 +139,7 @@ func (h *handlers) handleEventPlayOffline(
source = *req.Source
}
if err := h.events.RecordOfflinePlay(
r.Context(), user.ID, trackID, clientID, source,
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
); err != nil {
h.logger.Error("api: events: play_offline", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
-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
-16
View File
@@ -30,8 +30,6 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)),
YouMightLikeAlbums: make([]AlbumRef, 0, len(data.YouMightLikeAlbums)),
YouMightLikeArtists: make([]ArtistRef, 0, len(data.YouMightLikeArtists)),
}
for _, row := range data.RecentlyAddedAlbums {
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
@@ -48,12 +46,6 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
for _, row := range data.LastPlayedArtists {
out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
}
for _, row := range data.YouMightLikeAlbums {
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
}
for _, row := range data.YouMightLikeArtists {
out.YouMightLikeArtists = append(out.YouMightLikeArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
}
writeJSON(w, http.StatusOK, out)
}
@@ -86,8 +78,6 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)),
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)),
YouMightLikeAlbums: make([]string, 0, len(data.YouMightLikeAlbums)),
YouMightLikeArtists: make([]string, 0, len(data.YouMightLikeArtists)),
}
for _, row := range data.RecentlyAddedAlbums {
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID))
@@ -104,12 +94,6 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
for _, row := range data.LastPlayedArtists {
out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID))
}
for _, row := range data.YouMightLikeAlbums {
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, uuidToString(row.Album.ID))
}
for _, row := range data.YouMightLikeArtists {
out.YouMightLikeArtists = append(out.YouMightLikeArtists, uuidToString(row.Artist.ID))
}
writeJSON(w, http.StatusOK, out)
}

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