diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt index 771ef03a..e291f4a5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt @@ -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 `` and `` 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 = "", ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 1f6a178b..07c42a7d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index a7897087..bccc60c6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -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: + * + * + * ... + * object.item.audioItem.musicTrack + * ... + * + * + * 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("") + append("") + append("").append(xmlEscape(safeTitle)).append("") + append("object.item.audioItem.musicTrack") + append("").append(xmlEscape(uri)).append("") + append("") + append("") + } + setAVTransportURI(uri, didl) + } + + private fun xmlEscape(v: String): String = v + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + suspend fun play() { soap.call( controlUrl = controlUrl, diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index 37751150..e638308f 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -3,9 +3,11 @@ package api import ( "net/http" "strconv" + "strings" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) const ( @@ -23,6 +25,36 @@ type castTokenResponse struct { Token string `json:"token"` Exp int64 `json:"exp"` 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 + // `` and `` 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 @@ -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")) 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) exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix() 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 + "/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