From 04b3a445cab8d98617e555e1715b85afaeb93717 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 16 May 2026 15:57:37 +0200 Subject: [PATCH] feat(bmx): make TuneIn formats= configurable via Settings.TuneInStreamFormats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #249 added "hls" unconditionally to TuneIn's Tune.ashx formats= query. That regressed playback on the SoundTouch line: TuneIn returns an .m3u8 HLS playlist for stations like K-LOVE (s33828), the speaker can't parse it, blinks amber and falls silent. Verified that firmware 27 on ST10 and ST20 ships the byte-identical Mozilla CCADB bundle and validates the actual stream chain cleanly, so it isn't a cert-expiry issue (#292's hypothesis) — the speaker simply has no HLS support. Changes: - TuneInStream is now a builder, not a const: takes the station ID plus a formats string (empty falls back to the new exported DefaultTuneInStreamFormats = "mp3,aac,ogg" — matches the pre-#249 request shape). - TuneInPlayback and TuneInPlaybackPodcast take the formats string. - New Settings.TuneInStreamFormats string. Empty by default. Operators with HLS-capable speakers can set it to "mp3,aac,ogg,hls" — or any other comma-separated list — via settings.json. The value is passed through verbatim; AfterTouch does not validate the individual format tokens, so this is also the right knob for trialling additional formats without code changes. - Two regression tests pin both the empty-uses-default contract and the override-passes-through contract (with the whitespace- trim sub-case) so PR #249-style regressions surface at compile/test time. The setting is settings.json-only (matches the existing pattern for AllowInsecureUpstreamTLS / TrustForwardedHeaders / TrustedProxyCIDRs which are also edit-the-file settings). UI surface can be a small follow-up if reporters ask for it. Example settings.json snippet to re-enable HLS (only if your speaker can actually play it): { "server_url": "http://aftertouch.local:8000", "tunein_stream_formats": "mp3,aac,ogg,hls" } Restart soundtouch-service after editing. Related to https://github.com/gesellix/Bose-SoundTouch/issues/292. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/bmx/bmx.go | 41 +++++++++++++++++++---- pkg/service/bmx/bmx_test.go | 49 ++++++++++++++++++++++++++++ pkg/service/datastore/datastore.go | 13 ++++++++ pkg/service/handlers/handlers_bmx.go | 24 ++++++++++++-- 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index e3f999a..fc04589 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -20,11 +20,35 @@ import ( // TuneIn endpoint templates used to resolve station and stream URLs. const ( TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s" - TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg,hls" TuneInNavigateAshx = "http://opml.radiotime.com/?render=json" TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query=" + + // DefaultTuneInStreamFormats is the comma-separated format list + // AfterTouch sends to TuneIn's Tune.ashx by default. Matches the + // pre-2026-05-10 behaviour from before PR #249 added "hls" + // unconditionally — HLS playback is broken on SoundTouch 10/ + // firmware 27 (and probably the rest of the line; see #292). + // Speakers receive an .m3u8 playlist URL they can't parse, blink + // amber, fall silent. Operators with HLS-compatible speakers can + // override via Settings.TuneInStreamFormats. + DefaultTuneInStreamFormats = "mp3,aac,ogg" ) +// TuneInStream returns the formatted Tune.ashx URL for a station or +// podcast. The formats argument controls the formats= query parameter; +// empty falls back to DefaultTuneInStreamFormats. Operators can set +// arbitrary lists (e.g. "mp3,aac,ogg,hls" to re-enable HLS, or +// "aac" to force a single format) via Settings.TuneInStreamFormats. +// The value is passed through verbatim — no token-level validation. +func TuneInStream(stationID, formats string) string { + formats = strings.TrimSpace(formats) + if formats == "" { + formats = DefaultTuneInStreamFormats + } + + return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats) +} + var tuneInClient = &http.Client{Timeout: 10 * time.Second} // allowedTuneInHosts restricts outbound fetches to known TuneIn domains. @@ -555,8 +579,10 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) { } // TuneInPlayback resolves a live radio station and returns a Bose-compatible -// playback response with primary stream and variants. -func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { +// playback response with primary stream and variants. formats is the +// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to +// DefaultTuneInStreamFormats (the SoundTouch-line-compatible shape). +func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, error) { describeURL := fmt.Sprintf(TuneInDescribe, stationID) resp, err := http.Get(describeURL) @@ -588,7 +614,7 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { station := opml.Body.Outline.Station - streamReq := fmt.Sprintf(TuneInStream, stationID) + streamReq := TuneInStream(stationID, formats) streamResp, err := http.Get(streamReq) if err != nil { @@ -697,8 +723,9 @@ func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoRes } // TuneInPlaybackPodcast resolves an on-demand podcast episode and returns -// a playback response suitable for SoundTouch devices. -func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error) { +// a playback response suitable for SoundTouch devices. formats has the +// same semantics as in TuneInPlayback. +func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) { describeURL := fmt.Sprintf(TuneInDescribe, podcastID) resp, err := http.Get(describeURL) @@ -733,7 +760,7 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error topic := opml.Body.Outline.Topic - streamReq := fmt.Sprintf(TuneInStream, podcastID) + streamReq := TuneInStream(podcastID, formats) streamResp, err := http.Get(streamReq) if err != nil { diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go index d16d3af..11a45e4 100644 --- a/pkg/service/bmx/bmx_test.go +++ b/pkg/service/bmx/bmx_test.go @@ -221,3 +221,52 @@ func TestTuneInPodcastInfo_Base64(t *testing.T) { t.Errorf("Expected name %s, got %s", name, resp.Name) } } + +// TestTuneInStream_EmptyFormatsUsesDefault pins the post-#292 contract: +// AfterTouch must NOT request HLS streams from TuneIn unless the +// operator has explicitly opted in. The default request shape is +// "mp3,aac,ogg" — matches pre-2026-05-10 behaviour and works on +// every SoundTouch model verified. PR #249 had added "hls" +// unconditionally; that regressed playback on ST10/firmware 27 (the +// speaker can't parse the .m3u8 playlist TuneIn returns when HLS is +// in the format list). +func TestTuneInStream_EmptyFormatsUsesDefault(t *testing.T) { + got := TuneInStream("s33828", "") + + if strings.Contains(got, "hls") { + t.Errorf("default TuneInStream URL must NOT request HLS; got %s", got) + } + + want := "formats=" + DefaultTuneInStreamFormats + if !strings.Contains(got, want) { + t.Errorf("default TuneInStream URL must request %q; got %s", want, got) + } + + if !strings.Contains(got, "id=s33828") { + t.Errorf("TuneInStream URL must carry the station ID; got %s", got) + } +} + +// TestTuneInStream_OverrideHonoured verifies the opt-in path: when an +// operator sets Settings.TuneInStreamFormats to a custom list, +// TuneInStream passes it through verbatim. Two sub-cases catch the +// common opt-in (re-add hls) and a more drastic override (single +// format) so a future regression in the trim/fallback logic surfaces +// at compile/test time. +func TestTuneInStream_OverrideHonoured(t *testing.T) { + cases := []struct { + formats string + want string + }{ + {"mp3,aac,ogg,hls", "formats=mp3,aac,ogg,hls"}, // opt-in: re-add HLS + {"aac", "formats=aac"}, // single format + {" mp3 ", "formats=mp3"}, // whitespace stripped + } + + for _, tc := range cases { + got := TuneInStream("s33828", tc.formats) + if !strings.Contains(got, tc.want) { + t.Errorf("TuneInStream(%q) URL must contain %q; got %s", tc.formats, tc.want, got) + } + } +} diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 1ee93c9..96f128c 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -2127,6 +2127,19 @@ type Settings struct { // reverse proxy on the same host. Override only if the proxy lives on a // different host within a known-good private subnet. TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"` + + // TuneInStreamFormats overrides the comma-separated format list + // AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value + // uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which + // matches AfterTouch's pre-2026-05-10 behaviour and plays on + // every SoundTouch model verified so far. PR #249 had added + // "hls" unconditionally; that regressed playback on the + // SoundTouch line (#292 — speaker can't parse the .m3u8 playlist + // and blinks amber). Operators with HLS-compatible speakers can + // set this to e.g. "mp3,aac,ogg,hls" via settings.json. The value + // is passed through verbatim; AfterTouch does not validate the + // individual format tokens. + TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"` } // GetSettings retrieves the global service settings. diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 6431c25..fb74a67 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -15,6 +15,26 @@ import ( "github.com/go-chi/chi/v5" ) +// tuneInStreamFormats returns the formats= list AfterTouch should send +// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when +// set. Empty (the default) lets bmx.TuneInStream fall back to +// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible +// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set +// the field to "mp3,aac,ogg,hls" (or any other comma-separated list) +// in settings.json. +func (s *Server) tuneInStreamFormats() string { + if s == nil || s.ds == nil { + return "" + } + + settings, err := s.ds.GetSettings() + if err != nil { + return "" + } + + return settings.TuneInStreamFormats +} + // HandleBMXRegistry returns the BMX service registry. func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) { baseURL := s.serverURL @@ -62,7 +82,7 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) { stationID := chi.URLParam(r, "stationID") - resp, err := bmx.TuneInPlayback(stationID) + resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -109,7 +129,7 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ podcastID := chi.URLParam(r, "podcastID") - resp, err := bmx.TuneInPlaybackPodcast(podcastID) + resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return