• v2026.06.03
    test-go / test (push) Successful in 30s
    android / Build + lint + test (push) Successful in 4m49s
    test-go / integration (push) Successful in 10m56s
    release / Build signed APK (tag releases only) (push) Successful in 4m15s
    release / Build + push container image (push) Successful in 17s
    Stable

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

    Same-day re-cut on the per-day mutable CalVer scheme. This re-cut is a hotfix for v2026.06.03's UPnP path — operator on-device testing with a real Sonos speaker found three issues that ship together in this re-cut.

    Same-day hotfix re-cut (PR #78)

    Root cause — UPnP error 714 from Sonos

    On-device tap of a Sonos device dismissed the picker sheet but no audio routed. Logcat:

    UPnP select: mint token for track=da85199c-..., route=Living Room
    POST /api/cast/stream-token  → 200 (token minted)
    SetAVTransportURI to http://minstrel.fabledsword.com/api/tracks/.../stream?token=...&exp=...
    <-- 500 from Sonos
    SoapFaultException: SOAP fault 714 (IllegalMimeType)
    

    UPnP error 714 = "Illegal MIME-Type" — Sonos does a HEAD/probe to detect the audio MIME at the URL. Our cast_token.go URL builder trusted r.TLS for the scheme, which is nil behind a TLS-terminating reverse proxy, so the URL came out http://. Sonos hit http:// → 301-redirected to https:// by the proxy → MIME probe found no audio body → 714.

    Fix: honor X-Forwarded-Proto + X-Forwarded-Host before falling back to r.TLS + r.Host. URL the speaker fetches now matches the scheme/host the client used.

    Sonos friendlyName cleaned in the picker

    Devices appeared as Living Room - Sonos Play:1 Media Renderer - RINCON_5CAAFD79... — the room name buried at the start of a long ID-laden string. UpnpDiscoveryController.displayName(...) now strips on the first - for Sonos-manufactured devices so the chip shows just Living Room / Kitchen / etc. Generic UPnP devices that append a (192.168.x.x) IP suffix also get that stripped. Manufacturer + model still render as the row subtitle, so the device-type info isn't lost.

    Operator-diagnostic plumbing

    • Loud Timber.w / Timber.i logs on every code path of selectUpnp — the silent early-return cases (currentTrack == null, transportFor() == null) now log loudly, and the success path adds breadcrumbs (mint, SetAVTransportURI, Play, done) so future SOAP failures show exactly how far we got.
    • ReleaseTree Timber tree on release builds. Debug builds had DebugTree; release builds had nothing planted, so the diagnostic Timber output was silently dropped in production. ReleaseTree now plants at WARN+ via android.util.Log.println with the canonical Minstrel tag — keeps DEBUG/INFO chatter out of production logcat while letting operator-driven adb logcat sessions see real failures.

    29 commits across four feature slices + a small bug-fix batch. PR #77.

    Slice 1 — Media3 like / heart button on the media session

    Heart appears in the phone notification card, lock-screen card, Pixel Watch system media controls, and Android Auto. Toggle routes through the existing LikesRepository.toggleLike so it inherits the offline-resilient MutationQueue.

    • LikeMediaCallback grants CMD_TOGGLE_LIKE in onConnect (Media3 issue #2679 guard) and routes onCustomCommand through the repository.
    • MinstrelPlayerService injects LikesRepository, attaches the callback, sets initial preferences, and launches a currentMediaItem × observeIsLiked like-state job so the icon mirrors cross-device likes (web tap flips the phone notification heart automatically).
    • PlayerController.setQueue now thread-dispatches to the MediaController's applicationLooper — cold-boot resume was crashing with IllegalStateException: method is called from a wrong thread.

    Notification-surface end-to-end verified.

    Slice 2 — Bluetooth output picker

    Symfonium-style device chip above the scrubber + bottom sheet showing all available routes (built-in / wired / Bluetooth).

    • OutputPickerController (Hilt singleton, MediaRouter facade) exposes routesState: StateFlow<RouteSnapshot> with passive-by-default discovery, upgrades to active when the sheet opens.
    • OutputPickerViewModel projects routes + owns sheet lifecycle, fans discovery toggle to the controller.
    • DeviceChip + OutputPickerSheet (Material 3 ModalBottomSheet) Compose UI with Lucide icons + BLUETOOTH_CONNECT permission flow.
    • OutputRoute.Protocol enum (SYSTEM / UPNP / CAST / SONOS) baked in as forward-compat — UPnP slot in without data-model changes.

    Slice 3 — UPnP / DLNA / Sonos basic playback

    Discovery + playback to any UPnP-compliant MediaRenderer (Sonos basic, Yamaha MusicCast, Bose SoundTouch, generic DLNA renderers). Audio flows server → speaker directly; the phone is just the controller.

    Server:

    • HMAC-SHA256 stream token: SignStreamToken(secret, trackID, exp) + VerifyStreamToken with hmac.Equal constant-time compare. The stream handler accepts either session cookie OR ?token=&exp= query params.
    • New POST /api/cast/stream-token endpoint — clamped expSeconds, returns {token, exp, url} for the client to hand off to AVTransport.
    • MINSTREL_STREAM_SECRET env var with file-fallback at <DataDir>/stream_secret (auto-generated on first boot, mode 0600, logged once).
    • auth.OptionalUser middleware — attaches user on cookie but doesn't 401 on absence.
    • Stream handler auth-checks BEFORE the DB lookup (also closes a small probe-via-404 info leak).

    Client:

    • SsdpDiscovery — UDP multicast listener on 239.255.255.250:1900, passive NOTIFY listen + explicit M-SEARCH when sheet opens, MulticastLock lifecycle.
    • DeviceDescription — pull-parser over the UPnP device XML, filters non-renderers, resolves relative control URLs.
    • SoapClient — minimal SOAP 1.1 envelope builder + POST via the shared OkHttp; throws SoapFaultException with the UPnP errorCode.
    • AVTransportClient — v1 surface: SetAVTransportURI + Play + Stop.
    • UpnpDiscoveryController — Hilt singleton composing SSDP + DeviceDescription + AVTransport.
    • OutputPickerController now combines system + UPnP routes; select() branches by protocol: SYSTEM → MediaRouter, UPNP → mint token + SOAP SetAVTransportURI + Play.
    • OutputPickerSheet gains a multicast-blocked footer hint.

    Slice 4 — System playlist daily rotation + diversity

    SQL audit of the 5 discovery-mix queries showed Rediscover, New for you, and First listens have no date parameter in their ORDER BY — same content day-over-day until library state shifts.

    • Unified the 5 near-identical produceXxx functions into one produceDiscoveryMix(spec) factory + a per-mix discoveryMixSpec slice. Adding a new mix is now one struct literal + a SQL query.
    • dailyRotate: true on all 3 deterministic mixes — applies a daily-deterministic offset rotate of the pool BEFORE diversify+truncate so each day's slice rotates while contiguous-block ordering is preserved (matters for album-coherent mixes). Deep Cuts + On this day stay false because their SQL already day-keys via md5(id||$2::text).
    • diversify: true on all 5 — per-album ≤2 / per-artist ≤3 caps applied everywhere, with topUpFromRaw fallback: when caps strip the pool below 100 (album-heavy libraries), append non-capped tracks from the raw pool until the target is hit. Diversity where possible, full playlist always.

    Bug-fix batch (Android)

    • LikesRepository.observeLikedTrackIds(): Flow<Set<String>> — direct DAO query, no trackDao join. PlaylistDetailViewModel + AlbumDetailViewModel switched to it; cross-device likes whose track row isn't cached locally now correctly mark playlist/album rows as liked.
    • PlaylistsRepository wraps coverPath/coverUrl through resolveServerUrl so the playlist header art actually fetches via BaseUrlInterceptor (was a relative URL Coil couldn't resolve).

    Verify on device

    • Notification heart tap on a playing track flips state; cross-device (web tap) flips the phone notification heart automatically.
    • Open NowPlaying sheet → Bluetooth devices listed, selection routes audio.
    • On a Sonos / UPnP speaker on the LAN: open NowPlaying sheet → device appears with manufacturer name; select → audio plays through it.
    • Multicast-blocked network: sheet shows "Your router may be blocking multicast discovery."
    • Open Rediscover / New for you / First listens today and tomorrow → content visibly rotates day-over-day.
    • Playlist detail header art renders (was blank); liked-state badge on tracks within a playlist reflects actual liked state.
    Downloads