• 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 | 493 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 | 496 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 | 515 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 | 524 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 | 616 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
  • v2026.06.02
    android / Build + lint + test (push) Successful in 5m36s
    release / Build signed APK (tag releases only) (push) Successful in 4m28s
    release / Build + push container image (push) Successful in 2m1s
    Stable

    bvandeusen released this 2026-06-02 22:30:20 -04:00 | 651 commits to main since this release

    Same-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.kt chained BaseUrlInterceptor before AuthCookieInterceptor. Both interceptors scope work on host == 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, and Set-Cookie from login was never captured. The AuthCookieInterceptorTest chains 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/me profile 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 + discover only 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 emitted source="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 an autoplay opt-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 replaceAllForUser DAO 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.CONFLATED silently dropped every emission except the latest. A network blip that failed 5 tracks surfaced only the last; the admin inbox only got one POST. Now Channel.BUFFERED.
    • Periodic GC worker (#565 #566 #567 #574 #575). New internal/gc package — 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 source field 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
  • v2026.06.01
    test-web / test (push) Successful in 45s
    release / Build + push container image (push) Successful in 19s
    release / Build signed APK (tag releases only) (push) Successful in 3m57s
    Stable

    bvandeusen released this 2026-06-01 21:55:49 -04:00 | 730 commits to main since this release

    First 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_request triggers (push covers it on a single-author repo), added concurrency with cancel-in-progress to 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 :main and :latest; tag pushes tag both :vYYYY.MM.DD and :latest.
    Downloads
  • bvandeusen released this 2026-05-21 15:42:28 -04:00 | 1029 commits to main since this release

    Highlights

    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-flutter image 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 to flutter_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

    bvandeusen released this 2026-05-19 15:48:31 -04:00 | 1047 commits to main since this release

    Mobile 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 / legacy homeProvider / home-data JSON encoders; the metadata prefetcher now warms artists off the per-item homeIndexProvider. Drift schemaVersion 10 → 11 drops the dead cached_home_snapshot table; the migration chain remains valid for upgrades from any prior schema.
    • #374 — web route-test triage. Restored the discover / requests Svelte 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

    bvandeusen released this 2026-05-19 07:48:25 -04:00 | 1052 commits to main since this release

    Hotfix 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.custom references 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 and PlaybackStateCompat.CustomAction.Builder throws 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