dev → main: Android UPnP/Sonos transport parity + server stream URL extension #79
+15
-8
@@ -1,3 +1,4 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
|
||||||
package com.fabledsword.minstrel.player.output.upnp
|
package com.fabledsword.minstrel.player.output.upnp
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -15,6 +16,7 @@ import okhttp3.HttpUrl
|
|||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -113,7 +115,9 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: return
|
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: return
|
||||||
val groups = runCatching {
|
val groups = runCatching {
|
||||||
ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState()
|
ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState()
|
||||||
}.getOrNull() ?: return
|
}
|
||||||
|
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
|
||||||
|
.getOrNull() ?: return
|
||||||
sonosTopologyInternal.value = groups
|
sonosTopologyInternal.value = groups
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,13 +128,11 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
|
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
|
||||||
*/
|
*/
|
||||||
fun coordinatorRouteFor(udn: String): UpnpRoute? {
|
fun coordinatorRouteFor(udn: String): UpnpRoute? {
|
||||||
val bare = udn.removePrefix("uuid:")
|
val bare = udn.bareUdn()
|
||||||
val coord = sonosTopologyInternal.value
|
val coord = sonosTopologyInternal.value
|
||||||
.firstOrNull { g -> g.members.any { it.udn.removePrefix("uuid:") == bare } }
|
.firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } }
|
||||||
?.coordinatorUdn ?: return null
|
?.coordinatorUdn ?: return null
|
||||||
return routesInternal.value.firstOrNull {
|
return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() }
|
||||||
it.id.removePrefix("uuid:") == coord.removePrefix("uuid:")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,8 +141,8 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun nonCoordinatorMemberUdns(): Set<String> {
|
fun nonCoordinatorMemberUdns(): Set<String> {
|
||||||
return sonosTopologyInternal.value.flatMap { g ->
|
return sonosTopologyInternal.value.flatMap { g ->
|
||||||
g.members.map { it.udn.removePrefix("uuid:") }
|
g.members.map { it.udn.bareUdn() }
|
||||||
.filter { it != g.coordinatorUdn.removePrefix("uuid:") }
|
.filter { it != g.coordinatorUdn.bareUdn() }
|
||||||
}.toSet()
|
}.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,3 +210,8 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uuid: prefix normalization — DeviceDescription <UDN> carries it; Sonos
|
||||||
|
// ZoneGroupState Coordinator/UUID attrs don't. Helper centralizes the strip
|
||||||
|
// so a future change to the convention isn't a silent compare-always-false bug.
|
||||||
|
private fun String.bareUdn(): String = removePrefix("uuid:")
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import okhttp3.HttpUrl
|
|||||||
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
|
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
|
||||||
* SSDP details so the picker UI consumes a narrow domain shape.
|
* SSDP details so the picker UI consumes a narrow domain shape.
|
||||||
*
|
*
|
||||||
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds
|
* Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the
|
||||||
* (group join/leave, zone topology) live in a separate Sonos extension
|
* ZoneGroupTopology service in their device description; non-Sonos devices
|
||||||
* scoped in
|
* leave it null. The discovery controller uses that URL to aggregate stereo
|
||||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md.
|
* pairs and multi-speaker groups into single picker rows.
|
||||||
*
|
*
|
||||||
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
|
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
|
||||||
* raw `<friendlyName>` straight from the device description — callers
|
* raw `<friendlyName>` straight from the device description — callers
|
||||||
|
|||||||
+1
@@ -58,6 +58,7 @@ class DeviceDescriptionTest {
|
|||||||
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
|
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
|
||||||
desc.renderingControlUrl?.toString(),
|
desc.renderingControlUrl?.toString(),
|
||||||
)
|
)
|
||||||
|
assertNull(desc.zoneGroupTopologyControlUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user