fix(server+android): DIDL-Lite metadata for Sonos UPnP (error 1023)
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:
@@ -39,11 +39,18 @@ data class StreamTokenRequest(
|
|||||||
/**
|
/**
|
||||||
* Response body. [url] is a fully-formed stream URL with [token] and
|
* Response body. [url] is a fully-formed stream URL with [token] and
|
||||||
* [exp] already embedded as query params — callers pass it verbatim
|
* [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
|
@Serializable
|
||||||
data class StreamTokenResponse(
|
data class StreamTokenResponse(
|
||||||
val token: String,
|
val token: String,
|
||||||
val exp: Long,
|
val exp: Long,
|
||||||
val url: String,
|
val url: String,
|
||||||
|
val mime: String = "audio/mpeg",
|
||||||
|
val title: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-2
@@ -194,8 +194,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
runCatching {
|
runCatching {
|
||||||
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
|
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
|
||||||
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
|
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
|
||||||
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
|
Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}")
|
||||||
transport.setAVTransportURI(token.url)
|
transport.setAVTransportURIWithMetadata(
|
||||||
|
uri = token.url,
|
||||||
|
mime = token.mime,
|
||||||
|
title = token.title,
|
||||||
|
)
|
||||||
Timber.i("UPnP select: Play")
|
Timber.i("UPnP select: Play")
|
||||||
transport.play()
|
transport.play()
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
|
|||||||
+41
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
|
||||||
suspend fun play() {
|
suspend fun play() {
|
||||||
soap.call(
|
soap.call(
|
||||||
controlUrl = controlUrl,
|
controlUrl = controlUrl,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,6 +25,36 @@ type castTokenResponse struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Exp int64 `json:"exp"`
|
Exp int64 `json:"exp"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
// MIME and Title let the client build proper DIDL-Lite metadata for
|
||||||
|
// SetAVTransportURI. Sonos rejects empty DIDL with vendor error 1023;
|
||||||
|
// passing back the track's MIME + title here lets the client populate
|
||||||
|
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
||||||
|
MIME string `json:"mime"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeForFormat maps the tracks.file_format column to an HTTP audio
|
||||||
|
// MIME type. Sonos requires the protocolInfo MIME on DIDL-Lite to match
|
||||||
|
// what the URL actually serves; "audio/*" wildcard is silently rejected.
|
||||||
|
// Unknown formats fall back to audio/mpeg — most Sonos firmware probes
|
||||||
|
// the URL anyway and recovers from a small MIME mismatch.
|
||||||
|
func mimeForFormat(format string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
|
case "mp3", "mpeg":
|
||||||
|
return "audio/mpeg"
|
||||||
|
case "flac":
|
||||||
|
return "audio/flac"
|
||||||
|
case "aac", "m4a", "mp4":
|
||||||
|
return "audio/mp4"
|
||||||
|
case "ogg", "vorbis":
|
||||||
|
return "audio/ogg"
|
||||||
|
case "opus":
|
||||||
|
return "audio/opus"
|
||||||
|
case "wav", "wave":
|
||||||
|
return "audio/wav"
|
||||||
|
default:
|
||||||
|
return "audio/mpeg"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCastStreamToken issues a short-lived HMAC stream token for the
|
// handleCastStreamToken issues a short-lived HMAC stream token for the
|
||||||
@@ -52,6 +84,14 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
|||||||
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
|
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Track lookup for the DIDL-Lite metadata the client builds for
|
||||||
|
// SetAVTransportURI. A missing track is a 404 — there's nothing to
|
||||||
|
// cast in that case.
|
||||||
|
track, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackUUID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.NotFound("track"))
|
||||||
|
return
|
||||||
|
}
|
||||||
expSec := clampExpSeconds(req.ExpSeconds)
|
expSec := clampExpSeconds(req.ExpSeconds)
|
||||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||||
@@ -77,7 +117,13 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
|||||||
url := scheme + "://" + host + "/api/tracks/" + req.TrackID +
|
url := scheme + "://" + host + "/api/tracks/" + req.TrackID +
|
||||||
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
|
writeJSON(w, http.StatusOK, castTokenResponse{
|
||||||
|
Token: token,
|
||||||
|
Exp: exp,
|
||||||
|
URL: url,
|
||||||
|
MIME: mimeForFormat(track.FileFormat),
|
||||||
|
Title: track.Title,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// clampExpSeconds applies the [60, 86400] window with a 6h default for
|
// clampExpSeconds applies the [60, 86400] window with a 6h default for
|
||||||
|
|||||||
Reference in New Issue
Block a user