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() {
|
override fun onCreate() {
|
||||||
super.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() }
|
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
|
override val workManagerConfiguration: Configuration
|
||||||
get() = Configuration.Builder()
|
get() = Configuration.Builder()
|
||||||
.setWorkerFactory(workerFactory)
|
.setWorkerFactory(workerFactory)
|
||||||
|
|||||||
+17
-2
@@ -178,13 +178,28 @@ class OutputPickerController @Inject constructor(
|
|||||||
* deserialize / SOAP-parse code paths).
|
* deserialize / SOAP-parse code paths).
|
||||||
*/
|
*/
|
||||||
private suspend fun selectUpnp(route: OutputRoute) {
|
private suspend fun selectUpnp(route: OutputRoute) {
|
||||||
val trackId = playerController.uiState.value.currentTrack?.id ?: return
|
val trackId = playerController.uiState.value.currentTrack?.id
|
||||||
val transport = upnpDiscovery.transportFor(route.id) ?: return
|
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 {
|
runCatching {
|
||||||
|
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
|
||||||
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
|
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
|
||||||
|
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
|
||||||
transport.setAVTransportURI(token.url)
|
transport.setAVTransportURI(token.url)
|
||||||
|
Timber.i("UPnP select: Play")
|
||||||
transport.play()
|
transport.play()
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
|
Timber.i("UPnP select: done")
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.w(e, "UPnP select failed for route ${route.id}")
|
Timber.w(e, "UPnP select failed for route ${route.id}")
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -106,7 +106,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
?.let { desc ->
|
?.let { desc ->
|
||||||
UpnpRoute(
|
UpnpRoute(
|
||||||
id = desc.udn,
|
id = desc.udn,
|
||||||
name = desc.friendlyName.ifBlank { "Network speaker" },
|
name = displayName(desc.friendlyName, desc.manufacturer),
|
||||||
manufacturer = desc.manufacturer,
|
manufacturer = desc.manufacturer,
|
||||||
modelName = desc.modelName,
|
modelName = desc.modelName,
|
||||||
avTransportControlUrl = desc.avTransportControlUrl,
|
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? {
|
private fun fetchBody(url: HttpUrl): String? {
|
||||||
val body = runCatching {
|
val body = runCatching {
|
||||||
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
|
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
|
||||||
@@ -123,4 +147,11 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}.getOrNull().orEmpty()
|
}.getOrNull().orEmpty()
|
||||||
return body.ifEmpty { null }
|
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()
|
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
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"
|
scheme := "http"
|
||||||
if r.TLS != nil {
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
} else if r.TLS != nil {
|
||||||
scheme = "https"
|
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)
|
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
|
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
|
||||||
|
|||||||
Reference in New Issue
Block a user