• v2026.07.14 — Taste-profile fidelity (M160)
    test-go / test (push) Successful in 44s
    test-web / test (push) Successful in 49s
    test-go / integration (push) Successful in 5m1s
    android / Build + lint + test (push) Successful in 5m5s
    release / Build signed APK (tag releases only) (push) Successful in 4m9s
    release / Build + push container image (push) Successful in 19s
    Stable

    bvandeusen released this 2026-07-14 13:37:44 -04:00 | 112 commits to main since this release

    Milestone #160Taste-profile fidelity via metadata enrichment (no sidecar) — plus adjacent home/discover polish. Merged via PR #109; main CI green on 4f69c230.

    Taste profile: 2 facets → multi-dimensional. Now scores across artists, enriched genre tags, era, and mood, with a collaborative co-play candidate arm and time/weekday/device context conditioning — all in the single Go+Postgres binary.

    • Folksonomy tag enrichment — MusicBrainz (keyless) + Last.fm (keyed) tag enricher → track_tags cache → tag facet; admin tag-sources card (web + Android); EnrichedTagScale knob.
    • Era facet — decade weights from albums.release_date; era_scale knob.
    • Mood facet — folksonomy tags → canonical mood buckets; mood_scale knob.
    • Time-of-day / weekday context — per-artist affinity for the current daypart × weekday cell; context_time_weight knob.
    • Device-class context — clients send device_class on play-start; radio narrows the affinity cell by current device.
    • Household co-play — SQL co-occurrence over play_eventsuser_cooccurrence similarity edges → candidate arm (empty on single-user servers).
    • (AcousticBrainz was evaluated and deliberately dropped — frozen 2022 dataset.)

    Also: Songs-like → dedicated home row + wider spread; CoverTile play-button un-clip; discover novelty rebalance; "Updating your mixes…" veil.

    Android: this release builds the first signed APK carrying the admin tag-sources screen (#1521) and the device-class play-event change (#1551). Schema migrations 0042→0048 apply automatically on server restart.

    🤖 Generated with Claude Code

    Downloads
  • 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 | 147 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 | 150 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)
    android / Build + lint + test (push) Successful in 4m11s
    test-go / test (push) Successful in 46s
    test-web / test (push) Successful in 55s
    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 | 161 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 | 185 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 | 194 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 | 216 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
    android / Build + lint + test (push) Successful in 4m7s
    test-go / integration (push) Successful in 4m30s
    release / Build signed APK (tag releases only) (push) Successful in 3m44s
    test-go / test (push) Successful in 35s
    test-web / test (push) Successful in 45s
    release / Build + push container image (push) Successful in 14s
    Stable

    bvandeusen released this 2026-06-06 23:37:08 -04:00 | 219 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
    release / Build + push container image (push) Successful in 13s
    android / Build + lint + test (push) Successful in 3m56s
    release / Build signed APK (tag releases only) (push) Successful in 3m50s
    Stable

    bvandeusen released this 2026-06-05 13:31:28 -04:00 | 238 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 | 247 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