fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI
android / Build + lint + test (push) Failing after 1m20s
android / Build + lint + test (push) Failing after 1m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+26
-3
@@ -12,6 +12,7 @@ import okhttp3.HttpUrl
|
|||||||
class AVTransportClient(
|
class AVTransportClient(
|
||||||
private val soap: SoapClient,
|
private val soap: SoapClient,
|
||||||
private val controlUrl: HttpUrl,
|
private val controlUrl: HttpUrl,
|
||||||
|
private val applySonosUriFix: Boolean = false,
|
||||||
) {
|
) {
|
||||||
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
||||||
soap.call(
|
soap.call(
|
||||||
@@ -43,7 +44,9 @@ class AVTransportClient(
|
|||||||
* when the caller doesn't supply one.
|
* when the caller doesn't supply one.
|
||||||
*/
|
*/
|
||||||
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
||||||
setAVTransportURI(uri, buildDidlLite(uri, mime, title))
|
val safeTitle = title.ifBlank { "Minstrel" }
|
||||||
|
val effectiveUri = maybeTransformForSonos(uri)
|
||||||
|
setAVTransportURI(effectiveUri, buildDidlLite(effectiveUri, mime, safeTitle))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,14 +55,16 @@ class AVTransportClient(
|
|||||||
* [setAVTransportURIWithMetadata].
|
* [setAVTransportURIWithMetadata].
|
||||||
*/
|
*/
|
||||||
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
|
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
|
||||||
|
val safeTitle = title.ifBlank { "Minstrel" }
|
||||||
|
val effectiveUri = maybeTransformForSonos(uri)
|
||||||
soap.call(
|
soap.call(
|
||||||
controlUrl = controlUrl,
|
controlUrl = controlUrl,
|
||||||
serviceType = SERVICE_TYPE,
|
serviceType = SERVICE_TYPE,
|
||||||
action = "SetNextAVTransportURI",
|
action = "SetNextAVTransportURI",
|
||||||
args = mapOf(
|
args = mapOf(
|
||||||
"InstanceID" to "0",
|
"InstanceID" to "0",
|
||||||
"NextURI" to uri,
|
"NextURI" to effectiveUri,
|
||||||
"NextURIMetaData" to buildDidlLite(uri, mime, title),
|
"NextURIMetaData" to buildDidlLite(effectiveUri, mime, safeTitle),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -135,6 +140,24 @@ class AVTransportClient(
|
|||||||
return TransportInfo(state)
|
return TransportInfo(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sonos firmware 6.4.2+ silently refuses plain http(s):// URIs in
|
||||||
|
* SetAVTransportURI -- the SOAP call returns 200 OK but the URI never
|
||||||
|
* loads. The documented fix (from SoCo et al.) is to replace the
|
||||||
|
* scheme with x-rincon-mp3radio:// for Sonos targets. Generic UPnP
|
||||||
|
* renderers (Yamaha, Bose, generic DLNA) reject this scheme, so the
|
||||||
|
* transform is conditional on the manufacturer.
|
||||||
|
*
|
||||||
|
* The transformed URI goes both in the SOAP CurrentURI / NextURI arg
|
||||||
|
* AND inside the DIDL-Lite `<res>` element so the two stay consistent.
|
||||||
|
*/
|
||||||
|
private fun maybeTransformForSonos(uri: String): String {
|
||||||
|
if (!applySonosUriFix) return uri
|
||||||
|
return uri
|
||||||
|
.replaceFirst("https://", "x-rincon-mp3radio://")
|
||||||
|
.replaceFirst("http://", "x-rincon-mp3radio://")
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
||||||
val safeTitle = title.ifBlank { "Minstrel" }
|
val safeTitle = title.ifBlank { "Minstrel" }
|
||||||
return buildString {
|
return buildString {
|
||||||
|
|||||||
+6
-1
@@ -90,7 +90,12 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun transportFor(routeId: String): AVTransportClient? {
|
fun transportFor(routeId: String): AVTransportClient? {
|
||||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||||
return AVTransportClient(loggingSoapClient(okHttp, route.name), route.avTransportControlUrl)
|
val isSonos = route.manufacturer.contains("Sonos", ignoreCase = true)
|
||||||
|
return AVTransportClient(
|
||||||
|
loggingSoapClient(okHttp, route.name),
|
||||||
|
route.avTransportControlUrl,
|
||||||
|
applySonosUriFix = isSonos,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleDiscovery(locationUrl: String) {
|
private suspend fun handleDiscovery(locationUrl: String) {
|
||||||
|
|||||||
+46
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.AfterEach
|
|||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class AVTransportClientTest {
|
class AVTransportClientTest {
|
||||||
@@ -122,6 +123,51 @@ class AVTransportClientTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setAVTransportURIWithMetadata transforms https to x-rincon-mp3radio when Sonos flag set`() =
|
||||||
|
runTest {
|
||||||
|
val sonosClient = AVTransportClient(
|
||||||
|
SoapClient(OkHttpClient()),
|
||||||
|
server.url("/MediaRenderer/AVTransport/Control"),
|
||||||
|
applySonosUriFix = true,
|
||||||
|
)
|
||||||
|
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||||
|
sonosClient.setAVTransportURIWithMetadata(
|
||||||
|
uri = "https://example.com/track.mp3",
|
||||||
|
mime = "audio/mpeg",
|
||||||
|
title = "Song",
|
||||||
|
)
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertTrue(
|
||||||
|
body.contains(
|
||||||
|
"<CurrentURI>x-rincon-mp3radio://example.com/track.mp3</CurrentURI>",
|
||||||
|
),
|
||||||
|
) { "missing transformed CurrentURI: $body" }
|
||||||
|
assertTrue(body.contains("x-rincon-mp3radio://example.com/track.mp3")) {
|
||||||
|
"transformed URI not present in body: $body"
|
||||||
|
}
|
||||||
|
assertFalse(body.contains("https://example.com/track.mp3")) {
|
||||||
|
"untransformed URI should not appear: $body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setAVTransportURIWithMetadata leaves https untransformed by default`() = runTest {
|
||||||
|
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||||
|
client.setAVTransportURIWithMetadata(
|
||||||
|
uri = "https://example.com/track.mp3",
|
||||||
|
mime = "audio/mpeg",
|
||||||
|
title = "Song",
|
||||||
|
)
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertTrue(body.contains("<CurrentURI>https://example.com/track.mp3</CurrentURI>")) {
|
||||||
|
"CurrentURI should be untransformed: $body"
|
||||||
|
}
|
||||||
|
assertFalse(body.contains("x-rincon-mp3radio")) {
|
||||||
|
"Sonos scheme should not appear without flag: $body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
||||||
"""<?xml version="1.0"?>
|
"""<?xml version="1.0"?>
|
||||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
|||||||
Reference in New Issue
Block a user