diff --git a/internal/api/api.go b/internal/api/api.go index 4a644661..e95a26a1 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -65,6 +65,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev // design at // docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md. api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream) + // Extension-bearing alias so Sonos's URL probe can identify the + // audio format from the path. The {ext} param is consumed by chi + // and ignored by the handler (which keys off {id}). See task #610. + api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream) api.Group(func(authed chi.Router) { authed.Use(auth.RequireUser(pool)) diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index e638308f..5f3f3046 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -57,6 +57,33 @@ func mimeForFormat(format string) string { } } +// 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. // @@ -114,8 +141,12 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) 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 + "/api/tracks/" + req.TrackID + - "/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) + "/stream." + extForFormat(track.FileFormat) + + "?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) writeJSON(w, http.StatusOK, castTokenResponse{ Token: token,