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 // `` 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 // 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 }