-
v2026.07.14 — Taste-profile fidelity (M160)
Stabletest-go / test (push) Successful in 44stest-web / test (push) Successful in 49stest-go / integration (push) Successful in 5m1sandroid / Build + lint + test (push) Successful in 5m5srelease / Build signed APK (tag releases only) (push) Successful in 4m9srelease / Build + push container image (push) Successful in 19sreleased this
2026-07-14 13:37:44 -04:00 | 112 commits to main since this releaseMilestone #160 — Taste-profile fidelity via metadata enrichment (no sidecar) — plus adjacent home/discover polish. Merged via PR #109;
mainCI green on4f69c230.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_tagscache → tag facet; admin tag-sources card (web + Android);EnrichedTagScaleknob. - Era facet — decade weights from
albums.release_date;era_scaleknob. - Mood facet — folksonomy tags → canonical mood buckets;
mood_scaleknob. - Time-of-day / weekday context — per-artist affinity for the current daypart × weekday cell;
context_time_weightknob. - Device-class context — clients send
device_classon play-start; radio narrows the affinity cell by current device. - Household co-play — SQL co-occurrence over
play_events→user_cooccurrencesimilarity 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
- Folksonomy tag enrichment — MusicBrainz (keyless) + Last.fm (keyed) tag enricher →
-
released this
2026-07-02 16:45:36 -04:00 | 147 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 | 150 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)
Stableandroid / Build + lint + test (push) Successful in 4m11stest-go / test (push) Successful in 46stest-web / test (push) Successful in 55stest-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 | 161 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 | 185 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 | 194 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 | 216 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
Stableandroid / Build + lint + test (push) Successful in 4m7stest-go / integration (push) Successful in 4m30srelease / Build signed APK (tag releases only) (push) Successful in 3m44stest-go / test (push) Successful in 35stest-web / test (push) Successful in 45srelease / Build + push container image (push) Successful in 14sreleased this
2026-06-06 23:37:08 -04:00 | 219 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 | 238 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 | 247 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