024493f2a7
Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit. - streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant. - audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs. - coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
164 lines
5.4 KiB
Go
164 lines
5.4 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"
|
|
)
|
|
|
|
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
|
|
// `<res protocolInfo>` and `<dc:title>` 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
|
|
}
|