Files
minstrel/internal/api/cast_token.go
T
bvandeusen 24b7c92abd
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m14s
test-go / integration (push) Failing after 9m12s
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.
2026-06-03 15:54:56 -04:00

144 lines
4.6 KiB
Go

package api
import (
"net/http"
"strconv"
"strings"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
castTokenMinExpSeconds = 60
castTokenMaxExpSeconds = 86400 // 24h
castTokenDefaultExp = 21600 // 6h
)
type castTokenRequest struct {
TrackID string `json:"trackId"`
ExpSeconds int `json:"expSeconds,omitempty"`
}
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
// `<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
// given trackId. Authenticated via the standard session cookie / bearer.
//
// The returned URL is a fully-formed stream URL (token + exp embedded
// as query params) that the client passes verbatim to a UPnP / Sonos
// device's AVTransport.SetAVTransportURI call — those devices cannot
// carry the user's session, so the signed query string is the only way
// they can fetch the bytes.
//
// expSeconds is clamped to [60, 86400]; default 21600 (6h) — long enough
// to play through any typical track without re-minting mid-playback.
//
// Part of the output-picker UPnP slice. See
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
var req castTokenRequest
if !decodeBody(w, r, &req) {
return
}
trackUUID, ok := parseUUID(req.TrackID)
if !ok || !trackUUID.Valid {
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)
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
// the public-facing URL is https://. UPnP devices (Sonos especially)
// reject SetAVTransportURI with error 714 (IllegalMimeType) when
// they hit an http:// URL that immediately 301s to https:// — the
// MIME probe fails to find an audio body. Honor X-Forwarded-Proto +
// X-Forwarded-Host first so the URL we hand to the speaker reaches
// it on the same scheme/host the client used. Falls back to
// r.TLS-based detection for direct (no-proxy) deployments.
scheme := "http"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS != nil {
scheme = "https"
}
host := r.Host
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
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,
MIME: mimeForFormat(track.FileFormat),
Title: track.Title,
})
}
// clampExpSeconds applies the [60, 86400] window with a 6h default for
// non-positive inputs. Extracted so it doesn't bloat the handler's
// detekt-equivalent line count and so the test can exercise edges directly.
func clampExpSeconds(v int) int {
if v <= 0 {
return castTokenDefaultExp
}
if v < castTokenMinExpSeconds {
return castTokenMinExpSeconds
}
if v > castTokenMaxExpSeconds {
return castTokenMaxExpSeconds
}
return v
}