fix(android): Sonos UDN comparison strips _MR/_MS suffix -- coordinator routing
android / Build + lint + test (push) Failing after 2m41s
android / Build + lint + test (push) Failing after 2m41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -13,6 +13,7 @@ import com.fabledsword.minstrel.player.StreamTokenProvider
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -100,7 +101,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
) { sys, upnp, _, upnpSelected ->
|
) { sys, upnp, _, upnpSelected ->
|
||||||
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
|
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
|
||||||
val visibleUpnp = upnp
|
val visibleUpnp = upnp
|
||||||
.filter { it.id.removePrefix("uuid:") !in suppressed } // suppressed set is bare UDNs
|
.filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs
|
||||||
.map { OutputRoute.fromUpnpRoute(it) }
|
.map { OutputRoute.fromUpnpRoute(it) }
|
||||||
val merged = sys.available + visibleUpnp
|
val merged = sys.available + visibleUpnp
|
||||||
val current = if (upnpSelected != null) {
|
val current = if (upnpSelected != null) {
|
||||||
|
|||||||
+28
-5
@@ -117,12 +117,23 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
|
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
|
||||||
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: return
|
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run {
|
||||||
|
Timber.w(
|
||||||
|
"Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled",
|
||||||
|
anySonos.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
val groups = runCatching {
|
val groups = runCatching {
|
||||||
ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState()
|
ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState()
|
||||||
}
|
}
|
||||||
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
|
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
|
||||||
.getOrNull() ?: return
|
.getOrNull() ?: return
|
||||||
|
Timber.w(
|
||||||
|
"Sonos topology refreshed for %s: %d group(s)",
|
||||||
|
anySonos.id,
|
||||||
|
groups.size,
|
||||||
|
)
|
||||||
sonosTopologyInternal.value = groups
|
sonosTopologyInternal.value = groups
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +227,19 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// uuid: prefix normalization — DeviceDescription <UDN> carries it; Sonos
|
/**
|
||||||
// ZoneGroupState Coordinator/UUID attrs don't. Helper centralizes the strip
|
* Normalize a Sonos UDN to its bare RINCON form for cross-comparison.
|
||||||
// so a future change to the convention isn't a silent compare-always-false bug.
|
*
|
||||||
private fun String.bareUdn(): String = removePrefix("uuid:")
|
* Three places use UDN strings with different conventions:
|
||||||
|
* - DeviceDescription's <UDN> tag carries the `uuid:` prefix.
|
||||||
|
* - Sonos exposes one UDN per embedded device. The MediaRenderer adds
|
||||||
|
* a `_MR` suffix and the MediaServer adds `_MS`.
|
||||||
|
* - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with
|
||||||
|
* neither the `uuid:` prefix nor any device suffix.
|
||||||
|
*
|
||||||
|
* To compare any pair of those, strip the prefix and the suffix so
|
||||||
|
* everything reduces to the underlying speaker identity.
|
||||||
|
*/
|
||||||
|
internal fun String.bareUdn(): String = removePrefix("uuid:")
|
||||||
|
.removeSuffix("_MR")
|
||||||
|
.removeSuffix("_MS")
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output.upnp
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class BareUdnTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips uuid prefix`() {
|
||||||
|
assertEquals("RINCON_ABC", "uuid:RINCON_ABC".bareUdn())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips MR suffix`() {
|
||||||
|
assertEquals("RINCON_ABC", "RINCON_ABC_MR".bareUdn())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips MS suffix`() {
|
||||||
|
assertEquals("RINCON_ABC", "RINCON_ABC_MS".bareUdn())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips both uuid prefix and MR suffix`() {
|
||||||
|
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MR".bareUdn())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips both uuid prefix and MS suffix`() {
|
||||||
|
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MS".bareUdn())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `leaves already bare UDN unchanged`() {
|
||||||
|
assertEquals("RINCON_ABC", "RINCON_ABC".bareUdn())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `MR suffix comparison crosses MediaRenderer vs ZGT response`() {
|
||||||
|
val routeId = "uuid:RINCON_5CAAFD794B6401400_MR"
|
||||||
|
val zgtMemberUdn = "RINCON_5CAAFD794B6401400"
|
||||||
|
assertEquals(routeId.bareUdn(), zgtMemberUdn.bareUdn())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user