mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
29c904b7e4
commit
a2f952495e
+40
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user