From 8652b86f40ac8dc1c42dff4b54db30ee97bf47a0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:20:25 -0400 Subject: [PATCH] fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/upnp/AVTransportClient.kt | 29 ++++++++++-- .../output/upnp/UpnpDiscoveryController.kt | 7 ++- .../output/upnp/AVTransportClientTest.kt | 46 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index 0eb581f0..fba29910 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -12,6 +12,7 @@ import okhttp3.HttpUrl class AVTransportClient( private val soap: SoapClient, private val controlUrl: HttpUrl, + private val applySonosUriFix: Boolean = false, ) { suspend fun setAVTransportURI(uri: String, metadata: String = "") { soap.call( @@ -43,7 +44,9 @@ class AVTransportClient( * when the caller doesn't supply one. */ 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]. */ suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) { + val safeTitle = title.ifBlank { "Minstrel" } + val effectiveUri = maybeTransformForSonos(uri) soap.call( controlUrl = controlUrl, serviceType = SERVICE_TYPE, action = "SetNextAVTransportURI", args = mapOf( "InstanceID" to "0", - "NextURI" to uri, - "NextURIMetaData" to buildDidlLite(uri, mime, title), + "NextURI" to effectiveUri, + "NextURIMetaData" to buildDidlLite(effectiveUri, mime, safeTitle), ), ) } @@ -135,6 +140,24 @@ class AVTransportClient( 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 `` 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 { val safeTitle = title.ifBlank { "Minstrel" } return buildString { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index d8e688c7..45521e9c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -90,7 +90,12 @@ class UpnpDiscoveryController @Inject constructor( */ fun transportFor(routeId: String): AVTransportClient? { 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) { diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 57ab15bb..194d3ca7 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue 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( + "x-rincon-mp3radio://example.com/track.mp3", + ), + ) { "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("https://example.com/track.mp3")) { + "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( """