Files
Tobias GesellchenandClaude Opus 4.8 ef9eea57a6 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>
2026-05-30 21:03:00 +02:00

75 lines
2.2 KiB
Go

package bmx
import (
"testing"
)
func TestPlayCustomStream(t *testing.T) {
// Test Standard Base64
dataStd := "eyJzdHJlYW1VcmwiOiJodHRwOi8vZXhhbXBsZS5jb20vc3RyZWFtLm1wMyIsImltYWdlVXJsIjoiaW1hZ2UucG5nIiwibmFtZSI6IlN0cmVhbSBOYW1lIn0="
resp, err := PlayCustomStream(dataStd)
if err != nil {
t.Fatalf("PlayCustomStream with standard base64 failed: %v", err)
}
if resp.Name != "Stream Name" {
t.Errorf("Expected name Stream Name, got %s", resp.Name)
}
// Test URL-safe Base64
dataURL := "eyJzdHJlYW1VcmwiOiJodHRwOi8vZXhhbXBsZS5jb20vc3RyZWFtLm1wMyIsImltYWdlVXJsIjoiaW1hZ2UucG5nIiwibmFtZSI6IlN0cmVhbSBOYW1lIn0="
resp, err = PlayCustomStream(dataURL)
if err != nil {
t.Fatalf("PlayCustomStream with URL-safe base64 failed: %v", err)
}
if resp.Name != "Stream Name" {
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)
}
}