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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-04-28 15:24:43 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 29c904b7e4
commit a2f952495e
2 changed files with 186 additions and 5 deletions
+40 -5
View File
@@ -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)
}
+146
View File
@@ -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 {