From a2f952495eed87cb7353f0740148cfc1e4cf4120 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 28 Apr 2026 15:24:43 +0200 Subject: [PATCH] fix: use proper URL manipulation for TuneIn render=json parameter (#189) Naive string concatenation (`rawURL + "&render=json"`) produced malformed URLs when the input had no query string yet, or already contained render=json. Replace with tuneInRenderJSONURI which parses and sets the parameter cleanly. Also fix TuneIn search query encoding in the self link and section href, and replace the http-prefix check for OPML URIs with a proper host comparison. Co-authored-by: Claude Sonnet 4.6 --- pkg/service/bmx/bmx.go | 45 +++++++++-- pkg/service/bmx/bmx_test.go | 146 ++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 5 deletions(-) diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index 38dd045..795f7bc 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -42,6 +42,41 @@ func isTuneInURL(rawURL string) bool { return allowedTuneInHosts[u.Hostname()] } +// isTuneInOpmlURI returns true when the URL's host is opml.radiotime.com, +// used to select the OPML/ashx parser over the JSON API parser. +func isTuneInOpmlURI(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + + return strings.EqualFold(u.Hostname(), "opml.radiotime.com") +} + +// tuneInRenderJSONURI returns the URL with render=json set as a query parameter, +// replacing any existing render value instead of appending a duplicate. +func tuneInRenderJSONURI(rawURL string) string { + if rawURL == "" { + return "" + } + + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + q := u.Query() + q.Set("render", "json") + u.RawQuery = q.Encode() + + return u.String() +} + +// tuneInSearchURI returns the TuneIn search API URL with the query properly URL-encoded. +func tuneInSearchURI(query string) string { + return TuneInSearchAPI + url.QueryEscape(query) +} + func fetchJSON(fetchURL string) (map[string]interface{}, error) { if !isTuneInURL(fetchURL) { return nil, fmt.Errorf("URL not in allowed list: %s", fetchURL) @@ -110,7 +145,7 @@ func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse, err error ) - if strings.HasPrefix(tuneInURI, "http://opml.radiotime.com/") { + if isTuneInOpmlURI(tuneInURI) { sections, err = tuneInSectionsAshx(tuneInURI, subsection) } else { sections, err = tuneInSectionsJSONAPI(tuneInURI, subsection) @@ -291,7 +326,7 @@ func tuneInNavigateLink(item map[string]interface{}) models.BmxNavItem { text, _ := item["text"].(string) subtext, _ := item["subtext"].(string) - encURL := base64.URLEncoding.EncodeToString([]byte(rawURL + "&render=json")) + encURL := base64.URLEncoding.EncodeToString([]byte(tuneInRenderJSONURI(rawURL))) return models.BmxNavItem{ Links: &models.Links{BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s", encURL)}}, @@ -303,7 +338,7 @@ func tuneInNavigateLink(item map[string]interface{}) models.BmxNavItem { // TuneInSearch returns live search results from TuneIn for the given query. func TuneInSearch(query string) (*models.BmxNavResponse, error) { - tuneInURI := TuneInSearchAPI + url.QueryEscape(query) + tuneInURI := tuneInSearchURI(query) templated := true bmxSearchLink := &models.Link{ @@ -336,7 +371,7 @@ func TuneInSearch(query string) (*models.BmxNavResponse, error) { return &models.BmxNavResponse{ Links: &models.Links{ - Self: &models.Link{Href: fmt.Sprintf("/v1/search?q=%s", query)}, + Self: &models.Link{Href: fmt.Sprintf("/v1/search?q=%s", url.QueryEscape(query))}, BmxSearch: bmxSearchLink, }, BmxSections: sections, @@ -353,7 +388,7 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str if pivotURL != "" { href = fmt.Sprintf("/v1/navigate/%s", base64.URLEncoding.EncodeToString([]byte(pivotURL))) } else { - encodedQuery := base64.URLEncoding.EncodeToString([]byte(TuneInSearchAPI + query)) + encodedQuery := base64.URLEncoding.EncodeToString([]byte(tuneInSearchURI(query))) href = fmt.Sprintf("/v1/navigate/sub/%d/%s", idx, encodedQuery) } diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go index 6a3607c..d16d3af 100644 --- a/pkg/service/bmx/bmx_test.go +++ b/pkg/service/bmx/bmx_test.go @@ -3,9 +3,155 @@ package bmx import ( "encoding/base64" "encoding/json" + "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 := base64.URLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("failed to decode navigate href: %v", err) + } + + got := string(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 := base64.URLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("failed to decode navigate href: %v", err) + } + + got := string(decoded) + if strings.Count(got, "render=json") != 1 { + t.Errorf("navigate href %q should contain render=json exactly once", got) + } +} + func TestPlayCustomStream(t *testing.T) { // Simple test for custom stream XML generation dataObj := struct {