diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index b1b4f21..18349e3 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -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) { diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go index 7a81bf3..6a85048 100644 --- a/pkg/service/bmx/bmx_test.go +++ b/pkg/service/bmx/bmx_test.go @@ -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) + } +} diff --git a/pkg/service/bmx/tunein.go b/pkg/service/bmx/tunein.go index 67bb6c6..5ad4740 100644 --- a/pkg/service/bmx/tunein.go +++ b/pkg/service/bmx/tunein.go @@ -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.