-
released this
2026-07-02 16:45:36 -04:00 | 12 commits to main since this releaseFixes the "fails once, stays broken forever" behavior (issue #1245, PR #106): a screen that failed to load in a dead zone or during a server restart now heals itself the moment connectivity returns — no more navigating away and back or pull-refreshing to unstick the app.
Install this APK to get screen-level auto-recovery everywhere.
Auto-recovery on reconnect
- New
NetworkStatusController.recoveries()signal — the screen-level half of the recovery idiom the sync/mutation/diagnostics singletons already used. - Every screen re-runs its load when server health returns: Home, Playlists, Liked, History, Hidden, Requests, Search, Discover, Album/Artist/Playlist detail, and all four admin screens. Direct-load screens only re-fetch from an error state, so good content never flashes a spinner.
Failure no longer looks like an empty account
- Cold-start against an unreachable server used to show "Welcome to Minstrel" / "No playlists yet". Home, Playlists, Liked, and Library now surface a real "Couldn't load…" error with a Retry button when the cache is empty and the refresh failed. A failed refresh over cached content stays silent — stale beats a full-screen error.
Stuck corners unstuck
- Requests: the 12s progress poll can now escape a failed initial load (it previously required a loaded list to ever fire), which also covers server errors that transport-level health monitoring never sees.
- Search: re-running the same query after a transient failure now works, and the error state has a Retry button.
- Artist detail: "Similar artists" / "Top tracks" re-fetch on recovery instead of staying silently absent after a blip.
Consistent Retry everywhere
- The shared error widget gained a title and now participates in pull-to-refresh, and it's used on every error branch — every load failure in the app is now recoverable by button, pull, or automatically on reconnect.
Use it
Install the APK, then try it: airplane-mode the phone, open a tab so it errors, drop out of airplane mode — the screen should populate itself within seconds of the server probe going healthy.
🤖 Generated with Claude Code
Downloads
- New
-
v2026.06.30 — Device diagnostics & debug reporting (M9) + follow-ups
Stabletest-go / test (push) Successful in 38stest-web / test (push) Successful in 47sandroid / Build + lint + test (push) Successful in 4m18stest-go / integration (push) Successful in 4m39srelease / Build signed APK (tag releases only) (push) Successful in 3m46srelease / Build + push container image (push) Successful in 16sreleased this
2026-06-30 19:24:58 -04:00 | 15 commits to main since this releasePer-day mutable CalVer, re-cut forward to
61171515(M9 + same-day diagnostics follow-ups, PRs #104 #105).Toggleable per-account device diagnostics: remotely enable debug mode on an account → its phone(s) stream a timeseries of device state → an admin timestamp view with copy/export for analysis. Targets roaming/poor-data recovery and locked-phone UPnP/Sonos desync.
Install this APK to get the diagnostics reporter + the follow-up fixes below; the prior build had only the index-level capture.
Diagnostics core (M9)
- Migration 0036:
diagnostic_events+ per-accountusers.debug_mode_enabled.POST /api/diagnostics(gated batch ingest),GET /api/admin/diagnostics(account/device/kind/time-window filters + export),/diagnostics/devices,PUT /api/admin/users/{id}/debug-mode. 30-day retention. - Android
DiagnosticsReporter(connectivity, server-health, UPnP, power/Doze/screen, lifecycle, heartbeat) → Room ring buffer →DiagnosticsUploader(offline-buffered, batched). Per-device OFF switch in Settings. - Web
/admin/diagnostics: timeline (auto-polls), remote toggle, Copy-JSON / Download-NDJSON.
Same-day follow-ups
- Web: diagnostics defaults to recent-500; time window under "Advanced"; newest-first sort.
playbackkind: route + player-state events relabeled out ofupnp_sync(they fire for any output); migration 0037.- Output-picker fix: connected Bluetooth now reads "Connected" (was "Available") —
isConnectedno longer derived from the wrongconnectionStatesignal. - Per-skip
track_changeevent + track-identity enrichment (local_track_id+sonos_uri) so a re-cast desync is unambiguous;sonos_*zeroed when not casting.
Use it
Install the APK, enable debug mode for the account at
/admin/diagnostics, open the app on that account, reproduce the issue, then export the window.🤖 Generated with Claude Code
Downloads
- Migration 0036:
-
v2026.06.20 — Image rendering resilience + player error recovery (skip, self-heal, live refresh)
Stabletest-go / test (push) Successful in 46stest-web / test (push) Successful in 55sandroid / Build + lint + test (push) Successful in 4m11stest-go / integration (push) Successful in 4m44srelease / Build signed APK (tag releases only) (push) Successful in 3m53srelease / Build + push container image (push) Successful in 1m41sreleased this
2026-06-20 21:04:04 -04:00 | 26 commits to main since this releasePer-day CalVer. Cover-art rendering and playback error-recovery hardening across server + web + Android, landed together. Issues #968 (images + player resilience) and #980 (stale-view refresh).
Covers never sit blank (#968)
One shared cover renderer per client, so a tile shows a placeholder while loading and a fallback on a 404 instead of an empty box — worst on the You might like row, whose unplayed items often have un-backfilled art.
- Web: new
Covercomponent owns the loading placeholder + error fallback; AlbumCard / ArtistCard / CompactTrackCard reuse it.ArtistCardpreviously had no error handler at all. - Android:
ServerImagenow tracks Coil's load state so the per-caller fallback doubles as a placeholder (loading) and an error painter (404), not just a null-URL guard.
Playback recovers instead of dead-ending (#968)
- Auto-skip a failed track (web + Android): a track that won't load advances to the next instead of stranding the player on "Try again" (web) / a stalled queue (Android). The error state only appears once the whole queue is unplayable. On Android the "Skipped N unplayable tracks" snackbar now tells the truth — it was firing while nothing actually skipped.
- Self-heal a stale queue: when the whole queue is unplayable — e.g. a tab/app left open across the daily 03:00 rebuild — the player re-pulls its source (a system playlist via the rotation-aware shuffle, or a radio re-seed) and resumes. Bounded to one re-pull per exhaustion so a still-broken refresh can't loop.
Browse views refresh themselves (#968)
The server now emits
playlist.system_rebuilton the daily rebuild and on manual refresh. The web client gained its first inbound SSE listener and Android consumes it viaEventsStream— both refresh the Home + playlist views, so a tab open since yesterday stops showing yesterday's mix.Stale-view refresh on an open mix (#980)
A system-playlist detail screen open across the rebuild shows a "This mix was refreshed · Refresh" banner (web) / snackbar (Android). Refresh re-resolves the rotated playlist id to the fresh mix — no forced redirect, no auto-reload, you refresh on your terms.
Verify
- Open Home (web or Android) with un-backfilled art → cover tiles show a placeholder then a fallback, never a blank box.
- Play a queue containing a dead/removed track → it skips to the next instead of dead-ending; only an all-unplayable queue surfaces "Try again".
- Leave a For You tab open across the 03:00 rebuild → the Home tiles refresh on their own; if you then play the stale queue it re-pulls the fresh mix.
- Open a system-playlist detail screen and trigger a refresh → a "This mix was refreshed · Refresh" prompt appears; Refresh lands on the new mix.
🤖 Generated with Claude Code
Downloads
- Web: new
-
released this
2026-06-12 21:26:09 -04:00 | 50 commits to main since this releasePer-day mutable CalVer, re-cut forward to
3e258507. A full day of Android UPnP/Sonos cast hardening on top of the morning's recommendation batch.Cast survives a locked screen (PR #97)
While casting, the wrapped ExoPlayer is paused, releasing its
WAKE_MODE_NETWORKWiFi/wake locks — so on a locked, on-battery phone the WiFi power-saved within seconds and starved the liveness poll, dropping a perfectly-healthy speaker. NewCastNetworkLock(high-perf/low-latencyWifiLock+ partialWakeLock) held for the lifetime of a cast.Don't drop the speaker (or blast local audio) on a phone-side network gap (PR #98)
A transient
ServerDowngap (WiFi still associated) tripped the drop threshold and fell back to the local player — which started playing the song out of the phone while the Sonos was still streaming it. The drop is now gated onNetworkStatusController: only drop when the phone's network isHealthy. During a phone-side outage the route is held and the poll reconciles on recovery (a local fallback couldn't reach the server either).Adopt a running session instead of clear+reload (PR #98)
Selecting a speaker that's already playing your queue (a session the phone got disconnected from but the speaker kept streaming) now attaches in place — syncs to its live track/position, no queue wipe, no jarring restart — and skip/seek drive its live queue. Falls through to the full reload only when it isn't your queue.
Anti-stickiness + faster recovery (PR #98)
- Powered-off speakers now leave the picker (lastSeen + post-scan prune) and the selection reverts to the phone, so a later play never targets a ghost route.
- On network recovery, a held cast reconciles immediately rather than waiting for the next poll tick.
Verify on device — on battery (USB masks the network-gap class)
- Cast → unplug → lock → keeps playing.
- Cast → brief network blip → Sonos keeps playing, phone does not blare local audio, reconciles on recovery.
- Phone dropped but Sonos still playing → re-select the group → attaches to the live session, skip works (no restart).
- Power a speaker off → reopen the picker → it disappears; selection falls back to phone.
Also in this release (recommendation tuning)
- You-might-like fallback (#790), taste 2b (
taste_overlaparm), recommendation metrics (Settings → Recommendation metrics). Deferred: taste phase 3 (context).
🤖 Generated with Claude Code
Downloads
-
released this
2026-06-11 23:34:02 -04:00 | 59 commits to main since this releasePer-day mutable CalVer, moved forward to
7b7bd0c3(PR #95). Adds a
liked-entity fallback so the "You might like" rows fill out for heavy
listeners.You-might-like fallback (this re-cut — PR #95)
The taste roll-up surfaces top-similar albums/artists, which for a heavy
listener are mostly ones they already play — so the dedup (vs Most Played +
Rediscover + Last Played) could strip the section to a single tile. When a row
comes up short, it now tops up from your liked artists/albums (a much larger
pool), reusing the same exclusions so nothing is shown twice or suggests an
actively-played entity. Read-side — fills immediately after deploy, no rebuild."You might like" — on web + Android (#790)
A You might like block (albums + artists) directly under the system
playlists row on web and Android, from the server's daily-built, taste-aware
recommendations + the liked fallback above.Taste profile — Minstrel learns taste (#796)
Persistent, decaying per-user taste model that re-ranks every recommendation
surface. Graded engagement (completion/skip, time-decayed); no dislike button
(passive, track-scoped); aTasteMatchscoring term toward learned taste.Earlier in this release
History repair + offline robustness (PR #90), server rows + taste profile
(PR #91), Android row (PR #92), repositioned (PR #93), web row (PR #94).Verify
- Redeploy this
:latest. On Home (web or Android), the You might like
section sits under the playlists row. With a decent number of likes it now
fills out (no longer a single tile) immediately — the liked fallback is
read-side, so no rebuild/gate wait is needed for it.
🤖 Generated with Claude Code
Downloads
- Redeploy this
-
v2026.06.07
Stablereleased this
2026-06-07 19:15:01 -04:00 | 81 commits to main since this releaseSingle-fix release on the per-day mutable CalVer scheme. (PR #88.)
Sonos — a single transport SOAP failure no longer reverts to local (Android)
The motivating bug: playing to a Sonos speaker, the phone locked, and a single transport command (lock-screen / Bluetooth / watch media button, or a queue-resync
play) issued during a WiFi power-save stall flashed "Disconnected from <Sonos>" and dropped playback back to the phone — even though the speaker was perfectly reachable.A transport command issued during screen-off WiFi sleep / Doze hit the 2 s connect timeout and threw.
handleSoapFailurethen cancelled the poll loop and firedonDropon that single failure, bypassing the poll loop's deliberate 30-consecutive-failure tolerance (DROP_THRESHOLD, bumped from 3 precisely for this case).Fix — make the 1 Hz poll loop the sole drop arbiter:
- Transport SOAP commands now retry transient IO failures (
retryTransientIo: 3 attempts, 400 ms backoff) so a brief WiFi stall lands the command once WiFi wakes instead of abandoning it. ASoapFaultException(renderer answered and rejected the action) is not retried — the device is alive. handleTransportFailureno longer cancels the poll loop or firesonDrop; 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 viaDROP_THRESHOLD.retryTransientIoextracted as an internal top-level fn with a unit test (first-success, retry-then-succeed, exhaust-and-rethrow, no-retry-on-SoapFault).
Verify on device
- Play to a Sonos speaker, lock the phone, let WiFi sleep, then hit a lock-screen / Bluetooth / watch transport button → the command lands (once WiFi wakes); the app stays routed to Sonos instead of flashing "Disconnected" and reverting to local.
- Power off the Sonos mid-session → after ~30 s of failed polls the app drops to local on its own (the deliberate tolerance still fires).
Downloads
- Transport SOAP commands now retry transient IO failures (
-
v2026.06.06
Stabletest-go / test (push) Successful in 35stest-web / test (push) Successful in 45sandroid / Build + lint + test (push) Successful in 4m7stest-go / integration (push) Successful in 4m30srelease / Build signed APK (tag releases only) (push) Successful in 3m44srelease / Build + push container image (push) Successful in 14sreleased this
2026-06-06 23:37:08 -04:00 | 84 commits to main since this releaseFour features (per-day CalVer). UPnP/Sonos gains graceful recovery, the library gains a filesystem watcher (and loses its scheduler), artist pages gain discovery, and request progress updates live. Cross-platform features landed on server + web + native together. (PR #87, 18 commits.)
UPnP / Sonos — graceful recovery (Android)
The motivating bug: open the phone, tap play, nothing happens — because the last session left the output pointed at a Sonos speaker you'd walked away from.
- Idle-revert: after 5 minutes paused on a UPnP/Sonos route, output reverts to the phone speaker, so a stale selection can't silently swallow a later "tap play."
- Play-intent recovery: a
play()that fails its SOAP (dead/stale renderer) now reverts to phone and honors the tap — it resumes locally instead of eating it. Recovery keys off the operator's last play/pause intent, which survives the SOAP error. - Fail-fast: transport SOAP uses a 2 s connect timeout (vs the shared 10 s) so the dead-renderer revert is near-instant rather than a multi-second stall.
Library — filesystem watcher + scheduler retirement (server, web)
- fsnotify watcher: new/changed files appear within seconds (debounced, recursive over the scan roots), scanning just the affected paths and resolving their cover art inline — local art (sidecar/embedded) instantly, remote providers rate-limited. "Drop a file → it appears with art in seconds."
- Scheduler retired: the configurable scan scheduler (
scan_scheduletable,GET/PATCH /api/admin/scan/schedule, the web admin schedule card) is gone — replaced by the watcher + a startup delta walk + a fixed 12 h safety-net walk that backstops inotify-watch-limit misses. Manual "scan now" + status are unchanged. Migration0033drops the table.
Artist detail — discovery (server, web, native)
- A Similar artists strip (in-library, ranked by similarity score, deduped across sources) and a per-user Top tracks panel on the artist page, on both clients. New
GET /api/artists/{id}/similar+/top-tracks. Tapping a top track plays that list; tapping a similar artist opens their page.
Requests — live progress auto-poll (web, native)
- Native requests now auto-poll while a request is mid-ingest (silent reloads, paused when nothing's in-flight) for parity with the existing web poll, and the SSE-driven refresh updates rows in place instead of flashing the loading spinner. Web discover submit now refreshes the requests list immediately. (The notifications-inbox half was intentionally deferred.)
Verify on device
- Pause on Sonos, wait ~5 min → output is back on the phone; tapping play just plays locally.
- Tap play onto a powered-off Sonos → reverts to phone and plays within ~2 s (not a long stall).
- Drop a new album into a scan folder → it appears (with art) within seconds, no manual scan.
- Open an artist with library neighbours → "Similar artists" + "Top tracks" show; both navigate/play.
- Submit a Lidarr request → it shows on the requests screen immediately and its status updates live without a manual refresh or a spinner flash.
Downloads
-
v2026.06.05
Stablereleased this
2026-06-05 13:31:28 -04:00 | 103 commits to main since this releaseFirst cut of v2026.06.05 (per-day mutable CalVer). Unifies the Android client's fragmented offline-detection code into one signal-driven authority, fixes the sticky offline banner, and reworks the offline playlist surface. (PR #86.)
The problem
Connectivity was answered by three half-systems that didn't talk to each other:
ConnectivityObserver(device link),VersionCheckController(which had reachability bolted onto a version poll), andServerHealthController(a passive derive with no triggers). Real failures — a track fast-failing, a stream read dying — were dead-ended; they never fed back. Home read the raw device link while the banner read the tri-state, so the two halves of "offline" disagreed. And recovery was bound to the 5-minute/healthzpoll, so the banner stuck around long after the connection returned.The fix
One authority —
NetworkStatusControllerAbsorbs
VersionCheckController+ServerHealthController. Every consumer (banner, Home, Playlists, search,OfflineGatedDataSource,TrackRow,LocalServerHealth) now reads its singlestate;ConnectivityObserveris just its device-link input. A pure, unit-testedReachabilityMachineholds the state logic.Signal-driven, corroborated reachability
- Success is self-proving — a successful stream read / API 2xx snaps straight to
Healthy(no waiting for the next poll). - Failure is ambiguous — a track fast-fail triggers a
/healthzprobe to arbitrate: corroborated →ServerDown; probe-ok → stayHealthy. One bad transcode no longer reads as "server down." - Inputs feeding the controller: device-link transitions (link-return → immediate probe), an adaptive poll (5 min healthy / ~20 s degraded), an OkHttp interceptor + the audio data source + the playback-error reporter (
reportSuccess/reportFailure), and pull-to-refresh (recheck).
Sticky banner — fixed
Link-return and any successful op now flip to
Healthyin seconds. The banner observes the controller directly (the oldWhileSubscribedre-wrap is gone).New non-gating
UnstablestateA mild "Reconnecting…" banner during the arbitration window — it never gates playback or flips a screen into offline mode (preserves the anti-flicker intent of the
/healthzhysteresis). Plus a transient "Back online" confirmation on recovery.Offline playlist UX
PlaylistRef.fullyCachedvia a DAO LEFT-JOIN (cached_playlist_tracks×audio_cache_index).- Tiles that can't be played offline (
refreshable || !fullyCached) grey out but stay tappable (the detail screen shuffles whatever subset is cached). - The offline Playlists row leads with the Recently-played / Liked pools, orders fully-cached before greyed, and drops not-yet-generated placeholders.
- Home and the Playlists list now derive
offlinefrom the unified controller, so they react toServerDown(server down on Wi-Fi), not just the device link.
New JVM unit suites run in CI:
ReachabilityMachineTest,PlaylistsRepositoryCacheMergeTest,BuildPlaylistsRowTest.Verify on device
- Toggle airplane mode mid-playback → the banner appears, then clears within seconds on reconnect (not minutes).
- A single unplayable/404 track does not flip the app to offline.
adb logcat -s MinstrelshowsNetworkStatus -> Xtransitions; a brief blip readsUnstable("Reconnecting…"), not an instantServerDown.- Go offline → the Playlists row leads with Recently-played / Liked; un-cached or refreshable tiles are greyed but still open into their detail.
- On recovery, a brief "Back online" flash appears before the banner collapses.
Downloads
- Success is self-proving — a successful stream read / API 2xx snaps straight to
-
v2026.06.04
Stablereleased this
2026-06-04 23:03:04 -04:00 | 112 commits to main since this releaseSame-day re-cut on the per-day mutable CalVer scheme. Sixth re-cut of v2026.06.04 — two evening on-device papercuts (notification art + false-offline) on top of the morning UPnP parity, afternoon offline-UX composite + server DRY,
/healthzhysteresis hotfix, and Sonos queue resync + prefetcher batch.Evening papercuts
1. Notification / lock-screen art now matches the in-app album cover (PR #84)
The MediaController notification and lock-screen background showed a different album cover than the in-app surfaces for some tracks.
- In-app covers come from
TrackRef.coverUrl→/api/albums/{id}/cover(server album-level art). - The notification art came from the stream's embedded ID3/FLAC tags (
MediaMetadata.artworkData), becausePlayerController.toMediaItemnever setartworkUri. Media3'sBitmapLoaderprefersartworkDataoverartworkUri, and ExoPlayer extracts embedded picture frames into the live metadata. For tracks whose embedded art differs from the server album cover (compilations, re-grouped singles), the two surfaces disagreed.
Fix:
PlayerController.toMediaItemsetsartworkUritocoverUrl(verified against Media3 1.10.1 source:MediaMetadata.populate()overwritesartworkUri+artworkDataas an atomic pair, so the MediaItem URI clears the embedded bytes).PlayerFactory.buildBitmapLoaderprovides an OkHttp-backedCacheBitmapLoaderso the authedplaceholder.invalidcover URL resolves.MinstrelPlayerServiceattaches it viaMediaSession.setBitmapLoader. Album cover now wins on every surface (notification, lock screen, BT/AVRCP, Auto, Pixel Watch).2. False "server offline" → playback "Source error" on a healthy LAN server (PR #85)
The app reported the server offline while it wasn't; a streaming track failed with
load_failed/ "Source error", then recovered on its own.ConnectivityObserver.onlinegated onNET_CAPABILITY_VALIDATED— Android's WAN internet-validation probe, not Minstrel reachability. A transient WAN/DNS blip dropsVALIDATEDwhile the LAN server stays reachable.ServerHealthControllerflipped toOfflineinstantly (no debounce; the/healthzhysteresis only guards ServerDown), andOfflineGatedDataSourcefast-failed the in-flight stream read → ExoPlayer SOURCE error.
Fix:
ConnectivityObserverrequiresINTERNETonly, notVALIDATED— the/healthzpoll (with its own failure hysteresis) is now the sole authority on whether Minstrel is reachable. Added a WARN-tierServerHealth -> Xtransition log (the signal previously had zero instrumentation).
For the morning / afternoon batches that make up the rest of v2026.06.04, see the same-day commits on
main(PRs #80–#83).Verify on device
- Play a track whose embedded tag art differs from its server album cover → notification / lock-screen background now matches the in-app cover.
- During a Wi-Fi/WAN hiccup,
adb logcat -s MinstrelshowsServerHealthstayingHealthy(orServerDownonly after sustained/healthzfailures) — never aOfflineflip from a brief validation drop. Streaming a non-cached track no longer dies with "Source error" on a momentary blip.
Downloads
- In-app covers come from
-
v2026.06.03
Stabletest-go / test (push) Successful in 30sandroid / Build + lint + test (push) Successful in 4m49stest-go / integration (push) Successful in 10m56srelease / Build signed APK (tag releases only) (push) Successful in 4m15srelease / Build + push container image (push) Successful in 17sreleased this
2026-06-03 15:45:09 -04:00 | 204 commits to main since this releaseSame-day re-cut on the per-day mutable CalVer scheme. This re-cut is a hotfix for v2026.06.03's UPnP path — operator on-device testing with a real Sonos speaker found three issues that ship together in this re-cut.
Same-day hotfix re-cut (PR #78)
Root cause — UPnP error 714 from Sonos
On-device tap of a Sonos device dismissed the picker sheet but no audio routed. Logcat:
UPnP select: mint token for track=da85199c-..., route=Living Room POST /api/cast/stream-token → 200 (token minted) SetAVTransportURI to http://minstrel.fabledsword.com/api/tracks/.../stream?token=...&exp=... <-- 500 from Sonos SoapFaultException: SOAP fault 714 (IllegalMimeType)UPnP error 714 = "Illegal MIME-Type" — Sonos does a HEAD/probe to detect the audio MIME at the URL. Our
cast_token.goURL builder trustedr.TLSfor the scheme, which is nil behind a TLS-terminating reverse proxy, so the URL came outhttp://. Sonos hithttp://→ 301-redirected tohttps://by the proxy → MIME probe found no audio body → 714.Fix: honor
X-Forwarded-Proto+X-Forwarded-Hostbefore falling back tor.TLS+r.Host. URL the speaker fetches now matches the scheme/host the client used.Sonos friendlyName cleaned in the picker
Devices appeared as
Living Room - Sonos Play:1 Media Renderer - RINCON_5CAAFD79...— the room name buried at the start of a long ID-laden string.UpnpDiscoveryController.displayName(...)now strips on the first-for Sonos-manufactured devices so the chip shows justLiving Room/Kitchen/ etc. Generic UPnP devices that append a(192.168.x.x)IP suffix also get that stripped. Manufacturer + model still render as the row subtitle, so the device-type info isn't lost.Operator-diagnostic plumbing
- Loud
Timber.w/Timber.ilogs on every code path ofselectUpnp— the silent early-return cases (currentTrack == null,transportFor() == null) now log loudly, and the success path adds breadcrumbs (mint, SetAVTransportURI, Play, done) so future SOAP failures show exactly how far we got. ReleaseTreeTimber tree on release builds. Debug builds hadDebugTree; release builds had nothing planted, so the diagnostic Timber output was silently dropped in production.ReleaseTreenow plants at WARN+ viaandroid.util.Log.printlnwith the canonicalMinstreltag — keeps DEBUG/INFO chatter out of production logcat while letting operator-drivenadb logcatsessions see real failures.
29 commits across four feature slices + a small bug-fix batch. PR #77.
Slice 1 — Media3 like / heart button on the media session
Heart appears in the phone notification card, lock-screen card, Pixel Watch system media controls, and Android Auto. Toggle routes through the existing
LikesRepository.toggleLikeso it inherits the offline-resilient MutationQueue.LikeMediaCallbackgrantsCMD_TOGGLE_LIKEinonConnect(Media3 issue #2679 guard) and routesonCustomCommandthrough the repository.MinstrelPlayerServiceinjectsLikesRepository, attaches the callback, sets initial preferences, and launches acurrentMediaItem × observeIsLikedlike-state job so the icon mirrors cross-device likes (web tap flips the phone notification heart automatically).PlayerController.setQueuenow thread-dispatches to the MediaController'sapplicationLooper— cold-boot resume was crashing withIllegalStateException: method is called from a wrong thread.
Notification-surface end-to-end verified.
Slice 2 — Bluetooth output picker
Symfonium-style device chip above the scrubber + bottom sheet showing all available routes (built-in / wired / Bluetooth).
OutputPickerController(Hilt singleton, MediaRouter facade) exposesroutesState: StateFlow<RouteSnapshot>with passive-by-default discovery, upgrades to active when the sheet opens.OutputPickerViewModelprojects routes + owns sheet lifecycle, fans discovery toggle to the controller.DeviceChip+OutputPickerSheet(Material 3 ModalBottomSheet) Compose UI with Lucide icons +BLUETOOTH_CONNECTpermission flow.OutputRoute.Protocolenum (SYSTEM/UPNP/CAST/SONOS) baked in as forward-compat — UPnP slot in without data-model changes.
Slice 3 — UPnP / DLNA / Sonos basic playback
Discovery + playback to any UPnP-compliant MediaRenderer (Sonos basic, Yamaha MusicCast, Bose SoundTouch, generic DLNA renderers). Audio flows server → speaker directly; the phone is just the controller.
Server:
- HMAC-SHA256 stream token:
SignStreamToken(secret, trackID, exp)+VerifyStreamTokenwithhmac.Equalconstant-time compare. The stream handler accepts either session cookie OR?token=&exp=query params. - New
POST /api/cast/stream-tokenendpoint — clampedexpSeconds, returns{token, exp, url}for the client to hand off to AVTransport. MINSTREL_STREAM_SECRETenv var with file-fallback at<DataDir>/stream_secret(auto-generated on first boot, mode 0600, logged once).auth.OptionalUsermiddleware — attaches user on cookie but doesn't 401 on absence.- Stream handler auth-checks BEFORE the DB lookup (also closes a small probe-via-404 info leak).
Client:
SsdpDiscovery— UDP multicast listener on239.255.255.250:1900, passiveNOTIFYlisten + explicitM-SEARCHwhen sheet opens,MulticastLocklifecycle.DeviceDescription— pull-parser over the UPnP device XML, filters non-renderers, resolves relative control URLs.SoapClient— minimal SOAP 1.1 envelope builder + POST via the shared OkHttp; throwsSoapFaultExceptionwith the UPnPerrorCode.AVTransportClient— v1 surface:SetAVTransportURI+Play+Stop.UpnpDiscoveryController— Hilt singleton composing SSDP + DeviceDescription + AVTransport.OutputPickerControllernowcombines system + UPnP routes;select()branches byprotocol: SYSTEM → MediaRouter, UPNP → mint token + SOAPSetAVTransportURI + Play.OutputPickerSheetgains a multicast-blocked footer hint.
Slice 4 — System playlist daily rotation + diversity
SQL audit of the 5 discovery-mix queries showed Rediscover, New for you, and First listens have no date parameter in their
ORDER BY— same content day-over-day until library state shifts.- Unified the 5 near-identical
produceXxxfunctions into oneproduceDiscoveryMix(spec)factory + a per-mixdiscoveryMixSpecslice. Adding a new mix is now one struct literal + a SQL query. dailyRotate: trueon all 3 deterministic mixes — applies a daily-deterministic offset rotate of the pool BEFORE diversify+truncate so each day's slice rotates while contiguous-block ordering is preserved (matters for album-coherent mixes). Deep Cuts + On this day stayfalsebecause their SQL already day-keys viamd5(id||$2::text).diversify: trueon all 5 — per-album ≤2 / per-artist ≤3 caps applied everywhere, withtopUpFromRawfallback: when caps strip the pool below 100 (album-heavy libraries), append non-capped tracks from the raw pool until the target is hit. Diversity where possible, full playlist always.
Bug-fix batch (Android)
LikesRepository.observeLikedTrackIds(): Flow<Set<String>>— direct DAO query, notrackDaojoin.PlaylistDetailViewModel+AlbumDetailViewModelswitched to it; cross-device likes whose track row isn't cached locally now correctly mark playlist/album rows as liked.PlaylistsRepositorywrapscoverPath/coverUrlthroughresolveServerUrlso the playlist header art actually fetches viaBaseUrlInterceptor(was a relative URL Coil couldn't resolve).
Verify on device
- Notification heart tap on a playing track flips state; cross-device (web tap) flips the phone notification heart automatically.
- Open NowPlaying sheet → Bluetooth devices listed, selection routes audio.
- On a Sonos / UPnP speaker on the LAN: open NowPlaying sheet → device appears with manufacturer name; select → audio plays through it.
- Multicast-blocked network: sheet shows "Your router may be blocking multicast discovery."
- Open Rediscover / New for you / First listens today and tomorrow → content visibly rotates day-over-day.
- Playlist detail header art renders (was blank); liked-state badge on tracks within a playlist reflects actual liked state.
Downloads
- Loud