package api import ( "net/http" "strconv" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" ) 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"` } // 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 } expSec := clampExpSeconds(req.ExpSeconds) exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix() token := SignStreamToken(h.streamSecret, req.TrackID, exp) scheme := "http" if r.TLS != nil { scheme = "https" } url := scheme + "://" + r.Host + "/api/tracks/" + req.TrackID + "/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url}) } // 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 }