package api import ( "net/http" "strconv" "strings" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/library" ) const ( castTokenMinExpSeconds = 60 castTokenMaxExpSeconds = 86400 // 24h castTokenDefaultExp = 21600 // 6h ) type castTokenRequest struct { TrackID string `json:"trackId"` ExpSeconds int `json:"expSeconds,omitempty"` // Level asks for the leveled stream (M464 #5001): the track rendered at // the user's loudness gain. AsAlbum says the track is playing as part of // its album in order, which picks album gain in auto mode; only the // client holding the queue knows it. Level bool `json:"level,omitempty"` AsAlbum bool `json:"asAlbum,omitempty"` // Prerender says the speaker will fetch this track soon: the current // track at a queue load, or the next one as it starts. Only those are // rendered ahead; a queue load mints every track and renders none of // the rest until the speaker asks. Prerender bool `json:"prerender,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"` // Leveled is true when URL is the leveled stream. A level request still // gets the plain stream when there is nothing to change: leveling off, // the track not yet measured, or a gain of 0. Leveled bool `json:"leveled"` } // 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) { user, ok := requireUser(w, r) if !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) path := streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) + "?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) mime := mimeForFormat(track.FileFormat) leveled := false if req.Level && h.leveled != nil { g, err := h.leveledGainFor(r.Context(), user.ID, trackUUID, req.AsAlbum) if err != nil { // The plain stream still plays; only the leveling is lost. h.logger.Warn("cast token: leveled gain lookup failed", "track", req.TrackID, "err", err) } else if !g.Unity() { token = SignLeveledStreamToken(h.streamSecret, req.TrackID, exp, g) path = leveledStreamPath(trackUUID) + leveledQuery(g, token, exp) mime = "audio/flac" leveled = true if req.Prerender { h.leveled.Prerender(library.LeveledSource{ TrackID: req.TrackID, Path: track.FilePath, DurationMs: track.DurationMs, }, g) } } } // 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 } // The path carries a file extension 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. writeJSON(w, http.StatusOK, castTokenResponse{ Token: token, Exp: exp, URL: scheme + "://" + host + path, MIME: mime, Title: track.Title, Leveled: leveled, }) } // 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 }