Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
6.8 KiB
Go
197 lines
6.8 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: 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.
|
|
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.
|
|
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
|
|
}
|