Merge pull request 'v2026.06.03 hotfix — Sonos cast URL + UPnP picker polish' (#78) from dev into main
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

This commit was merged in pull request #78.
This commit is contained in:
2026-06-03 15:30:09 -04:00
4 changed files with 99 additions and 6 deletions
@@ -158,10 +158,43 @@ class MinstrelApplication :
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
// Debug builds get the full DebugTree (verbose). Release builds
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
// still surfaces UPnP / cast failures, OkHttp errors, and our
// own Timber.w / Timber.e calls — without the chatty DEBUG /
// INFO traffic flooding the buffer in production.
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(ReleaseTree())
}
appScope.launch { resumeController.restore() }
}
/**
* Release-build Timber tree: emits at WARN and above only.
* `android.util.Log` with the canonical tag so `adb logcat` shows
* the line under the standard tag column without falling through
* to the package-stack-trace tag DebugTree produces.
*/
private class ReleaseTree : Timber.Tree() {
override fun isLoggable(tag: String?, priority: Int): Boolean =
priority >= android.util.Log.WARN
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val resolvedTag = tag ?: "Minstrel"
if (t == null) {
android.util.Log.println(priority, resolvedTag, message)
} else {
android.util.Log.println(
priority,
resolvedTag,
message + '\n' + android.util.Log.getStackTraceString(t),
)
}
}
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -178,13 +178,28 @@ class OutputPickerController @Inject constructor(
* deserialize / SOAP-parse code paths).
*/
private suspend fun selectUpnp(route: OutputRoute) {
val trackId = playerController.uiState.value.currentTrack?.id ?: return
val transport = upnpDiscovery.transportFor(route.id) ?: return
val trackId = playerController.uiState.value.currentTrack?.id
if (trackId == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return
}
val transport = upnpDiscovery.transportFor(route.id)
if (transport == null) {
Timber.w(
"UPnP select skipped: no transport for route id=${route.id} " +
"(route disappeared or id mismatch with discovery list)",
)
return
}
runCatching {
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
transport.setAVTransportURI(token.url)
Timber.i("UPnP select: Play")
transport.play()
playerController.pause()
Timber.i("UPnP select: done")
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${route.id}")
}
@@ -106,7 +106,7 @@ class UpnpDiscoveryController @Inject constructor(
?.let { desc ->
UpnpRoute(
id = desc.udn,
name = desc.friendlyName.ifBlank { "Network speaker" },
name = displayName(desc.friendlyName, desc.manufacturer),
manufacturer = desc.manufacturer,
modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl,
@@ -115,6 +115,30 @@ class UpnpDiscoveryController @Inject constructor(
}
}
/**
* Clean the raw UPnP friendlyName for picker display. Sonos uses
* the format `Room - Device Type - RINCON_<UDN>`; we strip
* everything after the first " - " so the chip shows just "Living
* Room" / "Kitchen" / etc. Generic UPnP devices (Yamaha, Samsung
* TV, etc.) often append a "(192.168.x.x)" IP suffix; strip that
* too. Empty / blank → "Network speaker" fallback.
*
* Device subtitle (manufacturer + model) is rendered separately
* by the picker sheet, so room-only here doesn't lose information.
*/
private fun displayName(friendlyName: String, manufacturer: String): String {
val trimmed = friendlyName.trim()
if (trimmed.isBlank()) return "Network speaker"
val byVendor = when {
manufacturer.contains("Sonos", ignoreCase = true) -> {
trimmed.substringBefore(" - ", trimmed)
}
else -> trimmed
}
val withoutIpSuffix = byVendor.replace(IP_SUFFIX_REGEX, "").trim()
return withoutIpSuffix.ifBlank { "Network speaker" }
}
private fun fetchBody(url: HttpUrl): String? {
val body = runCatching {
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
@@ -123,4 +147,11 @@ class UpnpDiscoveryController @Inject constructor(
}.getOrNull().orEmpty()
return body.ifEmpty { null }
}
private companion object {
// " (192.168.0.77)" trailing host suffix some generic UPnP
// devices append. Anchored to end-of-string so it never eats
// a legitimate parenthetical inside a name.
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
}
}
+16 -2
View File
@@ -56,11 +56,25 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
// the public-facing URL is https://. UPnP devices (Sonos especially)
// reject SetAVTransportURI with error 714 (IllegalMimeType) when
// they hit an http:// URL that immediately 301s to https:// — the
// MIME probe fails to find an audio body. Honor X-Forwarded-Proto +
// X-Forwarded-Host first so the URL we hand to the speaker reaches
// it on the same scheme/host the client used. Falls back to
// r.TLS-based detection for direct (no-proxy) deployments.
scheme := "http"
if r.TLS != nil {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS != nil {
scheme = "https"
}
url := scheme + "://" + r.Host + "/api/tracks/" + req.TrackID +
host := r.Host
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
url := scheme + "://" + host + "/api/tracks/" + req.TrackID +
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})