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
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:
@@ -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)
|
||||
|
||||
+17
-2
@@ -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}")
|
||||
}
|
||||
|
||||
+32
-1
@@ -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+\)$""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user