-
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 | 204 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