fix(server+android): DIDL-Lite metadata for Sonos UPnP (error 1023)
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m14s
test-go / integration (push) Failing after 9m12s

After the X-Forwarded-Proto fix Sonos now gets a clean https:// URL
but returns vendor error 1023 - empty CurrentURIMetaData. Sonos
requires DIDL-Lite metadata with at minimum <res protocolInfo>
carrying the audio MIME type so it can validate the source before
playback. The original spec said 'Sonos accepts empty DIDL; recoverable
if a device rejects' - that was wrong for Sonos.

Server (cast_token.go):
- Look up the track and return mime (from tracks.file_format) +
  title in the cast-token response. mimeForFormat covers the common
  formats - mp3, flac, m4a/aac, ogg, opus, wav - falling through to
  audio/mpeg for unknowns.
- Missing track returns 404 (apierror.NotFound) instead of letting the
  caller mint a token for nothing.

Client (CastApi.kt, AVTransportClient.kt, OutputPickerController.kt):
- StreamTokenResponse gains mime + title (defaulted so old contracts
  stay parseable).
- AVTransportClient.setAVTransportURIWithMetadata builds minimal Sonos-
  acceptable DIDL-Lite around the URL + MIME + title. xml-escaped.
- selectUpnp calls the new overload; Timber.i now logs the MIME so the
  next on-device test shows it.

Generic UPnP renderers tolerate the DIDL shape too - no downside to
sending it everywhere.
This commit is contained in:
2026-06-03 15:54:56 -04:00
parent 7d15f57e86
commit 24b7c92abd
4 changed files with 102 additions and 4 deletions
@@ -39,11 +39,18 @@ data class StreamTokenRequest(
/**
* Response body. [url] is a fully-formed stream URL with [token] and
* [exp] already embedded as query params — callers pass it verbatim
* to `AVTransport.SetAVTransportURI`.
* to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits
* the client needs to build DIDL-Lite metadata for that call: Sonos
* rejects empty DIDL with vendor error 1023, so the server hands back
* the track's MIME (from `tracks.file_format`) and title so the
* client can populate `<res protocolInfo>` and `<dc:title>` without
* a follow-up round trip.
*/
@Serializable
data class StreamTokenResponse(
val token: String,
val exp: Long,
val url: String,
val mime: String = "audio/mpeg",
val title: String = "",
)
@@ -194,8 +194,12 @@ class OutputPickerController @Inject constructor(
runCatching {
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
transport.setAVTransportURI(token.url)
Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}")
transport.setAVTransportURIWithMetadata(
uri = token.url,
mime = token.mime,
title = token.title,
)
Timber.i("UPnP select: Play")
transport.play()
playerController.pause()
@@ -26,6 +26,47 @@ class AVTransportClient(
)
}
/**
* Convenience overload that builds DIDL-Lite metadata around [uri]
* + [mime] + [title] and forwards to [setAVTransportURI]. Sonos
* rejects empty DIDL with vendor error 1023; this constructs the
* minimal-but-Sonos-acceptable shape:
* <DIDL-Lite>
* <item id="0" parentID="-1" restricted="1">
* <dc:title>...</dc:title>
* <upnp:class>object.item.audioItem.musicTrack</upnp:class>
* <res protocolInfo="http-get:*:<mime>:*">...</res>
* </item>
* </DIDL-Lite>
* Generic UPnP renderers tolerate this shape too — there's no
* downside to always sending it. Title falls back to "Minstrel"
* when the caller doesn't supply one.
*/
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
suspend fun play() {
soap.call(
controlUrl = controlUrl,