Compare commits

..

123 Commits

Author SHA1 Message Date
bvandeusen 962b4dbc8c Merge pull request 'v2026.06.07 — Sonos: one SOAP failure no longer drops to local' (#88) from dev into main
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 15s
2026-06-07 19:14:45 -04:00
bvandeusen aa4089118e chore: configure Renovate (tuned)
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 1m34s
Activate Renovate with a tuned config: target dev, ignore retired
flutter_client/**, auto-merge GREEN patch/minor bumps, hold majors behind
dependency-dashboard approval, and group go/CI/docker/gradle/npm updates.
Throttled to a weekend schedule with prHourlyLimit 2.
2026-06-07 12:00:44 -04:00
bvandeusen dabca3ad24 fix(android): stop single SOAP failure from dropping Sonos to local
android / Build + lint + test (push) Successful in 3m44s
When the phone was locked, a transport command (lock-screen/Bluetooth/
watch media button, or queue-resync play) issued during a WiFi power-save
stall would hit the 2s connect timeout and throw. handleSoapFailure then
cancelled the poll loop and fired onDrop on that single failure -- showing
"Disconnected from <Sonos>" and reverting to local playback, even though
the renderer was perfectly reachable. This bypassed the poll loop's
deliberate 30-consecutive-failure tolerance (DROP_THRESHOLD, bumped from 3
precisely for screen-off WiFi sleep / Doze).

Make the 1 Hz poll loop the sole drop arbiter:
- Transport SOAP commands retry transient IO failures (retryTransientIo:
  3 attempts, 400ms backoff) so a brief WiFi stall lands the command once
  WiFi wakes instead of abandoning it. A SoapFaultException (renderer
  answered, rejected the action) is not retried -- the device is alive.
- handleTransportFailure no longer cancels the poll loop or fires onDrop;
  it logs and nudges an immediate poll so the UI reconciles to Sonos's
  actual state. If the renderer is truly gone, the poll loop trips the
  drop on its own via DROP_THRESHOLD.

Extract retryTransientIo as an internal top-level fn + unit test covering
first-success, retry-then-succeed, exhaust-and-rethrow, and no-retry-on-
SoapFault. Refresh now-stale drop-heuristic comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:54:14 -04:00
bvandeusen 7c791dc8e4 v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
release / Build + push container image (push) Successful in 14s
2026-06-06 23:31:27 -04:00
bvandeusen 8f29cc7414 feat: poll in-flight requests on native + refresh after submit (#369)
test-web / test (push) Successful in 33s
android / Build + lint + test (push) Successful in 3m37s
Native RequestsViewModel gains poll-while-approved (silent reloads, paused
when nothing in-flight) for parity with the web auto-poll, and the SSE
collector now reloads silently instead of flashing the loading spinner.
Web discover submit invalidates qk.myRequests() so a new request appears
on /requests immediately.
2026-06-06 23:18:44 -04:00
bvandeusen c8b21aa76e fix(android): grid items() resolution on artist detail (drop bad alias)
android / Build + lint + test (push) Successful in 3m35s
2026-06-06 22:56:58 -04:00
bvandeusen 483804fc9e fix(android): extract ArtistSuccessBody to satisfy detekt LongMethod
android / Build + lint + test (push) Failing after 2m56s
2026-06-06 22:48:45 -04:00
bvandeusen 12a8cfccb5 feat(android): similar-artists strip + top-tracks on artist detail
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 40s
android / Build + lint + test (push) Failing after 1m16s
test-go / integration (push) Successful in 4m29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:39:54 -04:00
bvandeusen e358a92cf4 feat(web): similar-artists strip + top-tracks panel on artist detail 2026-06-06 22:34:46 -04:00
bvandeusen 63b25e65ad feat(server): similar-artists + per-user artist top-tracks endpoints
GET /api/artists/{id}/similar — in-library artists ranked by similarity
score (deduped across sources), ArtistRef list with cover + album count.
GET /api/artists/{id}/top-tracks — current user's most-played tracks for
the artist (skips excluded, quarantine filtered).
2026-06-06 22:30:41 -04:00
bvandeusen a766b7193f test(server): drop removed scheduler arg from New() callers
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s
2026-06-06 22:00:21 -04:00
bvandeusen e95138d412 style(server): gofmt watcher_test map alignment
test-go / test (push) Failing after 10s
test-web / test (push) Successful in 33s
test-go / integration (push) Failing after 4m27s
2026-06-06 21:54:14 -04:00
bvandeusen 7426f6c718 feat(web): remove scan-schedule admin UI (scheduler retired)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:52:20 -04:00
bvandeusen e994aae613 feat(server): retire scan scheduler for watcher + safety-net walk
Wire the fsnotify watcher and a fixed 12h safety-net delta walk in main;
remove the configurable scan scheduler (scheduler.go, scan_schedule table via
migration 0033, GET/PATCH /api/admin/scan/schedule, and the server/api
plumbing). Manual scan + scan status are unchanged.
2026-06-06 21:50:08 -04:00
bvandeusen c39a9ca18f feat(server): targeted ScanFiles + fsnotify library watcher
Add Scanner.ScanFiles (watcher-driven targeted scan returning changed album
IDs) and a recursive fsnotify Watcher that debounces filesystem events and
enriches just the affected albums inline. Pure classifyEvent/drainPending
seams unit-tested; ScanFiles covered in the scanner integration test.
2026-06-06 21:43:27 -04:00
bvandeusen 06e155abb6 fix(android): rename IdleRevertDecision.kt to satisfy detekt MatchingDeclarationName
android / Build + lint + test (push) Successful in 4m11s
2026-06-06 17:36:07 -04:00
bvandeusen 6b11208e5a perf(android): fail-fast connect timeout for UPnP transport SOAP
android / Build + lint + test (push) Failing after 1m8s
2026-06-06 17:30:28 -04:00
bvandeusen 41da012494 fix(android): failed UPnP play reverts to phone and resumes locally 2026-06-06 17:29:58 -04:00
bvandeusen 8a3104443c feat(android): record play-intent on remote play/pause 2026-06-06 17:29:36 -04:00
bvandeusen 5f5ab69da6 feat(android): track UPnP play-intent surviving SOAP errors 2026-06-06 17:29:19 -04:00
bvandeusen 5386ae870f feat(android): revert UPnP output to phone after 5-min idle 2026-06-06 17:28:32 -04:00
bvandeusen d7b011e52f feat(android): pure idle-revert decision for UPnP output 2026-06-06 17:27:52 -04:00
bvandeusen 11466e1525 Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 13s
2026-06-05 13:27:36 -04:00
bvandeusen e185b36138 fix(android): arbitrate op-failure probe off the reducer thread
android / Build + lint + test (push) Successful in 3m33s
Awaiting probeOnce() inline in the OpFailure branch blocked the single-consumer
reducer for the /healthz timeout, delaying a concurrent self-proving success
from snapping back to Healthy. Launch the probe instead so recovery stays fast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:47:46 -04:00
bvandeusen 31d8c30dfe feat(android): grey offline-unavailable playlists, lead with cached pools
android / Build + lint + test (push) Successful in 3m28s
Home + Playlists list derive offline from NetworkStatusController (Offline or
ServerDown, not the raw device link), fixing the consistency gap. PlaylistRef
gains unavailableOffline (refreshable || !fullyCached); PlaylistCard dims the
whole tile when greyed but stays tappable. buildPlaylistsRow offline: pools
lead, real playlists partitioned available-first, placeholders dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:40:30 -04:00
bvandeusen 69ccd7b25d feat(android): per-playlist fullyCached via cache-index join
android / Build + lint + test (push) Successful in 3m27s
CachedPlaylistDao.observeCachedCounts LEFT-JOINs cached_playlist_tracks ×
audio_cache_index per playlist; PlaylistsRepository.observeAll combines it in
and stamps PlaylistRef.fullyCached (trackCount>0 && cached>=trackCount). Merge
extracted to a pure mergePlaylistsWithCache for Android-free unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:29:59 -04:00
bvandeusen 8b77c6be97 feat(android): 4-state connection banner with unstable + back-online flash
android / Build + lint + test (push) Successful in 3m27s
Banner VM collects NetworkStatusController.state directly (drops the redundant
WhileSubscribed re-wrap). Adds a mild 'Reconnecting…' treatment for the
non-gating Unstable state and a transient 'Back online' confirmation on
down→Healthy recovery (try/finally guards against a stuck flash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:23:55 -04:00
bvandeusen c33ef18a50 fix(android): extract OfflineGatedDataSource gate to satisfy detekt ThrowsCount
android / Build + lint + test (push) Successful in 3m24s
The added IOException rethrow pushed open() to 3 throws (max 2). Move the
two gating throws into a private gateOnHealth() helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:19:29 -04:00
bvandeusen 4c9450c117 feat(android): feed API/playback/pull-refresh signals into NetworkStatusController
android / Build + lint + test (push) Failing after 1m10s
OkHttp ReachabilityReportingInterceptor (Lazy to break the Hilt cycle) runs
first in the chain and reports only PLACEHOLDER_HOST (Minstrel-bound) 2xx/IO
outcomes so external artwork fetches don't read as server reachability.
OfflineGatedDataSource reports stream open success/failure; PlaybackErrorReporter
arbitrates on track failures; PullToRefreshScaffold re-probes on every pull.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:14:50 -04:00
bvandeusen b467cb7532 refactor(android): unify offline detection into NetworkStatusController
android / Build + lint + test (push) Successful in 3m25s
Absorbs VersionCheckController (/healthz poll + version parse) and
ServerHealthController (tri-state derive) into one signal-driven authority.
Adds the non-gating Unstable state across all ServerHealth branch sites
(OfflineGatedDataSource, SearchRepository, TrackRow, banner). Repoints
MinstrelApplication, MainActivity, PlayerFactory, VersionTooOldViewModel.
Drops the now-unused nowMs params the detekt UnusedParameter rule flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:06:22 -04:00
bvandeusen c78bbb7ba5 feat(android): pure ReachabilityMachine + 4-state ServerHealth enum
android / Build + lint + test (push) Failing after 1m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:35:22 -04:00
bvandeusen 301c3bfb86 Merge pull request 'fix(android): don't flip offline on WAN-validation flicker — trust /healthz' (#85) from dev into main
android / Build + lint + test (push) Successful in 5m6s
release / Build signed APK (tag releases only) (push) Successful in 4m42s
release / Build + push container image (push) Successful in 14s
2026-06-04 23:02:28 -04:00
bvandeusen 5c0db429b3 fix(android): don't flip offline on WAN-validation flicker — trust /healthz
android / Build + lint + test (push) Successful in 3m38s
A self-hosted Minstrel server is usually on the LAN, but ConnectivityObserver
gated 'online' on NET_CAPABILITY_VALIDATED — which tracks whether Android
reached its own WAN internet-validation probe, not whether Minstrel is
reachable. A transient WAN/DNS blip (or Android's periodic re-validation)
momentarily drops VALIDATED while the LAN server stays reachable. That flipped
ServerHealth -> Offline with NO debounce (only the /healthz path got hysteresis),
and OfflineGatedDataSource fast-failed the in-flight stream read with
OfflineException -> ExoPlayer SOURCE error -> the load_failed 'Source error'
event. On-device: 'app said server offline while it wasn't', one track failed,
then recovered when VALIDATED returned.

- ConnectivityObserver: require INTERNET only, not VALIDATED. The /healthz poll
  (VersionCheckController, with its own failure hysteresis) is the authority on
  whether Minstrel is reachable; the device-link signal only answers 'is there a
  network at all' (airplane mode).
- ServerHealthController: add a WARN-tier transition log. The signal had zero
  instrumentation, which is why this was hard to diagnose from logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:57:57 -04:00
bvandeusen 4d8c7d6566 Merge pull request 'fix(android): notification art uses album cover, not embedded stream tags' (#84) from dev into main
android / Build + lint + test (push) Successful in 4m1s
release / Build signed APK (tag releases only) (push) Successful in 6m12s
release / Build + push container image (push) Successful in 15s
2026-06-04 22:35:21 -04:00
bvandeusen 58810a860b fix(android): notification art uses album cover, not embedded stream tags
android / Build + lint + test (push) Successful in 3m38s
The MediaController notification / lock-screen background pulled artwork
from the stream's embedded ID3/FLAC tags (artworkData) because the
MediaItem never set artworkUri — a different source than the in-app
album cover (/api/albums/{id}/cover). For tracks whose embedded tag art
differs from the server album cover, the two surfaces disagreed.

- PlayerController.toMediaItem: set artworkUri to TrackRef.coverUrl.
  MediaMetadata.populate() overwrites artworkUri+artworkData as a pair,
  so the MediaItem URI clears the embedded bytes ExoPlayer extracts from
  the stream — the album cover now wins on both surfaces.
- PlayerFactory.buildBitmapLoader: OkHttp-backed CacheBitmapLoader so the
  authed placeholder cover URL resolves (the default DefaultHttpDataSource
  loader can't rewrite placeholder.invalid or attach the auth cookie).
- MinstrelPlayerService: attach it via MediaSession.setBitmapLoader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:24:20 -04:00
bvandeusen d6e6caa223 Merge pull request 'Sonos queue resync + cold-start prefetcher gate' (#83) from dev into main
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 13s
2026-06-04 17:36:31 -04:00
bvandeusen 9a31955fa4 perf(android): gate audio prefetch on isPlaying
android / Build + lint + test (push) Successful in 3m41s
Cold-start playback on a fresh install was taking ~25 s before any
audio played. Logcat showed AudioPrefetcher was kicking off N
concurrent CacheWriter jobs the instant setQueue updated uiState --
each prefetch a full upcoming-track download over the same OkHttp
client as the current-track DataSource. Five-way bandwidth split
plus parallel Coil cover fetches starved the current track until
its full file body had streamed through (~12 MB at ~1 MB/s under
contention).

Now reconcile() observes uiState.isPlaying and starts upcoming-track
prefetches only when the current track is actually playing.
Cancellation of out-of-window jobs always runs so a queue switch or
skip still frees the pipe immediately, even while paused. Cold start
should drop from ~25 s -> 5-7 s on the user's network: just the
single-stream throughput plus the one-time TLS/DNS tax.

Refactored the inline reconcile body into computeTargets /
cancelOutOfWindowLocked / startInWindowLocked helpers to keep
ReturnCount under the detekt cap.
2026-06-04 17:29:19 -04:00
bvandeusen e7d7cb2471 fix(android): incremental Sonos queue sync for playNext + radio-append
android / Build + lint + test (push) Successful in 3m55s
The previous fix re-loaded Sonos's full queue on every uiState.queue
identity change -- correct for playlist-switch (full replacement) but
disruptive for in-queue mutations: playNext and radio-append would
restart the currently-playing track on Sonos because removeAllTracks
+ AddURIToQueue x N + SetAVTransportURI re-anchors the transport.

Now the resync runs a longest-common-prefix / common-suffix diff first.
When the current Sonos track lies in the preserved prefix, applies the
minimum-incremental SOAP operations -- RemoveTrackRangeFromQueue on the
removed middle, AddURIToQueue at the same insertion point -- so Sonos
keeps playing the current track and the new entries land in place
without interrupting playback. Falls back to the full removeAllTracks
reload when the current track is in the removed slice (playlist
switch).

Adds AVTransportClient.removeTrackRangeFromQueue (Sonos-specific,
UpdateID=0 skips the queue-version check).

Cases now covered:
  - Playlist switch -> full reload (current track replaced, prefix=0)
  - playNext insert  -> 1 AddURIToQueue at the right slot
  - Radio-append     -> RemoveTrackRangeFromQueue for old tail + N
                        AddURIToQueue for new tracks at the end
2026-06-04 17:07:26 -04:00
bvandeusen 8017934334 fix(android): re-prime Sonos queue when user plays a different playlist
android / Build + lint + test (push) Successful in 3m56s
Before: tapping a different playlist while Sonos was the active route
updated the player view but Sonos kept the old queue and played those
tracks (or whatever was last there). PlayerController.setQueue replaced
the local ExoPlayer queue and called play(), which forwarded SOAP Play
to Sonos -- but Sonos's native queue (loaded once at route selection
via removeAllTracks + AddURIToQueue + SetAVTransportURI) was never
touched on subsequent setQueue calls.

Now: MinstrelForwardingPlayer.setMediaItems (all 3 overloads) clears
holder.active + sets target synchronously so the immediately-following
play() drops via isLoadingUpnp(). OutputPickerController observes
uiState.queue identity changes; when target or active is non-null and
the queue key shifted, it re-runs loadQueueOnSonos under the existing
selectUpnpMutex and restores active when done. Sonos resync failures
drop cleanly to local (selectedUpnpRouteIdInternal nulled).

Doesn't touch addMediaItem / radio-append paths -- those leave Sonos's
queue stale and need a separate AddURIToQueue extension hook; out of
scope for this fix.
2026-06-04 17:00:24 -04:00
bvandeusen 8b08482d13 Merge pull request 'fix(android): hysteresis on /healthz reachable signal' (#82) from dev into main
android / Build + lint + test (push) Successful in 4m46s
release / Build signed APK (tag releases only) (push) Successful in 4m26s
release / Build + push container image (push) Successful in 13s
2026-06-04 14:23:49 -04:00
bvandeusen faa0c7024b fix(android): hysteresis on /healthz reachable signal — 3 consecutive failures
android / Build + lint + test (push) Successful in 3m55s
The single-failure flip-to-false produced two false-positive permanent
banner cases:

1. Startup race — AuthStore.baseUrl loads from Room asynchronously, so the
   first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
   ("http://localhost:8080") before the real server URL has hydrated. One
   failure was enough to lock the banner on for the next 5 minutes.

2. Deployments whose reverse proxy routes only /api/* to the Go server —
   /healthz never reaches the handler, so /healthz polls fail forever even
   though every real /api/* call succeeds. User sees "Server unreachable"
   permanently and OfflineGatedDataSource starts throwing OfflineException
   on every audio cache miss, silently breaking playback of uncached
   tracks.

Now we require 3 consecutive failures (~15 min at the 5-min poll cadence)
before flipping reachable=false, and any single success resets the
counter. Adds Timber.w/i at the flip transitions so operator logcat can
diagnose genuine outages.
2026-06-04 13:40:49 -04:00
bvandeusen 7cf04fe24b Merge pull request 'Android #618 offline-mode UX + Sonos polish + server DRY' (#81) from dev into main
android / Build + lint + test (push) Successful in 4m35s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 13s
2026-06-04 12:53:59 -04:00
bvandeusen 1e17eeda72 feat(android): #618 Phase 5 — confirm offline writes via shell snackbar
android / Build + lint + test (push) Successful in 3m58s
MutationQueue now emits "Saved — will sync when online" on a SharedFlow
whenever a user-driven enqueue lands (like toggle, playlist append,
request create/cancel, quarantine flag/unflag). Background enqueues
(play-offline events, playback-error reports) do not emit — those fire
from non-foreground paths where a snackbar would be either dropped
(no shell mounted) or jarring (lock-screen toggle).

ShellScaffold subscribes via OfflineWriteHintViewModel and routes the
hint through its existing snackbar host. Replaces the prior silent-
queue UX where a tap on a like / playlist add looked successful but
the user couldn't tell whether the server had been hit or the call
was deferred for replay.
2026-06-04 12:40:59 -04:00
bvandeusen 1daea79f64 feat(android): #618 Phase 4 — row-level offline-unavailable affordance
android / Build + lint + test (push) Has been cancelled
TrackRow now consumes the LocalServerHealth CompositionLocal (provided
once at MainActivity from ServerHealthController.state). When the server
is Offline or ServerDown and the track id isn't in LocalCachedTrackIds,
the row dims to 0.4 alpha and a tap fires a Toast instead of attempting
playback. Replaces the silent "tap-and-fail-to-OfflineException" UX with
explicit at-a-glance signaling of which rows in a long list will work.

Trailing slot (kebab / like / playlist-add) stays interactive so write
affordances can route through MutationQueue — Phase 5 gates those at
the action level.
2026-06-04 12:37:42 -04:00
bvandeusen 48de720514 feat(android): #618 Phase 2 — search falls back to cached entities when offline
android / Build + lint + test (push) Has been cancelled
When ServerHealthController reports Offline or ServerDown, SearchRepository
runs Room LIKE queries against cached_artists / cached_albums / cached_tracks
instead of hitting /api/search. The screen draws a one-line hint above the
results so the user can tell server matches from on-device-only matches.

Adds searchByName / searchByTitle DAO methods; LOCAL_SEARCH_LIMIT=20 matches
the server's default page size.
2026-06-04 12:35:01 -04:00
bvandeusen fced6b681e feat(android): cache-only audio path when ServerHealth != Healthy
android / Build + lint + test (push) Successful in 4m58s
Phase 3 of #618. Wraps the OkHttpDataSource upstream of CacheDataSource with OfflineGatedDataSource. CacheDataSource only consults the upstream factory on a cache miss, so playback of cached audio is unaffected. Offline tap on a non-cached track now throws OfflineException immediately (subclass of IOException for ExoPlayer's PlaybackException to wrap) instead of waiting on a multi-second OkHttp timeout. AudioPrefetcher keeps its own ungated upstream -- writes fail silently when offline, no user-visible impact.
2026-06-04 11:27:11 -04:00
bvandeusen 80a6be25aa feat(android): ServerHealth tri-state composite + banner distinguishes offline vs server-down
android / Build + lint + test (push) Has been cancelled
Phase 1 of #618. VersionCheckController gains reachable: StateFlow<Boolean> from the same /healthz poll (no double polling). ServerHealthController combines connectivity.online + versionCheck.reachable into ServerHealth { Healthy, Offline, ServerDown }. ConnectionErrorBanner now branches on the tri-state, distinguishing 'no Wi-Fi' from 'server unreachable.' Phases 2-5 (local search, cache-only audio source, row-level not-cached affordance, write-affordance gray-out) ship separately as independent slices.
2026-06-04 11:25:22 -04:00
bvandeusen 4d0a0b8e09 fix(android): throttle UPnP extend with 50ms delay per successful AddURIToQueue
android / Build + lint + test (push) Successful in 4m10s
Closes Scribe #611. The 2026-06-04 logcat showed 33 consecutive AddURIToQueue failures clustered at ~10ms intervals once the burst hit offset 39 -- characteristic of Sonos's burst-add rate-limit. 50ms between successful adds adds ~5s to the 100-track background extension but eliminates the burst rejection. Next reproduction with the SOAP fault detail logging (audit commit c5b326c6) will confirm the fault code if any tracks still fail.
2026-06-04 11:03:55 -04:00
bvandeusen 6184c62721 feat(android): MediaSession picks up UPnP state via direct listener invocation
android / Build + lint + test (push) Has been cancelled
Closes Scribe #606. Two pieces: MediaMetadata gets durationMs (lock-screen scrubber gets a known total even when wrapped ExoPlayer is paused under UPnP); MinstrelForwardingPlayer keeps an externalListeners registry that mirrors super.addListener so we can directly invoke onIsPlayingChanged / onPlaybackStateChanged / onMediaItemTransition when remoteState mutates. Fires from pollOnce + play()/pause() onSuccess. dedup via lastNotified guards so we don't spam events at 1Hz when nothing changed.
2026-06-04 11:02:51 -04:00
bvandeusen 222a0ff636 Merge pull request 'dev → main: collage center-crop, server DRY, CI durability-off' (#80) from dev into main
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m27s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 12s
2026-06-04 08:42:36 -04:00
bvandeusen 28300e19fd perf(ci): turn off Postgres durability in integration tests
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s
TRUNCATE-everything ResetDB before every test forces a commit fsync; the CI DB is rebuilt each run so durability buys nothing. ALTER SYSTEM via docker exec (the services: block can't override the postgres command line). Non-fatal so a perms surprise degrades to slow, never red.

Per the playbook the operator shared from another project (~17x speedup observed there). Measure before/after in the next two CI runs.
2026-06-04 08:31:06 -04:00
bvandeusen 024493f2a7 refactor(server): unify stream URL builders + MIME tables + cover-path helper
test-go / test (push) Successful in 28s
test-go / integration (push) Has been cancelled
Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit.

- streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant.

- audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs.

- coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
2026-06-04 08:29:51 -04:00
bvandeusen edd198cdf5 fix(server): collage drawScaled uses center-crop instead of stretch
test-go / test (push) Successful in 27s
test-go / integration (push) Has been cancelled
2026-06-04 08:22:59 -04:00
bvandeusen d75c1ae37f Merge pull request 'dev → main: Android UPnP/Sonos transport parity + server stream URL extension' (#79) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 30s
release / Build + push container image (push) Successful in 1m24s
android / Build + lint + test (push) Successful in 4m12s
test-go / integration (push) Successful in 9m16s
2026-06-04 08:15:15 -04:00
bvandeusen 8cd2383a42 test(server): seed track for cast-token tests + assert file-ext in URL
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 9m34s
2026-06-04 07:44:33 -04:00
bvandeusen 27bd38e005 feat(server): stream URL gets file extension so Sonos can probe duration
test-go / test (push) Successful in 37s
test-go / integration (push) Failing after 10m34s
2026-06-04 07:29:28 -04:00
bvandeusen aa23a72693 fix(android): effectiveDuration uses desiredIdx so duration tracks the displayed title
android / Build + lint + test (push) Successful in 4m2s
2026-06-04 07:17:17 -04:00
bvandeusen 4021938046 fix(android): polling tick no longer force-syncs controller -- avoids Sonos seek-to-0
android / Build + lint + test (push) Successful in 3m26s
2026-06-04 07:06:07 -04:00
bvandeusen 7486bc2444 fix(android): split tickPositionPoll into helpers for detekt complexity
android / Build + lint + test (push) Has been cancelled
2026-06-04 07:04:01 -04:00
bvandeusen ee8a1fdc93 fix(android): event-driven pending-transport clear (Sonos ack OR 5s safety)
android / Build + lint + test (push) Failing after 1m12s
2026-06-04 07:00:22 -04:00
bvandeusen 8e578d2068 fix(android): 2s transport-ack lockout so Prev/Next isn't undone by stale poll
android / Build + lint + test (push) Successful in 3m25s
2026-06-04 06:55:39 -04:00
bvandeusen cacb280832 fix(android): polling tick track update is forward-only so user Next isn't undone
android / Build + lint + test (push) Successful in 3m27s
2026-06-04 06:50:35 -04:00
bvandeusen 36054506c2 fix(android): polling tick owns track-change updates when delegate.seekTo silent
android / Build + lint + test (push) Successful in 3m26s
2026-06-04 06:46:38 -04:00
bvandeusen 5db90844cb fix(android): DIDL-Lite includes Rincon namespace + cdudn desc for Sonos
android / Build + lint + test (push) Successful in 3m39s
2026-06-04 06:34:00 -04:00
bvandeusen d5437d517e fix(android): single break in extend loop for detekt LoopWithTooManyJumpStatements
android / Build + lint + test (push) Successful in 3m52s
2026-06-04 01:07:57 -04:00
bvandeusen 3085d6f409 fix(android): DIDL-Lite restricted=true / id=-1 so Sonos accepts metadata
android / Build + lint + test (push) Failing after 1m33s
2026-06-04 00:59:31 -04:00
bvandeusen c5b326c620 fix(android): UPnP extend captures SOAP fault, aborts after 3 failures
android / Build + lint + test (push) Has been cancelled
2026-06-04 00:56:48 -04:00
bvandeusen 389c896d65 fix(android): force poll on ON_RESUME + cap interpolation drift at 5s
android / Build + lint + test (push) Has been cancelled
2026-06-04 00:55:10 -04:00
bvandeusen 41230b5afb fix(android): single jump per polling loop for detekt LoopWithTooManyJumpStatements
android / Build + lint + test (push) Successful in 4m0s
2026-06-04 00:42:35 -04:00
bvandeusen c245b1ef0b fix(android): hold UI patch until cursor sync lands; reanchor on track flip
android / Build + lint + test (push) Failing after 1m21s
2026-06-04 00:37:34 -04:00
bvandeusen 2425a305eb fix(android): interpolate UPnP position between polls + suppress post-seek race
android / Build + lint + test (push) Successful in 4m5s
2026-06-04 00:16:57 -04:00
bvandeusen 88b161193d fix(android): TrackRef.durationSec is final fallback so duration is never 0
android / Build + lint + test (push) Successful in 5m54s
2026-06-04 00:03:48 -04:00
bvandeusen 9628ed1749 fix(android): duration falls back to local while Sonos hasn't reported one
android / Build + lint + test (push) Has been cancelled
2026-06-04 00:00:45 -04:00
bvandeusen 85926f4ec0 fix(android): keep service alive when UPnP is playing -- override playWhenReady
android / Build + lint + test (push) Successful in 3m53s
2026-06-03 23:51:54 -04:00
bvandeusen 47b0894ad6 test(android): update RemotePlayerState threshold expectation to 30
android / Build + lint + test (push) Has been cancelled
2026-06-03 23:50:37 -04:00
bvandeusen e62fac3a0e fix(android): onEvents reads UPnP state so resume keeps duration
android / Build + lint + test (push) Has been cancelled
2026-06-03 23:48:52 -04:00
bvandeusen eae5dcad23 fix(android): correct ErrorCopy import path in PlaylistPlayback
android / Build + lint + test (push) Failing after 6m2s
2026-06-03 23:42:18 -04:00
bvandeusen 3576e241c0 fix(android): split playPlaylistShuffled to satisfy detekt ReturnCount
android / Build + lint + test (push) Failing after 3m42s
2026-06-03 23:37:30 -04:00
bvandeusen 8f89279fa4 fix(android): UPnP survives screen-off + cursor catches up to Sonos
android / Build + lint + test (push) Has been cancelled
2026-06-03 23:36:43 -04:00
bvandeusen b1a66f18bd feat(android): shuffle on system playlist tile play -- Home + Playlists list
android / Build + lint + test (push) Failing after 1m30s
2026-06-03 23:29:42 -04:00
bvandeusen 6a7958c921 fix(android): debounce non-PLAYING poll to suppress UPnP track-change flicker
android / Build + lint + test (push) Successful in 4m13s
2026-06-03 23:19:29 -04:00
bvandeusen 33285b53c6 fix(android): refresh uiState.isPlaying from remoteState during UPnP
android / Build + lint + test (push) Successful in 4m1s
2026-06-03 23:08:32 -04:00
bvandeusen 87ad7f4dc2 feat(android): lazy queue activation + loading spinner during UPnP load
android / Build + lint + test (push) Successful in 3m55s
Part A: split loadQueueOnSonos into an initial phase (tracks[0..currentIndex]
only, then SetAV+Seek+Play) plus a background extendQueueOnSonos coroutine
that appends the remainder after activation. Reduces the UPnP activation
block from ~17s (100 tracks serial) to ~200ms (1 track at currentIndex=0).
Background extension cancels cleanly when activeUpnpHolder.active changes.

Part B: add PlayerUiState.isUpnpLoading (target set, active null). Projected
inline in onEvents so it stays consistent with the rest of the snapshot, plus
a separate combine(target, active) collector that updates uiState between
player-event fires. NowPlayingScreen.TransportRow and MiniPlayer.MiniRow
replace the play/pause icon with a CircularProgressIndicator while loading
and disable the button tap to prevent premature commands to the Sonos queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:56:06 -04:00
bvandeusen 9c0013f4b6 fix(android): defer holder.active until SOAP wired -- drop transport during load
android / Build + lint + test (push) Successful in 4m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:46:57 -04:00
bvandeusen 6129536153 diag(android): log ForwardingPlayer transport overrides on entry
android / Build + lint + test (push) Successful in 3m50s
2026-06-03 22:32:33 -04:00
bvandeusen e6c3c959fa fix(android): handleZeroDurationIfNeeded ReturnCount within cap
android / Build + lint + test (push) Successful in 3m45s
2026-06-03 22:25:53 -04:00
bvandeusen e011b04e04 fix(android): remove pollLoop cursor sync -- races with queue load and SOAP
android / Build + lint + test (push) Has been cancelled
2026-06-03 22:24:34 -04:00
bvandeusen e2866795ef fix(android): skip zero-duration auto-advance during UPnP playback
android / Build + lint + test (push) Failing after 1m26s
2026-06-03 22:18:11 -04:00
bvandeusen e20d7b1438 fix(android): correct SOAPACTION assertions in next/previous tests
android / Build + lint + test (push) Successful in 3m58s
2026-06-03 22:06:54 -04:00
bvandeusen 2a098a78fe feat(android): Sonos queue mode -- ClearQueue + AddURIToQueue + x-rincon-queue
android / Build + lint + test (push) Failing after 9m9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:55:03 -04:00
bvandeusen ece37e9a92 fix(android): remove pollLoop natural-advance -- races with selectUpnp and skip
android / Build + lint + test (push) Successful in 4m1s
Polling alone cannot distinguish Sonos auto-advancing via SetNextAVTransportURI
from URI changes we made ourselves via syncCurrentItemToRemote.  This produced
two races: (1) activation race -- first poll returns stale URI from prior
session, second returns new URI, false-positive fires and double-advances the
cursor; (2) user-skip race -- skip's syncCurrentItemToRemote changes the URI,
next poll sees the change and fires again.  Remove the detection block and
previousTrackUri capture from pollOnce entirely.  pollLoop is now a pure
state-tracker (position + transport state) plus the one-shot initial pre-queue
gate.  GENA event subscriptions to AVTransport LastChange are the correct fix;
deferred to its own slice (see parity-map).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:42:01 -04:00
bvandeusen 8a1203c4a1 fix(android): revert x-rincon-mp3radio Sonos URI -- plain https for music tracks
android / Build + lint + test (push) Successful in 4m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:31:31 -04:00
bvandeusen 487d1bd430 fix(android): SOAP/UPnP parsers enable processNamespaces -- correct name extraction
android / Build + lint + test (push) Successful in 4m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:58:59 -04:00
bvandeusen 5c99341b34 fix(android): test assertions use JUnit Jupiter for lazy-message Supplier<String>
android / Build + lint + test (push) Failing after 3m31s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:51:28 -04:00
bvandeusen 8fe3308afd fix(android): detekt -- parseHhMmSs ReturnCount/MagicNumber + readUntilEndTag jumps
android / Build + lint + test (push) Failing after 3m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:45:00 -04:00
bvandeusen 96594ba52b fix(android): Sonos ZGT robust extraction -- escaped + nested element fallback + diag
android / Build + lint + test (push) Failing after 1m29s
2026-06-03 20:40:52 -04:00
bvandeusen 2c61d7a333 fix(android): Sonos UDN comparison strips _MR/_MS suffix -- coordinator routing
android / Build + lint + test (push) Failing after 2m41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:29:08 -04:00
bvandeusen 8652b86f40 fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI
android / Build + lint + test (push) Failing after 1m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:20:25 -04:00
bvandeusen 75132a2afe diag(android): log raw SOAP response body for first 3 UPnP polls
android / Build + lint + test (push) Failing after 1m22s
Add optional onRawResponse callback to SoapClient; loggingSoapClient
factory emits the first 6 GetPositionInfo/GetTransportInfo bodies
(3 poll cycles) at WARN so release logs capture them. Wire into
transportFor so every AVTransportClient for a new UPnP session logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:12:15 -04:00
bvandeusen 673f98487f fix(android): UPnP local pause via delegate -- no SOAP race
android / Build + lint + test (push) Failing after 1m30s
Move local ExoPlayer pause from OutputPickerController.selectUpnp
into MinstrelForwardingPlayer.onActiveChanged (handler.post { delegate.pause() }).
This guarantees the pause hits ExoPlayer before the holder is live, eliminating
the async race that caused SOAP fault 701 on Sonos when pause() was dispatched
via playerController after holder.active was already set.
Also adds per-poll Timber.w before initialPreQueueDone for diagnostics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:53:41 -04:00
bvandeusen c556388a6b fix(android): UPnP activation order -- pause local before holder.set; sync+preQueue sequential; diagnostic Timber.w
android / Build + lint + test (push) Failing after 1m28s
- OutputPickerController.selectUpnp: pause ExoPlayer BEFORE setting
  activeUpnpHolder so ForwardingPlayer.pause() routes to ExoPlayer,
  not SOAP; remove now-redundant playerController.pause() from inside
  runCatching; bump activation Timber.i -> Timber.w for release logcat
- MinstrelForwardingPlayer: remove Player.Listener onMediaItemTransition
  that raced with seekToNext/Prev override's syncCurrentItemToRemote;
  seekToNext/Prev now launch sync -> preQueueNext sequentially in one
  coroutine; remove early preQueueNext from onActiveChanged (raced with
  selectUpnp SOAP); move initial pre-queue to pollLoop, fires once
  trackUri lands confirming Sonos accepted SetAV+Play
- Extract pollOnce from pollLoop to stay within detekt LongMethod=60;
  natural-advance branch now calls preQueueNext explicitly (no listener)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:47:19 -04:00
bvandeusen edffdec2b2 fix(android): UPnP drop falls back to local; pollLoop detects natural advance
android / Build + lint + test (push) Failing after 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:07:20 -04:00
bvandeusen 1ab21d81ca feat(android): SetNextAVTransportURI pre-queue for gap-free UPnP advance
android / Build + lint + test (push) Failing after 1m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:53:10 -04:00
bvandeusen 81794e2475 fix(android): UPnP volume cache keyed to active route id
android / Build + lint + test (push) Has been cancelled
2026-06-03 17:52:00 -04:00
bvandeusen 29309d9bfb ui(android): drop snackbar + hardware volume keys when UPnP route active
android / Build + lint + test (push) Failing after 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:43:55 -04:00
bvandeusen 70b29567fb refactor(android): service holds Player not ExoPlayer for ForwardingPlayer wrap
android / Build + lint + test (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:36:35 -04:00
bvandeusen 2f4d67d3c8 feat(android): PlayerFactory wraps ExoPlayer in ForwardingPlayer + drop events
android / Build + lint + test (push) Failing after 1m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:34:35 -04:00
bvandeusen b2bfe96559 ui(android): LazyColumn stable keys for in-place output picker row continuity
android / Build + lint + test (push) Failing after 1m21s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:33:01 -04:00
bvandeusen e9dd3e4d2a fix(android): OutputPicker -- local capture, selectUpnp mutex, shared dedup helper, suppress
android / Build + lint + test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:31:40 -04:00
bvandeusen b29875fd30 feat(android): UPnP selection state, disconnect, BuiltIn-pinned alphabetical sort
android / Build + lint + test (push) Failing after 1m21s
OutputPickerController now owns selection state for the UPnP leg and
runs the disconnect flow when the user picks a system route while a
renderer is active.

- Inject OkHttpClient + RemotePlayerState so we can build a
  RenderingControlClient at selection time and capture the last-known
  remote position on disconnect.
- selectUpnp publishes ActiveUpnp(routeId, routeName, avTransport,
  rendering) to ActiveUpnpHolder, marks the route id in
  selectedUpnpRouteIdInternal, and honors Sonos topology by routing
  through coordinatorRouteFor before SOAP.
- selectSystem now does the disconnect: AVTransport.Stop -> clear the
  holder -> seek local ExoPlayer to the remembered position -> resume
  if the remote was playing.
- routesState combines 4 sources (system, UPnP, Sonos topology,
  upnp-selected id). Non-coordinator Sonos members are filtered out
  of the visible list. current resolves from the merged list when a
  UPnP route is selected; otherwise from the system snapshot.
- sortRoutes drops the current-first rule -- BuiltIn "Phone speaker"
  pins to the top, everything else lowercase-alphabetical. Selection
  state moves to the radio-button indicator in the picker row.
- RemotePlayerState gets @Singleton + @Inject constructor() so Hilt
  can provide the shared instance to both the picker and the
  forthcoming MinstrelForwardingPlayer.
2026-06-03 17:24:08 -04:00
bvandeusen 85cea8d559 fix(android): UPnP forwarding -- route mint failures through drop, no double drop
android / Build + lint + test (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:18:53 -04:00
bvandeusen ab6c3a1354 feat(android): MinstrelForwardingPlayer wraps ExoPlayer with UPnP branching
android / Build + lint + test (push) Failing after 1m25s
Task 7 of UPnP transport-parity slice. Introduces the central
ForwardingPlayer that branches between local ExoPlayer and the active
UPnP renderer:

- MinstrelForwardingPlayer wraps the delegate Player; play/pause/seek
  and the next/previous transport calls translate to AVTransport SOAP
  when an ActiveUpnp is set, otherwise forward to super. Position +
  isPlaying + duration + playbackState reads pull from
  RemotePlayerState while remote.
- 1Hz poll loop drives GetPositionInfo + GetTransportInfo, feeding
  RemotePlayerState; the rolling-3 failure heuristic fires onDrop on
  the looper for the factory to surface as a snackbar.
- StreamTokenProvider extracts the CastApi.create() Retrofit wiring
  into a Hilt singleton so the service-side player and the
  controller-side picker share one CastApi instance.
- OutputPickerController constructor swaps Retrofit for
  StreamTokenProvider + ActiveUpnpHolder (the holder is wired now for
  Task 8). selectUpnp now mints via streamTokens.mint(trackId).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 17:12:11 -04:00
bvandeusen 3aee2276bc feat(android): RemotePlayerState container for UPnP-synthesized player state
android / Build + lint + test (push) Failing after 1m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:08:04 -04:00
bvandeusen 9a7d3b2d30 feat(android): ActiveUpnpHolder singleton for picker -> player handoff
android / Build + lint + test (push) Failing after 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:06:33 -04:00
bvandeusen 9002cf5559 refactor(android): UPnP discovery cleanup -- bareUdn helper, Timber, suppress, kdoc
android / Build + lint + test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:05:31 -04:00
bvandeusen a5e4570f01 feat(android): UPnP in-place updates + Sonos topology aggregation
android / Build + lint + test (push) Failing after 1m14s
2026-06-03 16:59:31 -04:00
bvandeusen bfcb9c42a0 feat(android): Sonos ZoneGroupTopology client + parser
android / Build + lint + test (push) Failing after 1m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:53:29 -04:00
bvandeusen 799d50024c test(android): RenderingControl lower clamp + GetVolume request body checks
android / Build + lint + test (push) Failing after 1m17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:51:07 -04:00
bvandeusen 8c0c4c8600 feat(android): RenderingControl get/set volume
android / Build + lint + test (push) Failing after 1m33s
2026-06-03 16:48:40 -04:00
bvandeusen 3cdb416f94 fix(android): AVTransport test assertions escape DIDL; consolidate xmlEscape
android / Build + lint + test (push) Failing after 1m18s
DIDL assertion now checks for XML-escaped form (&lt;dc:title&gt;) since
SoapClient.buildEnvelope escapes all arg values. Lifts xmlEscape to a
top-level internal fun in SoapClient.kt, removing the duplicate private
copy from AVTransportClient. Fixes @Suppress rationale (not Compose).
Renames seek test to reflect colon-separated format; adds unknown-state
getTransportInfo test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:47:18 -04:00
bvandeusen d62a3b8134 feat(android): AVTransport pause/seek/nextURI/positionInfo/transportInfo
android / Build + lint + test (push) Failing after 1m35s
- Add pause(), seek(positionMs), setNextAVTransportURI(uri, mime, title)
- Add getPositionInfo() -> PositionInfo, getTransportInfo() -> TransportInfo
- Extract buildDidlLite() helper; add formatHhMmSs / parseHhMmSs helpers
- Add PositionInfo, TransportState, TransportInfo top-level types
- Add AVTransportClientTest covering all four new call shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:42:04 -04:00
bvandeusen 574bf29a7e fix(android): PlayerController transport methods dispatch to controller thread
android / Build + lint + test (push) Successful in 4m17s
After DIDL fix, Sonos accepted SetAVTransportURI + Play, but
playerController.pause() threw IllegalStateException 'method is
called from a wrong thread' because selectUpnp runs the whole
UPnP-selection flow on Dispatchers.Default. UI tap handlers were
fine - they're already on Main - but the cross-thread background
call from OutputPickerController.selectUpnp hit the MediaController's
application-thread guard.

Same fix as the cold-boot resume one earlier today (commit e69a5204
wrapped setQueue): pause / play / seekTo / skipToNext / skipToPrevious
now route through runOnControllerThread, which is a no-op when
already on the application looper and Handler.post otherwise.

Logcat from on-device confirmed Sonos plays after this fix lands -
SetAVTransportURI -> 200, Play -> 200, then the IllegalStateException
was the last failure path.
2026-06-03 16:00:53 -04:00
bvandeusen 24b7c92abd fix(server+android): DIDL-Lite metadata for Sonos UPnP (error 1023)
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m14s
test-go / integration (push) Failing after 9m12s
After the X-Forwarded-Proto fix Sonos now gets a clean https:// URL
but returns vendor error 1023 - empty CurrentURIMetaData. Sonos
requires DIDL-Lite metadata with at minimum <res protocolInfo>
carrying the audio MIME type so it can validate the source before
playback. The original spec said 'Sonos accepts empty DIDL; recoverable
if a device rejects' - that was wrong for Sonos.

Server (cast_token.go):
- Look up the track and return mime (from tracks.file_format) +
  title in the cast-token response. mimeForFormat covers the common
  formats - mp3, flac, m4a/aac, ogg, opus, wav - falling through to
  audio/mpeg for unknowns.
- Missing track returns 404 (apierror.NotFound) instead of letting the
  caller mint a token for nothing.

Client (CastApi.kt, AVTransportClient.kt, OutputPickerController.kt):
- StreamTokenResponse gains mime + title (defaulted so old contracts
  stay parseable).
- AVTransportClient.setAVTransportURIWithMetadata builds minimal Sonos-
  acceptable DIDL-Lite around the URL + MIME + title. xml-escaped.
- selectUpnp calls the new overload; Timber.i now logs the MIME so the
  next on-device test shows it.

Generic UPnP renderers tolerate the DIDL shape too - no downside to
sending it everywhere.
2026-06-03 15:54:56 -04:00
123 changed files with 6673 additions and 1621 deletions
+21
View File
@@ -116,6 +116,27 @@ jobs:
# Wait for Postgres to accept TCP (no health-check dependency).
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
# Relax durability on the throwaway CI Postgres. Our test pattern
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
# every test, and the per-TRUNCATE commit fsync is the dominant
# cost of the integration suite. The CI DB is rebuilt every run so
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
# via docker exec because:
# - The act_runner `services:` block can't override the container
# command, so `postgres -c fsync=off` at boot isn't an option.
# - ALTER SYSTEM cannot run inside a transaction; psql -c
# auto-commits each statement, which is what we need.
# - fsync / full_page_writes are sighup GUCs and
# synchronous_commit is user-context, so pg_reload_conf() picks
# all three up with no restart.
# Non-fatal: a perms surprise degrades to "slower", never red CI.
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
-c "ALTER SYSTEM SET fsync = off" \
-c "ALTER SYSTEM SET synchronous_commit = off" \
-c "ALTER SYSTEM SET full_page_writes = off" \
-c "SELECT pg_reload_conf()" \
|| echo "WARN: durability relax failed; continuing"
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
+13
View File
@@ -1754,6 +1754,19 @@
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="formFactor" value="Tablet" />
<option name="id" value="tangorpro" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
@@ -0,0 +1,690 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
"entities": [
{
"tableName": "sync_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cursor",
"columnName": "cursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncAt",
"columnName": "lastSyncAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortName",
"columnName": "sortName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "artistThumbPath",
"columnName": "artistThumbPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistFanartPath",
"columnName": "artistFanartPath",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTitle",
"columnName": "sortTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "releaseDate",
"columnName": "releaseDate",
"affinity": "TEXT"
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trackNumber",
"columnName": "trackNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT"
},
{
"fieldPath": "fileFormat",
"columnName": "fileFormat",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_likes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"entityType",
"entityId"
]
}
},
{
"tableName": "cached_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPublic",
"columnName": "isPublic",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "trackCount",
"columnName": "trackCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "durationSec",
"columnName": "durationSec",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemVariant",
"columnName": "systemVariant",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_playlist_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId",
"trackId"
]
}
},
{
"tableName": "cached_quarantine_mine",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reason",
"columnName": "reason",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackTitle",
"columnName": "trackTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackDurationMs",
"columnName": "trackDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumTitle",
"columnName": "albumTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCoverArtPath",
"columnName": "albumCoverArtPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistName",
"columnName": "artistName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "audio_cache_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cachedAt",
"columnName": "cachedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastPlayedAt",
"columnName": "lastPlayedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "cached_mutations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAttemptAt",
"columnName": "lastAttemptAt",
"affinity": "INTEGER"
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_resume_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_home_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
"fields": [
{
"fieldPath": "section",
"columnName": "section",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"section",
"position"
]
}
},
{
"tableName": "cached_history_snapshot",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "auth_session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionCookie",
"columnName": "sessionCookie",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userJson",
"columnName": "userJson",
"affinity": "TEXT"
},
{
"fieldPath": "themeMode",
"columnName": "themeMode",
"affinity": "TEXT"
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT"
},
{
"fieldPath": "cacheSettingsJson",
"columnName": "cacheSettingsJson",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb73ed8674efb1d82a586551baba5ef0')"
]
}
}
@@ -21,6 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -38,6 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
@@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
)
@@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
@@ -93,11 +99,13 @@ private fun App(
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
LocalCachedTrackIds provides cached,
LocalServerHealth provides health,
) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination
@@ -19,7 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.VersionCheckController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -114,12 +114,13 @@ class MinstrelApplication :
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/**
* Same construct-the-singleton trick — VersionCheckController's
* init block starts a 5-min poll loop against /healthz so the
* shell-level VersionTooOldBanner can surface min_client_version
* mismatches without waiting for the next user-driven request.
* Same construct-the-singleton trick — NetworkStatusController owns the
* /healthz poll loop + the device-link collector + the reachability state
* machine, and is the single authority on the tri-state ServerHealth
* signal (plus the VersionTooOld byproduct). It must exist from launch so
* the poll loop runs and the StateFlow stays warm for every consumer.
*/
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
@@ -45,9 +46,15 @@ object NetworkModule {
fun provideOkHttp(
baseUrl: BaseUrlInterceptor,
auth: AuthCookieInterceptor,
reachability: ReachabilityReportingInterceptor,
logging: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
// ReachabilityReportingInterceptor MUST run first: it identifies
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
// (so external artwork fetches don't read as server reachability)
// and observes the final transport outcome by wrapping the chain.
.addInterceptor(reachability)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches
@@ -39,11 +39,18 @@ data class StreamTokenRequest(
/**
* Response body. [url] is a fully-formed stream URL with [token] and
* [exp] already embedded as query params — callers pass it verbatim
* to `AVTransport.SetAVTransportURI`.
* to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits
* the client needs to build DIDL-Lite metadata for that call: Sonos
* rejects empty DIDL with vendor error 1023, so the server hands back
* the track's MIME (from `tracks.file_format`) and title so the
* client can populate `<res protocolInfo>` and `<dc:title>` without
* a follow-up round trip.
*/
@Serializable
data class StreamTokenResponse(
val token: String,
val exp: Long,
val url: String,
val mime: String = "audio/mpeg",
val title: String = "",
)
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire
import com.fabledsword.minstrel.models.wire.TrackWire
import retrofit2.http.GET
import retrofit2.http.Path
@@ -35,9 +36,26 @@ interface LibraryApi {
@GET("api/artists/{id}/tracks")
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
@GET("api/artists/{id}/similar")
suspend fun getSimilarArtists(
@Path("id") id: String,
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
): List<ArtistWire>
@GET("api/artists/{id}/top-tracks")
suspend fun getArtistTopTracks(
@Path("id") id: String,
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
): List<TrackWire>
@GET("api/albums/{id}")
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5
}
}
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums WHERE id = :id")
fun observeById(id: String): Flow<CachedAlbumEntity?>
@Query(
"SELECT * FROM cached_albums " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortTitle COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedAlbumEntity>
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -18,6 +18,14 @@ interface CachedArtistDao {
@Query("SELECT * FROM cached_artists WHERE id = :id")
fun observeById(id: String): Flow<CachedArtistEntity?>
@Query(
"SELECT * FROM cached_artists " +
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortName COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByName(q: String, limit: Int): List<CachedArtistEntity>
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
@Query("SELECT * FROM cached_playlists WHERE id = :id")
suspend fun getById(id: String): CachedPlaylistEntity?
/**
* Per-playlist count of member tracks resident in the audio cache index.
* LEFT JOINs so playlists with zero cached tracks still appear
* (cachedCount = 0). Drives the offline "fully cached" greying.
*/
@Query(
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
"FROM cached_playlists p " +
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
"GROUP BY p.id",
)
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
@@ -24,6 +24,14 @@ interface CachedTrackDao {
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
@Query(
"SELECT * FROM cached_tracks " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY title COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedTrackEntity>
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -0,0 +1,11 @@
package com.fabledsword.minstrel.cache.db.dao
/**
* Projection: how many of a playlist's member tracks are resident in the audio
* cache index. Backs the offline "fully cached" greying — a playlist is fully
* available offline when [cachedCount] reaches its `trackCount`.
*/
data class PlaylistCachedCount(
val playlistId: String,
val cachedCount: Int,
)
@@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
private const val QUEUED_MESSAGE = "Saved — will sync when online"
/**
* Stable mutation kinds the queue knows how to replay. Strings are
* persisted in `cached_mutations.kind` so renaming a variant breaks
@@ -71,47 +77,59 @@ class MutationQueue @Inject constructor(
private val dao: CachedMutationDao,
private val json: Json,
) {
// capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N
// tracks while offline) surfaces as one snackbar rather than queueing
// N. replay=0 because a hint observed at enqueue time isn't useful
// to a screen that mounts later.
private val _userEnqueueHints = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold]
* to surface "Saved — will sync when online" as a snackbar whenever a
* user-driven write hits the offline-fallback path. Background-only
* enqueues (play-events, playback-error reports) do not emit — those
* fire from non-foreground paths where a snackbar would be either
* dropped (no shell mounted) or jarring (lock-screen toggle).
*/
val userEnqueueHints: SharedFlow<String> = _userEnqueueHints.asSharedFlow()
suspend fun enqueueLikeToggle(
entityType: String,
entityId: String,
desiredState: Boolean,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.LIKE_TOGGLE,
payload = json.encodeToString(
LikeTogglePayload.serializer(),
LikeTogglePayload(entityType, entityId, desiredState),
),
): Long = insertUserDriven(
MutationKind.LIKE_TOGGLE,
json.encodeToString(
LikeTogglePayload.serializer(),
LikeTogglePayload(entityType, entityId, desiredState),
),
)
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CREATE,
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
),
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
MutationKind.REQUEST_CREATE,
json.encodeToString(RequestCreatePayload.serializer(), payload),
)
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.QUARANTINE_UNFLAG,
payload = json.encodeToString(
QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload(trackId),
),
suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
MutationKind.QUARANTINE_UNFLAG,
json.encodeToString(
QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload(trackId),
),
)
suspend fun enqueuePlaylistAppend(
playlistId: String,
trackIds: List<String>,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAYLIST_APPEND,
payload = json.encodeToString(
PlaylistAppendPayload.serializer(),
PlaylistAppendPayload(playlistId, trackIds),
),
): Long = insertUserDriven(
MutationKind.PLAYLIST_APPEND,
json.encodeToString(
PlaylistAppendPayload.serializer(),
PlaylistAppendPayload(playlistId, trackIds),
),
)
@@ -119,13 +137,19 @@ class MutationQueue @Inject constructor(
trackId: String,
reason: String,
notes: String,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.QUARANTINE_FLAG,
payload = json.encodeToString(
QuarantineFlagPayload.serializer(),
QuarantineFlagPayload(trackId, reason, notes),
),
): Long = insertUserDriven(
MutationKind.QUARANTINE_FLAG,
json.encodeToString(
QuarantineFlagPayload.serializer(),
QuarantineFlagPayload(trackId, reason, notes),
),
)
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
),
)
@@ -136,22 +160,18 @@ class MutationQueue @Inject constructor(
),
)
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CANCEL,
payload = json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
),
),
)
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAYBACK_ERROR_REPORT,
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
),
)
private suspend fun insertUserDriven(kind: String, payload: String): Long {
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
return id
}
}
/**
@@ -0,0 +1,20 @@
package com.fabledsword.minstrel.cache.mutations
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Hilt-injectable wrapper exposing [MutationQueue.userEnqueueHints] to
* ShellScaffold. The queue itself is an app-scoped singleton; this VM
* just bridges its SharedFlow into a `hiltViewModel()`-resolvable
* surface so ShellScaffold can collect it without an EntryPoint
* accessor. Mirrors PlaybackErrorViewModel.
*/
@HiltViewModel
class OfflineWriteHintViewModel @Inject constructor(
mutationQueue: MutationQueue,
) : ViewModel() {
val messages: Flow<String> = mutationQueue.userEnqueueHints
}
@@ -14,11 +14,23 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Single source of truth for the device's "is the internet usable
* right now" signal — wraps [ConnectivityManager] and exposes a hot
* cold-startable Flow that emits `false` while the active network
* lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier,
* captive portal, etc.) and `true` once a usable network appears.
* Single source of truth for "does the device have a network link at
* all" — wraps [ConnectivityManager] and exposes a hot cold-startable
* Flow that emits `false` only when there is no active INTERNET-capable
* network (airplane mode, no carrier/Wi-Fi) and `true` once any network
* link appears.
*
* Deliberately does NOT require `NET_CAPABILITY_VALIDATED`. VALIDATED
* tracks whether Android reached its own WAN internet-validation probe
* (Google's `generate_204`) — which is the wrong question for a
* self-hosted server that is usually on the LAN. A transient WAN/DNS
* blip (or Android's periodic re-validation) momentarily drops VALIDATED
* while the Minstrel box stays perfectly reachable; gating on it flipped
* the app to Offline with no debounce and fast-failed in-flight playback
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
* authority on whether *Minstrel* is reachable is the `/healthz` poll
* ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
* has its own failure hysteresis), not this coarse device-link signal.
*
* Used by the shell-level ConnectionErrorBanner; downstream
* repositories can also collect this to gate retry loops.
@@ -35,36 +47,36 @@ class ConnectivityObserver @Inject constructor(
.build()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(hasUsableInternet())
trySend(hasActiveNetwork())
}
override fun onLost(network: Network) {
trySend(hasUsableInternet())
trySend(hasActiveNetwork())
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
// INTERNET only -- NOT VALIDATED. A WAN/validation flicker
// must not read as "device offline" when the LAN (and the
// Minstrel server on it) is still reachable. /healthz is the
// authority on server reachability.
trySend(
capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET,
) &&
capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED,
),
),
)
}
}
cm.registerNetworkCallback(request, callback)
// Seed the initial value so the banner doesn't flash before the
// first capability callback fires.
trySend(hasUsableInternet())
trySend(hasActiveNetwork())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
private fun hasUsableInternet(): Boolean {
private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
@@ -0,0 +1,176 @@
package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import com.fabledsword.minstrel.update.api.HealthzResponse
import com.fabledsword.minstrel.update.data.VersionResult
import com.fabledsword.minstrel.update.data.isVersionNewer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import timber.log.Timber
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
private const val POLL_DEGRADED_MS = 20_000L
private const val ARBITRATE_MIN_GAP_MS = 2_000L
/**
* THE single authority on network/server reachability. Absorbs the former
* VersionCheckController (the /healthz poll + version parsing) and
* ServerHealthController (the tri-state derive) into one signal-driven unit so
* the app has one place that answers "can we reach Minstrel?" — not three
* half-systems reporting differently.
*
* Inputs (all funnel through a single-consumer intent channel for thread
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
* and OkHttp threads):
* - device link transitions ([ConnectivityObserver]); link-return triggers an
* immediate probe so recovery is near-instant (fixes the sticky banner).
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
* - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner.
*
* Version compatibility is a byproduct of the same /healthz response.
*
* Constructed at launch via the construct-the-singleton trick in
* [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class NetworkStatusController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
connectivity: ConnectivityObserver,
private val authStore: AuthStore,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val machine = ReachabilityMachine()
private val lastProbeAtMs = AtomicLong(0)
private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()
private sealed interface Intent {
data class Link(val up: Boolean) : Intent
data class Probe(val resp: HealthzResponse?) : Intent
object OpSuccess : Intent
object OpFailure : Intent
}
private val intents = Channel<Intent>(Channel.UNLIMITED)
init {
scope.launch { reduceLoop() }
scope.launch {
connectivity.online.collect { up ->
intents.trySend(Intent.Link(up))
if (up) probeOnce()
}
}
scope.launch { pollLoop() }
}
/** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
fun reportSuccess() {
if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
}
/** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
fun reportFailure() {
if (stateInternal.value == ServerHealth.Offline) return
intents.trySend(Intent.OpFailure)
}
/** One-shot recheck for pull-to-refresh and the banner. */
fun recheck() {
scope.launch { probeOnce(force = true) }
}
private suspend fun reduceLoop() {
for (intent in intents) {
val now = System.currentTimeMillis()
when (intent) {
is Intent.Link -> machine.onLinkChange(intent.up)
is Intent.Probe -> applyProbe(intent.resp, now)
Intent.OpSuccess -> machine.onSuccess()
Intent.OpFailure -> {
machine.onOpFailure(now)
// Arbitrate off the reducer thread: awaiting a stalled
// /healthz here would block a concurrent self-proving
// success from snapping us straight back to Healthy.
scope.launch { probeOnce() }
}
}
emit(machine.health())
}
}
private fun applyProbe(resp: HealthzResponse?, now: Long) {
if (resp == null) {
machine.onProbeFailure(now)
} else {
machine.onSuccess()
versionInternal.value = versionResultFor(resp)
}
}
private fun emit(next: ServerHealth) {
if (stateInternal.value != next) {
Timber.w("NetworkStatus -> %s", next)
stateInternal.value = next
}
}
private suspend fun pollLoop() {
probeOnce()
while (true) {
val interval =
if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
else POLL_DEGRADED_MS
delay(interval)
probeOnce()
}
}
private suspend fun probeOnce(force: Boolean = false) {
// Startup guard: don't poll the localhost placeholder before AuthStore
// hydrates the real base URL — that false failure used to flash the banner.
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
val now = System.currentTimeMillis()
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
lastProbeAtMs.set(now)
val resp = runCatching { api.check() }.getOrNull()
intents.trySend(Intent.Probe(resp))
}
private fun versionResultFor(resp: HealthzResponse): VersionResult {
val min = resp.minClientVersion
return when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
/**
* Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
* composables (TrackRow gating, write-affordance disabling) branch on health
* without re-injecting the controller. Defaults to Healthy so previews/tests
* don't crash.
*/
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
@@ -0,0 +1,88 @@
package com.fabledsword.minstrel.connectivity
internal const val ESCALATE_AFTER_MS = 120_000L
internal const val CORROBORATION_WINDOW_MS = 30_000L
internal const val CORROBORATION_OP_THRESHOLD = 2
/**
* Pure reachability state machine. No Android, no coroutines, no real clock —
* every entry point takes `nowMs`, so it is fully deterministic and unit-
* testable. [NetworkStatusController] wires real time + signals around it.
*
* Reachability (independent of the device link):
* - Reachable last evidence says the server answered.
* - Unstable a probe failed; arbitration/escalation pending.
* - Unreachable corroborated or sustained failure.
*
* [health] folds the device link over that: no link → Offline; otherwise the
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
*
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
* back to Reachable. A failure only escalates when a /healthz probe corroborates
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
* sustained-time backstop.
*/
class ReachabilityMachine {
private enum class Reachability { Reachable, Unstable, Unreachable }
private var linkUp = true
private var reachability = Reachability.Reachable
private var failureStreakStartMs: Long? = null
private val recentOpFailures = ArrayDeque<Long>()
fun onLinkChange(up: Boolean) {
linkUp = up
// Link transitions don't reset reachability — a restored link keeps the
// last-known server reachability until a fresh probe/op result arrives.
if (!up) recentOpFailures.clear()
}
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
fun onSuccess() {
reachability = Reachability.Reachable
failureStreakStartMs = null
recentOpFailures.clear()
}
/** A real network op failed. Ambiguous on its own — records corroboration. */
fun onOpFailure(nowMs: Long) {
pruneOpFailures(nowMs)
recentOpFailures.addLast(nowMs)
}
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
fun onProbeFailure(nowMs: Long) {
pruneOpFailures(nowMs)
if (reachability == Reachability.Reachable) {
reachability = Reachability.Unstable
failureStreakStartMs = nowMs
}
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
reachability = Reachability.Unreachable
}
}
fun health(): ServerHealth = when {
!linkUp -> ServerHealth.Offline
reachability == Reachability.Reachable -> ServerHealth.Healthy
reachability == Reachability.Unstable -> ServerHealth.Unstable
else -> ServerHealth.ServerDown
}
private fun shouldEscalate(nowMs: Long): Boolean {
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
val sustained =
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
return corroborated || sustained
}
private fun pruneOpFailures(nowMs: Long) {
while (recentOpFailures.isNotEmpty() &&
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
) {
recentOpFailures.removeFirst()
}
}
}
@@ -0,0 +1,49 @@
package com.fabledsword.minstrel.connectivity
import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
import dagger.Lazy
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
private const val HEALTHZ_PATH = "/healthz"
/**
* Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
* self-proving proof the server is reachable → reportSuccess(); a transport
* [IOException] (no response at all) → reportFailure(), which triggers /healthz
* arbitration.
*
* MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
* is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
* EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
* loading must NOT be read as "our server is reachable" — only sentinel-host
* requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
* answered), and /healthz is skipped to avoid a feedback loop with the poll.
*
* [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
* the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
* interceptor. By the time a request flows through, the controller singleton is
* already constructed (construct-the-singleton trick in MinstrelApplication).
*/
@Singleton
class ReachabilityReportingInterceptor @Inject constructor(
private val networkStatus: Lazy<NetworkStatusController>,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val isMinstrel = request.url.host == PLACEHOLDER_HOST
val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
if (!isMinstrel || isHealthz) return chain.proceed(request)
return try {
val response = chain.proceed(request)
if (response.isSuccessful) networkStatus.get().reportSuccess()
response
} catch (e: IOException) {
networkStatus.get().reportFailure()
throw e
}
}
}
@@ -0,0 +1,15 @@
package com.fabledsword.minstrel.connectivity
/**
* The single reachability signal every consumer branches on.
*
* - [Healthy] link up, /healthz ok — normal network behavior.
* - [Unstable] link up, a recent failure with arbitration pending —
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
* anti-flicker intent of commit 5c0db429 while still warning
* the user that something is flaky.
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
* gate to cache-only.
* - [Offline] no device link at all — gate to cache-only.
*/
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
@@ -14,77 +14,121 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L
private const val BACK_ONLINE_FLASH_MS = 2_000L
/**
* Tiny VM that just lifts the [ConnectivityObserver] singleton's
* Flow into a StateFlow with the standard sharing strategy. Keeps
* the banner composable pure-presentation.
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
*/
@HiltViewModel
class ConnectivityBannerViewModel @Inject constructor(
observer: ConnectivityObserver,
networkStatus: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val online: StateFlow<Boolean> = observer.online.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS),
initialValue = true,
)
val health: StateFlow<ServerHealth> = networkStatus.state
}
/**
* Banner shown at the top of the shell when the device has no usable
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile
* data" copy. Auto-hides via slide+fade when connectivity returns.
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
* window, and a brief "Back online" confirmation when health recovers so
* recovery is unmistakable. Sentence case, understated voice (design system).
*/
@Composable
fun ConnectionErrorBanner(
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
) {
val online by viewModel.online.collectAsStateWithLifecycle()
val health by viewModel.health.collectAsStateWithLifecycle()
var showBackOnline by remember { mutableStateOf(false) }
var wasDown by remember { mutableStateOf(false) }
LaunchedEffect(health) {
val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
if (health == ServerHealth.Healthy && wasDown) {
// try/finally so a mid-delay cancellation (health flips again) can't
// orphan the flag and leave "Back online" stuck on screen.
try {
showBackOnline = true
delay(BACK_ONLINE_FLASH_MS)
} finally {
showBackOnline = false
}
}
wasDown = down
}
AnimatedVisibility(
visible = !online,
visible = health != ServerHealth.Healthy || showBackOnline,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Lucide.CloudOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "No connection — check Wi-Fi or mobile data.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
BannerContent(health = health, backOnline = showBackOnline)
}
}
@Composable
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
val scheme = MaterialTheme.colorScheme
val background: Color
val foreground: Color
when {
backOnline -> {
background = scheme.secondaryContainer
foreground = scheme.onSecondaryContainer
}
health == ServerHealth.Unstable -> {
background = scheme.surfaceVariant
foreground = scheme.onSurfaceVariant
}
else -> {
background = scheme.errorContainer
foreground = scheme.onErrorContainer
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(background)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
Text(
text = bannerText(health, backOnline),
style = MaterialTheme.typography.bodyMedium,
color = foreground,
)
}
}
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
backOnline -> "Back online."
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
health == ServerHealth.ServerDown ->
"Server unreachable — your cached content is still available."
health == ServerHealth.Unstable -> "Reconnecting…"
else -> ""
}
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
@@ -69,7 +70,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
@@ -95,11 +96,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades
@@ -136,7 +135,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,
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
@@ -144,9 +143,13 @@ class HomeViewModel @Inject constructor(
/** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
/** True when the device has no usable internet — gates the offline-pool cards. */
val offline: StateFlow<Boolean> = connectivity.online
.map { !it }
/**
* Cache-only when there's no link OR the server is unreachable. Reads the
* unified [NetworkStatusController] (not the raw device link) so Home reacts
* to ServerDown too; the transient Unstable state stays calm (not offline).
*/
val offline: StateFlow<Boolean> = networkStatus.state
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -259,45 +262,9 @@ class HomeViewModel @Inject constructor(
*/
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
val detail = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
playlistsRepository.systemShuffle(playlist.systemVariant)
} else {
playlistsRepository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException,
) {
poolMessages.trySend("Couldn't load playlist - check your connection")
return@launch
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
"Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}",
)
return@launch
playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it)
}
// Shared with PlaylistDetailViewModel.play - filters out
// unplayable rows (missing trackId or empty streamUrl) so the
// queue can't end up with tracks Media3 silently rejects.
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
return@launch
}
// Drift #564: send the BARE systemVariant string, not
// "playlist:<variant>" — the server's rotation matcher
// (internal/playevents/writer.go systemPlaylistSources)
// keys on the bare variant. Web sends the bare form too
// (web/src/lib/components/PlaylistCard.svelte:83), so this
// brings Android into alignment. Wrong prefix here meant
// system-mix plays from Android Home never advanced the
// rotation.
val source = if (playlist.refreshable) playlist.systemVariant else null
player.setQueue(tracks, initialIndex = 0, source = source)
}.join()
}
@@ -739,18 +706,19 @@ private fun PlaylistsRow(
icon = iconForPool(item.kind),
onClick = { onPlayPool(item.kind) },
)
is PlaylistRowItem.Real -> PlaylistCard(
playlist = item.playlist,
onClick = { onPlaylistClick(item.playlist.id) },
onPlay = { onPlayPlaylist(item.playlist) },
// Match Flutter: refreshable system playlists need
// the live server endpoints, so disable their play
// overlay when offline (user can still tap into the
// detail and shuffle all from cache). User playlists
// play from cache + survive offline.
playEnabled = item.playlist.trackCount > 0 &&
!(offline && item.playlist.refreshable),
)
is PlaylistRowItem.Real -> {
// Greyed offline when the tile needs the live server or
// isn't fully cached — dimmed but still tappable into the
// detail to shuffle whatever subset is cached.
val greyed = offline && item.playlist.unavailableOffline
PlaylistCard(
playlist = item.playlist,
onClick = { onPlaylistClick(item.playlist.id) },
onPlay = { onPlayPlaylist(item.playlist) },
playEnabled = item.playlist.trackCount > 0 && !greyed,
greyed = greyed,
)
}
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
label = item.label,
variant = item.variant,
@@ -766,7 +734,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
}
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
private sealed interface PlaylistRowItem {
internal sealed interface PlaylistRowItem {
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
@@ -779,55 +747,81 @@ enum class OfflinePoolKind(val label: String) {
}
/**
* Builds the Home Playlists row. When [offline], the two cache-backed
* pool cards (Recently played, Liked) lead the row. Then For You +
* Discover + 3× Songs-like fixed slots (real card when generated,
* placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they
* exist — no placeholders for these since they're conditional on
* library shape, not guaranteed singletons. Finally user-owned
* playlists.
* Builds the Home Playlists row.
*
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
* / rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then
* user-owned playlists.
*
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached
* first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
*/
private fun buildPlaylistsRow(
internal fun buildPlaylistsRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
): List<PlaylistRowItem> {
if (!offline) return buildOnlineRow(owned, status)
val out = mutableListOf<PlaylistRowItem>(
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
)
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
return out
}
/** The online layout: fixed system slots (with placeholders), secondary, user. */
private fun buildOnlineRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
): List<PlaylistRowItem> {
val out = mutableListOf<PlaylistRowItem>()
if (offline) {
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
}
out += owned.firstOrNull { it.systemVariant == "for_you" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
// Secondary system kinds in server-registry order. Only included
// when actually generated — these depend on library shape (Deep
// cuts needs deep albums, On this day needs prior history, etc.)
// so a missing one means "not enough data" rather than "still
// building".
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }
?.let { out += PlaylistRowItem.Real(it) }
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
}
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
return out
}
/**
* Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first.
*/
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>()
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
}
owned.filter { it.systemVariant == null }.forEach { out += it }
return out
}
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
s.inFlight -> "building"
s.lastError != null -> "failed"
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
api.getArtistTracks(id).map { it.toDomain() }
/**
* Pulls related artists for the ArtistDetail "Similar artists"
* strip. Network-only — non-critical UI, callers swallow failures
* and render nothing rather than blocking the screen.
*/
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
api.getSimilarArtists(id).map { it.toDomain() }
/**
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
* panel. Network-only; tapping a row plays this exact list as a
* queue, so no persistence is needed.
*/
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
api.getArtistTopTracks(id).map { it.toDomain() }
/**
* Pulls the album detail (AlbumRef + tracks), persists both, and
* returns the in-memory snapshot. Lets the AlbumDetail screen
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items as lazyItems
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.TrackRow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistDetailScreen(
navController: NavHostController,
viewModel: ArtistDetailViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
@@ -102,23 +111,35 @@ fun ArtistDetailScreen(
title = "Couldn't load artist",
body = s.message,
)
is ArtistDetailUiState.Success -> {
val artistLiked by viewModel.artistLiked
.collectAsStateWithLifecycle()
ArtistBody(
detail = s.detail,
artistLiked = artistLiked,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
is ArtistDetailUiState.Success ->
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
}
}
}
}
}
@Composable
private fun ArtistSuccessBody(
state: ArtistDetailUiState.Success,
viewModel: ArtistDetailViewModel,
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
ArtistBody(
state = state,
artistLiked = artistLiked,
playingTrackId = playerState.currentTrack?.id,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTopTrackClick = viewModel::playTopTracks,
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
)
}
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
is ArtistDetailUiState.Success -> state.detail.artist.name
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
@@ -127,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
@Composable
private fun ArtistBody(
detail: ArtistDetailRef,
state: ArtistDetailUiState.Success,
artistLiked: Boolean,
playingTrackId: String?,
onPlay: () -> Unit,
onToggleLike: () -> Unit,
onAlbumClick: (String) -> Unit,
onTopTrackClick: (Int) -> Unit,
onSimilarArtistClick: (String) -> Unit,
) {
val detail = state.detail
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
modifier = Modifier.fillMaxSize(),
@@ -149,6 +174,23 @@ private fun ArtistBody(
onToggleLike = onToggleLike,
)
}
if (state.topTracks.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
TopTracksPanel(
tracks = state.topTracks,
playingTrackId = playingTrackId,
onTrackClick = onTopTrackClick,
)
}
}
if (state.similarArtists.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SimilarArtistsStrip(
artists = state.similarArtists,
onArtistClick = onSimilarArtistClick,
)
}
}
if (detail.albums.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyAlbumsHint()
@@ -161,6 +203,67 @@ private fun ArtistBody(
}
}
@Composable
private fun TopTracksPanel(
tracks: List<TrackRef>,
playingTrackId: String?,
onTrackClick: (Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Top tracks",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
tracks.forEachIndexed { index, track ->
TopTrackRow(
track = track,
nowPlaying = track.id == playingTrackId,
onClick = { onTrackClick(index) },
)
}
}
}
@Composable
private fun TopTrackRow(
track: TrackRef,
nowPlaying: Boolean,
onClick: () -> Unit,
) {
TrackRow(
title = track.title,
artist = track.artistName,
trackId = track.id,
onClick = onClick,
nowPlaying = nowPlaying,
trailing = {
Text(
text = formatDuration(track.durationSec, zero = "--:--"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
},
)
}
@Composable
private fun SimilarArtistsStrip(
artists: List<ArtistRef>,
onArtistClick: (String) -> Unit,
) {
HorizontalScrollRow(title = "Similar artists") {
lazyItems(items = artists, key = { it.id }) { artist ->
ArtistCard(
artist = artist,
onClick = { onArtistClick(artist.id) },
)
}
}
}
@Composable
private fun ArtistHeader(
artist: ArtistRef,
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.player.PlayerController
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface ArtistDetailUiState {
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
data class Success(
val detail: ArtistDetailRef,
val similarArtists: List<ArtistRef> = emptyList(),
val topTracks: List<TrackRef> = emptyList(),
) : ArtistDetailUiState
data class Error(val message: String) : ArtistDetailUiState
}
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
try {
val detail = repository.refreshArtistDetail(artistId)
internal.value = ArtistDetailUiState.Success(detail)
loadSecondarySections()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
}
}
/**
* Pulls the non-critical "Similar artists" + "Top tracks" sections
* and folds them into the live Success state. Each failure is
* swallowed (logged only) — these sections render absent rather than
* blocking, so the core artist view never depends on them.
*/
private suspend fun loadSecondarySections() {
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
val current = internal.value
if (current is ArtistDetailUiState.Success) {
internal.value = current.copy(similarArtists = similar, topTracks = top)
}
}
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
try {
block()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
emptyList()
}
/**
* Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on
@@ -118,4 +150,20 @@ class ArtistDetailViewModel @Inject constructor(
}
}
}
/**
* Plays the loaded "Top tracks" list starting at [index] — the top
* tracks are themselves the playable queue (mirrors the web client's
* sectionTracks = top-tracks behaviour). No network round-trip; the
* list is already in the Success state.
*/
fun playTopTracks(index: Int) {
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
if (tracks.isEmpty()) return
player.setQueue(
tracks = tracks,
initialIndex = index.coerceIn(0, tracks.lastIndex),
source = "artist-top:$artistId",
)
}
}
@@ -20,6 +20,8 @@ data class PlaylistRef(
val trackCount: Int = 0,
val coverUrl: String = "",
val ownerUsername: String = "",
/** All member tracks resident in the audio cache — playable fully offline. */
val fullyCached: Boolean = false,
) {
val isSystem: Boolean get() = systemVariant != null
@@ -30,6 +32,13 @@ data class PlaylistRef(
* expose a refresh trigger; rebuilds are scheduler-driven).
*/
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
/**
* Can't be relied on while offline — either it needs the live server to
* (re)generate (a refreshable system mix) or not all its tracks are cached.
* Callers combine this with the current offline state to grey the tile.
*/
val unavailableOffline: Boolean get() = refreshable || !fullyCached
}
/**
@@ -58,59 +58,98 @@ class AudioPrefetcher @Inject constructor(
private val activeJobs = mutableMapOf<String, Job>()
private val mutex = Mutex()
private data class ReconcileInput(
val queue: List<Pair<String, String>>,
val index: Int,
val window: Int,
val isPlaying: Boolean,
)
init {
scope.launch {
combine(
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
playerController.uiState.map { it.queueIndex },
authStore.cacheSettings.map { it.prefetchWindow },
) { queue, index, window -> Triple(queue, index, window) }
playerController.uiState.map { it.isPlaying },
) { queue, index, window, isPlaying ->
ReconcileInput(queue, index, window, isPlaying)
}
.distinctUntilChanged()
.collect { (queue, index, window) -> reconcile(queue, index, window) }
.collect { input ->
reconcile(input.queue, input.index, input.window, input.isPlaying)
}
}
}
/**
* Apply the prefetch window to the current queue.
*
* Cancellation of out-of-window jobs always runs -- a queue mutation
* or skip should free bandwidth from stale prefetches immediately.
* Starting new prefetches is gated on [isPlaying]: until the current
* track is actually playing, every byte of upstream bandwidth should
* land on it, not on upcoming-track prefetches. Without this gate a
* cold start fanned out 4-6 concurrent CacheWriter jobs against the
* same OkHttp client as the playback DataSource and the user waited
* ~25 s for the first audio to start; with the gate the current
* track gets the full pipe to its first STATE_READY, then the
* prefetcher fills in the next window.
*/
private suspend fun reconcile(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
isPlaying: Boolean,
) {
mutex.withLock {
if (index < 0 || queue.isEmpty() || window <= 0) {
val targets = computeTargets(queue, index, window)
if (targets.isEmpty()) {
cancelAllLocked()
return
}
// Exclude the currently-playing track (it's loaded by the
// player itself) and walk `window` tracks forward.
val firstIdx = (index + 1).coerceAtMost(queue.size)
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
if (firstIdx > lastIdx) {
cancelAllLocked()
return
}
val targets = queue.subList(firstIdx, lastIdx + 1)
val targetIds = targets.mapTo(mutableSetOf()) { it.first }
// Cancellation always runs so a queue mutation or skip frees
// the pipe immediately, even while paused.
cancelOutOfWindowLocked(targetIds)
if (isPlaying) startInWindowLocked(targets)
}
}
// Cancel jobs for tracks that have slid out of the window.
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
private fun computeTargets(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
): List<Pair<String, String>> {
// Exclude the currently-playing track (it's loaded by the player
// itself) and walk `window` tracks forward.
val firstIdx = index + 1
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
val isValid = index >= 0 && queue.isNotEmpty() && window > 0 && firstIdx <= lastIdx
return if (isValid) queue.subList(firstIdx, lastIdx + 1) else emptyList()
}
// Start prefetches for new arrivals. Skip blank URLs (these
// come from minimal TrackRefs synthesized from playlist rows
// when the upstream track was removed from the library).
for ((trackId, streamUrl) in targets) {
if (trackId in activeJobs || streamUrl.isBlank()) continue
val job = scope.launch(Dispatchers.IO) {
runCatching { prefetchOne(trackId, streamUrl) }
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
private fun cancelOutOfWindowLocked(targetIds: Set<String>) {
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
}
private fun startInWindowLocked(targets: List<Pair<String, String>>) {
// Skip blank URLs (these come from minimal TrackRefs synthesized
// from playlist rows when the upstream track was removed from
// the library).
for ((trackId, streamUrl) in targets) {
if (trackId in activeJobs || streamUrl.isBlank()) continue
val job = scope.launch(Dispatchers.IO) {
runCatching { prefetchOne(trackId, streamUrl) }
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
}
}
@@ -0,0 +1,587 @@
@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API.
package com.fabledsword.minstrel.player
import android.os.Handler
import android.os.SystemClock
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import timber.log.Timber
/**
* Integration point for UPnP transport parity. Wraps the local
* ExoPlayer; every transport method either forwards (local route
* active -- the default) or translates into AVTransport SOAP +
* [RemotePlayerState] updates (UPnP route active).
*
* Created inside [MinstrelPlayerService]; runs on the service's main
* looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is
* active, the MediaSession's reads of [Player.isPlaying] and
* [Player.getCurrentPosition] pull from [RemotePlayerState]; the
* wrapped ExoPlayer stays paused at the position it had when the
* route was selected.
*
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop syncs the local cursor by comparing the
* 1-based Track index from GetPositionInfo.
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null
// Tracks consecutive non-PLAYING poll observations so a single transient
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
// flip the play/pause button. Manual pause still feels instant because it
// bypasses the poll entirely via applyTransportPaused().
@Volatile private var nonPlayingPollStreak = 0
// Wall-clock of the most-recent within-track seek we issued to Sonos.
// pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS
// -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that
// window reports the *old* position. Without the lockout the scrubber
// visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
// pollLoop's select{} races the delay against this channel so the next
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
// Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI
// catches up to Sonos within RTT rather than the full poll cadence.
// CONFLATED so repeated trySend's between polls don't queue up.
private val pollTrigger = Channel<Unit>(Channel.CONFLATED)
// External Player.Listener registry (separate from super.addListener which
// forwards to the wrapped ExoPlayer). The wrapped player is paused with
// no audio loaded while UPnP is active, so it never fires events for our
// synthesized remote state -- the MediaSession's notification card and
// lock-screen scrubber stay frozen on whatever state was last captured
// before UPnP took over. We dual-register: super.addListener keeps the
// listener attached to the delegate (so local-playback events still
// reach it), AND we hold a ref here so we can directly invoke listener
// callbacks on remote-state changes. The listener's read of isPlaying /
// duration / position then routes through our overrides to remoteState.
private val externalListeners = mutableListOf<Player.Listener>()
@Volatile private var lastNotifiedIsPlaying: Boolean = false
@Volatile private var lastNotifiedTrackIdx: Int = -1
private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
pollTrigger.trySend(Unit)
}
}
init {
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
}
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
// addObserver requires it. The observer just trySend's to the channel.
handler.post {
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
}
}
private fun isRemote(): Boolean = holder.active.value != null
/**
* Returns true when selectUpnp has marked a UPnP route as the intended
* target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this
* window we drop transport commands silently -- they would hit Sonos's
* stale state from a prior session and trigger restarts.
*/
private fun isLoadingUpnp(): Boolean =
holder.target.value != null && holder.active.value == null
// ─── setMediaItems intercepts ──────────────────────────────────────
// When PlayerController.setQueue replaces the queue while Sonos is the
// active route, the wrapped delegate's queue gets the new items but
// Sonos's native queue still holds the OLD tracks -- and the play()
// that PlayerController fires immediately after setMediaItems would
// resume the old Sonos queue (user reported on-device: "player view
// updates but Sonos queue does not"). We clear active + set target
// synchronously here so the next play() in the same IPC sequence
// drops via isLoadingUpnp() = true; the OutputPickerController
// observes the uiState.queue change and runs the resync (re-clears
// Sonos's native queue + AddURIToQueue the new tracks + Play).
override fun setMediaItems(mediaItems: List<MediaItem>) {
super.setMediaItems(mediaItems)
markPendingResyncIfRemote()
}
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
super.setMediaItems(mediaItems, resetPosition)
markPendingResyncIfRemote()
}
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
super.setMediaItems(mediaItems, startIndex, startPositionMs)
markPendingResyncIfRemote()
}
private fun markPendingResyncIfRemote() {
val wasActive = holder.active.value ?: return
Timber.w(
"setMediaItems while UPnP active (%s) -- marking pending resync",
wasActive.routeName,
)
holder.set(null)
holder.setTarget(wasActive.routeId)
}
override fun play() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
if (active == null) {
super.play()
} else {
remoteState.setPlayIntent(true)
scope.launch {
runCatching { retryTransport { active.avTransport.play() } }
.onSuccess {
remoteState.applyTransportPlaying()
notifyRemoteStateChanged()
}
.onFailure { handleTransportFailure(active, it) }
}
}
}
override fun pause() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
if (active == null) {
super.pause()
} else {
remoteState.setPlayIntent(false)
scope.launch {
runCatching { retryTransport { active.avTransport.pause() } }
.onSuccess {
remoteState.applyTransportPaused()
notifyRemoteStateChanged()
}
.onFailure { handleTransportFailure(active, it) }
}
}
}
override fun seekTo(positionMs: Long) {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
if (active == null) {
super.seekTo(positionMs)
} else {
lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
remoteState.applyPositionInfo(
positionMs = positionMs,
durationMs = remoteState.durationMs,
trackUri = remoteState.currentTrackUri,
trackNumber = remoteState.trackNumber,
)
scope.launch {
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
.onFailure { handleTransportFailure(active, it) }
}
}
}
/**
* Widget-driven track change (user taps a track in the queue widget).
* Seeks Sonos to the correct queue slot, then seeks within-track if
* [positionMs] is non-zero.
*/
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w(
"ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
mediaItemIndex, positionMs, active?.routeName,
)
if (active == null) {
super.seekTo(mediaItemIndex, positionMs)
return
}
super.seekTo(mediaItemIndex, positionMs)
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching {
retryTransport {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}.onFailure { handleTransportFailure(active, it) }
}
}
override fun seekToNextMediaItem() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
if (active == null) {
super.seekToNextMediaItem()
return
}
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Next. PollLoop reconciles cursor via
// Track index if they diverge.
super.seekToNextMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.next() } }
.onFailure { handleTransportFailure(active, it) }
}
}
override fun seekToPreviousMediaItem() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
if (active == null) {
super.seekToPreviousMediaItem()
return
}
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Previous. PollLoop reconciles.
super.seekToPreviousMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.previous() } }
.onFailure { handleTransportFailure(active, it) }
}
}
override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
override fun getDuration(): Long =
if (isRemote()) remoteState.durationMs else super.getDuration()
override fun isPlaying(): Boolean =
if (isRemote()) remoteState.isPlaying else super.isPlaying()
override fun getPlaybackState(): Int =
if (isRemote()) Player.STATE_READY else super.getPlaybackState()
// Mirror remote state so any consumer that gates on playWhenReady --
// notably MediaSessionService's foreground-keepalive checks and our own
// onTaskRemoved -- sees the remote renderer as the source of truth.
// Without this, swiping the app away with Sonos playing would stop the
// service, kill the poll loop, and leave Sonos orphaned.
override fun getPlayWhenReady(): Boolean =
if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()
override fun addListener(listener: Player.Listener) {
super.addListener(listener)
synchronized(externalListeners) { externalListeners.add(listener) }
}
override fun removeListener(listener: Player.Listener) {
super.removeListener(listener)
synchronized(externalListeners) { externalListeners.remove(listener) }
}
/**
* Direct-invoke the externally-registered Player.Listeners so the
* MediaSession's PlaybackState publisher (notification card, lock-screen
* scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state.
* The listeners then query isPlaying / getDuration / getCurrentPosition,
* all of which route through to remoteState while UPnP is active.
*
* Posted to the player's application looper because Player.Listener
* callbacks contract on the application thread.
*/
private fun notifyRemoteStateChanged() {
if (!isRemote()) return
val playing = remoteState.isPlaying
val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
val isPlayingChanged = playing != lastNotifiedIsPlaying
val trackChanged = trackIdx != lastNotifiedTrackIdx
if (!isPlayingChanged && !trackChanged) return
lastNotifiedIsPlaying = playing
lastNotifiedTrackIdx = trackIdx
val snapshot = synchronized(externalListeners) { externalListeners.toList() }
handler.post {
for (l in snapshot) {
if (isPlayingChanged) {
l.onIsPlayingChanged(playing)
l.onPlaybackStateChanged(Player.STATE_READY)
}
if (trackChanged) {
val item = if (trackIdx < delegate.mediaItemCount) {
delegate.getMediaItemAt(trackIdx)
} else {
null
}
l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
}
}
}
}
override fun release() {
pollJob?.cancel()
scope.cancel()
handler.post {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
}
super.release()
}
/**
* Run a transport SOAP block, retrying transport-level (IO) failures a
* few times with backoff. A locked phone's WiFi power-save can stall the
* first socket I/O for a second or two; a retry lets the command land once
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
* renderer answered and rejected the action) is NOT retried -- the device
* is alive and retrying won't change its verdict.
*/
private suspend fun <T> retryTransport(block: suspend () -> T): T =
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
/**
* A transport SOAP command failed even after retries. Deliberately does
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
* WiFi power-save can fail one command while the renderer is perfectly
* reachable; dropping on a single command falsely kicked playback back to
* the phone. We log, leave the poll loop running, and nudge an immediate
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
* truly gone, the poll loop trips the drop on its own.
*/
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
if (t is SoapFaultException) {
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
} else {
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
}
pollTrigger.trySend(Unit)
}
private fun onActiveChanged(active: ActiveUpnp?) {
pollJob?.cancel()
nonPlayingPollStreak = 0
// Reset notify cache so the first poll after a route flip republishes
// playing/track state to the MediaSession even if it happens to match
// the prior session's values numerically.
lastNotifiedIsPlaying = false
lastNotifiedTrackIdx = -1
if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
// 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
// our override calls -- no race with the pause() override branching
// to SOAP (holder.active is already non-null by the time this post
// fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) }
} else {
remoteState.reset()
}
}
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
private suspend fun pollLoop(active: ActiveUpnp) {
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching { pollOnce(active) }
if (outcome.isSuccess) {
remoteState.recordPollSuccess()
} else if (remoteState.recordPollFailure()) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
return
}
// Race the normal cadence against any external wake (activity
// resume). Whichever wins continues to the next pollOnce.
select<Unit> {
onTimeout(POLL_INTERVAL_MS) {}
pollTrigger.onReceive {}
}
}
}
/**
* One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and forward-sync the local cursor to Sonos's Track
* index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
val now = SystemClock.elapsedRealtime()
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
// Inside the seek-ack window, keep the optimistic position we wrote in
// seekTo -- the poll's reported position is stale until Sonos finishes
// processing the Seek SOAP. Other fields still refresh from the poll.
remoteState.applyPositionInfo(
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
trackNumber = info.track,
)
maybeSyncLocalCursor(info.track)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> {
nonPlayingPollStreak = 0
remoteState.applyTransportPlaying()
}
TransportState.PAUSED -> {
nonPlayingPollStreak += 1
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
remoteState.applyTransportPaused()
}
}
TransportState.STOPPED -> {
nonPlayingPollStreak += 1
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
remoteState.applyTransportStopped()
}
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
notifyRemoteStateChanged()
}
private fun maybeSyncLocalCursor(sonosTrack: Int) {
if (holder.target.value != null) return
if (sonosTrack <= 0) return
val sonosIdx = sonosTrack - 1
handler.post {
val localIdx = delegate.currentMediaItemIndex
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
Timber.w(
"UPnP cursor catch-up: local=%d -> sonos=%d",
localIdx, sonosIdx,
)
delegate.seekTo(sonosIdx, 0L)
}
}
}
private companion object {
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
// Safety upper bound on how long the polling tick will wait for
// Sonos to ack a user transport. The common case clears event-driven
// when Sonos's reported Track matches the wrapped player; this only
// kicks in if SOAP fails or Sonos drops the ack entirely.
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
// Transport commands retry transient IO failures so a single WiFi
// power-save stall (locked phone) doesn't abandon the command. 3
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
// worst case at ~7s; a still-failing command then defers to the poll.
const val TRANSPORT_RETRY_ATTEMPTS = 3
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
}
}
/**
* Retry [block] on transient transport-level ([IOException]) failures, with
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
* an [IOException] -- notably [SoapFaultException], where the renderer
* answered and rejected the action -- propagates immediately: the device is
* alive, so retrying won't change its verdict. Extracted from
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
* abandon a single transport command (the bug that falsely reverted Sonos
* playback to local audio).
*/
internal suspend fun <T> retryTransientIo(
attempts: Int,
backoffMs: Long,
block: suspend () -> T,
): T {
var attempt = 0
while (true) {
try {
return block()
} catch (io: IOException) {
attempt += 1
if (attempt >= attempts) throw io
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
delay(backoffMs)
}
}
}
@@ -72,11 +72,12 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val player = playerFactory.build()
val player: Player = playerFactory.build()
val callback = LikeMediaCallback(likesRepository, serviceScope)
val session = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent())
.setCallback(callback)
.setBitmapLoader(playerFactory.buildBitmapLoader())
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
.build()
mediaSession = session
@@ -176,7 +177,11 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent)
val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED
// player.isPlaying is overridden on MinstrelForwardingPlayer to return
// remoteState.isPlaying while UPnP is active, so a swipe-away with
// Sonos playing keeps the service (and its UPnP poll loop) alive.
val activelyPlaying = player.isPlaying ||
(player.playWhenReady && player.playbackState != Player.STATE_ENDED)
if (!activelyPlaying) {
stopSelf()
}
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import java.io.IOException
import java.io.InterruptedIOException
/**
* DataSource wrapper that fails the network read immediately when
* [NetworkStatusController] reports a gating state. CacheDataSource only
* calls this upstream factory on cache misses, so playback of cached audio is
* unaffected -- only "tap a non-cached track while offline" hits this branch
* and gets a fast, meaningful error instead of a multi-second network timeout
* (which then surfaced as a silent decode failure to the user).
*
* Wrapping rather than substituting the OkHttp data source lets the cache
* write path remain intact for when health returns and we DO want to fetch:
* we keep the same upstream all the time, just gate `open()`.
*/
class OfflineGatedDataSource(
private val delegate: DataSource,
private val health: NetworkStatusController,
) : DataSource {
override fun open(dataSpec: DataSpec): Long {
gateOnHealth()
return try {
val opened = delegate.open(dataSpec)
health.reportSuccess() // bytes flowing from the server == reachable
opened
} catch (e: IOException) {
health.reportFailure() // real network read failed → arbitrate via /healthz
throw e
}
}
/** Fast-fail before touching the network when the server can't be reached. */
private fun gateOnHealth() {
when (health.state.value) {
ServerHealth.Offline -> throw OfflineException(
"Track not in the on-device cache and the device is offline.",
)
ServerHealth.ServerDown -> throw OfflineException(
"Track not in the on-device cache and the Minstrel server is unreachable.",
)
// Unstable is non-gating: still try the network. Healthy too.
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
}
}
override fun close() = delegate.close()
override fun getUri() = delegate.uri
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
delegate.read(buffer, offset, length)
override fun addTransferListener(transferListener: TransferListener) =
delegate.addTransferListener(transferListener)
override fun getResponseHeaders() = delegate.responseHeaders
}
class OfflineGatedDataSourceFactory(
private val upstream: DataSource.Factory,
private val health: NetworkStatusController,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
OfflineGatedDataSource(upstream.createDataSource(), health)
}
/**
* Signals the audio-source error path that the request was denied because the
* device is offline / the server is unreachable. ExoPlayer's [androidx.media3
* .common.PlaybackException] catches it via [InterruptedIOException]'s
* `IOException` ancestor and surfaces it as a SOURCE error, which then flows
* through the existing [PlaybackErrorReporter] -> snackbar path.
*/
class OfflineException(message: String) : IOException(message)
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
class PlaybackErrorReporter @Inject constructor(
private val playerController: PlayerController,
private val repository: PlaybackErrorRepository,
private val networkStatus: NetworkStatusController,
@ApplicationScope private val scope: CoroutineScope,
) {
private val outChannel = Channel<String>(Channel.BUFFERED)
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
val buffer = mutableListOf<String>()
var debounceJob: kotlinx.coroutines.Job? = null
playerController.playbackErrorEvents.collect { event ->
// A track failing to play is ambiguous (dead server vs. one bad
// file) — let the controller arbitrate via /healthz. No-op when
// already Offline; cheap otherwise.
networkStatus.reportFailure()
// Fire-and-forget the server report — repository handles
// success/queue branching so callers don't see throws.
scope.launch { repository.report(event) }
@@ -5,6 +5,8 @@ import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
@@ -20,8 +22,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -59,7 +63,17 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController,
private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
) {
/**
* UPnP drop events surfaced from [PlayerFactory.dropEvents]. NowPlaying
* collects this into its snackbar host so a transport / poll failure
* during UPnP playback shows "Disconnected from <name>" to the user.
*/
val dropEvents: SharedFlow<String> = playerFactory.dropEvents
private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -130,11 +144,24 @@ class PlayerController @Inject constructor(
// ── Transport (no-op until the controller is connected) ──────────────
fun play() { mediaController?.play() }
fun pause() { mediaController?.pause() }
fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) }
fun skipToNext() { mediaController?.seekToNextMediaItem() }
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
// Each transport call must run on the MediaController's
// applicationLooper; calling from a background coroutine throws
// IllegalStateException (see PlayerController.setQueue's note).
// UI tap handlers are already on Main so the in-place branch hits;
// the background path only fires for cross-thread callers like
// OutputPickerController.selectUpnp (which calls pause() after
// handing playback off to a UPnP renderer).
fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } }
fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } }
fun seekTo(positionMs: Long) {
mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } }
}
fun skipToNext() {
mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } }
}
fun skipToPrevious() {
mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } }
}
/** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */
fun toggleShuffle() {
@@ -299,7 +326,15 @@ class PlayerController @Inject constructor(
* and advance past the dead track. Otherwise no-op.
*/
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
val current = queueRefs.getOrNull(idx) ?: return
// Skip during UPnP playback (active) AND during the activation load
// window (target set, active not yet wired). ExoPlayer is intentionally
// paused throughout both windows so its STATE_READY duration is always
// 0 / TIME_UNSET -- without this guard we rapid-advance through the
// entire local queue (and spam /api/playback-errors).
val current = queueRefs.getOrNull(idx)
val upnpEngaged = activeUpnpHolder.active.value != null ||
activeUpnpHolder.target.value != null
if (current == null || upnpEngaged) return
val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return
@@ -341,6 +376,19 @@ class PlayerController @Inject constructor(
// awaitReady, the Player.Listener is wired too.
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
startPositionPolling(controller)
// Keep isUpnpLoading current between player-event fires: holder state
// changes (setTarget / set(active)) are independent of Media3 events, so
// onEvents alone would lag behind by up to one event cycle. This collector
// runs for the process lifetime alongside the position poller.
scope.launch {
combine(
activeUpnpHolder.target,
activeUpnpHolder.active,
) { target, active -> target != null && active == null }
.collect { isLoading ->
uiStateInternal.value = uiStateInternal.value.copy(isUpnpLoading = isLoading)
}
}
controller.addListener(
object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
@@ -364,6 +412,11 @@ class PlayerController @Inject constructor(
// Reset the per-item evaluation guard so the new
// item's STATE_READY transition gets a fresh check.
lastEvaluatedItemIndex = -1
// The track flipped -- re-anchor the position interpolator
// so the next polling tick treats the new track's
// remoteState.positionMs as fresh rather than carrying the
// old anchor + elapsed forward.
lastSeenRemotePositionMs = -1L
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -381,15 +434,38 @@ class PlayerController @Inject constructor(
?.mediaMetadata
?.extras
?.getString(MINSTREL_SOURCE_KEY)
val isUpnpLoading = activeUpnpHolder.target.value != null &&
activeUpnpHolder.active.value == null
// When UPnP is active, the wrapped ExoPlayer is paused with
// no real audio loaded -- player.duration / isPlaying /
// currentPosition all reflect that. Read from remoteState
// instead so an onEvents fire (e.g. activity resume) doesn't
// clobber the UI with zeros. Duration falls back to the
// wrapped player's value when Sonos hasn't reported one yet
// (pre-first-poll window, or Sonos still buffering) -- the
// wrapped ExoPlayer was prepared with the same MediaItem so
// it knows the real duration before any SOAP poll lands.
val upnpActive = activeUpnpHolder.active.value != null
uiStateInternal.value =
PlayerUiState(
currentTrack = current,
queue = queueRefs,
queueIndex = idx,
isPlaying = player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying,
isBuffering = !upnpActive &&
player.playbackState == Player.STATE_BUFFERING,
positionMs = if (upnpActive) {
remoteState.positionMs
} else {
player.currentPosition
}.coerceAtLeast(0),
durationMs = effectiveDuration(
upnpActive,
remoteState.durationMs,
player.duration,
desiredIdx = idx,
controllerIdx = idx,
),
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
playbackError = player.playerError?.message,
currentSource = source,
@@ -399,6 +475,7 @@ class PlayerController @Inject constructor(
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
else -> RepeatMode.OFF
},
isUpnpLoading = isUpnpLoading,
)
}
},
@@ -423,15 +500,138 @@ class PlayerController @Inject constructor(
scope.launch(Dispatchers.Main.immediate) {
while (isActive) {
delay(POSITION_POLL_INTERVAL_MS)
if (!controller.isPlaying) continue
uiStateInternal.value = uiStateInternal.value.copy(
positionMs = controller.currentPosition.coerceAtLeast(0),
bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0),
)
tickPositionPoll(controller)
}
}
}
/**
* One position-polling tick. Owns track-change detection too: when UPnP
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
* uiState.queueIndex stuck on the old track even after Sonos has
* advanced. So the tick reads Sonos's reported Track as the source of
* truth, rebuilds the index/title fields itself, and force-syncs the
* wrapped player as defense in depth.
*/
private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) {
interpolatedRemotePosition(effectiveIsPlaying)
} else {
controller.currentPosition
}
val desiredIdx = desiredQueueIndex(controller, upnpActive)
val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration(
upnpActive,
remoteState.durationMs,
controller.duration,
desiredIdx = desiredIdx,
controllerIdx = controller.currentMediaItemIndex,
)
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
// Track adjustments are forward-only AND suppressed while a user
// transport press is pending Sonos confirmation. Together those keep
// either direction of user input from being undone by a stale poll.
val trackChanged = !pendingTransport &&
desiredIdx > current.queueIndex &&
desiredIdx in queueRefs.indices
publishTickIfChanged(
current, trackChanged, desiredIdx,
effectiveIsPlaying, newPos, newDur, newBuf,
)
}
/**
* Event-driven primary path: clear pending the moment Sonos's reported
* Track matches the wrapped player's index. Safety fallback: clear on
* deadline so we don't ignore Sonos's actual state forever if SOAP fails.
*/
private fun resolvePendingTransport(controller: MediaController, upnpActive: Boolean) {
if (!upnpActive || remoteState.pendingTransportDeadlineMs <= 0L) return
val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs
if (sonosIdx == controller.currentMediaItemIndex || timedOut) {
remoteState.clearPendingTransport()
}
}
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
private fun publishTickIfChanged(
current: PlayerUiState,
trackChanged: Boolean,
desiredIdx: Int,
effectiveIsPlaying: Boolean,
newPos: Long,
newDur: Long,
newBuf: Long,
) {
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur
if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
// Intentionally do NOT call controller.seekTo here. That would route
// through MinstrelForwardingPlayer's seekTo override and re-issue
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
// start of the same track it's already playing, restarting the song.
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
// delegate.seekTo (which bypasses the override). If it lags briefly,
// the next pollOnce catches up; the uiState above already reflects
// Sonos's truth for the user.
}
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
if (upnpActive) {
(remoteState.trackNumber - 1).coerceAtLeast(0)
} else {
controller.currentMediaItemIndex
}
// ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
// sleep can stall it for many seconds). To keep the scrubber moving
// smoothly we anchor each fresh reading + an elapsed-realtime stamp;
// between updates we display anchor + elapsed when Sonos is playing.
// A real correction lands as soon as the next poll arrives.
@Volatile private var lastSeenRemotePositionMs: Long = -1L
@Volatile private var positionAnchorMs: Long = 0L
@Volatile private var positionAnchorAtRealtimeMs: Long = 0L
private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
val raw = remoteState.positionMs
val now = SystemClock.elapsedRealtime()
if (raw != lastSeenRemotePositionMs) {
lastSeenRemotePositionMs = raw
positionAnchorMs = raw
positionAnchorAtRealtimeMs = now
}
if (!isPlaying) return positionAnchorMs
// Cap how far past the last anchor we extrapolate. After
// MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the
// displayed position at anchor + cap rather than projecting wildly.
// The next successful poll re-anchors and motion resumes.
val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS)
return positionAnchorMs + delta
}
/**
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
* to a suspend function without pulling in `kotlinx-coroutines-guava`
@@ -467,7 +667,23 @@ class PlayerController @Inject constructor(
.setArtist(artistName)
.setAlbumTitle(albumTitle)
.apply {
// Server-known duration -- gives the lock-screen / notification
// scrubber a real total even when the wrapped ExoPlayer is
// paused under UPnP (it never probes a duration in that state).
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
if (source != null) setExtras(sourceExtras(source))
// Point the notification / lock-screen art at the SAME album
// cover the in-app surfaces use (TrackRef.coverUrl ->
// /api/albums/{id}/cover). Without this, Media3 falls back to
// whatever art is embedded in the stream's tags, which can be a
// different image than the server's album cover. Setting
// artworkUri here is load-bearing: MediaMetadata.populate()
// overwrites artworkUri + artworkData as a pair, so the
// MediaItem's URI clears any embedded artworkData ExoPlayer
// extracts from the stream -- the cover endpoint wins on both
// surfaces. The session's OkHttp-backed BitmapLoader (see
// PlayerFactory) is what makes this authed placeholder URL load.
if (coverUrl.isNotEmpty()) setArtworkUri(coverUrl.toUri())
}
.build()
// Server's stream_url is a relative path (/api/tracks/{id}/stream);
@@ -489,8 +705,40 @@ class PlayerController @Inject constructor(
private fun sourceExtras(source: String): Bundle =
Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) }
/**
* Duration to surface to the UI. Priority: Sonos's reported duration
* (only when UPnP active and non-zero) -> wrapped ExoPlayer's value
* (only valid once it's probed the stream) -> TrackRef.durationSec
* (always known from the server response). The third tier is what
* keeps the scrubber populated when the user taps Sonos before the
* wrapped player has had time to probe its own duration -- without
* it, both top tiers report 0/TIME_UNSET and the field reads empty
* until the first SOAP poll lands.
*/
@Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns
private fun effectiveDuration(
upnpActive: Boolean,
remoteMs: Long,
localMs: Long,
desiredIdx: Int,
controllerIdx: Int,
): Long {
if (upnpActive && remoteMs > 0) return remoteMs
// Tier 2 (wrapped player's probed duration) only valid when the
// wrapped player is on the same track we're trying to show. After a
// Sonos natural advance the polling tick updates desiredIdx from
// Sonos's truth while controllerIdx is briefly stale -- using the
// wrapped player's duration here would surface the old track's
// length under the new track's title.
if (localMs > 0 && controllerIdx == desiredIdx) return localMs
val ref = queueRefs.getOrNull(desiredIdx) ?: return 0
return ref.durationSec.toLong() * MS_PER_SECOND
}
companion object {
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
private const val MS_PER_SECOND = 1_000L
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
}
}
@@ -3,7 +3,10 @@ package com.fabledsword.minstrel.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.BitmapLoader
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
@@ -11,15 +14,21 @@ import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds the process-singleton ExoPlayer with our shared OkHttp +
* Builds the process-singleton player with our shared OkHttp +
* SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
* `build()` once during onCreate.
*
@@ -31,12 +40,22 @@ import javax.inject.Singleton
* (sizeBytes cap = rollingCap); our policy layer in the worker layers
* the 2-bucket protection on top by feeding `removeSpan` only for
* unprotected tracks.
*
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
* ExoPlayer. When the UPnP route drops (3 consecutive poll failures or
* a SOAP failure), [dropEvents] emits the route name so the NowPlaying
* surface can show a snackbar. The MutableSharedFlow uses DROP_OLDEST
* with capacity=1 so a burst of failures during a single tear-down
* surfaces as one event rather than queueing N.
*/
@Singleton
class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -46,11 +65,36 @@ class PlayerFactory @Inject constructor(
StandaloneDatabaseProvider(context),
)
fun build(): ExoPlayer {
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
// of drop events (rapid SOAP failures during a single tear-down) surfaces
// as one snackbar rather than queueing N.
private val dropEventsInternal = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
fun build(): Player {
val exo = buildExoPlayer()
return MinstrelForwardingPlayer(
delegate = exo,
holder = activeUpnpHolder,
remoteState = remoteState,
onDrop = { name -> emitDrop(name) },
)
}
private fun buildExoPlayer(): ExoPlayer {
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
// Gate network reads on ServerHealth so a cache miss while offline
// fails fast with an OfflineException instead of hitting an OkHttp
// timeout. CacheDataSource only consults the upstream factory on a
// cache miss, so playback of cached audio is unaffected.
val gatedUpstream = OfflineGatedDataSourceFactory(httpDataSource, serverHealth)
val cacheDataSource = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(httpDataSource)
.setUpstreamDataSourceFactory(gatedUpstream)
.setCacheWriteDataSinkFactory(
CacheDataSink.Factory()
.setCache(simpleCache)
@@ -71,4 +115,26 @@ class PlayerFactory @Inject constructor(
.setHandleAudioBecomingNoisy(true)
.build()
}
/**
* BitmapLoader for the MediaSession's notification / lock-screen art.
* Backed by the shared [okHttpClient] so it inherits the same
* BaseUrlInterceptor placeholder rewrite + auth cookie that Coil uses
* for in-app covers — without it the default DefaultHttpDataSource
* loader can't resolve `http://placeholder.invalid/...` and would 401
* on the cover endpoint. Wrapped in CacheBitmapLoader so a cover the
* notification already fetched isn't re-loaded on every metadata
* refresh. Lets the album-cover artworkUri set in
* [PlayerController.toMediaItem] actually render on the media card.
*/
fun buildBitmapLoader(): BitmapLoader =
CacheBitmapLoader(
DataSourceBitmapLoader.Builder(context)
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
.build(),
)
private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName)
}
}
@@ -28,4 +28,6 @@ data class PlayerUiState(
val currentSource: String? = null,
val shuffleEnabled: Boolean = false,
val repeatMode: RepeatMode = RepeatMode.OFF,
/** True while the UPnP initial-batch load is in progress (target set, active not yet wired). */
val isUpnpLoading: Boolean = false,
)
@@ -0,0 +1,110 @@
package com.fabledsword.minstrel.player
import javax.inject.Inject
import javax.inject.Singleton
/**
* Synthesized state for the UPnP route -- what the ForwardingPlayer
* exposes via Player.getCurrentPosition / isPlaying / etc. when the
* remote leg is active. Not a Player; a container.
*
* Updates flow in from:
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
* - Poll failures -> recordPollFailure()
*
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
* consecutive poll failures = remote considered dropped (returns true
* from recordPollFailure for the caller to surface). Any success resets it.
* Failed transport commands no longer drop the route -- they retry and
* otherwise defer to this counter (see MinstrelForwardingPlayer).
*/
@Singleton
class RemotePlayerState @Inject constructor() {
@Volatile var positionMs: Long = 0L; private set
@Volatile var durationMs: Long = 0L; private set
@Volatile var isPlaying: Boolean = false; private set
@Volatile var currentTrackUri: String = ""; private set
@Volatile var lastError: Throwable? = null; private set
@Volatile var trackNumber: Int = 0; private set
// The operator's last explicit transport intent (play=true, pause=false),
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
// local playback based on THIS, not isPlaying -- so a play() that fails
// its SOAP (stale/dead renderer) still resumes on the phone instead of
// swallowing the tap. Deliberately NOT cleared by applyError.
@Volatile var lastPlayIntent: Boolean = false; private set
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
// give up waiting). When > 0, a user transport action (next/prev/seekTo
// idx) is in flight: ForwardingPlayer has already moved the wrapped
// player's index, but Sonos's reported Track hasn't refreshed via a
// SOAP poll yet. PlayerController.tickPositionPoll skips track
// adjustments while pending is non-zero. Pending clears when:
// (a) [event-driven, primary] a poll lands and Sonos's reported Track
// matches the wrapped player's currentMediaItemIndex; or
// (b) [safety fallback] the deadline expires (covers SOAP-fail cases
// where Sonos never acks).
@Volatile var pendingTransportDeadlineMs: Long = 0L; private set
fun beginPendingTransport(deadlineMs: Long) {
pendingTransportDeadlineMs = deadlineMs
}
fun clearPendingTransport() {
pendingTransportDeadlineMs = 0L
}
@Volatile private var consecutivePollFailures: Int = 0
fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) {
this.positionMs = positionMs
this.durationMs = durationMs
this.currentTrackUri = trackUri
this.trackNumber = trackNumber
}
fun applyTransportPlaying() { isPlaying = true }
fun applyTransportPaused() { isPlaying = false }
fun applyTransportStopped() {
isPlaying = false
positionMs = 0L
}
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
fun applyError(t: Throwable) {
isPlaying = false
lastError = t
}
/** Returns true when the rolling threshold trips this call. */
fun recordPollFailure(): Boolean {
consecutivePollFailures += 1
return consecutivePollFailures >= DROP_THRESHOLD
}
fun recordPollSuccess() { consecutivePollFailures = 0 }
fun reset() {
positionMs = 0L
durationMs = 0L
isPlaying = false
lastPlayIntent = false
currentTrackUri = ""
lastError = null
consecutivePollFailures = 0
trackNumber = 0
pendingTransportDeadlineMs = 0L
}
private companion object {
// ~30 seconds of consecutive poll failures before declaring the route
// dropped. Bumped from 3 because screen-off WiFi sleep / brief Doze
// can stall socket I/O for several seconds without the renderer
// actually being unreachable -- a 3-failure drop kicked us back to
// local audio every time the phone went into a pocket.
const val DROP_THRESHOLD = 30
}
}
@@ -0,0 +1,24 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.api.endpoints.StreamTokenResponse
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mints stream tokens for a given track id. Pulled into its own Hilt
* singleton so [MinstrelForwardingPlayer] (service-side) and
* [com.fabledsword.minstrel.player.output.OutputPickerController]
* (controller-side) don't each construct their own [CastApi] from
* Retrofit. The shared Retrofit instance is unchanged.
*/
@Singleton
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
private val api: CastApi = retrofit.create()
suspend fun mint(trackId: String): StreamTokenResponse =
api.streamToken(StreamTokenRequest(trackId = trackId))
}
@@ -0,0 +1,43 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Shared singleton handle to the currently-active UPnP route's transport
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
* from [OutputPickerController] -- the picker writes; the forwarding
* player reads. Null = no UPnP active (local ExoPlayer path).
*/
data class ActiveUpnp(
val routeId: String,
val routeName: String,
val avTransport: AVTransportClient,
val rendering: RenderingControlClient?,
)
@Singleton
class ActiveUpnpHolder @Inject constructor() {
private val internal = MutableStateFlow<ActiveUpnp?>(null)
val active: StateFlow<ActiveUpnp?> = internal.asStateFlow()
/**
* Pending route id during selectUpnp's queue-load window. Set when
* loadQueueOnSonos starts; cleared on completion (success or failure).
* ForwardingPlayer overrides treat (target != null && active == null)
* as "UPnP intended but SOAP not yet wired" -- drop transport commands
* silently rather than send them to a half-loaded queue.
*/
private val targetInternal = MutableStateFlow<String?>(null)
val target: StateFlow<String?> = targetInternal.asStateFlow()
fun set(active: ActiveUpnp?) { internal.value = active }
fun setTarget(routeId: String?) { targetInternal.value = routeId }
}
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.player.output
/** Action the idle-revert collector should take on a player-state change. */
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
/**
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
* idle timer Job is currently running. We ARM only on the transition INTO
* "engaged + not playing" (so repeated paused emissions don't reset the
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
* otherwise IGNORE.
*/
internal fun idleRevertAction(
upnpEngaged: Boolean,
isPlaying: Boolean,
armed: Boolean,
): IdleRevertAction = when {
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
armed -> IdleRevertAction.CANCEL
else -> IdleRevertAction.IGNORE
}
@@ -1,24 +1,35 @@
@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
package com.fabledsword.minstrel.player.output
import android.content.Context
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
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.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,9 +37,9 @@ import javax.inject.Singleton
/**
* Snapshot of the audio output route state. [current] is the live
* route audio is being delivered to. [available] is every route the
* picker knows about MediaRouter system routes merged with
* UPnP/DLNA renderers discovered on the LAN — sorted current-first
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
* picker knows about -- MediaRouter system routes merged with
* UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
* "Phone speaker" pinned first, everything else alphabetical.
*/
data class RouteSnapshot(
val current: OutputRoute,
@@ -48,7 +59,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [CastApi.streamToken], drive the discovered renderer with
* [StreamTokenProvider.mint], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
@@ -65,10 +76,12 @@ class OutputPickerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
retrofit: Retrofit,
private val playerFactory: PlayerFactory,
private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
) {
private val castApi: CastApi = retrofit.create()
private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder()
@@ -82,12 +95,26 @@ class OutputPickerController @Inject constructor(
*/
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
private val selectUpnpMutex = Mutex()
val routesState: StateFlow<RouteSnapshot> = combine(
systemRoutesInternal,
upnpDiscovery.routes,
) { sys, upnp ->
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
upnpDiscovery.sonosTopology,
selectedUpnpRouteIdInternal,
) { sys, upnp, _, upnpSelected ->
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
val visibleUpnp = upnp
.filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs
.map { OutputRoute.fromUpnpRoute(it) }
val merged = sys.available + visibleUpnp
val current = if (upnpSelected != null) {
merged.firstOrNull { it.id == upnpSelected } ?: sys.current
} else {
sys.current
}
RouteSnapshot(current = current, available = sortRoutes(merged))
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
private val callback = object : MediaRouter.Callback() {
@@ -113,6 +140,20 @@ class OutputPickerController @Inject constructor(
) = refresh()
}
// Last-synced queue identity. Used by observeQueueChangesForSonosResync
// to detect when the user has mutated the queue (full replacement,
// playNext insert, or radio-append) while UPnP is active and apply the
// minimum-incremental set of Sonos SOAP operations to bring its native
// queue back in sync. Stored as the full id list (not a join-key) so we
// can run the longest-common-prefix / common-suffix diff.
private var lastSyncedQueueIds: List<String>? = null
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
// non-playing. Cancelled on resume, route change, or explicit disconnect.
// See idleRevertAction for the arm/cancel decision.
private var idleRevertJob: Job? = null
init {
// Two-arg addCallback registers with no discovery flag —
// androidx.mediarouter 1.7.0's default passive behavior:
@@ -120,6 +161,284 @@ class OutputPickerController @Inject constructor(
// without forcing Bluetooth scans. (There is no
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback)
// When MinstrelForwardingPlayer reports the active UPnP route has
// dropped (the poll loop's consecutive-failure threshold tripping --
// the sole drop arbiter; a failed transport command no longer drops),
// clear the UPnP selection state and fall back to local ExoPlayer at
// the last-known remote position. This mirrors selectSystem's disconnect
// path but skips the Stop SOAP since the device is already unreachable.
scope.launch {
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeIdleRevertWhileUpnp() }
}
/**
* Arm/cancel the idle-revert timer as playback state changes while a
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
* selectUpnp/resync window (active momentarily null, target set) doesn't
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
*/
private suspend fun observeIdleRevertWhileUpnp() {
playerController.uiState.collect { state ->
val engaged = activeUpnpHolder.active.value != null ||
activeUpnpHolder.target.value != null
val armed = idleRevertJob?.isActive == true
when (idleRevertAction(engaged, state.isPlaying, armed)) {
IdleRevertAction.ARM ->
idleRevertJob = scope.launch {
delay(IDLE_REVERT_MS)
revertToPhoneOnIdle()
}
IdleRevertAction.CANCEL -> {
idleRevertJob?.cancel()
idleRevertJob = null
}
IdleRevertAction.IGNORE -> Unit
}
}
}
/**
* Timer fired: the operator paused on a UPnP route and never resumed.
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
* clear the UPnP overlay so routesState falls back to the phone, and
* seek local ExoPlayer to the last remote position. No play(): the
* operator was paused, so we revert routing silently. Guarded by the
* selectUpnpMutex so it can't race selectUpnp/resync.
*/
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
if (remoteState.isPlaying) return@withLock // resumed at the boundary
val active = activeUpnpHolder.active.value
val capturedPositionMs = remoteState.positionMs
runCatching { active?.avTransport?.stop() }
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
idleRevertJob = null
playerController.seekTo(capturedPositionMs)
}
/**
* When the user plays a different playlist while Sonos is active,
* PlayerController.setQueue replaces the local queue but Sonos's
* native queue still holds the OLD tracks. MinstrelForwardingPlayer's
* setMediaItems override clears holder.active + sets target so the
* imminent play() call drops (drops via isLoadingUpnp() = true). Then
* this collector observes the uiState.queue change and re-runs
* loadQueueOnSonos to push the new tracks to Sonos.
*
* Discrimination: selectUpnp's initial-load path doesn't change
* uiState.queue (the queue was already populated before route
* selection), so this collector doesn't fire during that window. Only
* a fresh setQueue from PlayerController bumps the joined-ids key.
*/
private suspend fun observeQueueChangesForSonosResync() {
playerController.uiState.collect { state ->
val newIds = state.queue.map { it.id }
val oldIds = lastSyncedQueueIds
if (newIds == oldIds) return@collect
lastSyncedQueueIds = newIds
// Route can be in target (setMediaItems-induced clearing already
// ran in ForwardingPlayer) OR in active (queue changed via
// addMediaItem / removeMediaItems etc. which don't hit the
// markPending hook).
val routeId = activeUpnpHolder.target.value
?: activeUpnpHolder.active.value?.routeId
?: return@collect
if (state.queue.isEmpty()) {
Timber.w("Sonos resync skipped: empty queue (clearing target)")
activeUpnpHolder.setTarget(null)
return@collect
}
scope.launch {
resyncSonosQueue(routeId, oldIds.orEmpty(), state.queue, state.queueIndex)
}
}
}
/**
* Bring Sonos's native queue back in sync with the local queue after a
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
* + AddURIToQueue at the insertion point) so playback continues without
* interruption -- that's what playNext / radio-append want. Falls back to
* the full removeAllTracks + reload path when the diff implies the current
* Sonos track was deleted (e.g. user switched playlists), which is what
* the user-reported "Sonos queue does not update" bug needed.
*/
private suspend fun resyncSonosQueue(
routeId: String,
oldIds: List<String>,
newQueue: List<TrackRef>,
newCurrentIndex: Int,
) = selectUpnpMutex.withLock {
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
val transport = upnpDiscovery.transportFor(routeId)
if (upnpRoute == null || transport == null) {
Timber.w(
"Sonos resync: route or transport gone for %s, dropping to local",
routeId,
)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
return@withLock
}
val handledIncrementally = runCatching {
tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false
}
if (handledIncrementally) {
// Active was never cleared on the incremental path; clear any
// target that markPendingResyncIfRemote set (it didn't, for
// incremental cases that don't go through setMediaItems, but
// belt-and-suspenders).
activeUpnpHolder.setTarget(null)
return@withLock
}
// Full rebuild: ensure active is cleared so transport calls drop
// (markPendingResyncIfRemote may already have done this on the
// setMediaItems path).
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(routeId)
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
val rendering = renderingClientFor(routeId)
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
runCatching {
loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
activeUpnpHolder.set(
ActiveUpnp(
routeId = routeId,
routeName = outputRoute.name,
avTransport = transport,
rendering = rendering,
),
)
activeUpnpHolder.setTarget(null)
}.onFailure { e ->
Timber.w(e, "Sonos resync (full) failed -- dropping to local")
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
private suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
/**
* Called when the active UPnP route drops unexpectedly (the poll loop's
* consecutive-failure threshold tripping). Captures the last remote
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
* the same point. Skips the Stop SOAP (device already unreachable).
* The snackbar is handled independently by the NowPlaying surface
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
*/
private fun handleRemoteDrop() {
val capturedPositionMs = remoteState.positionMs
// Resume on the operator's last play-intent, not the observed remote
// state: after a pause, isPlaying is false, but a failed play() that
// triggered this drop means the operator DID ask to play -- honor it.
val wasPlayingRemote = remoteState.lastPlayIntent
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
}
/**
@@ -163,48 +482,201 @@ class OutputPickerController @Inject constructor(
}
private fun selectSystem(route: OutputRoute) {
val wasUpnp = selectedUpnpRouteIdInternal.value
if (wasUpnp != null) {
val active = activeUpnpHolder.active.value
val capturedPositionMs = remoteState.positionMs
val wasPlayingRemote = remoteState.isPlaying
scope.launch {
runCatching { active?.avTransport?.stop() }
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
}
}
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
mediaRouter.selectRoute(target)
}
/**
* Drive the UPnP renderer: mint a token for the currently playing
* track, set the renderer's URI, play, then pause local playback so
* audio yields to the speaker. Wrapped in `runCatching` at each
* step — token failure, transport-lookup failure, and SOAP failure
* each abandon the selection cleanly rather than crashing. Failures
* log at warn level via Timber so on-device verification can find
* the cause in logcat (OkHttp's logger doesn't cover our own
* deserialize / SOAP-parse code paths).
* Drive the UPnP renderer using Sonos native queue mode:
* clear the device's queue, load every track from our local queue
* via AddURIToQueue, point the transport at the queue URI, seek to
* the current index, and play. Wrapped in `runCatching` — SOAP
* failure abandons the selection cleanly.
*
* Order of operations is deliberate:
* 1. Pause local so the user doesn't keep hearing local audio.
* 2. Set target early so ForwardingPlayer drops transport taps
* while the 17-second queue load is in progress.
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
* are never routed to a half-loaded Sonos queue.
*/
private suspend fun selectUpnp(route: OutputRoute) {
val trackId = playerController.uiState.value.currentTrack?.id
if (trackId == null) {
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
val uiState = playerController.uiState.value
val currentTrack = uiState.currentTrack
if (currentTrack == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return
return@withLock
}
val transport = upnpDiscovery.transportFor(route.id)
// Honor Sonos topology: pick the coordinator's route when the user
// tapped a group row. Suppression in routesState already keeps the
// visible row at the coordinator's id, so this is identity in the
// common case -- defensive for follow-up flows.
val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id)
?.let { OutputRoute.fromUpnpRoute(it) } ?: route
val transport = upnpDiscovery.transportFor(effectiveRoute.id)
if (transport == null) {
Timber.w(
"UPnP select skipped: no transport for route id=${route.id} " +
"UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
"(route disappeared or id mismatch with discovery list)",
)
return
return@withLock
}
val rendering = renderingClientFor(effectiveRoute.id)
// Pause local before flipping UI state -- user shouldn't keep hearing
// local audio while we queue up Sonos.
playerController.pause()
selectedUpnpRouteIdInternal.value = effectiveRoute.id
// 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.
activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching {
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
transport.setAVTransportURI(token.url)
Timber.i("UPnP select: Play")
transport.play()
playerController.pause()
Timber.i("UPnP select: done")
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
// Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set(
ActiveUpnp(
routeId = effectiveRoute.id,
routeName = effectiveRoute.name,
avTransport = transport,
rendering = rendering,
),
)
// Selecting a route loads the queue and Plays on the renderer, so
// the standing intent is "play" -- a later drop should resume local.
remoteState.setPlayIntent(true)
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${route.id}")
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value
.firstOrNull { it.id == routeId }
?.renderingControlUrl ?: return null
return RenderingControlClient(SoapClient(okHttp), rcUrl)
}
private fun refresh() {
systemRoutesInternal.value = snapshotFromRouter()
}
@@ -214,24 +686,26 @@ class OutputPickerController @Inject constructor(
.filter { it.matchesSelector(selector) }
.map { OutputRoute.fromRouteInfo(it) }
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
return RouteSnapshot(current = current, available = sortRoutes(current, all))
return RouteSnapshot(current = current, available = sortRoutes(all))
}
/**
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
* Other (UPnP renderers fall in Other). Keeps the active output at
* the top + likely-wanted alternatives next + fallback last.
* BuiltIn "Phone speaker" pinned first; everything else
* alphabetical. Selection state is conveyed by the radio button
* indicator in the picker row, not by sort order.
*/
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
val rank: (OutputRoute) -> Int = { route ->
when {
route.id == current.id -> 0
route.kind == OutputRoute.Kind.Bluetooth -> 1
route.kind == OutputRoute.Kind.Wired -> 2
route.kind == OutputRoute.Kind.BuiltIn -> 3
else -> 4
}
}
return all.sortedBy(rank)
private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
return builtIn + rest.sortedBy { it.name.lowercase() }
}
private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing.
const val IDLE_REVERT_MS = 5 * 60 * 1000L
}
}
@@ -4,8 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -69,12 +72,19 @@ fun OutputPickerSheet(
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 8.dp),
)
snapshot.available.forEach { route ->
RouteRow(
route = route,
isSelected = route.id == snapshot.current.id,
onClick = { onRouteSelected(route) },
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = ROUTE_LIST_MAX_HEIGHT_DP.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(items = snapshot.available, key = { it.id }) { route ->
RouteRow(
route = route,
isSelected = route.id == snapshot.current.id,
onClick = { onRouteSelected(route) },
)
}
}
if (permissionDenied) {
PermissionHintRow()
@@ -198,3 +208,4 @@ private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
private const val ROW_ICON_DP = 24
private const val HINT_ICON_DP = 20
private const val ROUTE_LIST_MAX_HEIGHT_DP = 400
@@ -24,6 +24,7 @@ import javax.inject.Inject
@HiltViewModel
class OutputPickerViewModel @Inject constructor(
private val controller: OutputPickerController,
private val activeUpnpHolder: ActiveUpnpHolder,
) : ViewModel() {
val routes: StateFlow<RouteSnapshot> = controller.routesState
@@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor(
initialValue = controller.routesState.value,
)
val activeUpnp: StateFlow<ActiveUpnp?> = activeUpnpHolder.active
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
private val sheetVisibleInternal = MutableStateFlow(false)
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
@@ -3,12 +3,13 @@ package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service. Three calls
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred
* until we have hardware in the loop to verify each device's quirks
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs
* reject Pause without DIDL).
* High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* GetPositionInfo / GetTransportInfo / queue management
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
*/
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions")
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
@@ -26,6 +27,135 @@ class AVTransportClient(
)
}
/**
* Convenience overload that builds DIDL-Lite metadata around [uri]
* + [mime] + [title] and forwards to [setAVTransportURI]. Sonos
* rejects empty DIDL with vendor error 1023; this constructs the
* minimal-but-Sonos-acceptable shape:
* <DIDL-Lite>
* <item id="0" parentID="-1" restricted="1">
* <dc:title>...</dc:title>
* <upnp:class>object.item.audioItem.musicTrack</upnp:class>
* <res protocolInfo="http-get:*:<mime>:*">...</res>
* </item>
* </DIDL-Lite>
* Generic UPnP renderers tolerate this shape too — there's no
* downside to always sending it. Title falls back to "Minstrel"
* when the caller doesn't supply one.
*/
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
}
/**
* Remove all tracks from the renderer's queue. UPnP action name is
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
* leftover queue from prior sessions before loading our local queue.
*/
suspend fun removeAllTracksFromQueue() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "RemoveAllTracksFromQueue",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Remove a contiguous range of tracks from the renderer's native queue.
* Sonos-specific extension to AVTransport; `UpdateID=0` skips the queue-
* version check so this works without first calling GetQueue to learn
* the current update id.
*
* [startingIndex] is 1-based per Sonos convention; [numberOfTracks] is
* the count to remove. Used by OutputPickerController's incremental
* queue resync path (radio-append: remove tail, then AddURIToQueue
* the new items).
*/
suspend fun removeTrackRangeFromQueue(startingIndex: Int, numberOfTracks: Int) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "RemoveTrackRangeFromQueue",
args = mapOf(
"InstanceID" to "0",
"UpdateID" to "0",
"StartingIndex" to startingIndex.toString(),
"NumberOfTracks" to numberOfTracks.toString(),
),
)
}
/**
* Append a track to the renderer's queue. Sonos returns the assigned
* track number + new total queue length in the response, but we don't
* read those (we know our intended position). DIDL-Lite metadata is
* required; reuses the same shape as [setAVTransportURIWithMetadata].
*
* [enqueuedURIPosition] is 1-based; 0 means "append to end" per UPnP.
* Our caller passes 1, 2, 3, ... to ensure stable order.
*/
suspend fun addURIToQueue(
uri: String,
mime: String,
title: String,
enqueuedURIPosition: Int = 0,
) {
val safeTitle = title.ifBlank { "Minstrel" }
val didl = buildDidlLite(uri, mime, safeTitle)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "AddURIToQueue",
args = mapOf(
"InstanceID" to "0",
"EnqueuedURI" to uri,
"EnqueuedURIMetaData" to didl,
"DesiredFirstTrackNumberEnqueued" to enqueuedURIPosition.toString(),
"EnqueueAsNext" to "0",
),
)
}
/** Skip to the next track in the renderer's queue. */
suspend fun next() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Next",
args = mapOf("InstanceID" to "0"),
)
}
/** Skip to the previous track in the renderer's queue. */
suspend fun previous() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Previous",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Seek to a specific track in the queue. UPnP Seek unit "TRACK_NR";
* target is the 1-based track index. Separate from the existing
* [seek] which uses unit REL_TIME for position-within-track.
*/
suspend fun seekToTrack(trackNumber: Int) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Seek",
args = mapOf(
"InstanceID" to "0",
"Unit" to "TRACK_NR",
"Target" to trackNumber.toString(),
),
)
}
suspend fun play() {
soap.call(
controlUrl = controlUrl,
@@ -35,6 +165,15 @@ class AVTransportClient(
)
}
suspend fun pause() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Pause",
args = mapOf("InstanceID" to "0"),
)
}
suspend fun stop() {
soap.call(
controlUrl = controlUrl,
@@ -44,7 +183,114 @@ class AVTransportClient(
)
}
suspend fun seek(positionMs: Long) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Seek",
args = mapOf(
"InstanceID" to "0",
"Unit" to "REL_TIME",
"Target" to formatHhMmSs(positionMs),
),
)
}
suspend fun getPositionInfo(): PositionInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetPositionInfo",
args = mapOf("InstanceID" to "0"),
)
return PositionInfo(
track = result["Track"]?.toIntOrNull() ?: 0,
trackUri = result["TrackURI"].orEmpty(),
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
)
}
suspend fun getTransportInfo(): TransportInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetTransportInfo",
args = mapOf("InstanceID" to "0"),
)
val state = when (result["CurrentTransportState"]) {
"PLAYING" -> TransportState.PLAYING
"PAUSED_PLAYBACK" -> TransportState.PAUSED
"STOPPED" -> TransportState.STOPPED
"TRANSITIONING" -> TransportState.TRANSITIONING
else -> TransportState.UNKNOWN
}
return TransportInfo(state)
}
private fun buildDidlLite(uri: String, mime: String, title: String): String {
// Sonos requires (a) the rinconnetworks namespace declared on
// <DIDL-Lite> even if we don't use Rincon elements directly, and
// (b) a <desc id="cdudn"> element identifying the URI as an
// external (non-Sonos-library) source. Without those, Sonos
// discards our metadata content and regenerates its own with
// class=object.item and the URL query string as the title
// (logcat 2026-06-04 confirmed). Match SoCo's pattern.
val safeTitle = title.ifBlank { "Minstrel" }
return buildString {
append("<DIDL-Lite ")
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
append(":*\">").append(xmlEscape(uri)).append("</res>")
append("<desc id=\"cdudn\" ")
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("RINCON_AssociatedZPUDN")
append("</desc>")
append("</item>")
append("</DIDL-Lite>")
}
}
private fun formatHhMmSs(positionMs: Long): String {
val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0)
val h = totalSec / SECONDS_PER_HOUR
val m = (totalSec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
val s = totalSec % SECONDS_PER_MINUTE
return "%d:%02d:%02d".format(h, m, s)
}
private fun parseHhMmSs(raw: String): Long {
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
return if (parts.size == EXPECTED_HMS_PARTS) {
val (h, m, s) = parts
((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND
} else {
0L
}
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
const val MILLIS_PER_SECOND = 1000L
const val SECONDS_PER_MINUTE = 60L
const val SECONDS_PER_HOUR = 3600L
const val EXPECTED_HMS_PARTS = 3
}
}
data class PositionInfo(
val track: Int,
val trackUri: String,
val relTimeMs: Long,
val trackDurationMs: Long,
)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
data class TransportInfo(val state: TransportState)
@@ -22,10 +22,12 @@ data class DeviceDescription(
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
) {
companion object {
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
private const val ZGT_SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
private const val TAG_SERVICE = "service"
private const val TAG_UDN = "UDN"
@@ -43,6 +45,7 @@ data class DeviceDescription(
*/
fun parse(xml: String, base: HttpUrl): DeviceDescription? {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(xml.reader())
}
val acc = ParseState()
@@ -58,6 +61,7 @@ data class DeviceDescription(
modelName = acc.modelName,
avTransportControlUrl = avt,
renderingControlUrl = acc.rcControlUrl,
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
)
}
@@ -91,6 +95,7 @@ data class DeviceDescription(
when (acc.serviceType) {
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
}
acc.inService = false
}
@@ -115,6 +120,7 @@ data class DeviceDescription(
var modelName: String = ""
var avtControlUrl: HttpUrl? = null
var rcControlUrl: HttpUrl? = null
var zgtControlUrl: HttpUrl? = null
var inService: Boolean = false
var serviceType: String = ""
var serviceControlUrl: String = ""
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* RenderingControl service wrapper for hardware-volume routing while a
* UPnP route is active. GetVolume seeds an in-memory cache; SetVolume
* is invoked by NowPlayingScreen's volume-key interceptor. Clamps to
* the UPnP-standard 0..100 range.
*/
class RenderingControlClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun getVolume(channel: String = "Master"): Int {
val args = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetVolume",
args = mapOf("InstanceID" to "0", "Channel" to channel),
)
return args["CurrentVolume"]?.toIntOrNull() ?: 0
}
suspend fun setVolume(volume: Int, channel: String = "Master") {
val clamped = volume.coerceIn(VOLUME_MIN, VOLUME_MAX)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetVolume",
args = mapOf(
"InstanceID" to "0",
"Channel" to channel,
"DesiredVolume" to clamped.toString(),
),
)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
const val VOLUME_MIN = 0
const val VOLUME_MAX = 100
}
}
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player.output.upnp
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
@@ -8,6 +9,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import timber.log.Timber
/**
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
@@ -25,6 +27,7 @@ import org.xmlpull.v1.XmlPullParserFactory
*/
class SoapClient(
private val okHttp: OkHttpClient,
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
) {
suspend fun call(
controlUrl: HttpUrl,
@@ -41,6 +44,7 @@ class SoapClient(
.build()
okHttp.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
onRawResponse?.invoke(action, body)
if (!response.isSuccessful) {
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
}
@@ -69,15 +73,9 @@ class SoapClient(
append("</s:Envelope>")
}
private fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(body.reader())
}
val responseTag = "${action}Response"
@@ -102,7 +100,7 @@ class SoapClient(
} else {
if (inResponse) {
val name = parser.name
val text = runCatching { parser.nextText() }.getOrDefault("")
val text = readElementContent(parser, name)
args[name] = text
}
inResponse
@@ -112,6 +110,59 @@ class SoapClient(
else -> inResponse
}
/**
* Read the content of the currently-started element. Try nextText() first
* (works when the content is text -- escaped XML included). If that throws
* (because the content is nested elements), manually walk to the matching
* END_TAG, accumulating text and re-serializing child elements.
*
* Some Sonos firmware sends the GetZoneGroupState payload as nested XML
* elements without escaping; this fallback recovers that path.
*/
private fun readElementContent(parser: XmlPullParser, tagName: String): String {
return runCatching { parser.nextText() }.getOrElse {
readUntilEndTag(parser, tagName)
}
}
private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String {
val sb = StringBuilder()
var depth = 1
var done = false
while (!done && depth > 0) {
when (parser.next()) {
XmlPullParser.START_TAG -> {
appendStartTag(sb, parser)
depth += 1
}
XmlPullParser.END_TAG -> {
depth -= 1
if (depth == 0 && parser.name == tagName) {
done = true
} else {
sb.append("</").append(parser.name).append('>')
}
}
XmlPullParser.TEXT -> sb.append(parser.text.orEmpty())
XmlPullParser.END_DOCUMENT -> done = true
else -> Unit
}
}
return sb.toString()
}
private fun appendStartTag(sb: StringBuilder, parser: XmlPullParser) {
sb.append('<').append(parser.name)
for (i in 0 until parser.attributeCount) {
sb.append(' ')
.append(parser.getAttributeName(i))
.append("=\"")
.append(parser.getAttributeValue(i))
.append('"')
}
sb.append('>')
}
private fun faultCodeOf(body: String): String =
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
@@ -138,3 +189,42 @@ class SoapClient(
*/
class SoapFaultException(val code: String, val description: String) :
Exception("SOAP fault $code: $description")
private const val MAX_DIAGNOSTIC_RESPONSES = 6 // 3 polls x 2 action types
private const val MAX_BODY_LOG_CHARS = 2048
/**
* Returns a [SoapClient] that logs the raw response body for the first
* [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS]
* (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the
* callback is a no-op so there is no persistent log spam. Logged at WARN
* so release builds capture it without a separate log-level override.
*/
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
val counter = AtomicInteger(0)
return SoapClient(okHttp) { action, body ->
if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient
val n = counter.incrementAndGet()
if (n <= MAX_DIAGNOSTIC_RESPONSES) {
Timber.w(
"UPnP %s response #%d (%s): %s",
label, n, action,
body.take(MAX_BODY_LOG_CHARS),
)
}
}
}
private val DIAGNOSTIC_ACTIONS = setOf(
"GetPositionInfo",
"GetTransportInfo",
"GetZoneGroupState",
)
/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
internal fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
@@ -1,7 +1,10 @@
@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -13,6 +16,8 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -41,9 +46,21 @@ class UpnpDiscoveryController @Inject constructor(
) {
private val ssdp = SsdpDiscovery(context)
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
// this client. A dead renderer should fail fast so OutputPickerController's
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
// dispatcher stay shared with the app client.
private val controlHttp: OkHttpClient = okHttp.newBuilder()
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
init {
ssdp.start(appScope)
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
@@ -83,13 +100,77 @@ class UpnpDiscoveryController @Inject constructor(
*/
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
return AVTransportClient(
loggingSoapClient(controlHttp, route.name),
route.avTransportControlUrl,
)
}
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
routesInternal.value =
routesInternal.value.filterNot { it.id == route.id } + route
upsertRoute(route)
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
refreshSonosTopology(route)
}
}
private fun upsertRoute(route: UpnpRoute) {
val current = routesInternal.value
val idx = current.indexOfFirst { it.id == route.id }
routesInternal.value = if (idx < 0) {
current + route
} else {
current.toMutableList().also { it[idx] = route }
}
}
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run {
Timber.w(
"Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled",
anySonos.id,
)
return
}
val groups = runCatching {
ZoneGroupTopologyClient(
loggingSoapClient(okHttp, "ZGT-${anySonos.id}"),
zgtUrl,
).getZoneGroupState()
}
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
.getOrNull() ?: return
Timber.w(
"Sonos topology refreshed for %s: %d group(s)",
anySonos.id,
groups.size,
)
sonosTopologyInternal.value = groups
}
/**
* Returns the coordinator UDN's full route for the group [udn] belongs
* to, or null if topology hasn't loaded / the udn is in no group. UDN
* normalization strips the "uuid:" prefix because DeviceDescription's
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
*/
fun coordinatorRouteFor(udn: String): UpnpRoute? {
val bare = udn.bareUdn()
val coord = sonosTopologyInternal.value
.firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } }
?.coordinatorUdn ?: return null
return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() }
}
/**
* UDNs of every NON-coordinator Sonos group member. The picker
* controller suppresses these from the visible list.
*/
fun nonCoordinatorMemberUdns(): Set<String> {
return sonosTopologyInternal.value.flatMap { g ->
g.members.map { it.udn.bareUdn() }
.filter { it != g.coordinatorUdn.bareUdn() }
}.toSet()
}
/**
@@ -111,6 +192,7 @@ class UpnpDiscoveryController @Inject constructor(
modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl,
renderingControlUrl = desc.renderingControlUrl,
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
)
}
}
@@ -153,5 +235,27 @@ class UpnpDiscoveryController @Inject constructor(
// devices append. Anchored to end-of-string so it never eats
// a legitimate parenthetical inside a name.
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
// Aggressive connect timeout for transport SOAP so a dead renderer
// 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
}
}
/**
* Normalize a Sonos UDN to its bare RINCON form for cross-comparison.
*
* Three places use UDN strings with different conventions:
* - DeviceDescription's <UDN> tag carries the `uuid:` prefix.
* - Sonos exposes one UDN per embedded device. The MediaRenderer adds
* a `_MR` suffix and the MediaServer adds `_MS`.
* - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with
* neither the `uuid:` prefix nor any device suffix.
*
* To compare any pair of those, strip the prefix and the suffix so
* everything reduces to the underlying speaker identity.
*/
internal fun String.bareUdn(): String = removePrefix("uuid:")
.removeSuffix("_MR")
.removeSuffix("_MS")
@@ -7,10 +7,10 @@ import okhttp3.HttpUrl
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
* SSDP details so the picker UI consumes a narrow domain shape.
*
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds
* (group join/leave, zone topology) live in a separate Sonos extension
* scoped in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md.
* Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the
* ZoneGroupTopology service in their device description; non-Sonos devices
* leave it null. The discovery controller uses that URL to aggregate stereo
* pairs and multi-speaker groups into single picker rows.
*
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
* raw `<friendlyName>` straight from the device description — callers
@@ -24,4 +24,5 @@ data class UpnpRoute(
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
)
@@ -0,0 +1,90 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* One Sonos zone group as exposed by ZoneGroupTopology.GetZoneGroupState.
* Stereo pairs and multi-speaker groups all appear as one group with
* multiple members; one member is the coordinator we send SOAP to.
*/
data class SonosZoneGroup(
val coordinatorUdn: String,
val name: String,
val members: List<SonosZoneMember>,
)
data class SonosZoneMember(
val udn: String,
val roomName: String,
val location: String?,
val channelMapSet: String?,
)
object SonosTopology {
fun parse(xml: String): List<SonosZoneGroup> {
val effective = if (xml.contains("&lt;ZoneGroup")) {
unescapeXmlEntities(xml)
} else {
xml
}
return runCatching { parseStrict(effective) }.getOrDefault(emptyList())
}
private fun unescapeXmlEntities(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&") // must be last to avoid double-decoding
private fun parseStrict(xml: String): List<SonosZoneGroup> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(xml.reader())
}
val groups = mutableListOf<SonosZoneGroup>()
var currentCoordinator: String? = null
var currentMembers: MutableList<SonosZoneMember>? = null
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.name) {
TAG_ZONE_GROUP -> {
currentCoordinator = parser.getAttributeValue(null, ATTR_COORDINATOR)
currentMembers = mutableListOf()
}
TAG_ZONE_GROUP_MEMBER -> currentMembers?.add(memberOf(parser))
}
XmlPullParser.END_TAG -> if (parser.name == TAG_ZONE_GROUP) {
val members = currentMembers ?: emptyList()
val coord = currentCoordinator
if (coord != null && members.isNotEmpty()) {
val name = members.firstOrNull { it.udn == coord }?.roomName
?: members.first().roomName
groups.add(SonosZoneGroup(coord, name, members))
}
currentCoordinator = null
currentMembers = null
}
}
parser.next()
}
return groups
}
private fun memberOf(parser: XmlPullParser): SonosZoneMember = SonosZoneMember(
udn = parser.getAttributeValue(null, ATTR_UUID).orEmpty(),
roomName = parser.getAttributeValue(null, ATTR_ZONE_NAME).orEmpty(),
location = parser.getAttributeValue(null, ATTR_LOCATION),
channelMapSet = parser.getAttributeValue(null, ATTR_CHANNEL_MAP_SET),
)
private const val TAG_ZONE_GROUP = "ZoneGroup"
private const val TAG_ZONE_GROUP_MEMBER = "ZoneGroupMember"
private const val ATTR_COORDINATOR = "Coordinator"
private const val ATTR_UUID = "UUID"
private const val ATTR_ZONE_NAME = "ZoneName"
private const val ATTR_LOCATION = "Location"
private const val ATTR_CHANNEL_MAP_SET = "ChannelMapSet"
}
@@ -0,0 +1,37 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import okhttp3.HttpUrl
import timber.log.Timber
/**
* Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a
* standard UPnP service, exposed on port 1400 at
* /ZoneGroupTopology/Control. GetZoneGroupState returns the full
* network topology as one XML doc wrapped inside a SOAP arg.
*/
class ZoneGroupTopologyClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun getZoneGroupState(): List<SonosZoneGroup> {
val args = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetZoneGroupState",
args = emptyMap(),
)
val inner = args["ZoneGroupState"].orEmpty()
Timber.w(
"ZGT extracted ZoneGroupState (%d chars): %s",
inner.length,
inner.take(ZGT_LOG_TRUNCATE_CHARS),
)
return SonosTopology.parse(inner)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
const val ZGT_LOG_TRUNCATE_CHARS = 2048
}
}
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -164,6 +165,7 @@ fun MiniPlayer(
MiniRow(
track = track,
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
isLiked = isLiked,
onExpandClick = onExpandClick,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
private fun MiniRow(
track: TrackRef,
isPlaying: Boolean,
isUpnpLoading: Boolean,
isLiked: Boolean,
onExpandClick: () -> Unit,
onPlayPause: () -> Unit,
@@ -248,15 +251,39 @@ private fun MiniRow(
}
LikeButton(liked = isLiked, onToggle = onToggleLike)
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
TransportButton(
icon = if (isPlaying) Lucide.Pause else Lucide.Play,
description = if (isPlaying) "Pause" else "Play",
MiniPlayPauseButton(
isPlaying = isPlaying,
isUpnpLoading = isUpnpLoading,
onClick = onPlayPause,
)
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext)
}
}
@Composable
private fun MiniPlayPauseButton(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(MINI_PLAY_PAUSE_SPINNER_DP.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
private const val MINI_PLAY_PAUSE_SPINNER_DP = 24
@Composable
private fun TransportButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -50,10 +52,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -74,11 +85,13 @@ import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.DeviceChip
import com.fabledsword.minstrel.player.output.OutputPickerSheet
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
import com.fabledsword.minstrel.player.output.OutputRoute
import com.fabledsword.minstrel.player.output.RouteSnapshot
import kotlinx.coroutines.launch
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
@@ -129,27 +142,30 @@ fun NowPlayingScreen(
snackbarHostState.showSnackbar(msg)
}
}
LaunchedEffect(Unit) {
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
}
val track = state.currentTrack
if (track == null) {
// Session torn down (queue finished + auto-stop, or user cleared
// the queue from elsewhere). Pop back to whichever shell screen
// launched NowPlaying rather than stranding the user on an
// EmptyState with no escape. A short delay swallows the
// momentary null during MediaController IPC bind on cold-mount.
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(POP_GRACE_MS)
if (viewModel.uiState.value.currentTrack == null) {
navController.popBackStack()
}
}
NowPlayingNullTrackGuard(navController, viewModel)
return
}
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle()
val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() }
val dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() },
)
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
modifier = Modifier
.fillMaxSize()
.nestedScroll(dismissConnection)
.focusRequester(focusRequester)
.focusable()
.onKeyEvent(onKeyEvent),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
@@ -166,11 +182,32 @@ fun NowPlayingScreen(
navController = navController,
viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel,
outputViewModel = outputViewModel,
)
}
}
}
/**
* Null-track guard extracted from [NowPlayingScreen] to keep that
* function under detekt's LongMethod ceiling. Session torn down
* (queue finished + auto-stop, or user cleared the queue from
* elsewhere). Pops back after a short grace delay so a momentary
* null during MediaController IPC bind on cold-mount doesn't flash.
*/
@Composable
private fun NowPlayingNullTrackGuard(
navController: NavHostController,
viewModel: PlayerViewModel,
) {
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(POP_GRACE_MS)
if (viewModel.uiState.value.currentTrack == null) {
navController.popBackStack()
}
}
}
@Composable
private fun dominantGradient(top: Color): Brush {
val base = MaterialTheme.colorScheme.background
@@ -270,6 +307,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
)
}
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
@Composable
private fun NowPlayingBody(
inner: androidx.compose.foundation.layout.PaddingValues,
@@ -278,10 +316,10 @@ private fun NowPlayingBody(
navController: NavHostController,
viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
outputViewModel: OutputPickerViewModel,
) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
@@ -387,6 +425,7 @@ private fun PlaybackControlsBlock(
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
@@ -667,6 +706,7 @@ private fun ScrubTrack(fraction: Float, accent: Color) {
@Composable
private fun TransportRow(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onPrev: () -> Unit,
onPlayPause: () -> Unit,
onNext: () -> Unit,
@@ -685,13 +725,20 @@ private fun TransportRow(
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
strokeWidth = 3.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
}
}
IconButton(onClick = onNext) {
Icon(
@@ -703,3 +750,40 @@ private fun TransportRow(
}
}
}
/**
* Returns a key-event handler that intercepts volume-up/down when a UPnP
* route is active and routes the step through [ActiveUpnp.rendering].
* Volume is cached locally so rapid key presses don't each wait on a
* getVolume() round-trip. Returns false (not consumed) for every event
* when no UPnP route is active so the system handles volume normally.
*/
@Composable
private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope()
val cache = remember(activeUpnp?.routeId) { VolumeCache() }
return remember(activeUpnp) {
handler@{ event: KeyEvent ->
val rc = activeUpnp?.rendering ?: return@handler false
if (event.type != KeyEventType.KeyDown) return@handler false
val delta = when (event.key) {
Key.VolumeUp -> VOLUME_KEY_STEP
Key.VolumeDown -> -VOLUME_KEY_STEP
else -> return@handler false
}
scope.launch {
val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0
val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT)
cache.value = next
runCatching { rc.setVolume(next) }
}
true
}
}
}
private class VolumeCache(var value: Int? = null)
private const val VOLUME_KEY_STEP = 5
private const val VOLUME_MIN_PERCENT = 0
private const val VOLUME_MAX_PERCENT = 100
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor(
) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
fun play() = controller.play()
fun pause() = controller.pause()
@@ -0,0 +1,71 @@
package com.fabledsword.minstrel.playlists.data
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.api.ErrorCopy
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
/**
* Fetch a playlist and hand it to the player as a shuffled queue.
*
* Behavior matches the tile play-button contract used on Home and the
* Playlists list: refreshable system playlists go through systemShuffle
* (server-side rotation-aware order; tagging with the variant advances
* rotation), everything else uses refreshDetail. System playlists are
* then client-side shuffled so the tile feels random rather than
* "start at the rotation head". User playlists keep their authored
* order.
*
* Errors and empty mixes surface through [onMessage] for the caller to
* present as a snackbar / toast / etc. Returns when the player has
* accepted the queue (or an error path bailed).
*/
suspend fun playPlaylistShuffled(
playlist: PlaylistRef,
repository: PlaylistsRepository,
player: PlayerController,
onMessage: (String) -> Unit,
) {
val detail = fetchPlaylistDetail(playlist, repository, onMessage) ?: return
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
onMessage("Mix isn't ready yet - try again in a moment")
return
}
// Drift #564: bare systemVariant string (not "playlist:<variant>") --
// server's rotation matcher keys on the bare variant.
val source = if (playlist.refreshable) playlist.systemVariant else null
// System playlist tile play button == "pick a random song + shuffle
// the rest" UX. Server's rotation-aware order still drives rotation
// bookkeeping via `source`; the client shuffle just removes the
// deterministic "start at rotation head" feel.
val ordered = if (playlist.isSystem) tracks.shuffled() else tracks
player.setQueue(ordered, initialIndex = 0, source = source)
}
private suspend fun fetchPlaylistDetail(
playlist: PlaylistRef,
repository: PlaylistsRepository,
onMessage: (String) -> Unit,
): PlaylistDetailRef? = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
repository.systemShuffle(playlist.systemVariant)
} else {
repository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: TimeoutCancellationException,
) {
onMessage("Couldn't load playlist - check your connection")
null
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}")
null
}
@@ -7,6 +7,7 @@ import retrofit2.HttpException
import java.net.HttpURLConnection
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
/** Owned + public; UI splits by isSystem / userId comparison. */
/**
* Owned + public; UI splits by isSystem / userId comparison. Joined with
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
* greying without each consumer re-querying the cache index.
*/
fun observeAll(): Flow<List<PlaylistRef>> =
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
combine(
playlistDao.observeAll(),
playlistDao.observeCachedCounts(),
) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
/** User-owned playlists only (systemVariant IS NULL). */
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
/**
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
* from the cache-index counts. A playlist is fully cached when it has tracks
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
* of the Flow so the predicate is unit-testable without a Room database.
*/
internal fun mergePlaylistsWithCache(
rows: List<CachedPlaylistEntity>,
counts: List<PlaylistCachedCount>,
): List<PlaylistRef> {
val cachedById = counts.associate { it.playlistId to it.cachedCount }
return rows.map { row ->
val cached = cachedById[row.id] ?: 0
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
}
}
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
PlaylistRef(
id = id,
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -23,11 +27,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
@@ -37,12 +45,19 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
// ─── ViewModel ───────────────────────────────────────────────────────
@@ -50,9 +65,25 @@ import javax.inject.Inject
@HiltViewModel
class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val player: PlayerController,
private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val poolMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from playlist tile play taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
val offline: StateFlow<Boolean> = networkStatus.state
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
init {
refresh()
// Live updates: a playlist created/updated/deleted from another
@@ -69,6 +100,15 @@ class PlaylistsListViewModel @Inject constructor(
runCatching { repository.refreshList() }
}
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
playPlaylistShuffled(playlist, repository, player) {
poolMessages.trySend(it)
}
}.join()
}
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll()
.map { list ->
@@ -88,6 +128,10 @@ fun PlaylistsListScreen(
navController: NavHostController,
viewModel: PlaylistsListViewModel = hiltViewModel(),
) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -97,8 +141,10 @@ fun PlaylistsListScreen(
currentRouteName = Playlists::class.qualifiedName,
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
@@ -117,7 +163,9 @@ fun PlaylistsListScreen(
)
is UiState.Success -> PlaylistsGrid(
playlists = s.data,
offline = offline,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onPlay = viewModel::playPlaylist,
)
}
}
@@ -127,7 +175,9 @@ fun PlaylistsListScreen(
@Composable
private fun PlaylistsGrid(
playlists: List<PlaylistRef>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlay: suspend (PlaylistRef) -> Unit,
) {
val systemPlaylists = playlists.filter { it.isSystem }
val userPlaylists = playlists.filter { !it.isSystem }
@@ -142,7 +192,16 @@ private fun PlaylistsGrid(
SectionHeader("System playlists")
}
items(items = systemPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
// Greyed offline when it needs the live server or isn't fully
// cached — dimmed but still tappable into the detail.
val greyed = offline && playlist.unavailableOffline
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
playEnabled = playlist.trackCount > 0 && !greyed,
greyed = greyed,
)
}
}
if (userPlaylists.isNotEmpty()) {
@@ -150,7 +209,14 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists")
}
items(items = userPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
val greyed = offline && playlist.unavailableOffline
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
playEnabled = playlist.trackCount > 0 && !greyed,
greyed = greyed,
)
}
}
}
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
* uses it for system playlists in offline mode (their server-side
* shuffle endpoint is unreachable) and for empty playlists.
*
* [greyed] dims the whole tile (it can't be reliably played offline) while
* keeping it tappable — the detail screen is the escape hatch for shuffling
* whatever subset is cached. Greyed always disables the play overlay too.
*/
@Composable
fun PlaylistCard(
@@ -48,11 +53,13 @@ fun PlaylistCard(
modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null,
playEnabled: Boolean = true,
greyed: Boolean = false,
) {
val seedCache = LocalDetailSeedCache.current
Surface(
modifier = modifier
.width(176.dp)
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
.clickable {
seedCache.stashPlaylist(playlist)
onClick()
@@ -63,7 +70,7 @@ fun PlaylistCard(
PlaylistCardCover(
playlist = playlist,
onPlay = onPlay,
playEnabled = playEnabled,
playEnabled = playEnabled && !greyed,
)
Spacer(Modifier.height(8.dp))
Text(
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
}
private const val PILL_BG_ALPHA = 0.85f
private const val GREYED_ALPHA = 0.45f
private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.requests.data.CancelOutcome
import com.fabledsword.minstrel.requests.data.RequestsRepository
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -20,6 +22,10 @@ import javax.inject.Inject
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
// Cadence for in-flight request polling — matches the web client (#369):
// fast enough to feel live, slow enough not to hammer the server.
private const val POLL_INTERVAL_MS = 12_000L
@HiltViewModel
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository,
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
init {
refresh()
// SSE push: reconciler completions arrive here; reload silently so the
// row updates in place rather than flashing the loading spinner.
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
.collect { silentReload() }
}
viewModelScope.launch { pollWhileInFlight() }
}
fun refresh(): Job = viewModelScope.launch {
@@ -54,6 +63,43 @@ class RequestsViewModel @Inject constructor(
}
}
/**
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
* when nothing is in-flight — complements the SSE push so progress stays
* live even if the event stream drops.
*/
private suspend fun pollWhileInFlight() {
while (true) {
if (hasInFlightRequest()) {
silentReload()
}
delay(POLL_INTERVAL_MS)
}
}
private fun hasInFlightRequest(): Boolean {
val state = internal.value
return state is UiState.Success &&
state.data.any { it.status == RequestStatus.APPROVED }
}
/**
* Reload the list in place without flipping to Loading — used by the poll
* loop and the SSE collector so visible rows update without a spinner
* flash. A transient failure keeps the current view; the next reload wins.
*/
private suspend fun silentReload() {
try {
val rows = repository.listMine()
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Keep the current view; the next poll / SSE / pull reconciles.
}
}
/**
* Optimistically removes [id] from the success list so the row
* disappears immediately; on server-side success the row is
@@ -1,6 +1,11 @@
package com.fabledsword.minstrel.search.data
import com.fabledsword.minstrel.api.endpoints.SearchApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit
@@ -8,17 +13,35 @@ import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20
/**
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in
* the ViewModel, not here, so the repository stays trivial.
* Cache-first when offline. When [NetworkStatusController] reports Healthy or
* Unstable the repository hits `/api/search` and returns the server's three-facet
* paged response. When health is Offline or ServerDown it falls back to
* Room LIKE queries against `cached_*` so the user can still find
* something to play from what's already on the device; the outcome's
* [SearchOutcome.localOnly] flag lets the screen draw an "offline
* results" hint instead of pretending the server answered.
*/
@Singleton
class SearchRepository @Inject constructor(
retrofit: Retrofit,
private val serverHealth: NetworkStatusController,
private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
) {
private val api: SearchApi = retrofit.create()
suspend fun search(query: String): SearchResponseRef {
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
ServerHealth.Healthy, ServerHealth.Unstable ->
SearchOutcome(remoteSearch(query), localOnly = false)
ServerHealth.Offline, ServerHealth.ServerDown ->
SearchOutcome(localSearch(query), localOnly = true)
}
private suspend fun remoteSearch(query: String): SearchResponseRef {
val wire = api.search(query)
return SearchResponseRef(
artists = wire.artists.items.map { it.toDomain() },
@@ -26,4 +49,21 @@ class SearchRepository @Inject constructor(
tracks = wire.tracks.items.map { it.toDomain() },
)
}
private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef(
artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
)
}
/**
* Wraps the search response with the signal of whether the result came
* from the server or from the local cached entities. The screen renders
* the same SearchResponseRef either way; the flag drives the offline
* banner copy.
*/
data class SearchOutcome(
val response: SearchResponseRef,
val localOnly: Boolean,
)
@@ -158,17 +158,28 @@ private fun ResultsPane(
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
is SearchResultsState.Loaded -> {
if (state.response.isEmpty) {
CenteredHint("No matches for that query.")
} else {
ResultsList(
response = state.response,
playingTrackId = playingTrackId,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
CenteredHint(
if (state.localOnly) {
"No matches in your on-device library."
} else {
"No matches for that query."
},
)
} else {
Column(modifier = Modifier.fillMaxSize()) {
if (state.localOnly) {
OfflineResultsHint()
}
ResultsList(
response = state.response,
playingTrackId = playingTrackId,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
}
}
@@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) {
}
}
@Composable
private fun OfflineResultsHint() {
Text(
text = "Showing on-device matches only — the server is unreachable.",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun CenteredHint(text: String) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
@@ -26,7 +26,10 @@ sealed interface SearchResultsState {
/** Empty query — screen shows "type to search" hint. */
data object Idle : SearchResultsState
data object Loading : SearchResultsState
data class Loaded(val response: SearchResponseRef) : SearchResultsState
data class Loaded(
val response: SearchResponseRef,
val localOnly: Boolean = false,
) : SearchResultsState
data class Error(val message: String) : SearchResultsState
}
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
private suspend fun runSearch(q: String) {
internal.update { it.copy(results = SearchResultsState.Loading) }
try {
val response = repository.search(q)
internal.update { it.copy(results = SearchResultsState.Loaded(response)) }
val outcome = repository.search(q)
internal.update {
it.copy(
results = SearchResultsState.Loaded(
response = outcome.response,
localOnly = outcome.localOnly,
),
)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* Routes a deliberate pull-to-refresh into a /healthz recheck so the connection
* banner clears within seconds rather than waiting for the next poll — even on
* cache-only screens whose own refresh never touches the network. Backs
* [PullToRefreshScaffold].
*/
@HiltViewModel
class PullRefreshNetworkViewModel @Inject constructor(
private val networkStatus: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
fun recheck() = networkStatus.recheck()
}
@@ -10,12 +10,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
/**
* Wraps [content] in a Material3 PullToRefreshBox so the user can
* swipe-down to trigger [onRefresh]. The wrapper manages the
* `isRefreshing` indicator while [onRefresh] is in flight.
* `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
* fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
* connection banner clears promptly on a deliberate user refresh.
*
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
* indicator hides exactly when the underlying coroutine completes,
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
fun PullToRefreshScaffold(
onRefresh: suspend () -> Unit,
modifier: Modifier = Modifier,
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
var isRefreshing by remember { mutableStateOf(false) }
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
scope.launch {
isRefreshing = true
try {
netVm.recheck() // deliberate pull → re-probe the server now
onRefresh()
} finally {
isRefreshing = false
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
@@ -48,6 +49,7 @@ fun ShellScaffold(
modifier: Modifier = Modifier,
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
@@ -61,6 +63,11 @@ fun ShellScaffold(
snackbarHostState.showSnackbar(msg)
}
}
LaunchedEffect(Unit) {
offlineWriteHintViewModel.messages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
// Consume the status-bar inset once here so the banner stack sits
// below the status bar (mirrors Flutter's SafeArea(bottom:false)).
// statusBarsPadding consumes the inset for descendants, so the in-
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.shared.widgets
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
private const val OFFLINE_UNAVAILABLE_ALPHA = 0.4f
private const val OFFLINE_TAP_MESSAGE = "Not downloaded — connect to play"
/**
* Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every
@@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp
* for applying its own alpha if it should match (the row doesn't
* cascade because the trailing slot's content is the caller's, not
* ours).
*
* Reads [LocalServerHealth] + [LocalCachedTrackIds] and intercepts taps
* on tracks that aren't downloaded when the server is unreachable —
* fires a Toast instead of attempting playback. The text dims so the
* user can see at a glance which rows in a long list will work offline.
* The trailing slot stays interactive so the kebab / like / playlist-
* add affordances can still queue mutations for offline replay (Phase
* 5 of #618 gates those at the action level).
*/
@Composable
fun TrackRow(
@@ -45,15 +60,31 @@ fun TrackRow(
leading: @Composable () -> Unit = {},
trailing: @Composable RowScope.() -> Unit = {},
) {
val context = LocalContext.current
val health = LocalServerHealth.current
// Unstable is non-gating — only a real gating state dims uncached rows.
val offlineUnavailable =
(health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
trackId !in LocalCachedTrackIds.current
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
val effectiveAlpha = if (offlineUnavailable) {
minOf(contentAlpha, OFFLINE_UNAVAILABLE_ALPHA)
} else {
contentAlpha
}
val effectiveOnClick: () -> Unit = if (offlineUnavailable) {
{ Toast.makeText(context, OFFLINE_TAP_MESSAGE, Toast.LENGTH_SHORT).show() }
} else {
onClick
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.clickable(enabled = enabled, onClick = effectiveOnClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement,
@@ -63,7 +94,7 @@ fun TrackRow(
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = titleColor.copy(alpha = contentAlpha),
color = titleColor.copy(alpha = effectiveAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -71,7 +102,7 @@ fun TrackRow(
Text(
text = artist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -7,8 +7,8 @@ import retrofit2.http.GET
/**
* Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
* to surface the VersionTooOld banner.
* it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
* for both reachability and the VersionTooOld banner.
*/
interface HealthzApi {
@GET("healthz")
@@ -1,67 +0,0 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
/**
* Result of the most recent /healthz version-compatibility check.
* `Skipped` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
/**
* Polls /healthz periodically and exposes the current
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
* on network errors — keeps the last-known result rather than
* showing a misleading "too old" on a connectivity blip.
*
* Constructed at app launch via the construct-the-singleton trick
* in [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class VersionCheckController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val internal = MutableStateFlow(VersionResult.SKIPPED)
val result: StateFlow<VersionResult> = internal.asStateFlow()
init {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
fun recheck() {
scope.launch { runOnce() }
}
private suspend fun runOnce() {
val response = runCatching { api.check() }.getOrNull() ?: return
val min = response.minClientVersion
internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
@@ -0,0 +1,8 @@
package com.fabledsword.minstrel.update.data
/**
* Result of the most recent /healthz version-compatibility check.
* `SKIPPED` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.update.data.VersionCheckController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.update.data.VersionResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* Tiny VM that exposes the [VersionCheckController]'s current
* Tiny VM that exposes the [NetworkStatusController]'s current
* [VersionResult] plus its recheck trigger for the banner button.
*/
@HiltViewModel
class VersionTooOldViewModel @Inject constructor(
private val controller: VersionCheckController,
private val controller: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val result: StateFlow<VersionResult> = controller.result
val result: StateFlow<VersionResult> = controller.versionResult
fun recheck() = controller.recheck()
}
@@ -0,0 +1,116 @@
package com.fabledsword.minstrel.connectivity
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReachabilityMachineTest {
private fun machine() = ReachabilityMachine()
@Test
fun `starts healthy`() {
val m = machine()
m.onLinkChange(up = true)
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `no link is offline regardless of probes`() {
val m = machine()
m.onLinkChange(up = false)
m.onProbeFailure(nowMs = 1)
assertEquals(ServerHealth.Offline, m.health())
}
@Test
fun `single probe failure is unstable not down`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
assertEquals(ServerHealth.Unstable, m.health())
}
@Test
fun `probe success from unstable recovers to healthy`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
m.onSuccess()
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `op success from unstable recovers to healthy`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
m.onSuccess() // a successful stream read / API 2xx is self-proving
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `two op failures plus a failed probe escalate immediately`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500) // corroboration reached
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
assertEquals(ServerHealth.ServerDown, m.health())
}
@Test
fun `op failures with a successful probe stay healthy (track-specific)`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500)
m.onSuccess() // arbiter says server is fine
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `stale op failures do not corroborate`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 0)
m.onOpFailure(nowMs = 1_000)
// both op failures are now older than the corroboration window:
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
}
@Test
fun `sustained failure backstop escalates after the window`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
assertEquals(ServerHealth.ServerDown, m.health())
}
@Test
fun `link restored keeps last-known down until a fresh result`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
m.onLinkChange(up = false)
assertEquals(ServerHealth.Offline, m.health())
m.onLinkChange(up = true)
// link back but no fresh probe result yet — last known reachability was down:
assertEquals(ServerHealth.ServerDown, m.health())
m.onSuccess()
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `unstable does not gate — it is not offline or serverdown`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
val health = m.health()
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
assertEquals(ServerHealth.Unstable, health)
}
}
@@ -0,0 +1,59 @@
package com.fabledsword.minstrel.home.ui
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BuildPlaylistsRowTest {
private fun user(id: String, cached: Boolean) =
PlaylistRef(id = id, userId = "u", name = id, trackCount = 1, fullyCached = cached)
@Test
fun `offline row leads with pools then available before greyed`() {
val owned = listOf(user("partial", cached = false), user("full", cached = true))
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = true)
assertTrue(row[0] is PlaylistRowItem.OfflinePool)
assertTrue(row[1] is PlaylistRowItem.OfflinePool)
// Fully-cached "full" comes before the greyed "partial".
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals(listOf("full", "partial"), reals)
}
@Test
fun `offline greys a refreshable system playlist even when fully cached`() {
val forYou = PlaylistRef(
id = "fy",
userId = "u",
name = "For You",
systemVariant = "for_you",
trackCount = 1,
fullyCached = true,
)
val row = buildPlaylistsRow(
listOf(forYou, user("u1", cached = true)),
SystemPlaylistsStatus(),
offline = true,
)
// The fully-cached user playlist is available; the refreshable system
// mix needs the server, so it greys out and sorts after.
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals(listOf("u1", "fy"), reals)
}
@Test
fun `offline row drops building-pending placeholders`() {
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = true)
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
}
@Test
fun `online row has no offline pools and keeps system-slot placeholders`() {
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = false)
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
}
}
@@ -0,0 +1,101 @@
package com.fabledsword.minstrel.player
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RemotePlayerStateTest {
@Test
fun `starts in idle state`() {
val state = RemotePlayerState()
assertFalse(state.isPlaying)
assertEquals(0L, state.positionMs)
assertEquals(0L, state.durationMs)
}
@Test
fun `applyPositionInfo updates position, duration, and trackNumber`() {
val state = RemotePlayerState()
state.applyPositionInfo(
positionMs = 65_000L, durationMs = 210_000L, trackUri = "x", trackNumber = 3,
)
assertEquals(65_000L, state.positionMs)
assertEquals(210_000L, state.durationMs)
assertEquals("x", state.currentTrackUri)
assertEquals(3, state.trackNumber)
}
@Test
fun `applyTransportPlaying flips isPlaying true`() {
val state = RemotePlayerState()
state.applyTransportPlaying()
assertTrue(state.isPlaying)
}
@Test
fun `applyTransportPaused flips isPlaying false`() {
val state = RemotePlayerState().apply { applyTransportPlaying() }
state.applyTransportPaused()
assertFalse(state.isPlaying)
}
@Test
fun `applyError resets to idle and records error`() {
val state = RemotePlayerState().apply { applyTransportPlaying() }
val ex = RuntimeException("disconnected")
state.applyError(ex)
assertFalse(state.isPlaying)
assertEquals(ex, state.lastError)
}
@Test
fun `recordPollFailure trips after threshold`() {
val state = RemotePlayerState()
// Threshold is 30 -- tolerate ~30s of screen-off WiFi sleep before
// declaring the remote dropped. Pre-threshold calls all return false.
repeat(DROP_THRESHOLD - 1) {
assertFalse(state.recordPollFailure())
}
assertTrue(state.recordPollFailure())
}
@Test
fun `recordPollSuccess clears the failure counter`() {
val state = RemotePlayerState()
repeat(DROP_THRESHOLD - 1) { state.recordPollFailure() }
state.recordPollSuccess()
assertFalse(state.recordPollFailure())
}
@Test
fun `play intent survives a SOAP error so a failed play can resume locally`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.applyError(RuntimeException("connect timeout"))
// isPlaying is cleared by the error, but the operator's intent persists.
assertFalse(state.isPlaying)
assertTrue(state.lastPlayIntent)
}
@Test
fun `pausing clears play intent`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.setPlayIntent(false)
assertFalse(state.lastPlayIntent)
}
@Test
fun `reset clears play intent`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.reset()
assertFalse(state.lastPlayIntent)
}
private companion object {
const val DROP_THRESHOLD = 30
}
}
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* Locks in the transport-command retry semantics behind the locked-phone
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
* SOAP fault from a responding renderer must NOT be retried, and an
* exhausted retry must rethrow rather than loop forever. Without this, a
* single failed command falsely reverted Sonos playback to the phone.
*/
class RetryTransientIoTest {
@Test
fun `returns immediately on first success`() = runTest {
var calls = 0
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
"ok"
}
assertEquals("ok", result)
assertEquals(1, calls)
}
@Test
fun `retries transient IO failure then succeeds`() = runTest {
var calls = 0
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
if (calls < 2) throw IOException("connect timeout")
"ok"
}
assertEquals("ok", result)
assertEquals(2, calls)
}
@Test
fun `rethrows IO failure after exhausting attempts`() = runTest {
var calls = 0
assertFailsWith<IOException> {
retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
throw IOException("still asleep")
}
}
assertEquals(3, calls)
}
@Test
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
var calls = 0
assertFailsWith<SoapFaultException> {
retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
throw SoapFaultException("718", "Invalid InstanceID")
}
}
assertEquals(1, calls)
}
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.player.output
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class IdleRevertDecisionTest {
@Test
fun `engaged and paused and not armed arms the timer`() {
assertEquals(
IdleRevertAction.ARM,
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = false),
)
}
@Test
fun `engaged and paused and already armed is ignored`() {
assertEquals(
IdleRevertAction.IGNORE,
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = true),
)
}
@Test
fun `resuming playback cancels an armed timer`() {
assertEquals(
IdleRevertAction.CANCEL,
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = true),
)
}
@Test
fun `playing with no timer armed is ignored`() {
assertEquals(
IdleRevertAction.IGNORE,
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = false),
)
}
@Test
fun `disengaging UPnP cancels an armed timer`() {
assertEquals(
IdleRevertAction.CANCEL,
idleRevertAction(upnpEngaged = false, isPlaying = false, armed = true),
)
}
@Test
fun `disengaged with no timer armed is ignored`() {
assertEquals(
IdleRevertAction.IGNORE,
idleRevertAction(upnpEngaged = false, isPlaying = true, armed = false),
)
}
}
@@ -0,0 +1,194 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class AVTransportClientTest {
private lateinit var server: MockWebServer
private lateinit var client: AVTransportClient
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
}
@AfterEach
fun tearDown() {
server.shutdown()
}
@Test
fun `seek sends Target in HH MM SS format`() = runTest {
server.enqueue(emptyResponse("Seek"))
client.seek(positionMs = 65_000L)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<Target>0:01:05</Target>")) { "body was $body" }
}
@Test
fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetPositionInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<Track>3</Track>
<TrackDuration>0:03:30</TrackDuration>
<TrackURI>http://x/y.mp3</TrackURI>
<RelTime>0:01:05</RelTime>
</u:GetPositionInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getPositionInfo()
assertEquals(3, info.track)
assertEquals(65_000L, info.relTimeMs)
assertEquals(210_000L, info.trackDurationMs)
assertEquals("http://x/y.mp3", info.trackUri)
}
@Test
fun `getTransportInfo maps PAUSED_PLAYBACK to PAUSED`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetTransportInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentTransportState>PAUSED_PLAYBACK</CurrentTransportState>
<CurrentTransportStatus>OK</CurrentTransportStatus>
<CurrentSpeed>1</CurrentSpeed>
</u:GetTransportInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getTransportInfo()
assertEquals(TransportState.PAUSED, info.state)
}
@Test
fun `getTransportInfo maps unknown state to UNKNOWN`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetTransportInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentTransportState>BUFFERING_PLAYBACK</CurrentTransportState>
<CurrentTransportStatus>OK</CurrentTransportStatus>
<CurrentSpeed>1</CurrentSpeed>
</u:GetTransportInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getTransportInfo()
assertEquals(TransportState.UNKNOWN, info.state)
}
@Test
fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest {
server.enqueue(emptyResponse("RemoveAllTracksFromQueue"))
client.removeAllTracksFromQueue()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("RemoveAllTracksFromQueue")) {
"SOAPACTION header missing action: ${request.getHeader("SOAPACTION")}"
}
val body = request.body.readUtf8()
assertTrue(body.contains("RemoveAllTracksFromQueue")) { "body: $body" }
}
@Test
fun `addURIToQueue sends EnqueuedURI and DIDL-Lite metadata`() = runTest {
server.enqueue(emptyResponse("AddURIToQueue"))
client.addURIToQueue(
uri = "http://x/y.mp3",
mime = "audio/mpeg",
title = "Song",
enqueuedURIPosition = 2,
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<EnqueuedURI>http://x/y.mp3</EnqueuedURI>")) {
"body missing EnqueuedURI: $body"
}
val positionTag = "<DesiredFirstTrackNumberEnqueued>2</DesiredFirstTrackNumberEnqueued>"
assertTrue(body.contains(positionTag)) { "body missing position: $body" }
assertTrue(body.contains("&lt;dc:title&gt;Song&lt;/dc:title&gt;")) {
"title missing in DIDL: $body"
}
assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" }
}
@Test
fun `next sends Next SOAP action`() = runTest {
server.enqueue(emptyResponse("Next"))
client.next()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Next\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `previous sends Previous SOAP action`() = runTest {
server.enqueue(emptyResponse("Previous"))
client.previous()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Previous\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `seekToTrack sends TRACK_NR unit with 1-based target`() = runTest {
server.enqueue(emptyResponse("Seek"))
client.seekToTrack(trackNumber = 4)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<Unit>TRACK_NR</Unit>")) { "body: $body" }
assertTrue(body.contains("<Target>4</Target>")) { "body: $body" }
}
@Test
fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI"))
client.setAVTransportURIWithMetadata(
uri = "https://example.com/track.mp3",
mime = "audio/mpeg",
title = "Song",
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<CurrentURI>https://example.com/track.mp3</CurrentURI>")) {
"CurrentURI should be sent as plain https: $body"
}
assertFalse(body.contains("x-rincon-mp3radio")) {
"x-rincon-mp3radio scheme should not appear: $body"
}
}
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:${action}Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
</s:Body>
</s:Envelope>""".trimIndent(),
)
}
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.player.output.upnp
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class BareUdnTest {
@Test
fun `strips uuid prefix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC".bareUdn())
}
@Test
fun `strips MR suffix`() {
assertEquals("RINCON_ABC", "RINCON_ABC_MR".bareUdn())
}
@Test
fun `strips MS suffix`() {
assertEquals("RINCON_ABC", "RINCON_ABC_MS".bareUdn())
}
@Test
fun `strips both uuid prefix and MR suffix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MR".bareUdn())
}
@Test
fun `strips both uuid prefix and MS suffix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MS".bareUdn())
}
@Test
fun `leaves already bare UDN unchanged`() {
assertEquals("RINCON_ABC", "RINCON_ABC".bareUdn())
}
@Test
fun `MR suffix comparison crosses MediaRenderer vs ZGT response`() {
val routeId = "uuid:RINCON_5CAAFD794B6401400_MR"
val zgtMemberUdn = "RINCON_5CAAFD794B6401400"
assertEquals(routeId.bareUdn(), zgtMemberUdn.bareUdn())
}
}
@@ -58,6 +58,7 @@ class DeviceDescriptionTest {
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
desc.renderingControlUrl?.toString(),
)
assertNull(desc.zoneGroupTopologyControlUrl)
}
@Test
@@ -81,6 +82,37 @@ class DeviceDescriptionTest {
assertNull(DeviceDescription.parse(xml, base))
}
@Test
fun `parses ZoneGroupTopology control URL when present`() {
val xml = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<UDN>uuid:RINCON_XYZ</UDN>
<friendlyName>Office</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ZoneGroupTopology:1</serviceType>
<controlURL>/ZoneGroupTopology/Control</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
val desc = DeviceDescription.parse(xml, base)
assertNotNull(desc)
assertEquals(
"http://192.168.1.50:1400/ZoneGroupTopology/Control",
desc.zoneGroupTopologyControlUrl?.toString(),
)
}
@Test
fun `handles missing optional fields`() {
val xml = """
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
class RenderingControlClientTest {
private lateinit var server: MockWebServer
private lateinit var client: RenderingControlClient
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
client = RenderingControlClient(SoapClient(OkHttpClient()), server.url("/RC"))
}
@AfterEach
fun tearDown() { server.shutdown() }
@Test
fun `getVolume parses CurrentVolume`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<CurrentVolume>42</CurrentVolume>
</u:GetVolumeResponse>
</s:Body>
</s:Envelope>""".trimIndent()))
assertEquals(42, client.getVolume())
val request = server.takeRequest()
val body = request.body.readUtf8()
assertTrue(body.contains("<Channel>Master</Channel>")) { body }
assertEquals(
"\"urn:schemas-upnp-org:service:RenderingControl:1#GetVolume\"",
request.getHeader("SOAPACTION"),
)
}
@Test
fun `setVolume clamps and sends DesiredVolume`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:SetVolumeResponse
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
</s:Body>
</s:Envelope>""".trimIndent()))
client.setVolume(150)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<DesiredVolume>100</DesiredVolume>")) { body }
}
@Test
fun `setVolume clamps below VOLUME_MIN to zero`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:SetVolumeResponse
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
</s:Body>
</s:Envelope>""".trimIndent()))
client.setVolume(-5)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<DesiredVolume>0</DesiredVolume>")) { body }
}
}
@@ -0,0 +1,109 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class SonosTopologyTest {
@Test
fun `single zone group with one member`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
val g = groups.first()
assertEquals("RINCON_A", g.coordinatorUdn)
assertEquals("Kitchen", g.name)
assertEquals(1, g.members.size)
}
@Test
fun `stereo pair collapses to one group with two members`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_L" ID="RINCON_L:2">
<ZoneGroupMember UUID="RINCON_L" ZoneName="Living Room"
Location="http://192.168.1.11:1400/xml/device_description.xml"
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
<ZoneGroupMember UUID="RINCON_R" ZoneName="Living Room"
Location="http://192.168.1.12:1400/xml/device_description.xml"
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
val g = groups.first()
assertEquals("RINCON_L", g.coordinatorUdn)
assertEquals("Living Room", g.name)
assertEquals(2, g.members.size)
assertNotNull(g.members[0].channelMapSet)
}
@Test
fun `multi-speaker group lists coordinator name`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_X" ID="RINCON_X:3">
<ZoneGroupMember UUID="RINCON_X" ZoneName="Office"/>
<ZoneGroupMember UUID="RINCON_Y" ZoneName="Bedroom"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals("Office", groups.first().name)
}
@Test
fun `malformed xml returns empty list`() {
assertEquals(emptyList(), SonosTopology.parse("<garbage"))
}
@Test
fun `parses inline non-escaped ZoneGroupState document`() {
// Some Sonos firmware sends the topology as nested elements with
// no escaping. After SoapClient.readUntilEndTag rebuilds a flat
// string, parse should still find the groups.
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
}
@Test
fun `parses escaped ZoneGroupState wrapped in entities`() {
val xml = """
&lt;ZoneGroupState&gt;
&lt;ZoneGroups&gt;
&lt;ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1"&gt;
&lt;ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/&gt;
&lt;/ZoneGroup&gt;
&lt;/ZoneGroups&gt;
&lt;/ZoneGroupState&gt;
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
}
}
@@ -0,0 +1,64 @@
package com.fabledsword.minstrel.playlists.data
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PlaylistsRepositoryCacheMergeTest {
private fun playlist(id: String, trackCount: Int) =
CachedPlaylistEntity(id = id, userId = "u", name = id, trackCount = trackCount)
@Test
fun `fully cached when every member track is resident`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 3)),
counts = listOf(PlaylistCachedCount("p", cachedCount = 3)),
)
assertTrue(out.single().fullyCached)
}
@Test
fun `not fully cached when only some tracks are resident`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 3)),
counts = listOf(PlaylistCachedCount("p", cachedCount = 2)),
)
assertFalse(out.single().fullyCached)
}
@Test
fun `not fully cached when no tracks are resident (missing count row)`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 3)),
counts = emptyList(),
)
assertFalse(out.single().fullyCached)
}
@Test
fun `empty playlist is never fully cached`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 0)),
counts = listOf(PlaylistCachedCount("p", cachedCount = 0)),
)
assertFalse(out.single().fullyCached)
}
@Test
fun `each playlist gets its own cache verdict`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("full", 2), playlist("partial", 2)),
counts = listOf(
PlaylistCachedCount("full", cachedCount = 2),
PlaylistCachedCount("partial", cachedCount = 1),
),
)
val byId = out.associateBy { it.id }
assertEquals(true, byId.getValue("full").fullyCached)
assertEquals(false, byId.getValue("partial").fullyCached)
}
}
+48 -4
View File
@@ -5,12 +5,15 @@ import (
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
@@ -53,6 +56,35 @@ func main() {
}
}
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
// full delta walk, catching anything the watcher missed (inotify watch-limit
// exhaustion, dropped events). Fixed — there is no operator configuration.
const safetyNetScanInterval = 12 * time.Hour
// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx
// is cancelled, deferring to TryStartScan's in-flight guard so it never
// collides with a manual, startup, or watcher-driven scan.
func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner,
enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig,
) {
ticker := time.NewTicker(safetyNetScanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg)
if err != nil {
logger.Error("safety-net scan: try start failed", "err", err)
} else if !started && existing != nil {
logger.Info("safety-net scan skipped — prior still in flight",
"in_flight_id", existing.ID)
}
}
}
}
func run() error {
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
flag.Parse()
@@ -237,12 +269,24 @@ func run() error {
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
}
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
scanner, coverEnricher, scanCfg)
scheduler.Start(ctx)
// Filesystem watcher: near-instant pickup of new/changed files via
// fsnotify, scanning just the affected paths + enriching their albums
// inline. Replaces the removed configurable scan scheduler.
watcher := library.NewWatcher(scanner, coverEnricher,
logger.With("component", "watcher"), cfg.Library.ScanPaths)
go func() {
if werr := watcher.Run(ctx); werr != nil {
logger.Warn("library watcher exited", "err", werr)
}
}()
// Safety-net delta walk backstops the watcher (inotify limits / dropped
// events) at a fixed low frequency. No operator config.
go runSafetyNetScans(ctx, pool, scanner, coverEnricher,
logger.With("component", "scan_safetynet"), scanCfg)
srv := server.New(logger, pool, scanner, subsonic.Config{
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
srv.StreamSecret = cfg.StreamSecret
+2
View File
@@ -4,6 +4,7 @@ go 1.25.0
require (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.2.5
github.com/go-co-op/gocron/v2 v2.21.2
github.com/golang-migrate/migrate/v4 v4.19.1
@@ -25,5 +26,6 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
+2
View File
@@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
-155
View File
@@ -1,155 +0,0 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
type scanScheduleResp struct {
Mode string `json:"mode"`
IntervalHours *int `json:"interval_hours"`
TimeOfDay *string `json:"time_of_day"`
WeeklyDay *int `json:"weekly_day"`
NextScheduledAt *string `json:"next_scheduled_at"`
}
type scanScheduleReq struct {
Mode string `json:"mode"`
IntervalHours *int `json:"interval_hours"`
TimeOfDay *string `json:"time_of_day"`
WeeklyDay *int `json:"weekly_day"`
}
func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
row, err := q.GetScanSchedule(r.Context())
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
h.logger.Error("admin: scan_schedule singleton row missing")
writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing")))
return
}
writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire()))
}
func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) {
var req scanScheduleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if err := validateScheduleReq(&req); err != nil {
writeErr(w, apierror.BadRequest("validation", err.Error()))
return
}
q := dbq.New(h.pool)
if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{
Mode: req.Mode,
IntervalHours: pgInt32Ptr(req.IntervalHours),
TimeOfDay: req.TimeOfDay,
WeeklyDay: pgInt32Ptr(req.WeeklyDay),
}); err != nil {
writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err))
return
}
h.scheduler.Refresh()
row, err := q.GetScanSchedule(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err))
return
}
cfg := library.ScheduleConfig{Mode: row.Mode}
if row.IntervalHours != nil {
cfg.IntervalHours = int(*row.IntervalHours)
}
if row.TimeOfDay != nil {
cfg.TimeOfDay = *row.TimeOfDay
}
if row.WeeklyDay != nil {
cfg.WeeklyDay = int(*row.WeeklyDay)
}
next := cfg.NextFire(time.Now())
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next))
}
func validateScheduleReq(req *scanScheduleReq) error {
switch req.Mode {
case "off":
req.IntervalHours = nil
req.TimeOfDay = nil
req.WeeklyDay = nil
return nil
case "interval":
if req.IntervalHours == nil || *req.IntervalHours <= 0 {
return fmt.Errorf("interval_hours required and > 0 when mode=interval")
}
req.TimeOfDay = nil
req.WeeklyDay = nil
return nil
case "daily":
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
return fmt.Errorf("time_of_day required when mode=daily")
}
req.IntervalHours = nil
req.WeeklyDay = nil
return nil
case "weekly":
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
return fmt.Errorf("time_of_day required when mode=weekly")
}
if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 {
return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly")
}
req.IntervalHours = nil
return nil
default:
return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode)
}
}
func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp {
resp := scanScheduleResp{Mode: row.Mode}
if row.IntervalHours != nil {
v := int(*row.IntervalHours)
resp.IntervalHours = &v
}
if row.TimeOfDay != nil {
v := *row.TimeOfDay
resp.TimeOfDay = &v
}
if row.WeeklyDay != nil {
v := int(*row.WeeklyDay)
resp.WeeklyDay = &v
}
if !nextFire.IsZero() {
s := nextFire.UTC().Format(time.RFC3339)
resp.NextScheduledAt = &s
}
return resp
}
func pgInt32Ptr(src *int) *int32 {
if src == nil {
return nil
}
v := int32(*src)
return &v
}
-181
View File
@@ -1,181 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
func newAdminScheduleRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/scan/schedule", h.handleGetScanSchedule)
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
})
return r
}
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedget", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp scanScheduleResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Mode != "off" {
t.Errorf("Mode = %q, want off", resp.Mode)
}
if resp.NextScheduledAt != nil {
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
}
}
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedpatch", "pw", true)
body := `{"mode":"daily","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp scanScheduleResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Mode != "daily" {
t.Errorf("Mode = %q, want daily", resp.Mode)
}
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
}
if resp.NextScheduledAt == nil {
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
} else {
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
if err != nil {
t.Errorf("NextScheduledAt parse: %v", err)
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
// Handler computes next-fire from time.Now() in the host zone, so
// the local-time hour is what's stable across CI runners regardless
// of TZ; the wire timestamp is UTC but parses back to the same
// instant either way.
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
}
}
}
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedweekly", "pw", true)
body := `{"mode":"weekly","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedoff", "pw", true)
if _, err := pool.Exec(context.Background(),
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
); err != nil {
t.Fatalf("seed: %v", err)
}
body := `{"mode":"off","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var timeOfDay *string
if err := pool.QueryRow(context.Background(),
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
).Scan(&timeOfDay); err != nil {
t.Fatalf("verify: %v", err)
}
if timeOfDay != nil {
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
}
}
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
user := seedUser(t, pool, "schednonadmin", "pw", false)
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rec.Code)
}
}
+7 -5
View File
@@ -28,7 +28,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
@@ -42,7 +42,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
coverSettings: coverSettings,
scanner: scanner,
scanCfg: scanCfg,
scheduler: scheduler,
dataDir: dataDir,
mailer: sender,
eventbus: bus,
@@ -65,6 +64,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
// Extension-bearing alias so Sonos's URL probe can identify the
// audio format from the path. The {ext} param is consumed by chi
// and ignored by the handler (which keys off {id}). See task #610.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
@@ -83,6 +86,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist)
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
authed.Get("/albums/{id}", h.handleGetAlbum)
authed.Get("/albums/{id}/cover", h.handleGetCover)
authed.Get("/library/shuffle", h.handleLibraryShuffle)
@@ -163,8 +168,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/scan/status", h.handleGetScanStatus)
admin.Post("/scan/run", h.handleTriggerScan)
admin.Get("/scan/schedule", h.handleGetScanSchedule)
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
@@ -218,7 +221,6 @@ type handlers struct {
coverSettings *coverart.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
scheduler *library.Scheduler
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
+69 -3
View File
@@ -3,9 +3,11 @@ package api
import (
"net/http"
"strconv"
"strings"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
@@ -23,6 +25,53 @@ type castTokenResponse struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
URL string `json:"url"`
// MIME and Title let the client build proper DIDL-Lite metadata for
// SetAVTransportURI. Sonos rejects empty DIDL with vendor error 1023;
// passing back the track's MIME + title here lets the client populate
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
MIME string `json:"mime"`
Title string `json:"title"`
}
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
// Wraps the canonical audioContentType lookup in media.go and overrides
// the unknown-format fallback to audio/mpeg, because Sonos rejects
// DIDL-Lite with protocolInfo=application/octet-stream (the browser
// fallback) -- most Sonos firmware probes the URL anyway and recovers
// from a small MIME mismatch.
func mimeForFormat(format string) string {
mime := audioContentType(format)
if mime == "application/octet-stream" {
return "audio/mpeg"
}
return mime
}
// extForFormat maps the tracks.file_format column to a path-safe file
// extension. Sonos firmware gates duration probing on the URL path
// extension (Content-Type header alone is insufficient) -- without a
// recognizable extension, Sonos reports TrackDuration=0 and seeks
// trigger auto-advance because every position past 0 looks past-the-
// end. Defaults to "mp3" for unknown formats. See task #610.
func extForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3", "mpeg":
return "mp3"
case "flac":
return "flac"
case "aac":
return "aac"
case "m4a", "mp4":
return "m4a"
case "ogg", "vorbis":
return "ogg"
case "opus":
return "opus"
case "wav", "wave":
return "wav"
default:
return "mp3"
}
}
// handleCastStreamToken issues a short-lived HMAC stream token for the
@@ -52,6 +101,14 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
return
}
// Track lookup for the DIDL-Lite metadata the client builds for
// SetAVTransportURI. A missing track is a 404 — there's nothing to
// cast in that case.
track, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackUUID)
if err != nil {
writeErr(w, apierror.NotFound("track"))
return
}
expSec := clampExpSeconds(req.ExpSeconds)
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
@@ -74,10 +131,19 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
url := scheme + "://" + host + "/api/tracks/" + req.TrackID +
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
// Include the file extension in the path so Sonos's URL probe sees a
// recognizable audio file. Without it, Sonos reports TrackDuration=0
// and seeks past 0s land "after the end" -> early track-skip.
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
writeJSON(w, http.StatusOK, castTokenResponse{
Token: token,
Exp: exp,
URL: url,
MIME: mimeForFormat(track.FileFormat),
Title: track.Title,
})
}
// clampExpSeconds applies the [60, 86400] window with a 6h default for
+22 -6
View File
@@ -10,15 +10,21 @@ import (
"time"
)
const testTrackUUID = "11111111-1111-1111-1111-111111111111"
// nonExistentTrackUUID is used by tests that exercise paths which don't
// require the track to actually exist (auth/UUID-shape rejection).
const nonExistentTrackUUID = "11111111-1111-1111-1111-111111111111"
func TestCastStreamToken_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
artist := seedArtist(t, pool, "Artist")
album := seedAlbum(t, pool, artist.ID, "Album", 0)
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
trackID := uuidToString(track.ID)
h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID,
TrackID: trackID,
ExpSeconds: 3600,
})
if err != nil {
@@ -44,10 +50,16 @@ func TestCastStreamToken_HappyPath(t *testing.T) {
if !strings.Contains(resp.URL, "token="+resp.Token) {
t.Fatalf("URL missing token query: %s", resp.URL)
}
if !strings.Contains(resp.URL, "/api/tracks/"+testTrackUUID+"/stream") {
if !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/stream") {
t.Fatalf("URL missing stream path: %s", resp.URL)
}
if !VerifyStreamToken(h.streamSecret, testTrackUUID, resp.Exp, resp.Token) {
// Stream URL must carry a file extension so Sonos's URL probe can
// identify the audio format (see task #610). Track seeded above is
// .flac via seedTrack's default file_format.
if !strings.Contains(resp.URL, "/stream.flac?") {
t.Fatalf("URL missing file-extension segment: %s", resp.URL)
}
if !VerifyStreamToken(h.streamSecret, trackID, resp.Exp, resp.Token) {
t.Fatal("returned token does not verify")
}
}
@@ -77,7 +89,7 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
h, _ := testHandlers(t)
h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{TrackID: testTrackUUID})
body, err := json.Marshal(castTokenRequest{TrackID: nonExistentTrackUUID})
if err != nil {
t.Fatalf("marshal: %v", err)
}
@@ -96,11 +108,15 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
artist := seedArtist(t, pool, "Artist")
album := seedAlbum(t, pool, artist.ID, "Album", 0)
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
trackID := uuidToString(track.ID)
h.streamSecret = []byte("cast-token-test-secret")
// Request 1 second (below min 60), expect clamp to 60s.
body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID,
TrackID: trackID,
ExpSeconds: 1,
})
if err != nil {
+9
View File
@@ -75,6 +75,15 @@ func streamURL(trackID pgtype.UUID) string {
return "/api/tracks/" + uuidToString(trackID) + "/stream"
}
// streamURLWithExt returns the extension-bearing stream URL used by UPnP
// cast tokens. Sonos's URL probe gates duration detection on a recognizable
// audio file extension; the bare `/stream` shape reports TrackDuration=0
// and breaks seek/auto-advance. The bare /stream route stays mounted as an
// alias for legacy / web / Subsonic clients. See task #610.
func streamURLWithExt(trackID pgtype.UUID, ext string) string {
return streamURL(trackID) + "." + ext
}
// artistRefFrom projects a dbq.Artist into an ArtistRef without cover.
// albumCount must be pre-computed by the caller. Used by code paths that
// don't have a representative-album lookup at hand (artist detail, search,
+79
View File
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out)
}
// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip;
// artistTopTracksDefaultLimit caps the per-user "top tracks" panel.
const (
similarArtistsDefaultLimit = 12
artistTopTracksDefaultLimit = 5
)
// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns
// in-library artists similar to {id} (ranked by similarity score) as a flat
// ArtistRef list with cover + album count. Empty when the similarity ingest
// has no matches yet; 404 when the artist doesn't exist.
func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("artist"))
return
}
h.logger.Error("api: get artist for similar", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{
SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit,
})
if err != nil {
h.logger.Error("api: list similar artists", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]ArtistRef, 0, len(rows))
for _, row := range rows {
out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
}
writeJSON(w, http.StatusOK, out)
}
// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns
// the current user's most-played tracks for {id} (skips excluded, quarantine
// filtered). Empty when the user hasn't played this artist; 404 when the
// artist doesn't exist.
func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("artist"))
return
}
h.logger.Error("api: get artist for top tracks", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{
ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit,
})
if err != nil {
h.logger.Error("api: list artist top tracks", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]TrackRef, 0, len(rows))
for _, row := range rows {
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
}
writeJSON(w, http.StatusOK, out)
}
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
// the online source for the client's always-present "Shuffle all"
// (#427 S4). N random tracks across the whole library, per-user
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
paths := []string{
"/api/artists",
+21 -25
View File
@@ -22,46 +22,42 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// resolveAlbumCoverPath returns the filesystem path to the album's cover art.
// It prefers an explicit cover_art_path (set by the scanner in a future
// milestone) and falls back to a sidecar next to the first track in the
// album's directory. "" means no art was found.
// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
// local alias so the call sites in this file read naturally.
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
if _, err := os.Stat(*album.CoverArtPath); err == nil {
return *album.CoverArtPath
}
}
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
if err != nil || len(tracks) == 0 {
return ""
}
return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath))
return coverart.ResolveAlbumPath(ctx, q, album)
}
// audioContentType maps the short file_format recorded on tracks (mp3, flac,
// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
// Unknown formats fall back to octet-stream so the browser downloads them
// rather than attempting to decode.
// This is the canonical table; both the browser stream endpoint and the
// UPnP cast token URL builder consult it. Unknown formats fall back to
// octet-stream so the browser downloads them rather than attempting to
// decode -- cast_token.go applies its own audio/mpeg fallback for Sonos.
//
// Aliases (mpeg/vorbis/wave) cover historical / alternate format spellings
// that have shown up in track rows. The trim+lowercase normalization makes
// the lookup permissive to whatever a scanner happened to write.
//
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case
// (we don't record that format). Don't "fix" these to match subsonic.
// intentional: opus/vorbis→audio/ogg (library .opus / .ogg files are
// Ogg-encapsulated, so this matches real library contents), aac→audio/aac
// (raw AAC is ADTS, not MP4, so audio/mp4 would mislead codec sniffers),
// and there is no "oga" case (we don't record that format). Subsonic is a
// frozen client contract -- don't "fix" these to match it.
func audioContentType(format string) string {
switch strings.ToLower(format) {
case "mp3":
switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3", "mpeg":
return "audio/mpeg"
case "flac":
return "audio/flac"
case "ogg", "opus":
case "ogg", "opus", "vorbis":
return "audio/ogg"
case "m4a":
case "m4a", "mp4":
return "audio/mp4"
case "aac":
return "audio/aac"
case "wav":
case "wav", "wave":
return "audio/wav"
}
return "application/octet-stream"
+2 -2
View File
@@ -476,8 +476,8 @@ func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
if t.TrackID != nil {
s := uuidToString(*t.TrackID)
v.TrackID = &s
streamURL := "/api/tracks/" + s + "/stream"
v.StreamURL = &streamURL
url := streamURL(*t.TrackID)
v.StreamURL = &url
}
if t.AlbumID != nil {
s := uuidToString(*t.AlbumID)
+24
View File
@@ -7,8 +7,11 @@
package coverart
import (
"context"
"os"
"path/filepath"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// SidecarNames is the lookup order for cover art living next to audio files.
@@ -33,3 +36,24 @@ func FindSidecar(albumDir string) string {
}
return ""
}
// ResolveAlbumPath returns the on-disk path to an album's cover image,
// preferring the explicit album.cover_art_path when set and the file
// exists, falling back to a sidecar (cover.jpg / folder.jpg) next to the
// first track in the album's directory. "" means no art was found.
//
// Shared by internal/api/media.go (browser endpoint) and
// internal/subsonic/stream.go (Subsonic endpoint); they were byte-identical
// duplicates before extraction.
func ResolveAlbumPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
if _, err := os.Stat(*album.CoverArtPath); err == nil {
return *album.CoverArtPath
}
}
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
if err != nil || len(tracks) == 0 {
return ""
}
return FindSidecar(filepath.Dir(tracks[0].FilePath))
}
-9
View File
@@ -439,15 +439,6 @@ type ScanRun struct {
ArtistArtEnrich []byte
}
type ScanSchedule struct {
ID bool
Mode string
IntervalHours *int32
TimeOfDay *string
WeeklyDay *int32
UpdatedAt pgtype.Timestamptz
}
type ScrobbleQueue struct {
ID pgtype.UUID
UserID pgtype.UUID
+77
View File
@@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
return items, nil
}
const listMostPlayedTracksForArtist = `-- name: ListMostPlayedTracksForArtist :many
WITH plays AS (
SELECT track_id, count(*) AS cnt
FROM play_events
WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
JOIN tracks t ON t.id = p.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
LIMIT $3
`
type ListMostPlayedTracksForArtistParams struct {
ArtistID pgtype.UUID
UserID pgtype.UUID
ResultLimit int32
}
type ListMostPlayedTracksForArtistRow struct {
Track Track
AlbumTitle string
ArtistName string
}
// Top tracks for one artist by this user's completed-play count (skips
// excluded, quarantine filtered). Same projection as
// ListMostPlayedTracksForUser plus an artist_id filter, so the handler
// reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMostPlayedTracksForArtistParams) ([]ListMostPlayedTracksForArtistRow, error) {
rows, err := q.db.Query(ctx, listMostPlayedTracksForArtist, arg.ArtistID, arg.UserID, arg.ResultLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListMostPlayedTracksForArtistRow
for rows.Next() {
var i ListMostPlayedTracksForArtistRow
if err := rows.Scan(
&i.Track.ID,
&i.Track.Title,
&i.Track.AlbumID,
&i.Track.ArtistID,
&i.Track.TrackNumber,
&i.Track.DiscNumber,
&i.Track.DurationMs,
&i.Track.FilePath,
&i.Track.FileSize,
&i.Track.FileFormat,
&i.Track.Bitrate,
&i.Track.Mbid,
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
WITH plays AS (
SELECT track_id, count(*) AS cnt
-71
View File
@@ -1,71 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: scan_schedule.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getScanSchedule = `-- name: GetScanSchedule :one
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
FROM scan_schedule WHERE id = true
`
type GetScanScheduleRow struct {
Mode string
IntervalHours *int32
TimeOfDay *string
WeeklyDay *int32
UpdatedAt pgtype.Timestamptz
}
// M7 recurring-scans: returns the singleton schedule row. Used by the
// scheduler goroutine on boot and config-refresh, and by the admin GET
// handler.
func (q *Queries) GetScanSchedule(ctx context.Context) (GetScanScheduleRow, error) {
row := q.db.QueryRow(ctx, getScanSchedule)
var i GetScanScheduleRow
err := row.Scan(
&i.Mode,
&i.IntervalHours,
&i.TimeOfDay,
&i.WeeklyDay,
&i.UpdatedAt,
)
return i, err
}
const updateScanSchedule = `-- name: UpdateScanSchedule :exec
UPDATE scan_schedule
SET mode = $1,
interval_hours = $2,
time_of_day = $3,
weekly_day = $4,
updated_at = now()
WHERE id = true
`
type UpdateScanScheduleParams struct {
Mode string
IntervalHours *int32
TimeOfDay *string
WeeklyDay *int32
}
// Admin PATCH replaces the whole row; CHECK constraints enforce validity.
// The application normalizes mode='off' to NULL the per-mode fields
// before calling this.
func (q *Queries) UpdateScanSchedule(ctx context.Context, arg UpdateScanScheduleParams) error {
_, err := q.db.Exec(ctx, updateScanSchedule,
arg.Mode,
arg.IntervalHours,
arg.TimeOfDay,
arg.WeeklyDay,
)
return err
}
+71
View File
@@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i
return items, nil
}
const listSimilarArtistsForArtist = `-- name: ListSimilarArtistsForArtist :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,
cnt.album_count::bigint AS album_count
FROM (
SELECT artist_b_id, max(score) AS sim_score
FROM artist_similarity
WHERE artist_a_id = $1
GROUP BY artist_b_id
) s
JOIN artists ON artists.id = s.artist_b_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 s.sim_score DESC, artists.sort_name
LIMIT $2
`
type ListSimilarArtistsForArtistParams struct {
SeedArtistID pgtype.UUID
ResultLimit int32
}
type ListSimilarArtistsForArtistRow struct {
Artist Artist
CoverAlbumID pgtype.UUID
AlbumCount int64
}
// In-library artists similar to a seed artist, ranked by best similarity
// score across sources (deduped per candidate). cover_album_id + album_count
// mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimilarArtistsForArtistParams) ([]ListSimilarArtistsForArtistRow, error) {
rows, err := q.db.Query(ctx, listSimilarArtistsForArtist, arg.SeedArtistID, arg.ResultLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSimilarArtistsForArtistRow
for rows.Next() {
var i ListSimilarArtistsForArtistRow
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 upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
@@ -0,0 +1,21 @@
-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is
-- reversible. The application no longer reads or writes this table.
CREATE TABLE scan_schedule (
id boolean PRIMARY KEY DEFAULT true,
mode text NOT NULL DEFAULT 'off',
interval_hours int,
time_of_day text,
weekly_day int,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT scan_schedule_singleton CHECK (id = true),
CONSTRAINT scan_schedule_mode_check
CHECK (mode IN ('off', 'interval', 'daily', 'weekly')),
CONSTRAINT scan_schedule_interval_check
CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)),
CONSTRAINT scan_schedule_daily_check
CHECK (mode != 'daily' OR time_of_day IS NOT NULL),
CONSTRAINT scan_schedule_weekly_check
CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7))
);
INSERT INTO scan_schedule (id) VALUES (true);
@@ -0,0 +1,4 @@
-- Retire the configurable scan scheduler. New-file pickup is now handled by
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
-- so the operator-configurable schedule (and its admin UI) is gone.
DROP TABLE IF EXISTS scan_schedule;

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