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 returns the audio MIME type for a cast (Sonos/UPnP) URL. // Wraps the canonical audioContentType lookup in media.go and overrides // the unknown-format fallback to audio/mpeg, because Sonos rejects // DIDL-Lite with protocolInfo=application/octet-stream (the browser // fallback) -- most Sonos firmware probes the URL anyway and recovers // from a small MIME mismatch. func mimeForFormat(format string) string { mime := audioContentType(format) if mime == "application/octet-stream" { return "audio/mpeg" } return mime } // extForFormat maps the tracks.file_format column to a path-safe file // extension. Sonos firmware gates duration probing on the URL path // extension (Content-Type header alone is insufficient) -- without a // recognizable extension, Sonos reports TrackDuration=0 and seeks // trigger auto-advance because every position past 0 looks past-the- // end. Defaults to "mp3" for unknown formats. See task #610. func extForFormat(format string) string { switch strings.ToLower(strings.TrimSpace(format)) { case "mp3", "mpeg": return "mp3" case "flac": return "flac" case "aac": return "aac" case "m4a", "mp4": return "m4a" case "ogg", "vorbis": return "ogg" case "opus": return "opus" case "wav", "wave": return "wav" default: return "mp3" } } // 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 } // Include the file extension in the path so Sonos's URL probe sees a // recognizable audio file. Without it, Sonos reports TrackDuration=0 // and seeks past 0s land "after the end" -> early track-skip. url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) + "?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 }