dev → main: Android UPnP/Sonos transport parity + server stream URL extension #79
+128
-26
@@ -3,12 +3,11 @@ package com.fabledsword.minstrel.player.output.upnp
|
|||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-level wrapper for the UPnP AVTransport service. Three calls
|
* High-level wrapper for the UPnP AVTransport service.
|
||||||
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred
|
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause /
|
||||||
* until we have hardware in the loop to verify each device's quirks
|
* Stop / Seek / GetPositionInfo / GetTransportInfo.
|
||||||
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs
|
|
||||||
* reject Pause without DIDL).
|
|
||||||
*/
|
*/
|
||||||
|
@Suppress("TooManyFunctions") // Compose-adjacent helper density: all are direct SOAP verbs.
|
||||||
class AVTransportClient(
|
class AVTransportClient(
|
||||||
private val soap: SoapClient,
|
private val soap: SoapClient,
|
||||||
private val controlUrl: HttpUrl,
|
private val controlUrl: HttpUrl,
|
||||||
@@ -43,29 +42,26 @@ 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) {
|
||||||
val safeTitle = title.ifBlank { "Minstrel" }
|
setAVTransportURI(uri, buildDidlLite(uri, mime, title))
|
||||||
val didl = buildString {
|
|
||||||
append("<DIDL-Lite ")
|
|
||||||
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
|
||||||
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
|
||||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">")
|
|
||||||
append("<item id=\"0\" parentID=\"-1\" restricted=\"1\">")
|
|
||||||
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
|
||||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
|
||||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
|
||||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
|
||||||
append("</item>")
|
|
||||||
append("</DIDL-Lite>")
|
|
||||||
}
|
|
||||||
setAVTransportURI(uri, didl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun xmlEscape(v: String): String = v
|
/**
|
||||||
.replace("&", "&")
|
* Queues the next track on the renderer so it can pre-buffer before
|
||||||
.replace("<", "<")
|
* the current track finishes. Uses the same DIDL-Lite shape as
|
||||||
.replace(">", ">")
|
* [setAVTransportURIWithMetadata].
|
||||||
.replace("\"", """)
|
*/
|
||||||
.replace("'", "'")
|
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
|
||||||
|
soap.call(
|
||||||
|
controlUrl = controlUrl,
|
||||||
|
serviceType = SERVICE_TYPE,
|
||||||
|
action = "SetNextAVTransportURI",
|
||||||
|
args = mapOf(
|
||||||
|
"InstanceID" to "0",
|
||||||
|
"NextURI" to uri,
|
||||||
|
"NextURIMetaData" to buildDidlLite(uri, mime, title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun play() {
|
suspend fun play() {
|
||||||
soap.call(
|
soap.call(
|
||||||
@@ -76,6 +72,15 @@ class AVTransportClient(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun pause() {
|
||||||
|
soap.call(
|
||||||
|
controlUrl = controlUrl,
|
||||||
|
serviceType = SERVICE_TYPE,
|
||||||
|
action = "Pause",
|
||||||
|
args = mapOf("InstanceID" to "0"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun stop() {
|
suspend fun stop() {
|
||||||
soap.call(
|
soap.call(
|
||||||
controlUrl = controlUrl,
|
controlUrl = controlUrl,
|
||||||
@@ -85,7 +90,104 @@ class AVTransportClient(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun seek(positionMs: Long) {
|
||||||
|
soap.call(
|
||||||
|
controlUrl = controlUrl,
|
||||||
|
serviceType = SERVICE_TYPE,
|
||||||
|
action = "Seek",
|
||||||
|
args = mapOf(
|
||||||
|
"InstanceID" to "0",
|
||||||
|
"Unit" to "REL_TIME",
|
||||||
|
"Target" to formatHhMmSs(positionMs),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPositionInfo(): PositionInfo {
|
||||||
|
val result = soap.call(
|
||||||
|
controlUrl = controlUrl,
|
||||||
|
serviceType = SERVICE_TYPE,
|
||||||
|
action = "GetPositionInfo",
|
||||||
|
args = mapOf("InstanceID" to "0"),
|
||||||
|
)
|
||||||
|
return PositionInfo(
|
||||||
|
trackUri = result["TrackURI"].orEmpty(),
|
||||||
|
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
|
||||||
|
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTransportInfo(): TransportInfo {
|
||||||
|
val result = soap.call(
|
||||||
|
controlUrl = controlUrl,
|
||||||
|
serviceType = SERVICE_TYPE,
|
||||||
|
action = "GetTransportInfo",
|
||||||
|
args = mapOf("InstanceID" to "0"),
|
||||||
|
)
|
||||||
|
val state = when (result["CurrentTransportState"]) {
|
||||||
|
"PLAYING" -> TransportState.PLAYING
|
||||||
|
"PAUSED_PLAYBACK" -> TransportState.PAUSED
|
||||||
|
"STOPPED" -> TransportState.STOPPED
|
||||||
|
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||||
|
else -> TransportState.UNKNOWN
|
||||||
|
}
|
||||||
|
return TransportInfo(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
||||||
|
val safeTitle = title.ifBlank { "Minstrel" }
|
||||||
|
return buildString {
|
||||||
|
append("<DIDL-Lite ")
|
||||||
|
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
||||||
|
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
||||||
|
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">")
|
||||||
|
append("<item id=\"0\" parentID=\"-1\" restricted=\"1\">")
|
||||||
|
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||||
|
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||||
|
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||||
|
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
||||||
|
append("</item>")
|
||||||
|
append("</DIDL-Lite>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun xmlEscape(v: String): String = v
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
|
||||||
|
private fun formatHhMmSs(positionMs: Long): String {
|
||||||
|
val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0)
|
||||||
|
val h = totalSec / SECONDS_PER_HOUR
|
||||||
|
val m = (totalSec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
|
||||||
|
val s = totalSec % SECONDS_PER_MINUTE
|
||||||
|
return "%d:%02d:%02d".format(h, m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHhMmSs(raw: String): Long {
|
||||||
|
if (raw.isBlank()) return 0L
|
||||||
|
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
if (parts.size != 3) return 0L
|
||||||
|
val (h, m, s) = parts
|
||||||
|
return ((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||||
|
const val MILLIS_PER_SECOND = 1000L
|
||||||
|
const val SECONDS_PER_MINUTE = 60L
|
||||||
|
const val SECONDS_PER_HOUR = 3600L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class PositionInfo(
|
||||||
|
val trackUri: String,
|
||||||
|
val relTimeMs: Long,
|
||||||
|
val trackDurationMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
||||||
|
|
||||||
|
data class TransportInfo(val state: TransportState)
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output.upnp
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
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.assertTrue
|
||||||
|
|
||||||
|
class AVTransportClientTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var client: AVTransportClient
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
|
||||||
|
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `seek formats position as hh-mm-ss`() = runTest {
|
||||||
|
server.enqueue(emptyResponse("Seek"))
|
||||||
|
client.seek(positionMs = 65_000L)
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertTrue(body.contains("<Target>0:01:05</Target>")) { "body was $body" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getPositionInfo parses RelTime and TrackDuration`() = runTest {
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse().setBody(
|
||||||
|
"""<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetPositionInfoResponse
|
||||||
|
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||||
|
<Track>1</Track>
|
||||||
|
<TrackDuration>0:03:30</TrackDuration>
|
||||||
|
<TrackURI>http://x/y.mp3</TrackURI>
|
||||||
|
<RelTime>0:01:05</RelTime>
|
||||||
|
</u:GetPositionInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val info = client.getPositionInfo()
|
||||||
|
assertEquals(65_000L, info.relTimeMs)
|
||||||
|
assertEquals(210_000L, info.trackDurationMs)
|
||||||
|
assertEquals("http://x/y.mp3", info.trackUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTransportInfo maps PAUSED_PLAYBACK to PAUSED`() = runTest {
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse().setBody(
|
||||||
|
"""<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetTransportInfoResponse
|
||||||
|
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||||
|
<CurrentTransportState>PAUSED_PLAYBACK</CurrentTransportState>
|
||||||
|
<CurrentTransportStatus>OK</CurrentTransportStatus>
|
||||||
|
<CurrentSpeed>1</CurrentSpeed>
|
||||||
|
</u:GetTransportInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val info = client.getTransportInfo()
|
||||||
|
assertEquals(TransportState.PAUSED, info.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setNextAVTransportURI sends DIDL-Lite with mime and title`() = runTest {
|
||||||
|
server.enqueue(emptyResponse("SetNextAVTransportURI"))
|
||||||
|
client.setNextAVTransportURI(
|
||||||
|
uri = "http://x/y.mp3",
|
||||||
|
mime = "audio/mpeg",
|
||||||
|
title = "Song",
|
||||||
|
)
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertTrue(body.contains("<NextURI>http://x/y.mp3</NextURI>")) {
|
||||||
|
"body missing NextURI: $body"
|
||||||
|
}
|
||||||
|
assertTrue(body.contains("<dc:title>Song</dc:title>")) {
|
||||||
|
"body missing dc:title: $body"
|
||||||
|
}
|
||||||
|
assertTrue(body.contains("audio/mpeg")) {
|
||||||
|
"body missing mime type: $body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
||||||
|
"""<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<u:${action}Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user