-
v2026.06.07
Stablereleased this
2026-06-07 19:15:01 -04:00 | 493 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 | 496 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 | 515 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 | 524 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 | 616 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
-
v2026.06.02
Stablereleased this
2026-06-02 22:30:20 -04:00 | 651 commits to main since this releaseSame-day re-cut on the per-day mutable CalVer scheme. This re-cut is a hotfix for the v2026.06.02 (run #289) connectivity regression — fresh installs of the prior cut couldn't sign in and were stuck at the Welcome screen.
Hotfix: Android interceptor order (PR #76)
NetworkModule.ktchainedBaseUrlInterceptorbeforeAuthCookieInterceptor. Both interceptors scope work onhost == PLACEHOLDER_HOST(drift #568/#569, to keep the session cookie off Coil image fetches to MusicBrainz / coverartarchive / Lidarr). With BaseUrl first, the URL was rewritten to the real Minstrel host before auth ran — so auth's placeholder check failed on every request, the session cookie was never attached on outgoing requests, andSet-Cookiefrom login was never captured. TheAuthCookieInterceptorTestchains the interceptors in the correct order, which is why the regression went undetected. Fix: swap to(auth, baseUrl, logging). Client-side only — server is unchanged.Anyone who installed the prior v2026.06.02 APK needs to re-install from this release.
Highlight: drift audit shipped (26 findings)
Multi-system audit run on 2026-06-02: 14 auditors (12 subsystem + 2 cross-cutting) fanned out across server / Android / web / Flutter looking for the 10-pattern drift taxonomy. 26 candidate findings surfaced; 24 confirmed by adversarial verify and shipped, 1 cancelled as duplicate, 1 doc-only correction. Scribe parent task: project 12 / task 552.
Highest-impact fixes
- Server
/api/meprofile shape (#578). The endpoint emitted UserView (id/username/is_admin) but Android's MyProfileWire expected display_name + email. Every Settings → Profile open silently saw nulls; saving from that blank state DESTROYED stored profile fields via the "empty string clears to NULL" path. Server now returns the full profile shape. Existing test extended; new regression-guard test added. - AuthCookieInterceptor scoping (#568 #569). The Minstrel session cookie was being attached to every external request that the shared OkHttpClient services (Coil image fetches to artwork.musicbrainz.org / coverartarchive.org / Lidarr /MediaCover URLs), and a 401 from any external host wiped the user's session. Both attach + clear now scoped to the placeholder.invalid sentinel host — mirrors the BaseUrlInterceptor fix from earlier today.
- System-playlist rotation alignment (#563 #564). Server's rotation whitelist had
for_you+discoveronly but migrations 0021 + 0028 added 6 more variants; plays from Rediscover / Deep Cuts / Songs Like X / etc. didn't advance the per-user rotation. Android also emittedsource="playlist:<variant>"while the server matched the bare variant, so system-mix plays from Android Home never registered at all. Whitelist extended; Android source format aligned with web's bare-variant contract. - Cold-boot resume coordination (#560 #562). ResumeController could race the MediaController IPC handshake — setQueue early-returned on null controller and the persisted queue was silently dropped. ResumeController now awaits a new
awaitReady()suspend before calling setQueue, and setQueue gains anautoplayopt-out so cold-boot resume restores paused (not auto-playing). - Cross-device unlike on Android (#570). LikesRepository.refreshIds only INSERTed — rows the server no longer surfaced (because the user unliked elsewhere) stayed in the local cache forever. Atomic
replaceAllForUserDAO transaction now wipes-then-inserts so the local set is exactly what the server reports. - CONFLATED playback-error channel (#561). The reporter's burst-coalescing path was dead code —
Channel.CONFLATEDsilently dropped every emission except the latest. A network blip that failed 5 tracks surfaced only the last; the admin inbox only got one POST. NowChannel.BUFFERED. - Periodic GC worker (#565 #566 #567 #574 #575). New
internal/gcpackage — one Worker, 5 idempotent sweeps on a 1h tick: close stale play_events (24h), close idle play_sessions (6h) / empty-starved (1h), expire scrobble_queue failed rows (14 days), reset stuck system_playlist_runs (10 min), delete expired password_resets. Closes the lifecycle-gap cluster from the audit in one shot. - Profile + lifecycle UX (#553 #554 #555 #557 #558 #559 #571 #572 #576 #577). Tailwind class fix on the playback-errors Delete confirm button; play_started
sourcefield added to the EventRequest TS type; coverage rollup test extended to deezer/lastfm; /forgot-password + /reset-password/[token] reachable without auth; SSR-only redirects converted to client-side; scanner indexes .opus/.aac/.wav; delete.go docstring honest about missing reconcile; LikesRepository uses real user UUID + clears on user-switch; RequestsViewModel doesn't refresh() on offline-queued cancel.
Verify on device
- Fresh APK install: sign in succeeds and lands on Home (was stuck at Welcome).
- Android Settings → Profile shows stored display_name + email (was wiping them).
- Drag-dismiss + cold-boot of NowPlaying: pop animation latches, resume opens paused (not playing).
- /admin/playback-errors → Delete file modal: confirm button is the proper Oxblood destructive color.
- Tap Cancel on a request while offline — row stays gone, no Error screen.
- Discover view shows artist artwork (regression for prior fix).
- New scrubber thumb reads as a small flat circle on a thin 4dp track.
Carried from prior cuts
Earlier same-day cuts already included: playback-errors slice (server + Android + admin UI), drag-dismiss latch, portrait lock, Material LibraryMusic icon (later reverted to Lucide.LibraryBig), notification tap → NowPlaying, NowPlaying polish (dominant swatch, slim scrubber, swipe tabs, refresh flicker fix, Start Radio preserves playback), web alphabet rail page-chasing, Songs Like fix, two-pane desktop NowPlaying, scrubber smoothing, CalVer-with-iteration APK versionName.
Downloads
- Server
-
v2026.06.01
Stablereleased this
2026-06-01 21:55:49 -04:00 | 730 commits to main since this releaseFirst release on the new per-day mutable tag scheme. Batch release covering web UX tiers A/B/C, M8 native-Android port, M7 server polish, and the CI dedup pass.
Web UX (this session)
- Tier A — Full-screen NowPlaying view, global keyboard shortcuts (Space/J/L/M/Arrows), multi-select + bulk actions on track lists, Home secondary system-playlists backflow.
- Tier B — Debounced quick-filter on every Library tab; smart empty-state onramps everywhere.
- Tier C — Keyboard reorder on playlist tracks, position-derived crossfade (0-12s, default off), copy-link button on owned public playlists.
Platform
- M8 Android native — full Kotlin/Compose client with Media3, Room cache-first storage, hydration prewarmer; ships as
minstrel-v2026.06.01.apk. - M7 web feature sweep — full Subsonic-API parity client + admin UI, system playlists v2 (For You / Discover / Deep Cuts / Rediscover / Most Played multi-row), event bus, cache-first Library, offline v2.
- Server — skip classification moved server-side, Lidarr bootstrap, admin dashboard, admin requests queue with override modal.
Infrastructure
- Migrated to Gitea Actions with keystore signing, registry auth via
REGISTRY_TOKEN. - CI dedup pass: dropped
pull_requesttriggers (push covers it on a single-author repo), addedconcurrencywithcancel-in-progressto every workflow. - Tag scheme changed to per-day mutable CalVer (
vYYYY.MM.DD). Same-day re-releases force-move the tag. - Main pushes now tag both
:mainand:latest; tag pushes tag both:vYYYY.MM.DDand:latest.
Downloads
-
released this
2026-05-21 15:42:28 -04:00 | 1029 commits to main since this releaseHighlights
Last meaningful-feature release on Flutter. Future releases on this codebase are bugfix-only; the v1 Android native rewrite begins in parallel.
Player / Wear OS
- Notification + Pixel Watch transport controls stay live across idle periods. Previously the buttons could go dead after ~5 min idle (Wear companion's MediaController held a stale binding to a torn-down MediaSession). Idle teardown now soft-tears-down only — display state clears, but the FGS + MediaSession persist so the Watch binding stays valid.
Playlists
- Tapping a system-playlist tile before its mix has loaded now surfaces clear SnackBar feedback ("Mix isn't ready yet — try again in a moment" / timeout / API error) instead of silently stalling.
Behind the scenes
- Drift test cohort re-enabled in CI after the
ci-flutterimage picked up libsqlite3-dev. - Tier-A dep sweep (audio_session 0.2, flutter_lucide, permission_handler now direct), Go server bumps, CI runner-image policy migration.
Known limitations
- Quarantine throwing-path test skipped pending Fable #476 (Riverpod StreamProvider lifecycle in async catch path).
- Offline-view flash on app resume (Fable #473) deferred — bugfix-future on Flutter; resolved in native rewrite.
- audio_service plugin is upstream-stale; this release works around the lifecycle bug client-side rather than waiting for a maintenance update that isn't coming.
What's next
Native Android rewrite begins under
android/sibling toflutter_client/. Targets: Media3 directly, Wear OS Compose first-class, Android Auto support, native MediaSessionService lifecycle. iOS will not ship.Downloads
-
v2026.05.19.3 Stable
released this
2026-05-19 15:48:31 -04:00 | 1047 commits to main since this releaseMobile client
2026.5.19+13. Flutter client + web-test changes only — no server code or server migration changes. Includes one Flutter (on-device) drift schema migration: 10 → 11.Headline — #66: playback stall resilience
Fixes the real-world failure you hit on poor coverage: a track finishes and the next one never starts (the stream attempt fails and nothing retries — the queue silently hangs). The player now:
- runs a 15s stall watchdog keyed off buffered-position progress (not wall-clock alone),
- makes one bounded recover attempt on the stalled track,
- then skips to the next cached track, or pauses cleanly, so the queue keeps moving instead of dead-ending.
Also in this release
- #406 — drop legacy home snapshot path. Removes
CachedHomeSnapshot/ legacyhomeProvider/ home-data JSON encoders; the metadata prefetcher now warms artists off the per-itemhomeIndexProvider. DriftschemaVersion10 → 11 drops the deadcached_home_snapshottable; the migration chain remains valid for upgrades from any prior schema. - #374 — web route-test triage. Restored the
discover/requestsSvelte route test suites (they crashed at module-load on the un-mocked$app/state); test-only, no runtime impact.
Caveat — verify in the field
#66 has not been device-verified on a throttled / poor-coverage connection (tracked). After installing, validate on a weak or airplane-toggled connection: when a next-track stream fails, the queue should retry once and then skip to a cached track (or pause cleanly) rather than hanging silently.
Regression check
Media notification + watch transport controls should still appear during playback (the v2026.05.19.2 fix stands — nothing in this release touches that path).
Downloads
-
v2026.05.19.2 Stable
released this
2026-05-19 07:48:25 -04:00 | 1052 commits to main since this releaseHotfix for v2026.05.19.1. Mobile client
2026.5.19+12. Flutter client only — no server/migration changes.Restores the media notification + watch controls, which were completely absent on physical devices (worked on the emulator).
Root cause (isolated on a physical Pixel 6 Pro via device logcat, after disproving permission / PathParser / FGS / aapt2 / git-tag / resource-shrinker): the v2026.05.19.0 custom favorite
MediaControl.customreferences a drawable that, while valid and present in the tagged source, is absent from the shipped CI release APK. audio_service resolves the icon to id 0 andPlaybackStateCompat.CustomAction.Builderthrows on every state broadcast, which aborts the entire media notification.Fix: remove the custom favorite control. The notification rebuilds with the standard transport controls (audio_service's own always-present icons). Like/favorite remains available in-app and on the lock-screen media UI. The auto-minimize-on-teardown fix from v2026.05.19.1 stands.
The underlying "valid tagged drawable not in the CI APK" is a separate CI build-packaging investigation (tracked) and does not block playback or the notification.
After installing: confirm the media notification appears during playback and the watch shows transport controls.
Downloads