• v2026.07.02 — Android auto-recovery: failed loads heal on reconnect
    android / Build + lint + test (push) Successful in 4m44s
    release / Build signed APK (tag releases only) (push) Successful in 4m31s
    release / Build + push container image (push) Successful in 16s
    Stable

    bvandeusen released this 2026-07-02 16:45:36 -04:00 | 12 commits to main since this release

    Fixes 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
  • v2026.06.30 — Device diagnostics & debug reporting (M9) + follow-ups
    test-go / test (push) Successful in 38s
    test-web / test (push) Successful in 47s
    android / Build + lint + test (push) Successful in 4m18s
    test-go / integration (push) Successful in 4m39s
    release / Build signed APK (tag releases only) (push) Successful in 3m46s
    release / Build + push container image (push) Successful in 16s
    Stable

    bvandeusen released this 2026-06-30 19:24:58 -04:00 | 15 commits to main since this release

    Per-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-account users.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.
    • playback kind: route + player-state events relabeled out of upnp_sync (they fire for any output); migration 0037.
    • Output-picker fix: connected Bluetooth now reads "Connected" (was "Available") — isConnected no longer derived from the wrong connectionState signal.
    • Per-skip track_change event + 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
  • v2026.06.20 — Image rendering resilience + player error recovery (skip, self-heal, live refresh)
    test-go / test (push) Successful in 46s
    test-web / test (push) Successful in 55s
    android / Build + lint + test (push) Successful in 4m11s
    test-go / integration (push) Successful in 4m44s
    release / Build signed APK (tag releases only) (push) Successful in 3m53s
    release / Build + push container image (push) Successful in 1m41s
    Stable

    bvandeusen released this 2026-06-20 21:04:04 -04:00 | 26 commits to main since this release

    Per-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 Cover component owns the loading placeholder + error fallback; AlbumCard / ArtistCard / CompactTrackCard reuse it. ArtistCard previously had no error handler at all.
    • Android: ServerImage now 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_rebuilt on the daily rebuild and on manual refresh. The web client gained its first inbound SSE listener and Android consumes it via EventsStream — 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
  • v2026.06.12 — UPnP/Sonos cast resilience (lock, drop-suppression, session adopt) + recommendation tuning
    android / Build + lint + test (push) Successful in 4m39s
    release / Build signed APK (tag releases only) (push) Successful in 4m31s
    release / Build + push container image (push) Successful in 15s
    Stable

    bvandeusen released this 2026-06-12 21:26:09 -04:00 | 50 commits to main since this release

    Per-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_NETWORK WiFi/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. New CastNetworkLock (high-perf/low-latency WifiLock + partial WakeLock) 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 ServerDown gap (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 on NetworkStatusController: only drop when the phone's network is Healthy. 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_overlap arm), recommendation metrics (Settings → Recommendation metrics). Deferred: taste phase 3 (context).

    🤖 Generated with Claude Code

    Downloads
  • v2026.06.11 — History repair, offline robustness, recommendations
    test-go / test (push) Successful in 30s
    release / Build signed APK (tag releases only) (push) Successful in 3m55s
    test-go / integration (push) Successful in 4m38s
    release / Build + push container image (push) Successful in 13s
    Stable

    bvandeusen released this 2026-06-11 23:34:02 -04:00 | 59 commits to main since this release

    Per-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); a TasteMatch scoring 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
  • v2026.06.07
    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
    Stable

    bvandeusen released this 2026-06-07 19:15:01 -04:00 | 81 commits to main since this release

    Single-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. handleSoapFailure then cancelled the poll loop and fired onDrop on 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. A SoapFaultException (renderer answered and 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.
    • retryTransientIo extracted 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
  • v2026.06.06
    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
    Stable

    bvandeusen released this 2026-06-06 23:37:08 -04:00 | 84 commits to main since this release

    Four 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_schedule table, 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. Migration 0033 drops 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
    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
    Stable

    bvandeusen released this 2026-06-05 13:31:28 -04:00 | 103 commits to main since this release

    First 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), and ServerHealthController (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 /healthz poll, so the banner stuck around long after the connection returned.

    The fix

    One authority — NetworkStatusController

    Absorbs VersionCheckController + ServerHealthController. Every consumer (banner, Home, Playlists, search, OfflineGatedDataSource, TrackRow, LocalServerHealth) now reads its single state; ConnectivityObserver is just its device-link input. A pure, unit-tested ReachabilityMachine holds 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 /healthz probe to arbitrate: corroborated → ServerDown; probe-ok → stay Healthy. 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 Healthy in seconds. The banner observes the controller directly (the old WhileSubscribed re-wrap is gone).

    New non-gating Unstable state

    A 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 /healthz hysteresis). Plus a transient "Back online" confirmation on recovery.

    Offline playlist UX

    • PlaylistRef.fullyCached via 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 offline from the unified controller, so they react to ServerDown (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 Minstrel shows NetworkStatus -> X transitions; a brief blip reads Unstable ("Reconnecting…"), not an instant ServerDown.
    • 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
  • v2026.06.04
    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
    Stable

    bvandeusen released this 2026-06-04 23:03:04 -04:00 | 112 commits to main since this release

    Same-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, /healthz hysteresis 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), because PlayerController.toMediaItem never set artworkUri. Media3's BitmapLoader prefers artworkData over artworkUri, 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.toMediaItem sets artworkUri to coverUrl (verified against Media3 1.10.1 source: MediaMetadata.populate() overwrites artworkUri + artworkData as an atomic pair, so the MediaItem URI clears the embedded bytes). PlayerFactory.buildBitmapLoader provides an OkHttp-backed CacheBitmapLoader so the authed placeholder.invalid cover URL resolves. MinstrelPlayerService attaches it via MediaSession.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.online gated on NET_CAPABILITY_VALIDATED — Android's WAN internet-validation probe, not Minstrel reachability. A transient WAN/DNS blip drops VALIDATED while the LAN server stays reachable.
    • ServerHealthController flipped to Offline instantly (no debounce; the /healthz hysteresis only guards ServerDown), and OfflineGatedDataSource fast-failed the in-flight stream read → ExoPlayer SOURCE error.

    Fix: ConnectivityObserver requires INTERNET only, not VALIDATED — the /healthz poll (with its own failure hysteresis) is now the sole authority on whether Minstrel is reachable. Added a WARN-tier ServerHealth -> X transition 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 Minstrel shows ServerHealth staying Healthy (or ServerDown only after sustained /healthz failures) — never a Offline flip from a brief validation drop. Streaming a non-cached track no longer dies with "Source error" on a momentary blip.
    Downloads
  • v2026.06.03
    test-go / test (push) Successful in 30s
    android / Build + lint + test (push) Successful in 4m49s
    test-go / integration (push) Successful in 10m56s
    release / Build signed APK (tag releases only) (push) Successful in 4m15s
    release / Build + push container image (push) Successful in 17s
    Stable

    bvandeusen released this 2026-06-03 15:45:09 -04:00 | 204 commits to main since this release

    Same-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.go URL builder trusted r.TLS for the scheme, which is nil behind a TLS-terminating reverse proxy, so the URL came out http://. Sonos hit http:// → 301-redirected to https:// by the proxy → MIME probe found no audio body → 714.

    Fix: honor X-Forwarded-Proto + X-Forwarded-Host before falling back to r.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 just Living 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.i logs on every code path of selectUpnp — 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.
    • ReleaseTree Timber tree on release builds. Debug builds had DebugTree; release builds had nothing planted, so the diagnostic Timber output was silently dropped in production. ReleaseTree now plants at WARN+ via android.util.Log.println with the canonical Minstrel tag — keeps DEBUG/INFO chatter out of production logcat while letting operator-driven adb logcat sessions 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.toggleLike so it inherits the offline-resilient MutationQueue.

    • LikeMediaCallback grants CMD_TOGGLE_LIKE in onConnect (Media3 issue #2679 guard) and routes onCustomCommand through the repository.
    • MinstrelPlayerService injects LikesRepository, attaches the callback, sets initial preferences, and launches a currentMediaItem × observeIsLiked like-state job so the icon mirrors cross-device likes (web tap flips the phone notification heart automatically).
    • PlayerController.setQueue now thread-dispatches to the MediaController's applicationLooper — cold-boot resume was crashing with IllegalStateException: 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) exposes routesState: StateFlow<RouteSnapshot> with passive-by-default discovery, upgrades to active when the sheet opens.
    • OutputPickerViewModel projects routes + owns sheet lifecycle, fans discovery toggle to the controller.
    • DeviceChip + OutputPickerSheet (Material 3 ModalBottomSheet) Compose UI with Lucide icons + BLUETOOTH_CONNECT permission flow.
    • OutputRoute.Protocol enum (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) + VerifyStreamToken with hmac.Equal constant-time compare. The stream handler accepts either session cookie OR ?token=&exp= query params.
    • New POST /api/cast/stream-token endpoint — clamped expSeconds, returns {token, exp, url} for the client to hand off to AVTransport.
    • MINSTREL_STREAM_SECRET env var with file-fallback at <DataDir>/stream_secret (auto-generated on first boot, mode 0600, logged once).
    • auth.OptionalUser middleware — 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 on 239.255.255.250:1900, passive NOTIFY listen + explicit M-SEARCH when sheet opens, MulticastLock lifecycle.
    • 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; throws SoapFaultException with the UPnP errorCode.
    • AVTransportClient — v1 surface: SetAVTransportURI + Play + Stop.
    • UpnpDiscoveryController — Hilt singleton composing SSDP + DeviceDescription + AVTransport.
    • OutputPickerController now combines system + UPnP routes; select() branches by protocol: SYSTEM → MediaRouter, UPNP → mint token + SOAP SetAVTransportURI + Play.
    • OutputPickerSheet gains 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 produceXxx functions into one produceDiscoveryMix(spec) factory + a per-mix discoveryMixSpec slice. Adding a new mix is now one struct literal + a SQL query.
    • dailyRotate: true on 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 stay false because their SQL already day-keys via md5(id||$2::text).
    • diversify: true on all 5 — per-album ≤2 / per-artist ≤3 caps applied everywhere, with topUpFromRaw fallback: 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, no trackDao join. PlaylistDetailViewModel + AlbumDetailViewModel switched to it; cross-device likes whose track row isn't cached locally now correctly mark playlist/album rows as liked.
    • PlaylistsRepository wraps coverPath/coverUrl through resolveServerUrl so the playlist header art actually fetches via BaseUrlInterceptor (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