Files
Bose-SoundTouch/pkg/service/bmx/tunein_test.go
T
Tobias Gesellchen b95bdae751 feat: Add RadioBrowser integration alongside TuneIn support
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
2026-05-18 22:34:26 +02:00

453 lines
12 KiB
Go

package bmx
import (
"encoding/base64"
"strings"
"testing"
)
func TestTuneInRenderJSONURI(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty URL returns empty",
input: "",
want: "",
},
{
name: "URL with no query params gets render=json added",
input: "http://opml.radiotime.com/Browse.ashx",
want: "http://opml.radiotime.com/Browse.ashx?render=json",
},
{
name: "URL with other params gets render=json appended",
input: "http://opml.radiotime.com/Browse.ashx?c=news",
want: "http://opml.radiotime.com/Browse.ashx?c=news&render=json",
},
{
name: "URL already containing render=json is not duplicated",
input: "http://opml.radiotime.com/?render=json",
want: "http://opml.radiotime.com/?render=json",
},
{
name: "URL with render=xml gets render replaced with json",
input: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=xml",
want: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tuneInRenderJSONURI(tt.input)
if got != tt.want {
t.Errorf("tuneInRenderJSONURI(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestIsTuneInOpmlURI(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"http://opml.radiotime.com/Browse.ashx", true},
{"https://opml.radiotime.com/Browse.ashx", true},
{"http://opml.radiotime.com/?render=json", true},
{"http://api.radiotime.com/profiles?fulltextsearch=true", false},
{"http://example.com", false},
{"not-a-url", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isTuneInOpmlURI(tt.input)
if got != tt.want {
t.Errorf("isTuneInOpmlURI(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestTuneInSearchURI(t *testing.T) {
tests := []struct {
name string
query string
check func(string) bool
}{
{
name: "spaces are percent-encoded",
query: "radio paradise",
check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "radio+paradise") },
},
{
name: "ampersand is encoded",
query: "news & talk",
check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "%26") },
},
{
name: "plain query is appended to base URL",
query: "jazz",
check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tuneInSearchURI(tt.query)
if !tt.check(got) {
t.Errorf("tuneInSearchURI(%q) = %q: check failed", tt.query, got)
}
})
}
}
func TestTuneInNavigateLinkEncodesRenderJSON(t *testing.T) {
item := map[string]interface{}{
"URL": "http://opml.radiotime.com/Browse.ashx?c=news",
"text": "News",
"subtext": "Latest",
"image": "http://example.com/news.png",
}
result := tuneInNavigateLink(item)
href := result.Links.BmxNavigate.Href
encoded := strings.TrimPrefix(href, "/v1/navigate/")
decoded, err := decodeBase64URI(encoded)
if err != nil {
t.Fatalf("failed to decode navigate href: %v", err)
}
got := decoded
if !strings.Contains(got, "render=json") {
t.Errorf("navigate href %q missing render=json", got)
}
if strings.Count(got, "render=json") > 1 {
t.Errorf("navigate href %q has duplicate render=json", got)
}
}
func TestTuneInNavigateLinkNoDuplicateRenderJSON(t *testing.T) {
item := map[string]interface{}{
"URL": "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
}
result := tuneInNavigateLink(item)
href := result.Links.BmxNavigate.Href
encoded := strings.TrimPrefix(href, "/v1/navigate/")
decoded, err := decodeBase64URI(encoded)
if err != nil {
t.Fatalf("failed to decode navigate href: %v", err)
}
got := decoded
if strings.Count(got, "render=json") != 1 {
t.Errorf("navigate href %q should contain render=json exactly once", got)
}
}
func TestTuneInPodcastInfo_Base64(t *testing.T) {
name := "Podcast Name / with special chars?"
// Test Standard Base64
encodedStd := base64.StdEncoding.EncodeToString([]byte(name))
resp, err := TuneInPodcastInfo("123", encodedStd)
if err != nil {
t.Fatalf("TuneInPodcastInfo with standard base64 failed: %v", err)
}
if resp.Name != name {
t.Errorf("Expected name %s, got %s", name, resp.Name)
}
// Test URL-safe Base64
encodedURL := base64.URLEncoding.EncodeToString([]byte(name))
resp, err = TuneInPodcastInfo("123", encodedURL)
if err != nil {
t.Fatalf("TuneInPodcastInfo with URL-safe base64 failed: %v", err)
}
if resp.Name != name {
t.Errorf("Expected name %s, got %s", name, resp.Name)
}
}
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)
}
}
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)
}
}
}
func TestParseTuneInStreamBody(t *testing.T) {
cases := []struct {
name string
body string
wantURLs []string
wantError bool
}{
{
name: "single URL",
body: "https://stream.example.com/foo.mp3\n",
wantURLs: []string{"https://stream.example.com/foo.mp3"},
},
{
name: "multiple URLs",
body: "https://a/1.mp3\nhttps://b/2.mp3\n",
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
},
{
name: "comment-only body — TuneIn 400 error",
body: "#STATUS: 400\n#description=Bad request\n",
wantError: true,
},
{
name: "comments mixed with real URL",
body: "#EXTM3U\nhttps://stream.example.com/foo.mp3\n#END\n",
wantURLs: []string{"https://stream.example.com/foo.mp3"},
},
{
name: "empty body",
body: "",
wantError: true,
},
{
name: "only blank lines",
body: "\n\n \n",
wantError: true,
},
{
name: "trims surrounding whitespace per line",
body: " https://a/1.mp3 \n\thttps://b/2.mp3\t\n",
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseTuneInStreamBody([]byte(tc.body), "test-guide-id")
if tc.wantError {
if err == nil {
t.Fatalf("expected error, got %v", got)
}
if !strings.Contains(err.Error(), "test-guide-id") {
t.Errorf("error should mention the guide-id for diagnosis: %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tc.wantURLs) {
t.Fatalf("len mismatch: got %d (%v), want %d (%v)", len(got), got, len(tc.wantURLs), tc.wantURLs)
}
for i := range got {
if got[i] != tc.wantURLs[i] {
t.Errorf("URL[%d] mismatch: got %q, want %q", i, got[i], tc.wantURLs[i])
}
}
})
}
}
func TestTuneInSearchProfileEmitsBmxPlayback(t *testing.T) {
cases := []struct {
name string
profileName string
guideID string
wantPlayback bool
wantType string
}{
{name: "Program with guide-id gets play link", profileName: "Program", guideID: "p290778", wantPlayback: true, wantType: "tracklisturl"},
{name: "Artist with guide-id is navigate-only", profileName: "Artist", guideID: "a12345", wantPlayback: false},
{name: "Program without guide-id is navigate-only", profileName: "Program", guideID: "", wantPlayback: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
item := map[string]interface{}{
"GuideId": tc.guideID,
"Title": "Die Nachrichten",
"Image": "http://example.com/logo.png",
"Subtitle": "Deutschlandfunk",
"Type": tc.profileName,
"Actions": map[string]interface{}{
"Profile": map[string]interface{}{
"Url": "https://api.radiotime.com/profiles/" + tc.guideID,
},
},
}
navItem := tuneInSearchProfile(item, tc.profileName)
if navItem.Links == nil {
t.Fatal("expected Links to be set")
}
if tc.wantPlayback {
if navItem.Links.BmxPlayback == nil {
t.Fatal("expected BmxPlayback link for Program")
}
if navItem.Links.BmxPlayback.Type != tc.wantType {
t.Errorf("BmxPlayback.Type = %q, want %q", navItem.Links.BmxPlayback.Type, tc.wantType)
}
if !strings.Contains(navItem.Links.BmxPlayback.Href, tc.guideID) {
t.Errorf("BmxPlayback.Href must carry the guide-id %q; got %q", tc.guideID, navItem.Links.BmxPlayback.Href)
}
if !strings.Contains(navItem.Links.BmxPlayback.Href, "encoded_name=") {
t.Errorf("BmxPlayback.Href should carry encoded_name; got %q", navItem.Links.BmxPlayback.Href)
}
} else if navItem.Links.BmxPlayback != nil {
t.Errorf("did not expect BmxPlayback link; got %+v", navItem.Links.BmxPlayback)
}
if navItem.Links.BmxNavigate == nil {
t.Error("expected BmxNavigate link to remain available")
}
})
}
}
func TestParseTuneInProgramContents(t *testing.T) {
const happyPath = `{
"Items": [
{
"ContainerType": "Topics",
"Title": "Episodes",
"Children": [
{ "GuideId": "t554138374", "Type": "Topic", "Title": "newest" },
{ "GuideId": "t554134863", "Type": "Topic", "Title": "previous" }
]
}
]
}`
const localisedTitle = `{
"Items": [
{
"ContainerType": "Topics",
"Title": "Folgen",
"Children": [
{ "GuideId": "t111", "Type": "Topic", "Title": "newest" }
]
}
]
}`
const episodesAfterRelated = `{
"Items": [
{
"ContainerType": "Topics",
"Title": "Related Shows",
"Children": [
{ "GuideId": "t999", "Type": "Topic", "Title": "wrong" }
]
},
{
"ContainerType": "Topics",
"Title": "Episodes",
"Children": [
{ "GuideId": "t222", "Type": "Topic", "Title": "right" }
]
}
]
}`
const skipsNonTopic = `{
"Items": [
{
"ContainerType": "Topics",
"Title": "Episodes",
"Children": [
{ "GuideId": "p333", "Type": "Container", "Title": "nested program" },
{ "GuideId": "t444", "Type": "Topic", "Title": "real episode" }
]
}
]
}`
cases := []struct {
name string
body string
wantID string
wantError bool
}{
{name: "happy path — first child wins", body: happyPath, wantID: "t554138374"},
{name: "localised title — falls back to first Topics container", body: localisedTitle, wantID: "t111"},
{name: "Episodes container preferred over Related", body: episodesAfterRelated, wantID: "t222"},
{name: "skips non-Topic children", body: skipsNonTopic, wantID: "t444"},
{name: "empty body — error", body: `{}`, wantError: true},
{name: "no Topics containers — error", body: `{"Items":[{"ContainerType":"Banner","Children":[]}]}`, wantError: true},
{name: "Topics with no t-prefixed children — error",
body: `{"Items":[{"ContainerType":"Topics","Title":"Episodes","Children":[{"GuideId":"p1"}]}]}`,
wantError: true},
{name: "malformed JSON — error", body: `{not json`, wantError: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseTuneInProgramContents([]byte(tc.body), "p290778")
if tc.wantError {
if err == nil {
t.Fatalf("expected error, got id=%q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.wantID {
t.Errorf("got episode id %q, want %q", got, tc.wantID)
}
})
}
}