Files
minstrel/internal/api/cast_token.go
T
bvandeusenandClaude Opus 5.5 2e36e70268
release / go (push) Successful in 2m49s
release / web (push) Successful in 2m21s
release / govulncheck (push) Successful in 25s
release / integration (push) Successful in 5m54s
release / android (push) Successful in 8m10s
release / Build signed APK (releases and dev) (push) Successful in 8m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m42s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(android): Sonos/UPnP queue plays leveled URLs, rendered a track ahead (M464 #5002)
Every URL the Sonos queue loader sends is minted with level=true and
the track's album-play verdict from its neighbours in the queue; the
server returns the plain stream when leveling is off or changes
nothing. The playing track and the one after it are rendered ahead,
and each time the renderer moves on, the next is.

Server: a mint no longer prerenders on its own. A queue load mints
every track, which would have started an ffmpeg render per track at
once. The request now carries prerender, and at most two prerenders
run at a time; past that they are dropped, since a fetch renders on
demand anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:38:21 -04:00

200 lines
7.0 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"
"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
// `<res protocolInfo>` and `<dc:title>` 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
}