mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
fix(service): forward all TuneIn stream candidates for failover
TuneIn's Tune.ashx returns several stream URLs per station (different bitrates/CDNs) so a speaker can fail over when one is dead. TuneInPlayback parsed the full list but forwarded only urls[0], wrapping a single URL in the audio.streams[] array. When TuneIn listed a dead variant first (e.g. station s56857 / NDR 2 Niedersachsen, whose aac/low 404s while mp3/128 plays), the speaker had no fallback and dead-ended retrying the 404. Add BuildCustomStreamResponseFromURLs to emit one Stream per candidate in provider order (top-level StreamUrl mirrors urls[0] for compatibility), have the single-URL BuildCustomStreamResponse delegate to it, and forward the full slice from TuneInPlayback. The other single-URL callers (PlayCustomStream, the custom-stream handler) are unchanged. Confirmed on real hardware: the speaker now fails over from the 404'd aac/low to the working mp3/128 stream and plays. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c7eda7ed7b
commit
ef9eea57a6
+21
-7
@@ -6,6 +6,7 @@ package bmx
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -36,20 +37,28 @@ func BuildOrionLocation(serviceURL, name, imageURL, streamURL string) string {
|
||||
return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded
|
||||
}
|
||||
|
||||
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
|
||||
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
|
||||
streamList := []models.Stream{
|
||||
{
|
||||
// BuildCustomStreamResponseFromURLs wraps one or more candidate stream URLs
|
||||
// in a playback response. The speaker fails over between entries in the
|
||||
// Streams array, so order matters — pass them as the provider listed them.
|
||||
// The top-level StreamUrl mirrors urls[0] for compatibility.
|
||||
func BuildCustomStreamResponseFromURLs(urls []string, imageURL, name string) (*models.BmxPlaybackResponse, error) {
|
||||
if len(urls) == 0 {
|
||||
return nil, fmt.Errorf("no stream URLs provided")
|
||||
}
|
||||
|
||||
streamList := make([]models.Stream, 0, len(urls))
|
||||
for _, u := range urls {
|
||||
streamList = append(streamList, models.Stream{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: streamURL,
|
||||
},
|
||||
StreamUrl: u,
|
||||
})
|
||||
}
|
||||
|
||||
audio := models.Audio{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: streamURL,
|
||||
StreamUrl: urls[0],
|
||||
Streams: streamList,
|
||||
}
|
||||
|
||||
@@ -63,6 +72,11 @@ func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPla
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
|
||||
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
|
||||
return BuildCustomStreamResponseFromURLs([]string{streamURL}, imageURL, name)
|
||||
}
|
||||
|
||||
// PlayCustomStream builds a playback response from a base64-encoded JSON blob
|
||||
// with fields streamUrl, imageUrl, and name.
|
||||
func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
|
||||
|
||||
@@ -29,3 +29,46 @@ func TestPlayCustomStream(t *testing.T) {
|
||||
t.Errorf("Expected name Stream Name, got %s", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCustomStreamResponseFromURLs(t *testing.T) {
|
||||
// Multiple candidates must all reach the speaker, in order, so it can
|
||||
// fail over from a dead variant to a working one (see s56857 / NDR 2).
|
||||
urls := []string{
|
||||
"https://example.com/aac/low",
|
||||
"https://example.com/mp3/128/stream.mp3",
|
||||
}
|
||||
|
||||
resp, err := BuildCustomStreamResponseFromURLs(urls, "image.png", "NDR 2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCustomStreamResponseFromURLs failed: %v", err)
|
||||
}
|
||||
|
||||
if got := len(resp.Audio.Streams); got != len(urls) {
|
||||
t.Fatalf("expected %d streams, got %d", len(urls), got)
|
||||
}
|
||||
|
||||
for i, want := range urls {
|
||||
if got := resp.Audio.Streams[i].StreamUrl; got != want {
|
||||
t.Errorf("stream %d: expected %q, got %q", i, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Audio.StreamUrl != urls[0] {
|
||||
t.Errorf("top-level StreamUrl: expected %q, got %q", urls[0], resp.Audio.StreamUrl)
|
||||
}
|
||||
|
||||
// Empty input is an error, not a panic.
|
||||
if _, err := BuildCustomStreamResponseFromURLs(nil, "", ""); err == nil {
|
||||
t.Error("expected error for empty URL list, got nil")
|
||||
}
|
||||
|
||||
// The single-URL wrapper still yields exactly one stream.
|
||||
single, err := BuildCustomStreamResponse("https://example.com/only", "", "Solo")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCustomStreamResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if got := len(single.Audio.Streams); got != 1 {
|
||||
t.Errorf("expected 1 stream from single-URL builder, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,7 +808,7 @@ func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, err
|
||||
|
||||
name, logo, _ := TuneInDescribeMeta(stationID)
|
||||
|
||||
return BuildCustomStreamResponse(urls[0], logo, name)
|
||||
return BuildCustomStreamResponseFromURLs(urls, logo, name)
|
||||
}
|
||||
|
||||
// TuneInPodcastInfo returns info for a TuneIn podcast.
|
||||
|
||||
Reference in New Issue
Block a user