Compare commits

...

44 Commits

Author SHA1 Message Date
bvandeusen 611715154b Merge pull request 'M9 diagnostics follow-ups: playback relabel, sort, connected fix, per-skip + track-identity' (#105) from dev into main
test-go / test (push) Successful in 38s
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m18s
test-go / integration (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 3m46s
release / Build + push container image (push) Successful in 16s
2026-06-30 19:19:15 -04:00
bvandeusen 392454b249 feat(android/diagnostics): track-identity enrichment + zero stale Sonos state
android / Build + lint + test (push) Successful in 3m27s
Numeric indices wobble across re-casts (offset +1↔0 seen during output
toggling), making "same track?" ambiguous. Enrich both the track_change
event and the heartbeat with local_track_id (TrackRef.id) and sonos_uri
(RemotePlayerState.currentTrackUri — the URL the speaker is actually
streaming), so a desync is unambiguous.

Also fixes the cast→phone stale-state pollution (#1211): sonos_* is now
zeroed unless a remote route is active, via a shared putSonos() helper —
so a just-ended cast's RemotePlayerState can't masquerade as live Sonos
data in the diagnostics.

Refs Scribe M9 (#119), tasks #1210 #1211.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 19:15:12 -04:00
bvandeusen cdfc79e6ab feat(android/diagnostics): per-skip track-change event
android / Build + lint + test (push) Successful in 3m35s
Heartbeats are 45s apart and missed a rapid skip burst (local_index
16→22 in one gap). Add a 'playback' track_change event emitted on each
queue-index / current-track change, snapshotting local vs Sonos
index+position + server_health + upnp_loading + route — so a transient
skip-induced desync is captured at the instant it happens. (uiState is a
conflated StateFlow, so a very rapid burst may coalesce intermediate
indices; we still get the boundaries + the snapshot.)

Refs Scribe M9 (#119), task #1210.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 18:48:35 -04:00
bvandeusen 79f2d79a2e feat(diagnostics): 'playback' kind, newest-first sort, fix active-route subtitle
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 41s
android / Build + lint + test (push) Successful in 3m40s
test-go / integration (push) Successful in 4m30s
Relabel (#1204): route + player_state events fire for every output route,
not just UPnP — split them into a new 'playback' kind; 'upnp_sync' now
means genuinely UPnP/Sonos signal (drops, resync). Migration 0037 adds
'playback' to the kind CHECK; server whitelist, Android reporter labels,
and the web kind filter updated.

Web sort: the diagnostics list gains a Newest/Oldest-first sort (default
newest at top); export follows the displayed order.

Fix (#1205): OutputRoute.isConnected was derived from RouteInfo.connectionState,
which stays DISCONNECTED for local SYSTEM routes even when active — so a
connected Bluetooth device showed "Available" and reported connected:false.
The picker subtitle now uses isSelected (route == selected route); the dead
isConnected field is removed and the misleading `connected` field dropped
from the diagnostics route event (it only ever logs the active route).

Refs Scribe M9 (#119), tasks #1204 #1205.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 16:33:01 -04:00
bvandeusen 96b15b75e6 feat(web/diagnostics): default to recent 500, move time window to Advanced
test-web / test (push) Successful in 39s
The diagnostics view already defaulted to the most recent 500 events (no
window); make that the obvious path. Device/Kind stay primary; the
start/end window + row cap move into a collapsed "Advanced filters"
disclosure (auto-opens when a window is active) with a "Reset to recent
500" action. Caption now states whether you're seeing the recent default
or a windowed slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 12:42:25 -04:00
bvandeusen 23a82fb38d Merge pull request 'M9 — Device diagnostics & debug reporting (connectivity + UPnP desync)' (#104) from dev into main
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m6s
test-go / integration (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 1m38s
2026-06-29 19:24:16 -04:00
bvandeusen 782f152d37 test(web/admin): AdminTabs now has seven tabs (Diagnostics added)
test-web / test (push) Successful in 31s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-29 19:11:39 -04:00
bvandeusen bffa5b28bd fix(diagnostics): StateFlow distinctUntilChanged build error + AdminUser test fixtures
test-web / test (push) Failing after 33s
android / Build + lint + test (push) Successful in 3m29s
- DiagnosticsReporter.collectServerHealth: drop distinctUntilChanged() on
  networkStatus.state (StateFlow is already distinct; the deprecation
  warning is a hard error under allWarningsAsErrors).
- web users.test.ts: add debug_mode_enabled to the alice/bob AdminUser
  fixtures now that the field is required on the type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-29 19:05:50 -04:00
bvandeusen 8a58f07237 fix(android/diagnostics): keep uploader drain within ReturnCount gate
android / Build + lint + test (push) Failing after 2m28s
drainSafe/drain each had 3 returns (detekt ReturnCount ≤ 2). Collapse the
guard clauses and convert drain's loop to a `more` flag — same behavior,
zero/two returns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-29 18:59:05 -04:00
bvandeusen 4d42e298dd feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
test-web / test (push) Failing after 11s
android / Build + lint + test (push) Failing after 1m19s
Android: a gated DiagnosticsReporter taps connectivity, server-health,
UPnP drops/player-state/route, power (Doze/battery-opt/screen), and
app fg/bg, plus a heartbeat snapshotting Sonos-vs-local position — the
locked-phone desync signal. Events buffer in a Room ring buffer
(deliberately NOT the MutationQueue: high-volume best-effort telemetry
that must survive the dead zone being debugged) and DiagnosticsUploader
drains them on a tick / health-recovery / sign-in.

Gating: the account flag (users.debug_mode_enabled) reaches the device
via a new /api/me refresh in AuthController; a per-device local OFF
switch lives in Settings. Reporter runs only when enabled && !optOut;
disabling drops the unsent buffer.

Web admin: /admin/diagnostics — pick account+device+kind+time-window,
see a chronological timeline, flip an account's debug mode remotely, and
Copy-JSON / Download-NDJSON the slice for analysis.

Room schema 6→7 (new diagnostic_events table + auth_session.diagnosticsOptOut;
pre-v1 destructive fallback).

Refs Scribe M9 (#119), tasks #1174 #1175 #1176 #1177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-29 18:56:36 -04:00
bvandeusen 4ed831d9c3 feat(server/diagnostics): device debug-reporting ingest + admin timeline + retention (M9)
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m44s
New diagnostic_events table + per-account users.debug_mode_enabled flag.
When an account's flag is on, its client(s) POST a batch timeseries of
connectivity / UPnP-sync / power / lifecycle events to /api/diagnostics
(no-op 204 when off, kind whitelist mirrors the CHECK constraint).

Admin surface: GET /api/admin/diagnostics (optional account/device/kind/
time-window filters, RFC3339-or-epoch-ms, export-sized paging) + a
/diagnostics/devices overview + PUT /api/admin/users/{id}/debug-mode to
flip an account remotely while a bug is live. debug_mode_enabled is now
exposed on /api/me (client gate) and the admin user views.

Retention: a 30-day gc-worker sweep (GcPruneDiagnostics), keyed on the
server clock so a skewed device clock can't keep rows alive.

Refs Scribe M9 (#119), tasks #1172 #1173.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-29 18:38:56 -04:00
bvandeusen 0de2437689 Merge pull request 'Image rendering + player resilience (#968, #980)' (#103) from dev into main
test-go / test (push) Successful in 46s
test-web / test (push) Successful in 55s
android / Build + lint + test (push) Successful in 4m11s
test-go / integration (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 3m53s
release / Build + push container image (push) Successful in 1m41s
2026-06-20 20:57:39 -04:00
bvandeusen f4f4df7708 feat(android/playlists): stale-view snackbar + Refresh on open system playlist after rebuild
android / Build + lint + test (push) Successful in 3m38s
#980, parity with web e932ab43. When playlist.system_rebuilt arrives (SSE)
while a system-playlist detail screen is open, the ViewModel marks it stale and
the screen shows an indefinite "This mix was refreshed · Refresh" snackbar.
Refresh re-resolves the rotated variant via PlaylistsRepository.systemShuffle
and reuses the existing regenerated navigate-replace flow to land on the fresh
playlist id — without triggering another server rebuild (unlike the manual
regenerate button). Dismiss clears the flag. Functional behaviors were already
correct; this closes the cosmetic stale-list gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:52:13 -04:00
bvandeusen e932ab438c feat(web/playlists): stale-view banner + Refresh on an open system-playlist after rebuild
test-web / test (push) Successful in 39s
#980. When the daily rebuild fires while a system-playlist detail page is
open, its cached data goes stale and can't be refetched in place — the
playlist id rotated, so the old id 404s. serverEvents now exposes a monotonic
rebuild counter; the detail page shows a "this mix was refreshed" banner with
a Refresh that re-resolves the variant (systemShuffle) to the new playlist id
and navigates there. No forced redirect, no auto-reload — the user refreshes
on their terms. Functional behaviors were already correct (tapping a song
plays it; tiles load the current mix); this closes the cosmetic list-staleness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:48:52 -04:00
bvandeusen d05264ff80 fix(web/playlists): attribute source when playing a system playlist from its detail page
test-web / test (push) Successful in 38s
Playing a system playlist from /playlists/<id> previously sent no source, so
it never advanced that playlist's rotation — inconsistent with the home tile
(and the Android detail screen, which already tags the variant). Pass
source: variant alongside the existing self-heal closure so a play is
attributed regardless of the surface it started from. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:35:45 -04:00
bvandeusen a23e2e36ca feat(android/home): refresh Home on playlist.system_rebuilt
android / Build + lint + test (push) Successful in 3m46s
Parity with the web SSE consumer (5a80a1e4). HomeViewModel now subscribes to
EventsStream and re-pulls Home (refreshIndex + system-playlist status) when
the server emits playlist.system_rebuilt — the daily 03:00 rebuild or a
manual refresh — so the system-playlist tiles and You-might-like rows reflect
the new snapshot without a manual reload. Browse-only: the active playback
queue is left to self-heal on the failure path. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:46:35 -04:00
bvandeusen 5a80a1e460 feat(web): subscribe to SSE and refresh home/playlists on playlist.system_rebuilt
test-web / test (push) Successful in 39s
The web client only ever SENT events; it had no inbound SSE listener, so a
tab left open across the daily system-playlist rebuild kept showing
yesterday's home + playlist snapshots until a manual reload (the stale-
browse-view bug behind #968). Add useServerEvents(): opens /api/events/stream
while authenticated and, on playlist.system_rebuilt, invalidates the home,
playlists, and system-playlist-status query caches. Deliberately does not
disturb the active playback queue — that self-heals on the failure path.
Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:44:05 -04:00
bvandeusen 16f76ea707 feat(server): emit playlist.system_rebuilt on daily + manual system-playlist rebuild
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s
The daily 03:00 scheduler rebuild (and the manual refresh endpoint) replace
a user's system playlists + You-might-like rows but published no event, so a
client left open across the rebuild served yesterday's snapshot until a
manual reload — the stale-tab case behind #968. Add a user-scoped
playlist.system_rebuilt event (envelope {kind,user_id,data:{}}) from both the
scheduler (bus threaded into NewScheduler) and handleSystemPlaylistRefresh.
Clients consume it to invalidate home / system-playlist views and proactively
re-pull a stale active queue. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:59:27 -04:00
bvandeusen d4cc177db4 feat(android/player): self-heal a stale system-playlist / radio queue on total failure
android / Build + lint + test (push) Successful in 3m56s
Web parity with 27766ae0. When a load error exhausts a fully-unplayable
queue, re-pull the source instead of stopping: a bare-variant source is a
refreshable system playlist (re-pull via PlaylistsRepository.systemShuffle),
"radio:<seed>" re-seeds via RadioController. Reads the source from the
current MediaItem extra; bounded to one re-pull per exhaustion (reset when
a track next loads with real audio) so a still-stale refresh can't loop.
Album / artist / user-playlist / offline sources have nothing to refresh
and still stop. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:56:01 -04:00
bvandeusen 27766ae063 feat(web/player): self-heal a stale system-playlist / radio queue on total failure
test-web / test (push) Successful in 39s
When the whole queue proves unplayable (e.g. a tab left open across the
daily system-playlist rebuild — the exact stale-snapshot case), the player
now re-pulls the fresh snapshot and resumes instead of dead-ending on
"Try again". The seeder hands the store an opaque refetch closure so the
store stays decoupled from the playlist API and the per-artist
(songs_like_artist) identity problem: single-instance variants re-pull via
systemShuffle, per-artist mixes via getPlaylist(id), radio re-seeds from
its track. Bounded to one self-heal per exhaustion (reset on the next
successful play) so a still-broken refresh can't loop; "Try again" stays
the genuine last resort. Wired from PlaylistCard, the playlist detail page,
and playRadio. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:51:47 -04:00
bvandeusen 335d782215 fix(android/player): auto-skip a failed track on load error, not just zero-duration
android / Build + lint + test (push) Successful in 3m47s
onPlayerError fired a `load_failed` event and the snackbar reporter coalesced
it into "Skipped N unplayable tracks" — but nothing actually skipped, so a
bad/stale track stranded playback while the toast claimed otherwise. Mirror
the zero_duration path: advance to the next item and re-prepare (a load error
leaves the player IDLE), or stop at the end. Forward-only bounds a fully-
unplayable queue. Web parity with 2a8de82a. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:41:30 -04:00
bvandeusen 2a8de82a17 fix(web/player): auto-skip a failed track instead of dead-ending on "Try again"
test-web / test (push) Successful in 39s
A track that fails to load (e.g. a stale system-playlist snapshot pointing
at a rebuilt/removed file) hard-set the player to the 'error' state and
stranded the user on a "Try again" button that just re-queued the same
failing track. Now a load error advances to the next track; the error
state only surfaces once the whole queue has proven unplayable — every
track failed, or we reached the end. A failure streak capped at queue
length stops a fully-broken queue from cycling, and resets on the next
successful play.

Next (Track B cont.): self-heal a stale system-playlist / radio queue by
re-pulling the fresh snapshot on total failure, plus the Android
equivalent. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:40:08 -04:00
bvandeusen 096a3c0b15 fix(clients): never leave cover tiles blank — shared web <Cover> + Android Coil placeholder/error
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 4m10s
Cover tiles (worst in the "You might like" home row, which surfaces
unplayed items whose art is often not yet backfilled) sat empty while
loading and stayed blank on a 404. The server returns a fast 404; the
gap was missing client-side loading/fallback states.

Web: new shared Cover.svelte owns the loading placeholder + onerror
fallback (static cover, or Disc3 for artists). AlbumCard, ArtistCard and
CompactTrackCard now reuse it instead of three hand-rolled <img> tags
that disagreed on fallback handling — notably ArtistCard had no onerror.

Android: ServerImage tracks Coil's load state so the per-caller fallback
doubles as a placeholder (loading) and an error state (404 / unreachable),
instead of only guarding the null-URL case. All five call sites pass an
explicit size modifier, so the new Box wrapper is layout-safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:29:05 -04:00
bvandeusen a251dce7e3 Merge pull request 'feat: scan on startup by default + README first-run walkthrough & screenshots' (#102) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 37s
release / Build + push container image (push) Successful in 1m44s
test-go / integration (push) Successful in 4m35s
2026-06-20 11:40:56 -04:00
bvandeusen 97e0e88483 feat(server): scan library on startup by default + README first-run walkthrough
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m38s
Make a fresh install usable out of the box and document the first-run flow
for the public-facing repo.

- Default scan_on_startup to true (Default() + config.example.yaml, which is
  the live config baked into the image). Previously false, so a fresh stack
  came up with an empty library and no hint to scan. Scans are incremental
  (mtime skip), so the per-restart cost is just a directory walk. Re-point
  the env-override test to exercise the override against the new default.
- README: add a "First run" walkthrough (register -> scan -> integrations ->
  install Android app -> invite users), each grounded in a real route.
- Add docs/screenshots/ with six captures, referenced via width-constrained
  <img> wrapped in a link (shrink inline + click to open full size).
  API token and invite token were cropped/redacted out of the captures
  before commit so no live credential lands in the public history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:35:22 -04:00
bvandeusen 938dae7163 Merge pull request 'docs: correct README setup/OOBE + fix data-volume mount' (#101) from dev into main 2026-06-20 10:58:07 -04:00
bvandeusen ad37937949 docs: correct README setup/OOBE + fix data-volume mount
Bring the README and client/README in line with the current product and
release pipeline ahead of making the repo public-facing:

- Highlights: replace the retired "Flutter client in flight" bullet with
  the native-Android-APK-bundled-in-the-image story.
- Quickstart: document the purpose of each compose volume mount, and fix
  the minstrel-data mount (/data -> /app/data) so it matches the image's
  MINSTREL_STORAGE_DATA_DIR and generated artefacts actually persist.
- Configuration: tie MINSTREL_STORAGE_DATA_DIR to the /app/data mount.
- Updating: replace the bogus :v1.0.x scheme with the real tag model
  (:latest, immutable :vYYYY.MM.DD, :main) and note the bundled APK.
- client/README: rewrite the Production section to match release.yml
  (android-release + needs: ordering, non-tag :latest bundle path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:57:28 -04:00
bvandeusen 93365cb555 Merge pull request 'ci(release): bundle the latest release APK into non-tag :latest builds' (#100) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 17s
2026-06-14 22:55:23 -04:00
bvandeusen 2c5c477a0d ci(release): bundle the latest release APK into non-tag :latest builds
Every main push moves :latest, but main builds don't build an APK — so the
in-app update channel silently vanished from :latest until the next tag.

Now the image-release job, on non-tag builds, pulls the most-recent
release's signed APK from the gitea API and reconstructs its exact
versionName (${TAG#v}.$(git rev-list --count TAG) — the same formula
android-release bakes in) for the version sidecar. No rebuild, just
rebundle; tag builds still bundle their own freshly-built APK. Checkout
gains fetch-depth:0 + fetch-tags so the commit count resolves. Degrades to
an empty client/ (404 update channel) — never a wrong version — if no
release / APK asset / tag count can be resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:54:54 -04:00
bvandeusen eeabdf1f2c Merge pull request 'Web UI: Most Played hover fix, narrower seek bar, Android-parity track kebab' (#99) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 35s
release / Build + push container image (push) Successful in 1m50s
2026-06-14 22:26:52 -04:00
bvandeusen f7278f2417 feat(web): drop "Remove from library" from the track kebab
test-web / test (push) Successful in 32s
No single-click destructive action belongs in the kebab. Removing the item
orphaned its whole path (RemoveTrackPopover was its only caller, and the
admin/tracks API client was the popover's only caller), so per the repo's
no-dead-code convention the chain is fully removed: the menu item + its
admin/isAdmin plumbing in TrackMenu, RemoveTrackPopover(.svelte/.test),
src/lib/api/admin/tracks(.ts/.test), and the now-needless transitive mocks
in the CompactTrackCard / PlaylistTrackRow / playlist specs.

The kebab is now an 8-item, admin-agnostic menu. The DELETE /api/admin/tracks
server endpoint is untouched — a future safer admin surface can rebind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:17:35 -04:00
bvandeusen 9a166f9032 test(web): update TrackMenu spec for Android-parity kebab
test-web / test (push) Successful in 32s
The kebab gained "Start radio" and dropped the duplicate "Flag this track…"
(its action now lives solely under "Hide", which opens the same FlagPopover).
Net item count is unchanged (9 admin / 8 non-admin), but the named-item and
flag-entry assertions needed updating:
- mock playRadio in the store mock; assert Start radio dispatches playRadio.
- swap the flag-item presence check for start-radio.
- replace the "click Flag" test with "click Hide opens the popover".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:57 -04:00
bvandeusen b3d6785543 feat(web): match track kebab to Android + add it to full-screen Now Playing
test-web / test (push) Failing after 32s
Bring the web TrackMenu to parity with Android's canonical TrackActionsSheet
so the kebab reads the same on both clients:
- Reorder to Android's groups: queue → like/add-to-playlist/start-radio →
  go-to-album/artist → hide.
- Drop the duplicate "Flag this track…" item — it opened the very same
  FlagPopover as "Hide" (Android folds flag into a single Hide).
- Align icons (ListVideo / ListMusic / ListPlus / Disc3 / User).
- Admin-only "Remove from library" stays as a web superset (Android has no
  surface for it), past its own divider.

Mount the kebab on the full-screen /now-playing route with hideQueueActions,
mirroring Android's NowPlayingScreen — Start radio / Add to playlist / Hide
were previously unreachable there (only like + volume + queue existed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:46:54 -04:00
bvandeusen 773275e916 feat(web): surface "Start radio" in the track kebab menu
test-web / test (push) Failing after 31s
Radio was fully wired (playRadio → /api/radio + 80% auto-refresh) but its
only entry point was TrackRow's inline 📻 button, so it was unreachable from
the kebab — i.e. missing on the Most Played compact cards and the mini-player.
Add a "Start radio" item to TrackMenu, shown even under hideQueueActions since
reseeding a station from the current track is meaningful there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:16 -04:00
bvandeusen 066616e196 fix(web): de-overlap Most Played hover controls + narrow desktop seek bar
test-web / test (push) Successful in 40s
CompactTrackCard is a short one-line row but reused CardActionCluster,
which corner-splits Like+Add (top) and the kebab menu (bottom). That split
is right for the tall square Album/Artist cards but makes the two groups
collide on the compact row's hover state. Give the compact card a single
inline, vertically-centred right cluster (Like + Add + menu in one group)
and widen its right padding reserve to match.

In the desktop PlayerBar, the left info column was a fixed w-72 (title kept
truncating) while the seek column was flex-1 (the scrubber hogged the slack
on wide screens). Let the left column grow up to max-w-md while holding its
288px floor at md, and cap the seek/transport column at max-w-xl centred, so
freed width flows to the title instead of stretching the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:26 -04:00
bvandeusen 3e258507bb Merge pull request 'fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening' (#98) from dev into main
android / Build + lint + test (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 15s
2026-06-12 21:25:25 -04:00
bvandeusen fd7d6dac4c feat(android): adopt a running UPnP session + tighten cast recovery/anti-stickiness
android / Build + lint + test (push) Successful in 3m53s
Three related improvements to UPnP/Sonos session handling, on top of the
WiFi-lock + drop-suppression fixes.

1. Adopt a running session instead of clear+reload (the headline).
   Selecting a renderer always did removeAllTracksFromQueue + full reload --
   a jarring restart if the speaker was already playing our queue (e.g. after
   the phone got disconnected but the autonomous Sonos kept going). selectUpnp
   now probes the renderer first; if it's mid-playback on the same track id at
   the same queue index, we ATTACH in place: sync the local cursor to its
   position, wire ActiveUpnpHolder, start polling -- no clear, no reload, and
   skip/seek immediately drive its live queue. Falls through to clear+reload
   when it isn't our queue. New PlayerController.moveCursorTo aligns the local
   cursor without auto-playing.

2. Discovery expiry + selection revert (anti-stickiness). upsertRoute only
   ever added, so a powered-off renderer lingered in the picker forever and
   could pin a stale selection. Stamp lastSeen per route; after the picker's
   active M-SEARCH scan, prune routes that didn't re-announce. A collector
   reverts the selection to the phone when the selected route leaves discovery
   while we're not actively casting -- so a later play never targets a ghost.
   Pruning is tied to picker-open scans only (no background timer -> no row
   flicker).

3. Reconcile immediately on network recovery. When NetworkStatus flips back to
   Healthy while a route is active, nudge an immediate poll instead of waiting
   up to POLL_INTERVAL_MS -- the held session re-confirms the renderer in one
   round-trip.

Verified (read-only): the tap-play-onto-dead-route fallback still fires when
the phone's network is Healthy (the poll-loop drop path is unchanged for that
case); the drop-suppression gate only holds during phone-side outages, where a
local fallback couldn't play either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:20:26 -04:00
bvandeusen d6290a3ef0 fix(android): don't drop Sonos (or blast local audio) when the phone's own network is down
android / Build + lint + test (push) Successful in 3m43s
Observed on device: casting to Sonos on battery + screen locked, a
transient ~67s reachability gap (NetworkStatus -> ServerDown while WiFi
itself stayed associated) starved the 1 Hz poll past DROP_THRESHOLD. The
poll loop then dropped the route and fell back to the local player, which
honored the play-intent -- so the phone suddenly started playing the song
out loud locally while the Sonos was still happily streaming it.

A poll failure during a phone-side network outage means "we can't see the
renderer right now," not "the renderer died": a UPnP renderer streams
autonomously and keeps playing, and the local player we'd fall back to
can't reach the server either. Dropping is strictly worse than waiting.

Gate the drop on NetworkStatusController: only drop when the phone's
network is Healthy (renderer genuinely unreachable on an otherwise-fine
link). While Unstable/ServerDown/Offline, hold the route, keep polling,
and clear the failure streak so recovery re-evaluates from scratch rather
than re-dropping on the first post-recovery hiccup. The poll reconciles to
the renderer's real (advanced) position once the network returns.

Complements the CastNetworkLock fix: the lock reduces how often these gaps
happen; this stops a gap that does happen from punishing the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:48:40 -04:00
bvandeusen 9550d8daaf Merge pull request 'fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation)' (#97) from dev into main
android / Build + lint + test (push) Successful in 4m5s
release / Build signed APK (tag releases only) (push) Successful in 4m4s
release / Build + push container image (push) Successful in 13s
2026-06-12 19:45:44 -04:00
bvandeusen 4d5af3599e fix(android): hold WiFi+wake lock during UPnP cast so a locked screen doesn't starve the poll
android / Build + lint + test (push) Successful in 3m46s
While casting, the wrapped ExoPlayer is paused, releasing its
WAKE_MODE_NETWORK locks -- so nothing kept the phone's radio awake. On a
locked, on-battery phone the WiFi power-saves within seconds and the CPU
dozes, stalling the 1 Hz liveness poll to the renderer and the
queue-extend calls to the server. The poll then trips DROP_THRESHOLD and
playback falls back to a phone that also has no network: silence, while
the Sonos was streaming fine the whole time.

Diagnosed from logcat: ~12s after screen-off the phone logged
"Unable to resolve host minstrel.fabledsword.com" (its own DNS, not the
server), the extend aborted (1/58 appended), then the drop tripped and
the local fallback came up active=null. USB charging masks it (no Doze
while charging), which is why it only bit on battery.

Add CastNetworkLock: a high-perf/low-latency WifiLock + partial WakeLock
acquired when a UPnP route goes active and released on drop/switch-back
(every teardown path funnels through holder.set(null) -> onActiveChanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:39:42 -04:00
bvandeusen db393bbe65 Merge PR #96: recommendation batch (You-might-like fallback + taste 2b + observability)
test-go / test (push) Successful in 41s
test-web / test (push) Successful in 43s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-12 01:10:09 -04:00
bvandeusen 1a7515e6ea feat(taste): phase 4 — recommendation observability (#796)
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m24s
Per-source play outcomes so the operator can see whether each recommendation
surface is landing and tune the now-operator-tunable taste weights.

Server:
- query RecommendationSourceMetricsForUser: groups the user's play_events by
  source (system-playlist surface), reporting plays / skips / avg completion
  over a window; NULL-source (library/radio) plays excluded.
- GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) →
  {window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}.
- handler test: 401 unauth; per-source aggregation + NULL-source exclusion +
  skip_rate / avg_completion math.

Web:
- lib/api/metrics.ts: query + friendly source labels.
- settings page gains a "Recommendation metrics" card (table of surface / plays
  / skip rate / avg completion), with loading/error/empty states.
- settings tests mock the new query (manual subscribe-store, hoisting-safe).

Note: You-might-like plays aren't source-tagged (it's a Home row, not a system
playlist), so this covers For-You / Discover / the mixes. Tagging YML plays
would be a client follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:28:30 -04:00
bvandeusen 6c26ba807e feat(taste): phase 2b — taste_overlap candidate arm (#796)
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m41s
2a re-ranks the existing pool by TasteMatch; this ensures taste-relevant tracks
ARE in the pool. Adds a 6th arm to LoadRadioCandidatesV2: in-library tracks by
the user's top positively-weighted taste-profile artists ($10 K, weight > 0,
deterministic weight-DESC,id order so it doesn't reintroduce same-day
nondeterminism). Pool-inclusion only (sim_score 0) — TasteMatch already scores
the fit. Empty for cold-start users (no profile).

- CandidateSourceLimits.TasteOverlap; default 20 (radio), 80 for For-You via
  systemForYouSourceLimits.
- You-might-like deliberately sets TasteOverlap=0: it surfaces NOT-actively-
  engaged artists, so flooding its pool with top-taste (mostly already-played)
  artists would just feed the read-time dedup.
- Test: positive-weight artist's track enters via the arm; negative-weight one
  is excluded (weight > 0). Existing pool tests unaffected (no profile seeded).

Deferred within 2b: profile-seeded For-You — marginal given the arm + TasteMatch
already inject taste broadly (top-played seed ≈ top-taste artist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:05:50 -04:00
bvandeusen c7adf2c87a fix(recommendation): broaden You-might-like fallback to liked album/track artists (#790)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m36s
The fallback pulled artists only from explicit artist-likes (general_likes_artists),
but most users like albums and tracks far more than artists — so the artists row
still came up thin (a couple of tiles) even with a rich library, while the albums
row filled fine.

Broaden both fallbacks to "entities you've shown affinity for":
- artist fallback = explicit artist-likes ∪ artists of liked albums ∪ artists of
  liked tracks.
- album fallback = explicit album-likes ∪ albums of liked tracks.
New dedicated queries (ListYouMightLike{Artist,Album}FallbackForUser) replace the
narrow Rediscover-fallback reuse; same projection so the Go layer still converts
directly. (Aliased + fully-qualified the UNION arms — sqlc merges UNION scopes,
so unqualified user_id was ambiguous across the three like tables.)

Test: 12 liked TRACKS by distinct artists, no artist-likes → the artist row now
fills from their artists (was empty before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:55:08 -04:00
104 changed files with 3977 additions and 524 deletions
+54 -5
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 (no APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built 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,6 +20,14 @@ 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.
@@ -181,6 +189,12 @@ 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
@@ -221,9 +235,8 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# 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).
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
uses: actions/download-artifact@v3
with:
@@ -247,6 +260,42 @@ 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: |
+44 -7
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.
<!-- TODO: screenshot of the home page -->
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
## 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.
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
- **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).
## Quickstart
@@ -24,10 +24,18 @@ 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
- minstrel-data:/data
# 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
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]
@@ -37,6 +45,8 @@ 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:
@@ -48,7 +58,29 @@ volumes:
docker compose up -d
```
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).
## 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>
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
@@ -57,7 +89,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`defaults to `./data`. Holds playlist cover collages and other generated artefacts.
- `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_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.
@@ -66,8 +98,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
## Updating
- `: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.
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.
## Specs
@@ -10,6 +10,8 @@ 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
@@ -155,6 +157,11 @@ 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() {
@@ -0,0 +1,36 @@
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,6 +1,7 @@
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
@@ -35,6 +36,7 @@ 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()
@@ -55,6 +57,10 @@ 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() }
}
/**
@@ -72,9 +78,32 @@ 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,6 +59,9 @@ 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 {
@@ -70,6 +73,7 @@ class AuthStore @Inject constructor(
themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
}
}
}
@@ -112,6 +116,11 @@ 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))
@@ -160,6 +169,14 @@ 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,
@@ -171,6 +188,7 @@ class AuthStore @Inject constructor(
CacheSettings.serializer(),
cacheSettingsState.value,
),
diagnosticsOptOut = diagnosticsOptOutState.value,
)
companion object {
@@ -16,6 +16,7 @@ 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
@@ -30,6 +31,7 @@ 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
/**
@@ -61,8 +63,11 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHomeIndexEntity::class,
CachedHistorySnapshotEntity::class,
AuthSessionEntity::class,
DiagnosticEventEntity::class,
],
version = 6,
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
version = 7,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -81,4 +86,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
}
@@ -10,6 +10,7 @@ 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
@@ -105,5 +106,10 @@ 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,4 +42,8 @@ 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)
}
@@ -0,0 +1,35 @@
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,4 +36,11 @@ 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,
)
@@ -0,0 +1,28 @@
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,
)
@@ -0,0 +1,328 @@
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
}
}
@@ -0,0 +1,118 @@
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
}
}
@@ -95,6 +95,7 @@ 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
@@ -139,6 +140,7 @@ 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() {
@@ -167,6 +169,15 @@ class HomeViewModel @Inject constructor(
init {
refresh()
// #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer.
viewModelScope.launch {
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" }
.collect { refresh() }
}
}
/**
@@ -16,4 +16,11 @@ data class UserRef(
val id: String,
val username: String,
val isAdmin: Boolean = false,
/**
* Account-level diagnostics opt-in (M9). NOT present in the login
* response (that's the narrow UserView) — populated by the /api/me
* refresh in AuthController so a remote admin flip reaches the
* device. Default false until the first refresh.
*/
val debugModeEnabled: Boolean = false,
)
@@ -18,4 +18,5 @@ 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,
)
@@ -0,0 +1,77 @@
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,6 +10,8 @@ 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
@@ -63,6 +65,8 @@ 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) {
@@ -116,6 +120,20 @@ 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 {
@@ -438,6 +456,11 @@ 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
@@ -447,20 +470,26 @@ 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()) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
return
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)
}
// Race the normal cadence against any external wake (activity
// resume). Whichever wins continues to the next pollOnce.
@@ -471,6 +500,30 @@ 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 forward-sync the local cursor to Sonos's Track
@@ -14,6 +14,8 @@ 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
@@ -63,6 +65,7 @@ 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,
@@ -111,6 +114,13 @@ 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
@@ -192,6 +202,20 @@ 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
@@ -337,7 +361,12 @@ 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) return
if (!isZeroDuration) {
// A track loaded with real audio — clear the self-heal budget so
// a later stale queue can recover again.
selfHealAttempts = 0
return
}
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = current.id,
@@ -353,6 +382,58 @@ 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() {
@@ -403,6 +484,17 @@ 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(
@@ -749,6 +841,10 @@ 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,6 +81,8 @@ class PlayerFactory @Inject constructor(
delegate = exo,
holder = activeUpnpHolder,
remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth,
onDrop = { name -> emitDrop(name) },
)
}
@@ -15,6 +15,7 @@ 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
@@ -29,6 +30,7 @@ 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
@@ -172,6 +174,31 @@ 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
}
}
}
/**
@@ -541,6 +568,13 @@ 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.
@@ -567,6 +601,102 @@ 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)
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
@@ -198,10 +198,13 @@ private fun MulticastHintRow() {
}
}
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
// 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) {
OutputRoute.Kind.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired"
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
OutputRoute.Kind.Cast -> "Cast"
OutputRoute.Kind.Other -> "Available"
}
@@ -21,7 +21,6 @@ data class OutputRoute(
val description: String?,
val kind: Kind,
val protocol: Protocol,
val isConnected: Boolean,
) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
@@ -43,9 +42,11 @@ 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]. The
* `connectionState` proxy is good enough for the chip's
* "Connected"/"Available" subtitle.
* 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.
*/
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) {
@@ -58,15 +59,12 @@ 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,
)
}
@@ -76,10 +74,10 @@ data class OutputRoute(
* network speakers into the same `OutputPickerController`
* routes stream the system routes come through.
*
* `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.
* 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
@@ -96,7 +94,6 @@ data class OutputRoute(
description = description,
kind = Kind.Other,
protocol = Protocol.UPNP,
isConnected = false,
)
}
}
@@ -8,6 +8,7 @@ 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
@@ -17,6 +18,7 @@ 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
@@ -58,6 +60,12 @@ 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()
@@ -78,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
* that just opened).
*/
fun upgradeDiscovery() {
appScope.launch { ssdp.requestActiveScan() }
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
}
/**
@@ -115,6 +147,7 @@ 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) {
@@ -240,6 +273,11 @@ 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
}
}
@@ -28,6 +28,10 @@ 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
@@ -139,6 +143,15 @@ 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(
@@ -163,6 +176,12 @@ 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) {
@@ -171,6 +190,14 @@ 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 {
@@ -220,6 +247,30 @@ 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()
@@ -261,8 +312,25 @@ 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)) },
@@ -28,6 +28,7 @@ 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
@@ -94,6 +95,7 @@ fun SettingsScreen(
onPickTheme = themeVm::setThemeMode,
onNavToRequests = { navController.navigate(Requests) },
onNavToAdmin = { navController.navigate(Admin) },
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
onSignOutClick = { showSignOutConfirm = true },
)
}
@@ -116,6 +118,7 @@ private fun SettingsList(
onPickTheme: (ThemeMode) -> Unit,
onNavToRequests: () -> Unit,
onNavToAdmin: () -> Unit,
onToggleDiagnostics: (Boolean) -> Unit,
onSignOutClick: () -> Unit,
) {
Column(
@@ -148,6 +151,12 @@ private fun SettingsList(
ProfileCard()
PasswordCard()
ListenBrainzCard()
if (state.diagnosticsEnabledByAdmin) {
DiagnosticsCard(
optOut = state.diagnosticsOptOut,
onToggle = onToggleDiagnostics,
)
}
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
StorageCard()
AboutCard()
@@ -262,6 +271,46 @@ 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,12 +19,15 @@ 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,
authStore: AuthStore,
private val authStore: AuthStore,
) : ViewModel() {
private val transient = MutableStateFlow(TransientState())
@@ -41,19 +44,27 @@ class SettingsViewModel @Inject constructor(
combine(
authStore.baseUrl,
authController.currentUser,
authStore.diagnosticsOptOut,
transient,
) { url, user, t ->
) { url, user, optOut, 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 {
@@ -1,15 +1,25 @@
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 or unresolvable.
* 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).
*/
@Composable
fun ServerImage(
@@ -22,12 +32,26 @@ fun ServerImage(
val resolved = resolveServerUrl(url)
if (resolved == null) {
fallback()
} else {
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) {
AsyncImage(
model = resolved,
contentDescription = contentDescription,
modifier = modifier,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
onState = { state = it },
)
if (state is AsyncImagePainter.State.Loading ||
state is AsyncImagePainter.State.Error
) {
fallback()
}
}
}
+18 -8
View File
@@ -6,17 +6,27 @@ in-app update flow (#397).
## Production
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/`.
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/`.
## Development
Empty directory works fine — the endpoints return 404 and the Flutter
Empty directory works fine — the endpoints return 404 and the Android
update banner stays hidden ("no update channel" graceful degradation).
To smoke-test the update flow locally, drop a real APK + a version
+6 -1
View File
@@ -242,7 +242,12 @@ 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)
playlistScheduler, err := playlists.NewScheduler(
pool,
logger.With("component", "playlist_scheduler"),
cfg.Storage.DataDir,
bus,
)
if err != nil {
return fmt.Errorf("init playlist scheduler: %w", err)
}
+7 -2
View File
@@ -26,8 +26,13 @@ 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. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
scan_on_startup: false
# 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
# Per-instance branding. Operators can rename their instance ("Family
# Jukebox", "Office Music", etc.) and override the OG share-preview
+19
View File
@@ -0,0 +1,19 @@
# 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.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+5
View File
@@ -22,6 +22,7 @@ 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"`
}
@@ -44,6 +45,7 @@ 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)
@@ -120,6 +122,7 @@ 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),
})
}
@@ -189,6 +192,7 @@ 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),
})
}
@@ -329,6 +333,7 @@ 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),
})
}
+12
View File
@@ -74,6 +74,7 @@ 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)
@@ -134,6 +135,12 @@ 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.
@@ -180,6 +187,11 @@ 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)
+346
View File
@@ -0,0 +1,346 @@
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
}
+15
View File
@@ -88,6 +88,21 @@ 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
+10 -5
View File
@@ -35,6 +35,10 @@ type meProfileResp struct {
DisplayName *string `json:"display_name"`
Email *string `json:"email"`
IsAdmin bool `json:"is_admin"`
// DebugModeEnabled is the account's diagnostics opt-in. The client
// reads it here to decide whether to run the on-device diagnostics
// reporter (M9). Admin-set; the client also has a local OFF switch.
DebugModeEnabled bool `json:"debug_mode_enabled"`
}
func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) {
@@ -120,10 +124,11 @@ func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request)
func profileViewFromUser(u dbq.User) meProfileResp {
return meProfileResp{
ID: uuidToString(u.ID),
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
IsAdmin: u.IsAdmin,
ID: uuidToString(u.ID),
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
IsAdmin: u.IsAdmin,
DebugModeEnabled: u.DebugModeEnabled,
}
}
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"net/http"
"strconv"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
recMetricsDefaultDays = 30
recMetricsMaxDays = 365
)
// recommendationMetric is one recommendation surface's outcomes.
type recommendationMetric struct {
Source string `json:"source"` // 'for_you' | 'discover' | mixes
Plays int64 `json:"plays"` // plays launched from this surface
Skips int64 `json:"skips"` // of those, marked skipped
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
}
type recommendationMetricsResp struct {
WindowDays int `json:"window_days"`
Sources []recommendationMetric `json:"sources"`
}
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
// caller over the last `days` (default 30, capped at 365), so the operator can
// see which recommendation surfaces are landing and tune the taste weights.
// Only plays tagged with a system-playlist source count; library/radio plays
// (no source) are excluded.
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
return
}
days, ok := parseMetricsDays(w, r)
if !ok {
return
}
rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
if err != nil {
h.logger.Error("api: recommendation metrics", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := recommendationMetricsResp{
WindowDays: days,
Sources: make([]recommendationMetric, 0, len(rows)),
}
for _, row := range rows {
source := ""
if row.Source != nil {
source = *row.Source
}
var skipRate float64
if row.Plays > 0 {
skipRate = float64(row.Skips) / float64(row.Plays)
}
out.Sources = append(out.Sources, recommendationMetric{
Source: source,
Plays: row.Plays,
Skips: row.Skips,
SkipRate: skipRate,
AvgCompletion: row.AvgCompletion,
})
}
writeJSON(w, http.StatusOK, out)
}
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
// Writes a 400 and returns ok=false on a malformed value.
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
v := r.URL.Query().Get("days")
if v == "" {
return recMetricsDefaultDays, true
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
return 0, false
}
if n > recMetricsMaxDays {
n = recMetricsMaxDays
}
return n, true
}
@@ -0,0 +1,108 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func newMetricsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics)
return r
}
// seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (library/radio play).
func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
source *string, completion float64, skipped bool,
) {
t.Helper()
if _, err := h.pool.Exec(context.Background(),
`INSERT INTO play_events
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
userID, trackID, sessionID, source, completion, skipped); err != nil {
t.Fatalf("seed sourced play: %v", err)
}
}
func TestRecommendationMetrics_NoSession401(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
rec := httptest.NewRecorder()
newMetricsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "metrics", "pw", false)
artist := seedArtist(t, pool, "MetricArtist")
album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020)
tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000)
session := seedPlaySession(t, pool, user.ID, time.Now())
forYou := "for_you"
discover := "discover"
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
// discover: 1 play.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
// library play (NULL source) — must be excluded.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newMetricsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp recommendationMetricsResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.WindowDays != recMetricsDefaultDays {
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
}
bySource := map[string]recommendationMetric{}
for _, m := range resp.Sources {
bySource[m.Source] = m
}
if _, present := bySource[""]; present {
t.Error("NULL-source (library) plays should be excluded")
}
fy, ok := bySource["for_you"]
if !ok {
t.Fatal("for_you metrics missing")
}
if fy.Plays != 3 || fy.Skips != 1 {
t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips)
}
if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 {
t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate)
}
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
}
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
}
}
+2
View File
@@ -73,6 +73,8 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
writeErr(w, apierror.InternalMsg("build failed", err))
return
}
// #968: announce the rebuild so the user's other clients refresh.
h.publishSystemRebuilt(user.ID)
q := dbq.New(h.pool)
v := kind
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
+5
View File
@@ -135,6 +135,11 @@ func Default() Config {
RadioSizeMax: 200,
},
Library: LibraryConfig{
// On by default so a fresh install populates its library on
// first boot without the operator hunting for a "scan now"
// button. Scans are incremental (unchanged files skipped by
// mtime), so every later restart is just a cheap directory walk.
ScanOnStartup: true,
CoverArtFromMBCAA: true,
},
}
+4 -3
View File
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
func TestLibraryEnvOverrides(t *testing.T) {
stubStreamSecret(t)
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
// Default is true; set false so the override path is actually exercised.
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
cfg, err := Load("")
if err != nil {
@@ -112,8 +113,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
}
}
if !cfg.Library.ScanOnStartup {
t.Error("scan_on_startup = false, want true")
if cfg.Library.ScanOnStartup {
t.Error("scan_on_startup = true, want false (env override of the true default)")
}
}
+221
View File
@@ -0,0 +1,221 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: diagnostics.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const gcPruneDiagnostics = `-- name: GcPruneDiagnostics :execrows
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days'
`
// Retention sweep for the gc worker. Deletes diagnostic_events older
// than 30 days by server clock (received_at) so a skewed client clock
// can't keep rows alive. Matches the other gc lifecycle sweeps'
// hardcoded-threshold style; diagnostics are debug telemetry, not user
// data, and 30 days is ample to investigate a live incident after the
// fact. Returns the affected row count for the sweep log line.
func (q *Queries) GcPruneDiagnostics(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcPruneDiagnostics)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const insertDiagnosticEvent = `-- name: InsertDiagnosticEvent :one
INSERT INTO diagnostic_events (
user_id, client_id, app_version, os_version, kind, payload, occurred_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, received_at
`
type InsertDiagnosticEventParams struct {
UserID pgtype.UUID
ClientID string
AppVersion *string
OsVersion *string
Kind string
Payload []byte
OccurredAt pgtype.Timestamptz
}
type InsertDiagnosticEventRow struct {
ID pgtype.UUID
ReceivedAt pgtype.Timestamptz
}
// Records one client-reported diagnostic event. The writer validates the
// `kind` category against its whitelist before this runs; the CHECK
// constraint is the belt-and-braces guard. payload carries the finer
// event discriminator + all event-specific fields. Returns received_at
// so the caller can confirm ingest.
func (q *Queries) InsertDiagnosticEvent(ctx context.Context, arg InsertDiagnosticEventParams) (InsertDiagnosticEventRow, error) {
row := q.db.QueryRow(ctx, insertDiagnosticEvent,
arg.UserID,
arg.ClientID,
arg.AppVersion,
arg.OsVersion,
arg.Kind,
arg.Payload,
arg.OccurredAt,
)
var i InsertDiagnosticEventRow
err := row.Scan(&i.ID, &i.ReceivedAt)
return i, err
}
const listAdminDiagnostics = `-- name: ListAdminDiagnostics :many
SELECT
de.id AS id,
de.user_id AS user_id,
u.username AS username,
de.client_id AS client_id,
de.app_version AS app_version,
de.os_version AS os_version,
de.kind AS kind,
de.payload AS payload,
de.occurred_at AS occurred_at,
de.received_at AS received_at
FROM diagnostic_events de
JOIN users u ON u.id = de.user_id
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
AND ($2::text IS NULL OR de.client_id = $2::text)
AND ($3::text IS NULL OR de.kind = $3::text)
AND ($4::timestamptz IS NULL
OR de.occurred_at >= $4::timestamptz)
AND ($5::timestamptz IS NULL
OR de.occurred_at <= $5::timestamptz)
ORDER BY de.occurred_at DESC
LIMIT $7::int OFFSET $6::int
`
type ListAdminDiagnosticsParams struct {
UserID pgtype.UUID
ClientID *string
Kind *string
FromTs pgtype.Timestamptz
ToTs pgtype.Timestamptz
Off int32
Lim int32
}
type ListAdminDiagnosticsRow struct {
ID pgtype.UUID
UserID pgtype.UUID
Username string
ClientID string
AppVersion *string
OsVersion *string
Kind string
Payload []byte
OccurredAt pgtype.Timestamptz
ReceivedAt pgtype.Timestamptz
}
// Admin timeline query. All filters are optional (NULL = no filter):
// account (user_id), device (client_id), category (kind), and the
// occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
// a chronological timeline / export. Joined with users for the username
// so the view renders without a second round-trip.
func (q *Queries) ListAdminDiagnostics(ctx context.Context, arg ListAdminDiagnosticsParams) ([]ListAdminDiagnosticsRow, error) {
rows, err := q.db.Query(ctx, listAdminDiagnostics,
arg.UserID,
arg.ClientID,
arg.Kind,
arg.FromTs,
arg.ToTs,
arg.Off,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAdminDiagnosticsRow
for rows.Next() {
var i ListAdminDiagnosticsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Username,
&i.ClientID,
&i.AppVersion,
&i.OsVersion,
&i.Kind,
&i.Payload,
&i.OccurredAt,
&i.ReceivedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDiagnosticDevices = `-- name: ListDiagnosticDevices :many
SELECT
de.client_id AS client_id,
de.user_id AS user_id,
u.username AS username,
max(de.app_version)::text AS app_version,
max(de.os_version)::text AS os_version,
max(de.occurred_at)::timestamptz AS last_seen,
count(*) AS event_count
FROM diagnostic_events de
JOIN users u ON u.id = de.user_id
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
GROUP BY de.client_id, de.user_id, u.username
ORDER BY last_seen DESC
`
type ListDiagnosticDevicesRow struct {
ClientID string
UserID pgtype.UUID
Username string
AppVersion string
OsVersion string
LastSeen pgtype.Timestamptz
EventCount int64
}
// Distinct devices that have reported, for the admin device filter +
// "who is currently reporting" overview. Optionally scoped to one account.
func (q *Queries) ListDiagnosticDevices(ctx context.Context, userID pgtype.UUID) ([]ListDiagnosticDevicesRow, error) {
rows, err := q.db.Query(ctx, listDiagnosticDevices, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDiagnosticDevicesRow
for rows.Next() {
var i ListDiagnosticDevicesRow
if err := rows.Scan(
&i.ClientID,
&i.UserID,
&i.Username,
&i.AppVersion,
&i.OsVersion,
&i.LastSeen,
&i.EventCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+13
View File
@@ -265,6 +265,18 @@ type CoverArtSourcesMetum struct {
LastRegisteredProvidersHash string
}
type DiagnosticEvent struct {
ID pgtype.UUID
UserID pgtype.UUID
ClientID string
AppVersion *string
OsVersion *string
Kind string
Payload []byte
OccurredAt pgtype.Timestamptz
ReceivedAt pgtype.Timestamptz
}
type GeneralLike struct {
UserID pgtype.UUID
TrackID pgtype.UUID
@@ -552,6 +564,7 @@ type User struct {
Email *string
Timezone string
TimezoneUpdatedAt pgtype.Timestamptz
DebugModeEnabled bool
}
type UserInvite struct {
+21 -2
View File
@@ -737,6 +737,17 @@ likes_overlap AS (
ORDER BY random()
LIMIT $8
),
taste_overlap AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM taste_profile_artists tpa
JOIN tracks t ON t.artist_id = tpa.artist_id
WHERE tpa.user_id = $1
AND tpa.weight > 0
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY tpa.weight DESC, t.id
LIMIT $10
),
random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t
@@ -747,6 +758,7 @@ random_fill AS (
UNION SELECT track_id FROM similar_artists
UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap
)
ORDER BY random()
LIMIT $9
@@ -763,6 +775,7 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM similar_artists
UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM random_fill
) u
JOIN tracks t ON t.id = u.track_id
@@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
Limit_3 int32
Limit_4 int32
Limit_5 int32
Limit_6 int32
}
type LoadRadioCandidatesV2Row struct {
@@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
SimilarityScore interface{}
}
// M4c: similarity-driven candidate pool. 5-way UNION:
// M4c: similarity-driven candidate pool. 6-way UNION:
//
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
// positively-weighted taste-profile artists, so taste-relevant tracks
// enter the pool even when the similarity/random arms miss them; scored
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
//
// Returns same shape as LoadRadioCandidates plus similarity_score column.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
@@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
arg.Limit_3,
arg.Limit_4,
arg.Limit_5,
arg.Limit_6,
)
if err != nil {
return nil, err
@@ -0,0 +1,73 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: recommendation_metrics.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
SELECT
pe.source,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
COALESCE(
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
0)::float8 AS avg_completion
FROM play_events pe
WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source
ORDER BY plays DESC
`
type RecommendationSourceMetricsForUserParams struct {
UserID pgtype.UUID
Column2 float64
}
type RecommendationSourceMetricsForUserRow struct {
Source *string
Plays int64
Skips int64
AvgCompletion float64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
// NULL for library / radio / user-playlist plays, which are excluded here.
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
// mean completion ratio over plays that recorded one (0 when none did).
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationSourceMetricsForUserRow
for rows.Next() {
var i RecommendationSourceMetricsForUserRow
if err := rows.Scan(
&i.Source,
&i.Plays,
&i.Skips,
&i.AvgCompletion,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+64 -12
View File
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type CreateUserParams struct {
@@ -89,6 +89,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -96,7 +97,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
const createUserAdmin = `-- name: CreateUserAdmin :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type CreateUserAdminParams struct {
@@ -134,6 +135,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -145,7 +147,7 @@ VALUES (
(SELECT NOT EXISTS (SELECT 1 FROM users)),
$4
)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type CreateUserFirstAdminRaceParams struct {
@@ -191,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -237,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
}
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1
`
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
@@ -258,12 +261,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1)
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1)
`
// Used by forgot-password lookup. Lowercase comparison both sides
@@ -287,12 +291,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
@@ -313,12 +318,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -339,6 +345,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -382,7 +389,8 @@ func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiv
}
const listUsers = `-- name: ListUsers :many
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
SELECT id, username, display_name, is_admin, auto_approve_requests,
debug_mode_enabled, created_at
FROM users
ORDER BY created_at DESC
`
@@ -393,6 +401,7 @@ type ListUsersRow struct {
DisplayName *string
IsAdmin bool
AutoApproveRequests bool
DebugModeEnabled bool
CreatedAt pgtype.Timestamptz
}
@@ -412,6 +421,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
&i.DisplayName,
&i.IsAdmin,
&i.AutoApproveRequests,
&i.DebugModeEnabled,
&i.CreatedAt,
); err != nil {
return nil, err
@@ -426,7 +436,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
const regenerateApiToken = `-- name: RegenerateApiToken :one
UPDATE users SET api_token = $2 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type RegenerateApiTokenParams struct {
@@ -454,6 +464,7 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -478,6 +489,44 @@ func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordPa
return err
}
const setDebugMode = `-- name: SetDebugMode :one
UPDATE users
SET debug_mode_enabled = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type SetDebugModeParams struct {
ID pgtype.UUID
DebugModeEnabled bool
}
// Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
// from /admin (flip remotely while a bug is live) or self-driven OFF from
// the client. Returns the updated row so the handler can echo it.
func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (User, error) {
row := q.db.QueryRow(ctx, setDebugMode, arg.ID, arg.DebugModeEnabled)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
UPDATE users
SET listenbrainz_enabled = $2
@@ -531,7 +580,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
UPDATE users
SET is_admin = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type UpdateUserAdminParams struct {
@@ -559,6 +608,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -567,7 +617,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
UPDATE users
SET auto_approve_requests = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type UpdateUserAutoApproveParams struct {
@@ -594,6 +644,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
@@ -603,7 +654,7 @@ UPDATE users
SET display_name = $2,
email = $3
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
`
type UpdateUserProfileParams struct {
@@ -632,6 +683,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
}
+148
View File
@@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig
return err
}
const listYouMightLikeAlbumFallbackForUser = `-- name: ListYouMightLikeAlbumFallbackForUser :many
WITH liked_album_ids AS (
SELECT likb.album_id AS album_id
FROM general_likes_albums likb
WHERE likb.user_id = $1
UNION
SELECT trk.album_id AS album_id
FROM general_likes likt
JOIN tracks trk ON trk.id = likt.track_id
WHERE likt.user_id = $1
)
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
FROM liked_album_ids lai
JOIN albums ON albums.id = lai.album_id
JOIN artists ON artists.id = albums.artist_id
ORDER BY md5(albums.id::text || current_date::text)
LIMIT $2
`
type ListYouMightLikeAlbumFallbackForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListYouMightLikeAlbumFallbackForUserRow struct {
Album Album
ArtistName string
}
// Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
// the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg ListYouMightLikeAlbumFallbackForUserParams) ([]ListYouMightLikeAlbumFallbackForUserRow, error) {
rows, err := q.db.Query(ctx, listYouMightLikeAlbumFallbackForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListYouMightLikeAlbumFallbackForUserRow
for rows.Next() {
var i ListYouMightLikeAlbumFallbackForUserRow
if err := rows.Scan(
&i.Album.ID,
&i.Album.Title,
&i.Album.SortTitle,
&i.Album.ArtistID,
&i.Album.ReleaseDate,
&i.Album.Mbid,
&i.Album.CoverArtPath,
&i.Album.CreatedAt,
&i.Album.UpdatedAt,
&i.Album.CoverArtSource,
&i.Album.CoverArtSourcesVersion,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
FROM you_might_like_albums yml
@@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou
return items, nil
}
const listYouMightLikeArtistFallbackForUser = `-- name: ListYouMightLikeArtistFallbackForUser :many
WITH liked_artist_ids AS (
SELECT lika.artist_id AS artist_id
FROM general_likes_artists lika
WHERE lika.user_id = $1
UNION
SELECT alb.artist_id AS artist_id
FROM general_likes_albums likb
JOIN albums alb ON alb.id = likb.album_id
WHERE likb.user_id = $1
UNION
SELECT trk.artist_id AS artist_id
FROM general_likes likt
JOIN tracks trk ON trk.id = likt.track_id
WHERE likt.user_id = $1
)
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count
FROM liked_artist_ids lai
JOIN artists ON artists.id = lai.artist_id
LEFT JOIN LATERAL (
SELECT id FROM albums
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
ORDER BY created_at DESC LIMIT 1
) cov ON true
LEFT JOIN LATERAL (
SELECT count(*) AS album_count
FROM albums WHERE artist_id = artists.id
) cnt ON true
ORDER BY md5(artists.id::text || current_date::text)
LIMIT $2
`
type ListYouMightLikeArtistFallbackForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListYouMightLikeArtistFallbackForUserRow struct {
Artist Artist
CoverAlbumID pgtype.UUID
AlbumCount int64
}
// Broad "artists you've shown affinity for" pool to fill a thin
// You-might-like artists row: explicitly liked artists PLUS the artists of
// liked albums and liked tracks. Most users like albums/tracks far more than
// they like artists explicitly, so the union is much deeper than
// general_likes_artists alone. Daily-stable order; same projection as
// ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
func (q *Queries) ListYouMightLikeArtistFallbackForUser(ctx context.Context, arg ListYouMightLikeArtistFallbackForUserParams) ([]ListYouMightLikeArtistFallbackForUserRow, error) {
rows, err := q.db.Query(ctx, listYouMightLikeArtistFallbackForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListYouMightLikeArtistFallbackForUserRow
for rows.Next() {
var i ListYouMightLikeArtistFallbackForUserRow
if err := rows.Scan(
&i.Artist.ID,
&i.Artist.Name,
&i.Artist.SortName,
&i.Artist.Mbid,
&i.Artist.CreatedAt,
&i.Artist.UpdatedAt,
&i.Artist.ArtistThumbPath,
&i.Artist.ArtistFanartPath,
&i.Artist.ArtistArtSource,
&i.Artist.ArtistArtSourcesVersion,
&i.CoverAlbumID,
&i.AlbumCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
cov.id AS cover_album_id,
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN IF EXISTS debug_mode_enabled;
DROP TABLE IF EXISTS diagnostic_events;
@@ -0,0 +1,54 @@
-- Device diagnostics / debug-reporting subsystem (M9).
--
-- When an account has users.debug_mode_enabled = true, that account's
-- client(s) stream a timeseries of device state here: connectivity +
-- server-health transitions (roaming / poor-data recovery), UPnP/Sonos
-- sync events (locked-phone desync), and power/lifecycle state (Doze /
-- battery-optimization, the prime suspect for the desync). The admin
-- /admin/diagnostics view filters by account + device + time window and
-- exports the slice as JSON for offline analysis.
--
-- `kind` is a COARSE category gated by a CHECK whitelist; the finer
-- event discriminator + all event-specific fields live in the `payload`
-- jsonb. This keeps the enum small and stable so new event variants
-- don't require a migration (per the enum-CHECK-whitelist rule, only a
-- new *category* would). If you add a category here, mirror it in the
-- handler whitelist in internal/api/diagnostics.go.
--
-- Two timestamps on purpose: occurred_at is the client device clock
-- (when the event happened on the phone — may be skewed/wrong, e.g. in
-- a dead zone), received_at is the server clock at ingest. Retention
-- pruning is keyed on received_at so a bad client clock can't keep rows
-- alive forever.
CREATE TABLE diagnostic_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id text NOT NULL,
app_version text,
os_version text,
kind text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
occurred_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http'
))
);
-- Admin reads filter by account then device, ordered newest-first.
CREATE INDEX idx_diagnostic_events_user
ON diagnostic_events (user_id, occurred_at DESC);
CREATE INDEX idx_diagnostic_events_client
ON diagnostic_events (client_id, occurred_at DESC);
-- Retention pruner deletes by server-clock age.
CREATE INDEX idx_diagnostic_events_received
ON diagnostic_events (received_at);
-- Per-account opt-in for diagnostics reporting. Admin-set primarily
-- (flip remotely while a bug is live); the client also exposes a local
-- OFF switch. Default false: no account reports until explicitly enabled.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS debug_mode_enabled boolean NOT NULL DEFAULT false;
@@ -0,0 +1,9 @@
-- Drop rows using the new value first so they don't violate the restored
-- (narrower) constraint, then revert the whitelist.
DELETE FROM diagnostic_events WHERE kind = 'playback';
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http'
));
@@ -0,0 +1,19 @@
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
--
-- `route` and `player_state` events fire for EVERY output route (phone
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
-- (drops, and future resync/desync); general playback + route telemetry
-- moves to the new 'playback' kind.
--
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
-- re-adding the constraint in the same change. Existing rows tagged
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http', 'playback'
));
+66
View File
@@ -0,0 +1,66 @@
-- name: InsertDiagnosticEvent :one
-- Records one client-reported diagnostic event. The writer validates the
-- `kind` category against its whitelist before this runs; the CHECK
-- constraint is the belt-and-braces guard. payload carries the finer
-- event discriminator + all event-specific fields. Returns received_at
-- so the caller can confirm ingest.
INSERT INTO diagnostic_events (
user_id, client_id, app_version, os_version, kind, payload, occurred_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, received_at;
-- name: ListAdminDiagnostics :many
-- Admin timeline query. All filters are optional (NULL = no filter):
-- account (user_id), device (client_id), category (kind), and the
-- occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
-- a chronological timeline / export. Joined with users for the username
-- so the view renders without a second round-trip.
SELECT
de.id AS id,
de.user_id AS user_id,
u.username AS username,
de.client_id AS client_id,
de.app_version AS app_version,
de.os_version AS os_version,
de.kind AS kind,
de.payload AS payload,
de.occurred_at AS occurred_at,
de.received_at AS received_at
FROM diagnostic_events de
JOIN users u ON u.id = de.user_id
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
AND (sqlc.narg(client_id)::text IS NULL OR de.client_id = sqlc.narg(client_id)::text)
AND (sqlc.narg(kind)::text IS NULL OR de.kind = sqlc.narg(kind)::text)
AND (sqlc.narg(from_ts)::timestamptz IS NULL
OR de.occurred_at >= sqlc.narg(from_ts)::timestamptz)
AND (sqlc.narg(to_ts)::timestamptz IS NULL
OR de.occurred_at <= sqlc.narg(to_ts)::timestamptz)
ORDER BY de.occurred_at DESC
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
-- name: ListDiagnosticDevices :many
-- Distinct devices that have reported, for the admin device filter +
-- "who is currently reporting" overview. Optionally scoped to one account.
SELECT
de.client_id AS client_id,
de.user_id AS user_id,
u.username AS username,
max(de.app_version)::text AS app_version,
max(de.os_version)::text AS os_version,
max(de.occurred_at)::timestamptz AS last_seen,
count(*) AS event_count
FROM diagnostic_events de
JOIN users u ON u.id = de.user_id
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
GROUP BY de.client_id, de.user_id, u.username
ORDER BY last_seen DESC;
-- name: GcPruneDiagnostics :execrows
-- Retention sweep for the gc worker. Deletes diagnostic_events older
-- than 30 days by server clock (received_at) so a skewed client clock
-- can't keep rows alive. Matches the other gc lifecycle sweeps'
-- hardcoded-threshold style; diagnostics are debug telemetry, not user
-- data, and 30 days is ample to investigate a live incident after the
-- fact. Returns the affected row count for the sweep log line.
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days';
+19 -2
View File
@@ -33,10 +33,14 @@ WHERE t.id <> $2
);
-- name: LoadRadioCandidatesV2 :many
-- M4c: similarity-driven candidate pool. 5-way UNION:
-- M4c: similarity-driven candidate pool. 6-way UNION:
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
-- positively-weighted taste-profile artists, so taste-relevant tracks
-- enter the pool even when the similarity/random arms miss them; scored
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
WITH
@@ -109,6 +113,17 @@ likes_overlap AS (
ORDER BY random()
LIMIT $8
),
taste_overlap AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM taste_profile_artists tpa
JOIN tracks t ON t.artist_id = tpa.artist_id
WHERE tpa.user_id = $1
AND tpa.weight > 0
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY tpa.weight DESC, t.id
LIMIT $10
),
random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t
@@ -119,6 +134,7 @@ random_fill AS (
UNION SELECT track_id FROM similar_artists
UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap
)
ORDER BY random()
LIMIT $9
@@ -135,6 +151,7 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM similar_artists
UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM random_fill
) u
JOIN tracks t ON t.id = u.track_id
@@ -0,0 +1,22 @@
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
-- operator can see whether each recommendation surface is landing and tune the
-- taste weights. Source is stamped on play_events when a play is launched from
-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
-- NULL for library / radio / user-playlist plays, which are excluded here.
-- name: RecommendationSourceMetricsForUser :many
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
-- mean completion ratio over plays that recorded one (0 when none did).
SELECT
pe.source,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
COALESCE(
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
0)::float8 AS avg_completion
FROM play_events pe
WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source
ORDER BY plays DESC;
+11 -1
View File
@@ -55,7 +55,8 @@ WHERE id = $1;
-- name: ListUsers :many
-- Admin user-management list. Sort newest-first.
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
SELECT id, username, display_name, is_admin, auto_approve_requests,
debug_mode_enabled, created_at
FROM users
ORDER BY created_at DESC;
@@ -115,6 +116,15 @@ UPDATE users
WHERE id = $1
RETURNING *;
-- name: SetDebugMode :one
-- Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
-- from /admin (flip remotely while a bug is live) or self-driven OFF from
-- the client. Returns the updated row so the handler can echo it.
UPDATE users
SET debug_mode_enabled = $2
WHERE id = $1
RETURNING *;
-- name: ChangeUserPassword :exec
-- Self-service password change. Caller (HTTP handler) verifies the
-- current password before calling this. Distinct from
+59
View File
@@ -64,3 +64,62 @@ LEFT JOIN LATERAL (
WHERE yml.user_id = $1
ORDER BY yml.rank
LIMIT $2;
-- name: ListYouMightLikeArtistFallbackForUser :many
-- Broad "artists you've shown affinity for" pool to fill a thin
-- You-might-like artists row: explicitly liked artists PLUS the artists of
-- liked albums and liked tracks. Most users like albums/tracks far more than
-- they like artists explicitly, so the union is much deeper than
-- general_likes_artists alone. Daily-stable order; same projection as
-- ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
WITH liked_artist_ids AS (
SELECT lika.artist_id AS artist_id
FROM general_likes_artists lika
WHERE lika.user_id = $1
UNION
SELECT alb.artist_id AS artist_id
FROM general_likes_albums likb
JOIN albums alb ON alb.id = likb.album_id
WHERE likb.user_id = $1
UNION
SELECT trk.artist_id AS artist_id
FROM general_likes likt
JOIN tracks trk ON trk.id = likt.track_id
WHERE likt.user_id = $1
)
SELECT sqlc.embed(artists),
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count
FROM liked_artist_ids lai
JOIN artists ON artists.id = lai.artist_id
LEFT JOIN LATERAL (
SELECT id FROM albums
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
ORDER BY created_at DESC LIMIT 1
) cov ON true
LEFT JOIN LATERAL (
SELECT count(*) AS album_count
FROM albums WHERE artist_id = artists.id
) cnt ON true
ORDER BY md5(artists.id::text || current_date::text)
LIMIT $2;
-- name: ListYouMightLikeAlbumFallbackForUser :many
-- Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
-- the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
WITH liked_album_ids AS (
SELECT likb.album_id AS album_id
FROM general_likes_albums likb
WHERE likb.user_id = $1
UNION
SELECT trk.album_id AS album_id
FROM general_likes likt
JOIN tracks trk ON trk.id = likt.track_id
WHERE likt.user_id = $1
)
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM liked_album_ids lai
JOIN albums ON albums.id = lai.album_id
JOIN artists ON artists.id = albums.artist_id
ORDER BY md5(albums.id::text || current_date::text)
LIMIT $2;
+2
View File
@@ -16,6 +16,7 @@
// - GcExpireScrobbleQueueFailedRows (#567)
// - GcResetStuckSystemPlaylistRuns (#574)
// - GcDeleteExpiredPasswordResets (#575)
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
package gc
import (
@@ -82,6 +83,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
}
// runSweep is a small adapter so each sweep call site is a one-liner
+30 -2
View File
@@ -28,6 +28,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)
@@ -47,14 +48,21 @@ type Scheduler struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
mu sync.Mutex
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
}
// NewScheduler builds an idle scheduler. Caller must invoke Start
// before any builds fire.
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) {
// before any builds fire. bus may be nil (tests); rebuild events are
// then skipped.
func NewScheduler(
pool *pgxpool.Pool,
logger *slog.Logger,
dataDir string,
bus *eventbus.Bus,
) (*Scheduler, error) {
g, err := gocron.NewScheduler()
if err != nil {
return nil, fmt.Errorf("init gocron: %w", err)
@@ -64,6 +72,7 @@ func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Sch
pool: pool,
logger: logger,
dataDir: dataDir,
bus: bus,
jobs: map[pgtype.UUID]uuid.UUID{},
}, nil
}
@@ -234,7 +243,26 @@ func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, no
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
s.logger.Warn("scheduler: build failed",
"user_id", uuidStringPL(userID), "err", err)
return
}
// #968: tell the user's connected clients their home content was
// regenerated, so a tab/app left open across the rebuild refreshes its
// system-playlist + You-might-like views (and a stale active queue can
// re-pull) instead of serving yesterday's snapshot until a manual reload.
s.publishRebuilt(userID)
}
// publishRebuilt broadcasts a user-scoped "system playlists rebuilt" event.
// No-op when the bus is nil (test construction).
func (s *Scheduler) publishRebuilt(userID pgtype.UUID) {
if s.bus == nil {
return
}
s.bus.Publish(eventbus.Event{
Kind: "playlist.system_rebuilt",
UserID: uuidStringPL(userID),
Data: map[string]any{},
})
}
// Stop drains gocron and stops the scheduler loop.
+4
View File
@@ -329,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
TagOverlap: 60,
LikesOverlap: 40,
RandomFill: 150,
// For-You / You-might-like are the taste-driven surfaces, so pull a
// deep slice of the user's top taste-profile artists into the pool
// (#796 phase 2b). Empty for cold-start users (no profile yet).
TasteOverlap: 80,
}
}
+7 -1
View File
@@ -89,9 +89,15 @@ func buildYouMightLike(
}
zeroVec := recommendation.SessionVector{Seed: true}
// You-might-like surfaces in-library artists the user does NOT actively
// engage with, so it deliberately skips the taste_overlap arm — that arm
// pulls top-taste (mostly already-played) artists, which would crowd the
// pool with entities the read-time dedup then strips. For-You/radio keep it.
ymlLimits := systemForYouSourceLimits()
ymlLimits.TasteOverlap = 0
cands, err := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seed, 1, zeroVec,
[]pgtype.UUID{seed}, systemForYouSourceLimits(),
[]pgtype.UUID{seed}, ymlLimits,
)
if err != nil {
logger.Warn("you-might-like: candidate load failed; skipping",
+6
View File
@@ -72,6 +72,10 @@ type CandidateSourceLimits struct {
TagOverlap int
LikesOverlap int
RandomFill int
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
// users have an empty profile, so it contributes nothing anyway).
TasteOverlap int
}
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
@@ -82,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
TagOverlap: 20,
LikesOverlap: 20,
RandomFill: 30,
TasteOverlap: 20,
}
}
@@ -113,6 +118,7 @@ func LoadCandidatesFromSimilarity(
Limit_3: int32(limits.TagOverlap),
Limit_4: int32(limits.LikesOverlap),
Limit_5: int32(limits.RandomFill),
Limit_6: int32(limits.TasteOverlap),
})
if err != nil {
return nil, err
@@ -262,6 +262,61 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
}
}
// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by
// a positively-weighted taste-profile artist enters the pool via taste_overlap
// even with every other arm disabled; a negatively-weighted artist's track does
// not (the WHERE weight > 0 filter).
func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
f := newFixture(t, 2) // seed + 1 other, both by the fixture artist
seed := f.tracks[0]
target := f.tracks[1]
ctx := context.Background()
// Fixture artist gets a positive taste weight.
if _, err := f.pool.Exec(ctx,
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`,
f.user, seed.ArtistID); err != nil {
t.Fatalf("insert taste (positive): %v", err)
}
// A second artist with a NEGATIVE weight — its track must be excluded.
negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"})
negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID})
negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID,
FilePath: "/tmp/neg.flac", DurationMs: 180_000,
})
if _, err := f.pool.Exec(ctx,
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`,
f.user, negArtist.ID); err != nil {
t.Fatalf("insert taste (negative): %v", err)
}
// Only the taste_overlap arm is enabled.
limits := CandidateSourceLimits{TasteOverlap: 10}
got, err := LoadCandidatesFromSimilarity(
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
)
if err != nil {
t.Fatalf("load: %v", err)
}
var sawTarget, sawNeg bool
for _, c := range got {
switch c.Track.ID {
case target.ID:
sawTarget = true
case negTrack.ID:
sawNeg = true
}
}
if !sawTarget {
t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)")
}
if sawNeg {
t.Error("negative-taste-artist track present (weight > 0 filter failed)")
}
}
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
f := newFixture(t, 1) // just the seed
seed := f.tracks[0]
+8 -7
View File
@@ -199,11 +199,12 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
const youMightLikeFallbackFetch = 100
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
// user's liked artists (daily-stable sample), excluding what's already shown
// on Home (the section itself, Rediscover, Most/Last Played) so the fallback
// neither duplicates a tile nor suggests an actively-played artist. The liked
// pool dwarfs the similarity roll-up, so the same exclusions still fill it.
// Best-effort: a query error leaves the section as-is.
// artists the user has shown affinity for — explicit artist-likes PLUS the
// artists of liked albums and liked tracks (most users like albums/tracks far
// more than artists, so the broad pool is what actually fills the row).
// Excludes what's already shown on Home (the section itself, Rediscover,
// Most/Last Played) so the fallback neither duplicates a tile nor suggests an
// actively-played artist. Best-effort: a query error leaves the section as-is.
func topUpYouMightLikeArtists(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
) []dbq.ListYouMightLikeArtistsForUserRow {
@@ -218,7 +219,7 @@ func topUpYouMightLikeArtists(
for _, r := range result {
excluded[r.Artist.ID] = struct{}{}
}
fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
UserID: userID, Limit: youMightLikeFallbackFetch,
})
if err != nil {
@@ -249,7 +250,7 @@ func topUpYouMightLikeAlbums(
for _, r := range result {
excluded[r.Album.ID] = struct{}{}
}
fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
UserID: userID, Limit: youMightLikeFallbackFetch,
})
if err != nil {
@@ -48,6 +48,40 @@ func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) {
}
}
// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback
// must surface the artists of LIKED TRACKS even with no explicit artist-likes
// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct
// artists, no artist-likes, no plays → the artist row fills from them.
func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "home-yml-trackartists")
fromLiked := make(map[pgtype.UUID]bool, 12)
for i := 0; i < 12; i++ {
a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "")
al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i))
tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i))
if _, err := pool.Exec(context.Background(),
`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil {
t.Fatalf("like track: %v", err)
}
fromLiked[a.ID] = true
}
got, err := HomeData(context.Background(), pool, user.ID)
if err != nil {
t.Fatalf("HomeData: %v", err)
}
if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)",
len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
}
for _, r := range got.YouMightLikeArtists {
if !fromLiked[r.Artist.ID] {
t.Errorf("artist %s not from the liked-track pool", r.Artist.Name)
}
}
}
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "home-newuser")
+86
View File
@@ -391,6 +391,7 @@ export type AdminUser = {
display_name: string | null;
is_admin: boolean;
auto_approve_requests: boolean;
debug_mode_enabled: boolean;
created_at: string;
};
@@ -451,6 +452,12 @@ export async function updateUserAutoApprove(id: string, autoApprove: boolean): P
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
}
// Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set;
// the client obeys it (with a local per-device OFF switch).
export async function updateUserDebugMode(id: string, enabled: boolean): Promise<AdminUser> {
return api.put<AdminUser>(`/api/admin/users/${id}/debug-mode`, { enabled });
}
export function createAdminUsersQuery() {
return createQuery({
queryKey: qk.adminUsers(),
@@ -497,3 +504,82 @@ export function createSMTPConfigQuery() {
staleTime: 60_000
});
}
// Device diagnostics (M9) ---------------------------------------------------
// Coarse event category. The finer event sub-type lives inside `payload`.
export type DiagnosticKind =
| 'connectivity'
| 'upnp_sync'
| 'power'
| 'lifecycle'
| 'heartbeat'
| 'http';
export type AdminDiagnostic = {
id: string;
user_id: string;
username: string;
client_id: string;
app_version?: string;
os_version?: string;
kind: DiagnosticKind | string;
payload: Record<string, unknown>;
occurred_at: string;
received_at: string;
};
export type AdminDiagnosticDevice = {
client_id: string;
user_id: string;
username: string;
app_version: string;
os_version: string;
last_seen: string;
event_count: number;
};
export type DiagnosticsFilter = {
userId?: string;
clientId?: string;
kind?: string;
from?: string; // RFC3339
to?: string; // RFC3339
limit?: number;
};
export async function listAdminDiagnostics(f: DiagnosticsFilter): Promise<AdminDiagnostic[]> {
const params = new URLSearchParams();
if (f.userId) params.set('user_id', f.userId);
if (f.clientId) params.set('client_id', f.clientId);
if (f.kind) params.set('kind', f.kind);
if (f.from) params.set('from', f.from);
if (f.to) params.set('to', f.to);
if (f.limit !== undefined) params.set('limit', String(f.limit));
const qs = params.toString();
return api.get<AdminDiagnostic[]>(qs ? `/api/admin/diagnostics?${qs}` : '/api/admin/diagnostics');
}
export async function listDiagnosticDevices(userId?: string): Promise<AdminDiagnosticDevice[]> {
const qs = userId ? `?user_id=${userId}` : '';
return api.get<AdminDiagnosticDevice[]>(`/api/admin/diagnostics/devices${qs}`);
}
export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) {
return createQuery({
queryKey: qk.adminDiagnostics(f as Record<string, string | number | undefined>),
queryFn: () => listAdminDiagnostics(f),
// The operator enables debug then watches events stream in; a short
// poll keeps the timeline live without manual refresh.
refetchInterval: 10_000,
staleTime: 5_000
});
}
export function createDiagnosticDevicesQuery(userId?: string) {
return createQuery({
queryKey: qk.adminDiagnosticDevices(userId),
queryFn: () => listDiagnosticDevices(userId),
staleTime: 15_000
});
}
-64
View File
@@ -1,64 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { removeTrack } from './tracks';
afterEach(() => {
vi.unstubAllGlobals();
});
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
const res = new Response(
body === null ? null : JSON.stringify(body),
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
);
const spy = vi.fn().mockResolvedValue(res);
vi.stubGlobal('fetch', spy);
return spy;
}
describe('removeTrack', () => {
test('DELETEs /api/admin/tracks/:id with no query string when unmonitor unset', async () => {
const spy = stubFetch(200, {
deleted_track_id: 't-1',
deleted_album_id: 'al-1'
});
const r = await removeTrack('t-1');
expect(r.deleted_track_id).toBe('t-1');
expect(r.deleted_album_id).toBe('al-1');
expect(r.deleted_artist_id).toBeUndefined();
expect(r.lidarr_unmonitor_failed).toBeUndefined();
const call = spy.mock.calls[0];
expect(call[0]).toBe('/api/admin/tracks/t-1');
expect((call[1] as RequestInit).method).toBe('DELETE');
});
test('appends ?unmonitor=true when option set', async () => {
const spy = stubFetch(200, {
deleted_track_id: 't-1',
lidarr_unmonitor_failed: true
});
const r = await removeTrack('t-1', { unmonitor: true });
expect(r.lidarr_unmonitor_failed).toBe(true);
const call = spy.mock.calls[0];
expect(call[0]).toBe('/api/admin/tracks/t-1?unmonitor=true');
});
test('omits the query string when unmonitor: false', async () => {
const spy = stubFetch(200, { deleted_track_id: 't-1' });
await removeTrack('t-1', { unmonitor: false });
const call = spy.mock.calls[0];
expect(call[0]).toBe('/api/admin/tracks/t-1');
});
test('surfaces not_found as ApiError', async () => {
stubFetch(404, { error: 'not_found' });
await expect(removeTrack('t-1')).rejects.toMatchObject({
code: 'not_found'
});
});
});
-42
View File
@@ -1,42 +0,0 @@
import { apiFetch } from '$lib/api/client';
export type RemoveTrackResult = {
deleted_track_id: string;
deleted_album_id?: string;
deleted_artist_id?: string;
/** Only present (always `true` when set) when the operator requested
* unmonitor=true AND the Lidarr unmonitor call failed. The destructive
* file + DB delete already succeeded — surface a follow-up toast so
* the operator knows to manually unmonitor in Lidarr. */
lidarr_unmonitor_failed?: true;
};
export type RemoveTrackOptions = {
/** When true, after the file + DB delete the server calls
* Lidarr.UnmonitorTrack so Lidarr won't search for a replacement.
* When false / omitted, no Lidarr call — Lidarr's monitoring will
* re-import on next scan, which is the operator's "find a replacement"
* path. */
unmonitor?: boolean;
};
/**
* DELETE /api/admin/tracks/{id} — admin-only. Deletes the file from
* disk and the DB row, cascading album/artist tidy-up. Optionally
* unmonitors the track in Lidarr.
*
* On success returns the deleted track id plus optional album/artist
* ids when their parent row was tidied up by the server-side cascade.
* Caller is responsible for invalidating any TanStack Query keys the
* deleted entities backed.
*/
export async function removeTrack(
id: string,
options: RemoveTrackOptions = {}
): Promise<RemoveTrackResult> {
const params = new URLSearchParams();
if (options.unmonitor) params.set('unmonitor', 'true');
const qs = params.toString();
const url = `/api/admin/tracks/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`;
return (await apiFetch(url, { method: 'DELETE' })) as RemoveTrackResult;
}
+46
View File
@@ -0,0 +1,46 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
// Mirrors internal/api/me_recommendation_metrics.go.
export type RecommendationMetric = {
source: string;
plays: number;
skips: number;
skip_rate: number;
avg_completion: number;
};
export type RecommendationMetrics = {
window_days: number;
sources: RecommendationMetric[];
};
export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
return api.get<RecommendationMetrics>('/api/me/recommendation-metrics');
}
export const REC_METRICS_QUERY_KEY = ['settings', 'recommendation-metrics'] as const;
export function createRecommendationMetricsQuery() {
return createQuery({
queryKey: REC_METRICS_QUERY_KEY,
queryFn: getRecommendationMetrics,
staleTime: 60_000
});
}
// Friendly labels for the system-playlist source keys (play_events.source).
const SOURCE_LABELS: Record<string, string> = {
for_you: 'For You',
discover: 'Discover',
deep_cuts: 'Deep cuts',
rediscover: 'Rediscover',
new_for_you: 'New for you',
on_this_day: 'On this day',
first_listens: 'First listens',
songs_like_artist: 'Songs like…'
};
export function sourceLabel(source: string): string {
return SOURCE_LABELS[source] ?? source;
}
+4
View File
@@ -50,6 +50,10 @@ export const qk = {
coverProviders: () => ['coverProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
adminInvites: () => ['adminInvites'] as const,
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
['adminDiagnostics', f] as const,
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
+1
View File
@@ -9,6 +9,7 @@
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/users', label: 'Users' }
];
+2 -1
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
);
});
test('renders all six tabs in order', () => {
test('renders all seven tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -62,6 +62,7 @@ describe('AdminTabs', () => {
'Requests',
'Quarantine',
'Playback errors',
'Diagnostics',
'Users'
]);
});
+3 -10
View File
@@ -1,18 +1,14 @@
<script lang="ts">
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';
import { api } from '$lib/api/client';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
import { Play } from 'lucide-svelte';
import AlbumMenu from './AlbumMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
import Cover from './Cover.svelte';
let { album }: { album: AlbumRef } = $props();
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
async function onAddClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
@@ -38,12 +34,9 @@
shadow-sm transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
<img
<Cover
src={album.cover_url}
alt=""
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
onerror={onImgError}
class="transition-transform group-hover:scale-[1.03]"
/>
<button
type="button"
+8 -13
View File
@@ -2,9 +2,10 @@
import type { ArtistRef, TrackRef } from '$lib/api/types';
import { api } from '$lib/api/client';
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
import { Disc3, Play } from 'lucide-svelte';
import { Play } from 'lucide-svelte';
import ArtistMenu from './ArtistMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
import Cover from './Cover.svelte';
import { listArtistTracks } from '$lib/api/artists';
let { artist }: { artist: ArtistRef } = $props();
@@ -48,18 +49,12 @@
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
{#if artist.cover_url}
<img
src={artist.cover_url}
alt=""
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
/>
{:else}
<div class="flex h-full w-full items-center justify-center">
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
</div>
{/if}
<Cover
src={artist.cover_url}
shape="round"
fallback="artist"
class="transition-transform group-hover:scale-[1.03]"
/>
<button
type="button"
aria-label={`Play ${artist.name}`}
+30 -23
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import type { TrackRef } from '$lib/api/types';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { coverUrl } from '$lib/media/covers';
import TrackMenu from './TrackMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
import LikeButton from './LikeButton.svelte';
import Cover from './Cover.svelte';
// Horizontal compact track row — cover thumb on the left, title +
// artist on the right. Mirrors Android's CompactTrackTile so the
@@ -22,10 +24,6 @@
const cover = $derived(coverUrl(track.album_id));
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
function onClick() {
playQueue(sectionTracks, index);
}
@@ -42,31 +40,40 @@
type="button"
aria-label={`Play ${track.title}`}
onclick={onClick}
class="flex w-full items-center gap-2 rounded-md p-1 pr-12 text-left
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
<img
src={cover}
alt=""
class="h-full w-full object-cover"
loading="lazy"
onerror={onImgError}
/>
<Cover src={cover} />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
</div>
</button>
<CardActionCluster
likeEntityType="track"
likeEntityId={track.id}
onAdd={onAddToQueue}
addLabel={`Add ${track.title} to queue`}
<!-- Single inline cluster, vertically centred on the right. The shared
CardActionCluster splits Like+Add (top) and the menu (bottom) into
opposite corners — correct for the tall square Album/Artist cards but
it makes the two groups collide on this short one-line row. A compact
row keeps all three controls in one horizontal group instead. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-2 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5
opacity-0 transition-opacity duration-150
group-hover:opacity-100 focus-within:opacity-100"
onclick={(e) => e.stopPropagation()}
>
{#snippet menu()}
<TrackMenu {track} />
{/snippet}
</CardActionCluster>
<LikeButton entityType="track" entityId={track.id} size="sm" />
<button
type="button"
aria-label={`Add ${track.title} to queue`}
onclick={onAddToQueue}
class="rounded-full bg-surface p-1 text-text-secondary hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
>
<Plus size={16} strokeWidth={1} />
</button>
<TrackMenu {track} />
</div>
</div>
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
}));
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('$lib/auth/store.svelte', () => ({
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { FALLBACK_COVER } from '$lib/media/covers';
import { Disc3 } from 'lucide-svelte';
// Shared cover-artwork renderer. The web counterpart of Android's
// CoverTile/ServerImage: one place that owns the loading placeholder and
// the error fallback so no card sits blank while art loads or 404s (the
// "You might like" row surfaces unplayed items whose art is often not yet
// backfilled). Replaces the three hand-rolled <img> tags that previously
// disagreed on fallback handling.
let {
src,
alt = '',
shape = 'square',
fallback = 'image',
class: klass = ''
}: {
src: string | undefined;
alt?: string;
// 'round' clips to a circle (artist avatars); 'square' leaves clipping
// to the parent so existing rounded-md / rounded corners still apply.
shape?: 'square' | 'round';
// 'artist' degrades to a Disc3 icon; 'image' to the static placeholder
// cover. A null/empty src is treated the same as a failed load.
fallback?: 'image' | 'artist';
class?: string;
} = $props();
// Per-image lifecycle. Reset when src changes so a reused card (virtualized
// list, client-side nav) never keeps a stale 'loaded'/'failed'.
let loaded = $state(false);
let failed = $state(false);
$effect(() => {
void src;
loaded = false;
failed = false;
});
const useIcon = $derived(fallback === 'artist' && (failed || !src));
// On failure for the image fallback, swap to the static placeholder asset.
// src doesn't change on the second error, so there's no reload loop.
const imgSrc = $derived(failed ? FALLBACK_COVER : src);
</script>
<div
class="relative h-full w-full overflow-hidden bg-surface-hover {shape === 'round'
? 'rounded-full'
: ''}"
>
{#if useIcon}
<div class="flex h-full w-full items-center justify-center">
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
</div>
{:else if imgSrc}
<img
src={imgSrc}
{alt}
class="h-full w-full object-cover transition-opacity duration-200 {loaded
? 'opacity-100'
: 'opacity-0'} {klass}"
loading="lazy"
onload={() => (loaded = true)}
onerror={() => (failed = true)}
/>
{/if}
</div>
+10 -3
View File
@@ -272,8 +272,12 @@
data-testid="player-bar-desktop"
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
>
<!-- Left: cover + title + artist + like + menu -->
<div class="flex w-72 min-w-0 items-center gap-3">
<!-- Left: cover + title + artist + like + menu.
basis-72 grow max-w-md: holds its 288px floor at the md breakpoint
(where the seek column is the flex-1 that absorbs the remainder), but
on wider screens it grows up to 448px instead of leaving the title
truncated while the seek bar hogs the slack. -->
<div class="flex basis-72 grow max-w-md min-w-0 items-center gap-3">
<a
href="/now-playing"
aria-label="Open now playing"
@@ -318,7 +322,10 @@
</button>
</div>
{:else}
<div class="flex flex-1 flex-col items-stretch gap-2">
<!-- max-w-xl + mx-auto caps the seek bar (and the transport row) at
576px and centres it, so on wide screens the extra width flows to
the title column on the left rather than stretching the scrubber. -->
<div class="flex flex-1 flex-col items-stretch gap-2 mx-auto w-full max-w-xl">
<!-- Transport row -->
<div class="flex items-center justify-center gap-3">
<button
+17 -2
View File
@@ -5,6 +5,7 @@
import { user } from '$lib/auth/store.svelte';
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
import { errCode } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
import { playQueue } from '$lib/player/store.svelte';
@@ -80,7 +81,14 @@
const detail = await systemShuffle(variant);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
playQueue(refs, 0, { source: variant });
playQueue(refs, 0, {
source: variant,
refetch: systemPlaylistRefetch({
variant,
playlistId: playlist.id,
perArtist: false,
}),
});
}
} else {
const detail = await getPlaylist(playlist.id);
@@ -93,7 +101,14 @@
// so source attribution stays absent — the PlaylistCard
// test pins this contract.
if (variant != null) {
playQueue(refs, 0, { source: variant });
playQueue(refs, 0, {
source: variant,
refetch: systemPlaylistRefetch({
variant,
playlistId: playlist.id,
perArtist: true,
}),
});
} else {
playQueue(refs, 0);
}
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
}));
vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
@@ -1,103 +0,0 @@
<script lang="ts">
import { Trash2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { removeTrack } from '$lib/api/admin/tracks';
import { errMessage } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
import type { TrackRef } from '$lib/api/types';
let {
track,
onClose
}: {
track: TrackRef;
onClose: () => void;
} = $props();
const queryClient = useQueryClient();
let unmonitor = $state(false);
let busy = $state(false);
let error = $state<string | null>(null);
async function confirm() {
busy = true;
error = null;
try {
const result = await removeTrack(track.id, { unmonitor });
// Invalidate caches for any vanished entity. The album/artist that
// contained the track is always touched; if the parent rows were
// tidied up by the server cascade, evict those too. Home payload
// surfaces albums/artists on the landing page so it always
// refreshes.
await queryClient.invalidateQueries({ queryKey: qk.album(track.album_id) });
await queryClient.invalidateQueries({ queryKey: qk.artist(track.artist_id) });
await queryClient.invalidateQueries({ queryKey: qk.home() });
if (result.deleted_album_id) {
await queryClient.invalidateQueries({ queryKey: qk.album(result.deleted_album_id) });
}
if (result.deleted_artist_id) {
await queryClient.invalidateQueries({ queryKey: qk.artist(result.deleted_artist_id) });
}
// Note: result.lidarr_unmonitor_failed is true when unmonitor was
// requested AND Lidarr couldn't be reached. The destructive part
// already succeeded; we still close the popover. A future toast
// surface (if any) can pick up the flag.
onClose();
} catch (e: unknown) {
error = errMessage(e);
} finally {
busy = false;
}
}
</script>
<div
role="dialog"
tabindex="-1"
aria-modal="false"
aria-labelledby="remove-track-popover-title"
class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose(); }}
>
<h4 id="remove-track-popover-title" class="text-sm font-medium text-text-primary">
Remove "{track.title}" from your library?
</h4>
<p class="mt-1 text-xs text-text-muted">
This deletes the file from disk.
</p>
<label class="mt-3 flex items-start gap-2 text-sm text-text-primary">
<input
type="checkbox"
bind:checked={unmonitor}
class="mt-0.5 rounded border-border"
/>
<span>Also stop Lidarr from finding a replacement</span>
</label>
{#if error}
<p class="mt-2 text-xs text-action-destructive">{error}</p>
{/if}
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
onclick={onClose}
disabled={busy}
>
Cancel
</button>
<button
type="button"
onclick={confirm}
disabled={busy}
class="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-sm text-action-destructive hover:bg-surface-hover disabled:opacity-50"
>
<Trash2 size={14} strokeWidth={1} />
{busy ? 'Removing…' : 'Remove'}
</button>
</div>
</div>
@@ -1,97 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { makeTrack } from '$test-utils/fixtures/track';
const invalidateMock = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
useQueryClient: () => ({ invalidateQueries: invalidateMock })
};
});
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn()
}));
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
import { removeTrack } from '$lib/api/admin/tracks';
const track = makeTrack({ title: 'Roygbiv' });
afterEach(() => vi.clearAllMocks());
describe('RemoveTrackPopover', () => {
test('renders the title and subtext', () => {
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument();
expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument();
});
test('renders the Lidarr unmonitor checkbox', () => {
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i });
expect(cb).toBeInTheDocument();
expect((cb as HTMLInputElement).checked).toBe(false);
});
test('Cancel button calls onClose without firing removeTrack', async () => {
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(removeTrack).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
test('Remove without checkbox calls removeTrack with unmonitor=false', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
// confirm() chains 3-5 awaited invalidateQueries calls before onClose,
// each a separate microtask. waitFor polls until the side effects land.
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false });
});
test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i }));
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true });
});
test('failed remove surfaces error via copyForCode and does not close', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'not_found' });
const onClose = vi.fn();
render(RemoveTrackPopover, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
await waitFor(() => expect(removeTrack).toHaveBeenCalled());
// The catch path runs synchronously after the rejected await; one more
// microtask boundary is enough.
await Promise.resolve();
expect(onClose).not.toHaveBeenCalled();
});
test('success invalidates album, artist and home queries', async () => {
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({
deleted_track_id: 't1',
deleted_album_id: 'a1',
deleted_artist_id: 'ar1'
});
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
// Wait for all three baseline invalidations (album, artist, home) plus
// the two cascade ones (deleted_album_id, deleted_artist_id) to land.
await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5));
const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey);
const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k));
expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true);
expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true);
expect(flat.some((k) => k === 'home')).toBe(true);
});
});
+26 -43
View File
@@ -1,30 +1,27 @@
<script lang="ts">
import {
MoreVertical,
ListPlus,
Plus,
ListVideo,
ListMusic,
Heart,
HeartOff,
ListMusic,
Album,
ListPlus,
Radio,
Disc3,
Flag,
User,
EyeOff,
Eye,
Trash2
Eye
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import FlagPopover from './FlagPopover.svelte';
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import { user } from '$lib/auth/store.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
import { qk } from '$lib/api/queries';
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types';
let {
@@ -44,7 +41,6 @@
let menuOpen = $state(false);
let flagOpen = $state(false);
let removeOpen = $state(false);
let addOpen = $state(false);
const queryClient = useQueryClient();
@@ -63,8 +59,6 @@
return rows.some((r) => r.track_id === track.id);
});
const isAdmin = $derived(user.value?.is_admin === true);
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
menuOpen = !menuOpen;
@@ -73,7 +67,6 @@
function closeAll() {
menuOpen = false;
flagOpen = false;
removeOpen = false;
addOpen = false;
}
@@ -87,6 +80,11 @@
closeAll();
}
function onStartRadio() {
playRadio(track.id);
closeAll();
}
async function onToggleLike() {
closeAll();
if (liked) {
@@ -119,18 +117,8 @@
goto(`/artists/${track.artist_id}`);
}
function onFlag() {
menuOpen = false;
flagOpen = true;
}
function onRemoveOpen() {
menuOpen = false;
removeOpen = true;
}
function onKeydown(e: KeyboardEvent) {
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
if (!menuOpen && !flagOpen && !addOpen) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAll();
@@ -161,9 +149,16 @@
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
>
<!-- Item set + order mirror Android's canonical TrackActionsSheet so
the kebab reads the same on both clients: queue group → like /
add-to-playlist / start-radio group → navigation group → hide.
"Start radio" is shown even under hideQueueActions (reseeding a
station from the current track is meaningful where play-next /
add-to-queue are not). No single-click destructive action lives
here — track removal is intentionally kept out of the kebab. -->
{#if !hideQueueActions}
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} />
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} />
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
<TrackMenuDivider />
{/if}
@@ -174,32 +169,24 @@
onclick={onToggleLike}
/>
<TrackMenuItem
icon={ListMusic}
icon={ListPlus}
label="Add to playlist…"
onclick={() => { addOpen = true; menuOpen = false; }}
/>
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
<TrackMenuDivider />
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} />
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} />
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
<TrackMenuDivider />
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
<TrackMenuItem
icon={hidden ? Eye : EyeOff}
label={hidden ? 'Unhide' : 'Hide'}
onclick={onToggleHide}
/>
{#if isAdmin}
<TrackMenuItem
icon={Trash2}
label="Remove from library"
onclick={onRemoveOpen}
danger
/>
{/if}
</div>
{/if}
@@ -207,10 +194,6 @@
<FlagPopover {track} onClose={closeAll} />
{/if}
{#if removeOpen}
<RemoveTrackPopover {track} onClose={closeAll} />
{/if}
{#if addOpen}
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
{/if}
+15 -18
View File
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
}));
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn()
enqueueTrack: vi.fn(),
playRadio: vi.fn()
}));
import TrackMenu from './TrackMenu.svelte';
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
const track = makeTrack({ title: 'Roygbiv' });
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
expect(kebab.getAttribute('aria-expanded')).toBe('true');
});
test('opening menu shows all 9 entries for an admin user', async () => {
test('opening menu shows all 8 entries', async () => {
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
@@ -63,19 +60,12 @@ describe('TrackMenu', () => {
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /start radio/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(9);
});
test('"Remove from library" hidden for non-admin', async () => {
userState.current = { id: 'u2', username: 'normal', is_admin: false };
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
// No "Remove from library" — destructive removal is intentionally absent.
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
});
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => {
test('"Start radio" dispatches playRadio(track.id)', async () => {
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i }));
await fireEvent.click(screen.getByRole('menuitem', { name: /start radio/i }));
expect(playRadio).toHaveBeenCalledWith(track.id);
});
test('clicking "Hide" opens the flag/quarantine popover and closes menu', async () => {
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
await fireEvent.click(screen.getByRole('menuitem', { name: /^hide$/i }));
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(
screen.getByRole('dialog', { name: /flag this track as broken/i })
+101 -6
View File
@@ -83,6 +83,23 @@ let _radioRefreshInFlight = false;
// queue clears it).
let _queueSource = $state<string | null>(null);
// Track B (#968): consecutive load failures since the last successful
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
// (capped against queue length) stops a fully-unplayable queue from
// looping forever. Reset on a successful play and on a fresh playQueue.
let _failureStreak = 0;
// Track B (#968): when a queue is seeded from a refreshable source (a system
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
// snapshot. On total failure (whole queue unplayable — likely a stale tab
// left open across the daily rebuild) we call it once to self-heal before
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
// playlist API and the per-artist (songs_like_artist) identity problem.
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
let _selfHealInFlight = false;
let _selfHealAttempts = 0;
const MAX_SELF_HEAL_ATTEMPTS = 1;
let _audioEl: HTMLAudioElement | null = null;
export const player = {
@@ -109,13 +126,20 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
export function playQueue(
tracks: TrackRef[],
startIndex = 0,
opts: { shuffle?: boolean; source?: string | null } = {},
opts: {
shuffle?: boolean;
source?: string | null;
refetch?: () => Promise<TrackRef[]>;
} = {},
): void {
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
// #415: a fresh queue resets the system-playlist source. Set only
// when seeded from a system playlist (server already returned the
// rotation-aware order, so no client shuffle in that path).
_queueSource = opts.source ?? null;
// #968: a fresh play replaces the self-heal closure + resets its budget.
_queueRefetch = opts.refetch ?? null;
_selfHealAttempts = 0;
if (opts.shuffle && tracks.length > 1) {
// Fisher-Yates over the whole list. startIndex is ignored — the
// caller is asking for "random play from this pool," so the first
@@ -142,6 +166,7 @@ export function playQueue(
_position = 0;
_duration = 0;
_error = null;
_failureStreak = 0;
}
export function togglePlay(): void {
@@ -263,6 +288,8 @@ export function reportStateFromAudio(
case 'playing':
_state = 'playing';
_error = null;
_failureStreak = 0;
_selfHealAttempts = 0;
return;
case 'paused':
_state = 'paused';
@@ -271,8 +298,7 @@ export function reportStateFromAudio(
_state = 'loading';
return;
case 'error':
_state = 'error';
_error = detail ?? 'Playback failed.';
handleLoadFailure(detail);
return;
case 'ended':
if (_repeat === 'one') {
@@ -301,6 +327,68 @@ export function reportStateFromAudio(
}
}
// Track B (#968): a single failed track must not strand the player on the
// "Try again" dead-end. Advance past it; only surface the error once the
// whole queue has proven unplayable — every track failed, or we reached the
// end with nothing playable. The streak cap (vs queue length) stops a
// fully-broken queue from cycling forever instead of settling.
function handleLoadFailure(detail?: string): void {
_failureStreak++;
if (_failureStreak < _queue.length && _index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
_error = null;
return;
}
// Whole queue is unplayable. If it came from a refreshable source, the
// snapshot is probably stale — re-pull it and resume before giving up.
if (trySelfHeal()) return;
_state = 'error';
_error = detail ?? 'Playback failed.';
}
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
// started (caller must not dead-end). Bounded to one attempt per exhaustion
// (reset on the next successful play) so a still-broken refresh can't loop.
function trySelfHeal(): boolean {
if (_selfHealInFlight) return true;
if (_queueRefetch === null) return false;
if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
_selfHealAttempts++;
_selfHealInFlight = true;
_state = 'loading';
_error = null;
const refetch = _queueRefetch;
void refetch()
.then((refs) => {
if (refs.length > 0) {
// Re-seed in place — keep _queueSource / _queueRefetch so the new
// snapshot can itself self-heal, and don't reset _selfHealAttempts
// (only a successful 'playing' clears the budget).
_queue = refs;
_index = 0;
_position = 0;
_duration = 0;
_failureStreak = 0;
_state = 'loading';
_error = null;
} else {
_state = 'error';
_error = 'Nothing playable in this mix right now.';
}
})
.catch(() => {
_state = 'error';
_error = 'Couldnt refresh this mix. Try again.';
})
.finally(() => {
_selfHealInFlight = false;
});
return true;
}
export function enqueueTrack(t: TrackRef): void {
_radioSeedId = null; // M4c
_queue = [..._queue, t];
@@ -345,12 +433,19 @@ export function playNextMany(ts: TrackRef[]): void {
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
}
export async function playRadio(seedTrackId: string): Promise<void> {
async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length === 0) return;
playQueue(resp.tracks, 0);
return resp.tracks;
}
export async function playRadio(seedTrackId: string): Promise<void> {
const tracks = await fetchRadioTracks(seedTrackId);
if (tracks.length === 0) return;
// #968: hand the player a self-heal closure so a fully-stale radio queue
// re-seeds from the same track instead of dead-ending.
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
}
+44
View File
@@ -288,6 +288,50 @@ describe('player store — shuffle + repeat + audio reports', () => {
expect(player.state).toBe('error');
expect(player.error).toBe('network lost');
});
test('reportStateFromAudio("error") auto-advances past a bad track', () => {
playQueue([track('1'), track('2'), track('3')]);
reportStateFromAudio('error', 'boom');
expect(player.index).toBe(1);
expect(player.state).toBe('loading');
expect(player.error).toBeNull();
});
test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => {
playQueue([track('1'), track('2')]);
reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2
expect(player.index).toBe(1);
expect(player.state).toBe('loading');
reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted
expect(player.state).toBe('error');
expect(player.error).toBe('boom');
});
test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => {
const fresh = [track('a'), track('b')];
const refetch = vi.fn().mockResolvedValue(fresh);
playQueue([track('1')], 0, { source: 'for_you', refetch });
reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal
expect(refetch).toHaveBeenCalledOnce();
expect(player.state).toBe('loading');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']);
expect(player.index).toBe(0);
});
test('self-heal fires at most once per exhaustion, then dead-ends', async () => {
const refetch = vi.fn().mockResolvedValue([track('x')]);
playQueue([track('1')], 0, { source: 'for_you', refetch });
reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error
expect(player.state).toBe('error');
expect(refetch).toHaveBeenCalledOnce();
});
});
import {
+33
View File
@@ -0,0 +1,33 @@
import type { TrackRef, PlaylistDetail } from '$lib/api/types';
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
import { playlistTrackToRef } from './playlistTrackToRef';
// #968: builds the self-heal closure for a system-playlist queue. On total
// playback failure (every queued track unplayable — typically a stale tab
// left open across the daily rebuild) the player calls this to re-pull the
// current snapshot and resume.
//
// Single-instance variants (for_you, discover, deep_cuts, …) re-pull by
// variant via the rotation-aware shuffle endpoint. Per-artist mixes
// (songs_like_artist — one playlist per seed artist) can't be addressed by
// variant alone, so they re-pull by playlist id. Mirrors the play routing
// in PlaylistCard.
function toRefs(detail: PlaylistDetail): TrackRef[] {
return detail.tracks
.map((r) => playlistTrackToRef(r))
.filter((t): t is TrackRef => t !== null);
}
export function systemPlaylistRefetch(opts: {
variant: string;
playlistId: string;
perArtist: boolean;
}): () => Promise<TrackRef[]> {
return async () => {
const detail = opts.perArtist
? await getPlaylist(opts.playlistId)
: await systemShuffle(opts.variant);
return toRefs(detail);
};
}
+50
View File
@@ -0,0 +1,50 @@
import { queryClient } from '$lib/query/client';
import { qk } from '$lib/api/queries';
import { user } from '$lib/auth/store.svelte';
// Inbound server events over SSE (#968). The web client's OUTBOUND half
// (play events) lives in player/events.svelte.ts; this is the inbound
// listener it never had. Today it reacts to `playlist.system_rebuilt` — the
// daily 03:00 rebuild or a manual refresh — by invalidating the home +
// system-playlist query caches so a tab left open across the rebuild stops
// serving yesterday's snapshot (the stale-browse-view bug). Active playback
// self-heals separately on the failure path (store.handleLoadFailure); we
// deliberately do NOT yank a playing queue here — that would interrupt a
// mid-song listen to restart the new mix at track 0.
// Monotonic rebuild counter (#980). Bumped on every playlist.system_rebuilt.
// An open system-playlist DETAIL page can't be invalidated in place (its id
// rotates on rebuild — a refetch would 404), so instead it watches this
// counter and offers a "this mix was refreshed → Refresh" affordance that
// re-resolves the variant to the new playlist.
let _systemRebuildCount = $state(0);
export const systemRebuilt = {
get count(): number {
return _systemRebuildCount;
}
};
function onSystemRebuilt(): void {
_systemRebuildCount++;
queryClient.invalidateQueries({ queryKey: qk.home() });
// Prefix match invalidates every kind ('user' | 'system' | 'all').
queryClient.invalidateQueries({ queryKey: ['playlists'] });
queryClient.invalidateQueries({ queryKey: qk.systemPlaylistsStatus() });
}
// Wire once from +layout.svelte's mount. Opens the stream only while
// authenticated and closes it on logout; EventSource auto-reconnects on
// transient network drops.
export function useServerEvents(): void {
$effect(() => {
if (!user.value) return;
// Absent under SSR / jsdom — the listener simply no-ops there.
if (typeof EventSource === 'undefined') return;
const es = new EventSource('/api/events/stream');
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
return () => {
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
es.close();
};
});
}
+2
View File
@@ -23,6 +23,7 @@
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
import { useEventsDispatcher } from '$lib/player/events.svelte';
import { useServerEvents } from '$lib/serverEvents.svelte';
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
import { audioLoader } from '$lib/player/audioLoader';
@@ -132,6 +133,7 @@
useMediaSession();
useEventsDispatcher();
useServerEvents();
useGlobalShortcuts();
applyMetaThemeColor();
</script>
@@ -0,0 +1,367 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Copy, Download, Activity } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminDiagnosticsQuery,
createDiagnosticDevicesQuery,
createAdminUsersQuery,
updateUserDebugMode,
type AdminDiagnostic,
type DiagnosticsFilter
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// Device diagnostics timeline (M9). The operator enables debug-mode on
// an account (remotely, here), then watches the account's device(s)
// stream connectivity / UPnP-sync / power events. The point of the page
// is the EXPORT: filter to the window in question and copy/download the
// slice as JSON to hand off for analysis.
const client = useQueryClient();
const KINDS = [
'connectivity',
'playback',
'upnp_sync',
'power',
'lifecycle',
'heartbeat',
'http'
] as const;
function kindLabel(k: string): string {
switch (k) {
case 'connectivity': return 'Connectivity';
case 'playback': return 'Playback';
case 'upnp_sync': return 'UPnP sync';
case 'power': return 'Power';
case 'lifecycle': return 'Lifecycle';
case 'heartbeat': return 'Heartbeat';
case 'http': return 'HTTP';
default: return k;
}
}
// Default view = the most recent N events, no time window. The
// start/end window is an Advanced affordance.
const DEFAULT_LIMIT = 500;
// Filter state.
let accountId = $state('');
let clientId = $state('');
let kind = $state('');
let fromLocal = $state('');
let toLocal = $state('');
let limit = $state(DEFAULT_LIMIT);
// True when an explicit time window is set (drives the caption wording).
const hasWindow = $derived(Boolean(fromLocal || toLocal));
function resetWindow() {
fromLocal = '';
toLocal = '';
limit = DEFAULT_LIMIT;
}
// datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
function toRfc(v: string): string | undefined {
if (!v) return undefined;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
const filter = $derived<DiagnosticsFilter>({
userId: accountId || undefined,
clientId: clientId || undefined,
kind: kind || undefined,
from: toRfc(fromLocal),
to: toRfc(toLocal),
limit
});
const usersStore = $derived(createAdminUsersQuery());
const usersQuery = $derived($usersStore);
const users = $derived(usersQuery.data ?? []);
const selectedUser = $derived(users.find((u) => u.id === accountId));
const devicesStore = $derived(createDiagnosticDevicesQuery(accountId || undefined));
const devicesQuery = $derived($devicesStore);
const devices = $derived(devicesQuery.data ?? []);
// Display sort. The API returns newest-first; default the view to that
// (most recent at the top), with an Oldest-first option for reading a
// timeline top-to-bottom. Export follows whatever's displayed.
let sortOrder = $state<'newest' | 'oldest'>('newest');
const diagStore = $derived(createAdminDiagnosticsQuery(filter));
const diagQuery = $derived($diagStore);
const rows = $derived.by(() => {
const data = [...((diagQuery.data ?? []) as AdminDiagnostic[])]; // newest-first
return sortOrder === 'oldest' ? data.reverse() : data;
});
function fmtTime(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
function payloadPreview(p: Record<string, unknown>): string {
const s = JSON.stringify(p);
return s.length > 120 ? s.slice(0, 120) + '…' : s;
}
// Toggle the selected account's debug-mode remotely.
let toggling = $state(false);
async function toggleDebug() {
if (!selectedUser) return;
toggling = true;
try {
await updateUserDebugMode(selectedUser.id, !selectedUser.debug_mode_enabled);
await client.invalidateQueries({ queryKey: qk.adminUsers() });
pushToast(
`Debug mode ${selectedUser.debug_mode_enabled ? 'disabled' : 'enabled'} for ${selectedUser.username}`
);
} catch (e: unknown) {
pushToast(`Toggle failed: ${errMessage(e)}`, 'error');
} finally {
toggling = false;
}
}
// Export the current (chronological) slice as an analysis-ready object.
function exportObject() {
return {
account: selectedUser?.username ?? accountId ?? 'all',
device: clientId || 'all',
kind: kind || 'all',
from: toRfc(fromLocal) ?? null,
to: toRfc(toLocal) ?? null,
count: rows.length,
events: rows.map((r) => ({
occurred_at: r.occurred_at,
received_at: r.received_at,
kind: r.kind,
client_id: r.client_id,
app_version: r.app_version,
os_version: r.os_version,
payload: r.payload
}))
};
}
async function onCopyJson() {
try {
await navigator.clipboard.writeText(JSON.stringify(exportObject(), null, 2));
pushToast(`Copied ${rows.length} events to clipboard`);
} catch (e: unknown) {
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
}
}
function onDownloadNdjson() {
const lines = exportObject().events.map((e) => JSON.stringify(e)).join('\n');
const blob = new Blob([lines], { type: 'application/x-ndjson' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const who = selectedUser?.username ?? 'all';
a.href = url;
a.download = `diagnostics-${who}-${Date.now()}.ndjson`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<svelte:head><title>{pageTitle('Admin · Diagnostics')}</title></svelte:head>
<div class="space-y-4">
<header>
<h1 class="font-display text-2xl font-medium text-text-primary">Device diagnostics</h1>
<p class="text-sm text-text-secondary">
Enable debug mode on an account to have its device(s) stream a timeseries
of connectivity, UPnP-sync, and power/Doze events here. Filter to the
window you care about, then copy or download the slice for analysis.
</p>
</header>
<!-- Account debug-mode control -->
<section class="rounded-md border border-border p-3">
<div class="flex flex-wrap items-end gap-3">
<label class="block text-xs text-text-secondary">
Account
<select
bind:value={accountId}
class="mt-1 block w-56 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="">All accounts</option>
{#each users as u (u.id)}
<option value={u.id}>{u.username}{u.debug_mode_enabled ? ' · debug on' : ''}</option>
{/each}
</select>
</label>
{#if selectedUser}
<button
type="button"
onclick={toggleDebug}
disabled={toggling}
class="rounded px-3 py-2 text-sm text-action-fg hover:opacity-90 disabled:opacity-50
{selectedUser.debug_mode_enabled ? 'bg-action-destructive' : 'bg-action-primary'}"
>
{selectedUser.debug_mode_enabled ? 'Disable debug mode' : 'Enable debug mode'}
</button>
<span class="inline-flex items-center gap-1 text-xs text-text-muted">
<Activity size={14} />
{selectedUser.debug_mode_enabled ? 'Reporting active' : 'Reporting off'}
</span>
{/if}
</div>
</section>
<!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time
window; the start/end window lives under Advanced. -->
<section class="space-y-3">
<div class="flex flex-wrap items-end gap-3">
<label class="block text-xs text-text-secondary">
Device
<select
bind:value={clientId}
class="mt-1 block w-48 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="">All devices</option>
{#each devices as d (d.client_id)}
<option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option>
{/each}
</select>
</label>
<label class="block text-xs text-text-secondary">
Kind
<select
bind:value={kind}
class="mt-1 block w-40 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="">All kinds</option>
{#each KINDS as k (k)}
<option value={k}>{kindLabel(k)}</option>
{/each}
</select>
</label>
<label class="block text-xs text-text-secondary">
Sort
<select
bind:value={sortOrder}
class="mt-1 block w-36 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<div class="ml-auto flex gap-2">
<button
type="button"
onclick={onCopyJson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
<Copy size={15} /> Copy JSON
</button>
<button
type="button"
onclick={onDownloadNdjson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
<Download size={15} /> Download
</button>
</div>
</div>
<!-- Advanced: explicit start/end window + row cap. Collapsed by
default so the common case (recent N) needs no interaction. -->
<details class="rounded-md border border-border" open={hasWindow}>
<summary class="cursor-pointer px-3 py-2 text-xs text-text-secondary">
Advanced filters{hasWindow ? ' · window active' : ''}
</summary>
<div class="flex flex-wrap items-end gap-3 border-t border-border px-3 py-3">
<label class="block text-xs text-text-secondary">
From
<input
type="datetime-local"
bind:value={fromLocal}
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="block text-xs text-text-secondary">
To
<input
type="datetime-local"
bind:value={toLocal}
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="block text-xs text-text-secondary">
Limit
<input
type="number"
min="1"
max="5000"
bind:value={limit}
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<button
type="button"
onclick={resetWindow}
class="rounded border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-hover"
>
Reset to recent {DEFAULT_LIMIT}
</button>
</div>
</details>
</section>
<!-- Timeline -->
{#if diagQuery.isError}
<p class="text-error">Couldn't load: {errMessage(diagQuery.error)}</p>
{:else if diagQuery.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
<p class="text-text-secondary">
No events for this filter. Enable debug mode on an account and have the
device reproduce the issue — events appear here within a minute.
</p>
{:else}
<div class="flex items-center justify-between">
<span class="text-xs text-text-muted">
{#if hasWindow}
{rows.length} events in window ({sortOrder === 'newest' ? 'newest' : 'oldest'} first)
{:else}
Most recent {rows.length} events ({sortOrder === 'newest' ? 'newest' : 'oldest'} first) · set a time window under Advanced
{/if}
</span>
</div>
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
{#each rows as r (r.id)}
<li class="flex items-start gap-3 px-3 py-2">
<span class="w-44 shrink-0 text-text-muted" title={`received ${fmtTime(r.received_at)}`}>
{fmtTime(r.occurred_at)}
</span>
<span
class="w-24 shrink-0 rounded bg-surface-hover px-1.5 py-0.5 text-center text-[10px] uppercase tracking-wide text-text-secondary"
>
{kindLabel(r.kind)}
</span>
<details class="min-w-0 flex-1">
<summary class="cursor-pointer truncate text-text-primary">
{payloadPreview(r.payload)}
</summary>
<pre class="mt-1 overflow-x-auto whitespace-pre-wrap text-text-secondary">{JSON.stringify(r.payload, null, 2)}</pre>
</details>
</li>
{/each}
</ul>
{/if}
</div>
+2
View File
@@ -38,6 +38,7 @@ const alice: AdminUser = {
display_name: null,
is_admin: true,
auto_approve_requests: false,
debug_mode_enabled: false,
created_at: '2026-05-01T00:00:00Z'
};
@@ -47,6 +48,7 @@ const bob: AdminUser = {
display_name: 'Bob B',
is_admin: false,
auto_approve_requests: false,
debug_mode_enabled: false,
created_at: '2026-05-02T00:00:00Z'
};
+10
View File
@@ -16,6 +16,7 @@
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import TrackMenu from '$lib/components/TrackMenu.svelte';
import QueueList from '$lib/components/QueueList.svelte';
import { pageTitle } from '$lib/branding';
@@ -78,6 +79,15 @@
<ArrowLeft size={22} strokeWidth={1.5} />
</button>
<div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div>
<!-- Full-screen Now Playing carries the same kebab as Android's
NowPlayingScreen (hideQueueActions — the menu's track IS the one
playing), so Start radio / Add to playlist / Hide etc. are reachable
here, not just from the mini-player bar. -->
{#if current}
<div class="ml-auto">
<TrackMenu track={current} hideQueueActions />
</div>
{/if}
</header>
{#if !current}
+77 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { pageTitle } from '$lib/branding';
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
@@ -12,12 +13,15 @@
deletePlaylist,
removePlaylistTrack,
reorderPlaylist,
refreshSystem
refreshSystem,
systemShuffle
} from '$lib/api/playlists';
import { qk } from '$lib/api/queries';
import { user } from '$lib/auth/store.svelte';
import { errCode, errMessage } from '$lib/api/errors';
import { playQueue } from '$lib/player/store.svelte';
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
import { systemRebuilt } from '$lib/serverEvents.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
import type { TrackRef } from '$lib/api/types';
@@ -61,16 +65,31 @@
function onPlay(position: number) {
if (!playlistQuery?.data) return;
const data = playlistQuery.data;
// Skip rows where track_id is null (track was removed from library).
const live = playlistQuery.data.tracks
.filter((t) => t.track_id !== null)
.map(toTrackRef);
const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef);
// Find which `live` index corresponds to the clicked position. Some
// positions in the original list may be unavailable (filtered out),
// so the index in the playable list != the playlist position.
const clickedTrackID = playlistQuery.data.tracks.find((t) => t.position === position)?.track_id;
const clickedTrackID = data.tracks.find((t) => t.position === position)?.track_id;
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
playQueue(live, Math.max(0, startIdx));
// #968: a system playlist played from its detail page is tagged with its
// source (so play_started advances the rotation, matching the home tile and
// the Android detail screen) and gets the self-heal closure so a stale
// snapshot re-pulls on total failure.
const variant = data.system_variant;
if (variant != null) {
playQueue(live, Math.max(0, startIdx), {
source: variant,
refetch: systemPlaylistRefetch({
variant,
playlistId: id,
perArtist: data.seed_artist_id != null
})
});
} else {
playQueue(live, Math.max(0, startIdx));
}
}
function toTrackRef(t: {
@@ -158,6 +177,40 @@
refreshingSystem = false;
}
}
// --- #980: stale-view banner for an open system-playlist detail page ---
// The daily rebuild rotates the playlist id, so this page's cached data goes
// stale and can't be refetched in place (the old id 404s). Watch the global
// rebuild counter; when it advances while we're on a system playlist, offer a
// Refresh that re-resolves the variant to the fresh playlist.
const isSystemPlaylist = $derived(playlistQuery?.data?.system_variant != null);
// Baseline starts at the current count so a rebuild earlier this session
// doesn't flash the banner on open; re-synced on navigation below.
let acknowledgedRebuild = $state(systemRebuilt.count);
$effect(() => {
id; // re-sync the baseline whenever we land on a different playlist
acknowledgedRebuild = untrack(() => systemRebuilt.count);
});
const staleSystemView = $derived(
isSystemPlaylist && systemRebuilt.count > acknowledgedRebuild
);
let reloadingStale = $state(false);
async function onReloadStale() {
const variant = playlistQuery?.data?.system_variant;
if (!variant) return;
reloadingStale = true;
try {
// The rebuild minted a new playlist id for this variant; resolve it and
// navigate there so the page loads the fresh mix.
const fresh = await systemShuffle(variant);
await goto(`/playlists/${fresh.id}`);
} catch (e: unknown) {
pushToast(`Couldnt load the refreshed mix: ${errCode(e)}`, 'error');
} finally {
reloadingStale = false;
}
}
</script>
<svelte:head>
@@ -171,6 +224,24 @@
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
{:else if playlistQuery?.data}
{@const pl = playlistQuery.data}
{#if staleSystemView}
<div
role="status"
class="mb-4 flex items-center justify-between gap-3 rounded-md border border-accent/40
bg-accent/10 px-4 py-2.5 text-sm"
>
<span class="text-text-primary">This mix was refreshed since you opened it.</span>
<button
type="button"
onclick={onReloadStale}
disabled={reloadingStale}
class="flex-shrink-0 rounded-md bg-action-secondary px-3 py-1 text-xs font-medium
text-action-fg disabled:opacity-50"
>
{reloadingStale ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{/if}
{#if !editing}
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">

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