mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
fix(web): play Radio Browser via native RADIO_BROWSER source (refs #479)
Selecting a Radio Browser station from the player UI returned HTTP 500 and the speaker dropped to INVALID_SOURCE. The play path sent the speaker a ContentItem with source="URL" and an absolute location (https://all.api.radio-browser.info/soundtouch/stations/byuuid/<uuid>). source="URL" makes the speaker fetch that location as a raw audio stream, but the URL returns station JSON, not audio, so the speaker rejects it. "URL" was never a real source: it is not in the speaker's sourceprovider registry and never persisted in any datastore. The rest of the stack is already built for the native RADIO_BROWSER source (BMX registry provider 39 with base URL .../soundtouch, a seeded RADIO_BROWSER source, marge classification, and the documented relative location form). Working RADIO_BROWSER items use source="RADIO_BROWSER" with a relative location="/stations/byuuid/<uuid>", which the speaker resolves against the registry base URL and plays directly. - stations.ResolveContentItem: emit source=RADIO_BROWSER for the provider - RadioBrowserSearch: emit the relative /stations/byuuid/<uuid> playback href so the speaker prepends the registry base URL - marge classifier: match the relative /stations/byuuid/ segment (covers both the relative and legacy absolute forms) - tests updated to assert the native source + relative location Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6c8a50c049
commit
2dada5a61a
@@ -67,8 +67,10 @@ func RadioBrowserSearchPage(query string, offset int) (*models.BmxNavResponse, e
|
||||
subtitle += tags
|
||||
}
|
||||
|
||||
// SoundTouch format location for RadioBrowser
|
||||
location := fmt.Sprintf("%s/soundtouch/stations/byuuid/%s", radioBrowserBaseURL, uuid)
|
||||
// Relative SoundTouch playback location for RadioBrowser. The speaker
|
||||
// prepends the BMX-registry base URL (radioBrowserBaseURL + "/soundtouch")
|
||||
// when it follows a RADIO_BROWSER source, so the href must stay relative.
|
||||
location := fmt.Sprintf("/stations/byuuid/%s", uuid)
|
||||
|
||||
item := models.BmxNavItem{
|
||||
Name: name,
|
||||
|
||||
@@ -45,6 +45,16 @@ func TestRadioBrowserSearch(t *testing.T) {
|
||||
if item.Name != "Radio Paradise" {
|
||||
t.Errorf("expected name 'Radio Paradise', got %q", item.Name)
|
||||
}
|
||||
|
||||
// The playback href must be the relative /stations/byuuid/<uuid> form so a
|
||||
// RADIO_BROWSER select resolves against the BMX-registry base URL (#479).
|
||||
if item.Links == nil || item.Links.BmxPlayback == nil {
|
||||
t.Fatal("expected a bmx_playback link on the station item")
|
||||
}
|
||||
|
||||
if want := "/stations/byuuid/123-456"; item.Links.BmxPlayback.Href != want {
|
||||
t.Errorf("expected playback href %q, got %q", want, item.Links.BmxPlayback.Href)
|
||||
}
|
||||
}
|
||||
|
||||
// makeStationsJSON returns a JSON array of n station objects.
|
||||
|
||||
@@ -67,13 +67,21 @@ func TestClassifyLearnedSource_RadioBrowserByProviderID(t *testing.T) {
|
||||
|
||||
// TestClassifyLearnedSource_RadioBrowserByLocation verifies that the dispatcher
|
||||
// routes to classifyAsRadioBrowser when the location contains the RadioBrowser
|
||||
// byuuid path segment (Source "URL" play path).
|
||||
// byuuid path segment, for both the relative form the native RADIO_BROWSER play
|
||||
// path now emits and the legacy absolute form (#479).
|
||||
func TestClassifyLearnedSource_RadioBrowserByLocation(t *testing.T) {
|
||||
src := &models.ConfiguredSource{}
|
||||
locations := []string{
|
||||
"/stations/byuuid/abc-123", // native relative location
|
||||
"https://all.api.radio-browser.info/soundtouch/stations/byuuid/abc-123", // legacy absolute location
|
||||
}
|
||||
|
||||
classifyLearnedSource(src, "", "https://all.api.radio-browser.info/soundtouch/stations/byuuid/abc-123", "")
|
||||
for _, location := range locations {
|
||||
src := &models.ConfiguredSource{}
|
||||
|
||||
if src.SourceKey.Type != constants.ProviderRadioBrowser {
|
||||
t.Errorf("expected RADIO_BROWSER from byuuid location, got %q", src.SourceKey.Type)
|
||||
classifyLearnedSource(src, "", location, "")
|
||||
|
||||
if src.SourceKey.Type != constants.ProviderRadioBrowser {
|
||||
t.Errorf("expected RADIO_BROWSER from byuuid location %q, got %q", location, src.SourceKey.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1850,7 +1850,7 @@ func classifyLearnedSource(src *models.ConfiguredSource, sourceID, location, sou
|
||||
classifyAsSpotify(src)
|
||||
case strings.Contains(location, "amazon") || sourceID == constants.ProviderAmazon || sourceProviderID == strconv.Itoa(constants.AmazonProviderID):
|
||||
classifyAsAmazon(src)
|
||||
case sourceProviderID == strconv.Itoa(constants.RadioBrowserProviderID) || strings.Contains(location, "/soundtouch/stations/byuuid/"):
|
||||
case sourceProviderID == strconv.Itoa(constants.RadioBrowserProviderID) || strings.Contains(location, "/stations/byuuid/"):
|
||||
classifyAsRadioBrowser(src)
|
||||
}
|
||||
// If we can't classify, leave SourceKey.Type empty so the canonical-by-ID
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
// Provider identifies the radio station source backend.
|
||||
@@ -132,7 +133,13 @@ func ResolveContentItem(item PlayItem) *models.ContentItem {
|
||||
ci.Location = item.Location
|
||||
ci.ContainerArt = item.ContainerArt
|
||||
case ProviderRadioBrowser:
|
||||
ci.Source = "URL"
|
||||
// Native RADIO_BROWSER source: the speaker prepends the BMX-registry
|
||||
// base URL (https://all.api.radio-browser.info/soundtouch) to the
|
||||
// relative location and talks to Radio Browser directly. Using
|
||||
// source="URL" here makes the speaker fetch the location as a raw
|
||||
// audio stream, but it returns station JSON, not audio -> the speaker
|
||||
// reports INVALID_SOURCE (issue #479).
|
||||
ci.Source = constants.ProviderRadioBrowser
|
||||
ci.Type = "stationurl"
|
||||
ci.IsPresetable = true
|
||||
ci.ItemName = item.Name
|
||||
|
||||
@@ -68,14 +68,14 @@ func TestResolveContentItem_TuneIn_ContainerArt(t *testing.T) {
|
||||
func TestResolveContentItem_RadioBrowser(t *testing.T) {
|
||||
item := PlayItem{
|
||||
Provider: ProviderRadioBrowser,
|
||||
Location: "https://all.api.radio-browser.info/soundtouch/stations/byuuid/abc-123",
|
||||
Location: "/stations/byuuid/abc-123",
|
||||
Name: "Radio Paradise",
|
||||
}
|
||||
|
||||
ci := ResolveContentItem(item)
|
||||
|
||||
if ci.Source != "URL" {
|
||||
t.Errorf("expected Source URL, got %q", ci.Source)
|
||||
if ci.Source != "RADIO_BROWSER" {
|
||||
t.Errorf("expected Source RADIO_BROWSER, got %q", ci.Source)
|
||||
}
|
||||
|
||||
if ci.Type != "stationurl" {
|
||||
@@ -131,21 +131,21 @@ func TestResolveContentItem_SourceAccountGuard_RealAccountKept(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveContentItem_SourceAccountGuard_RadioBrowserURLSource checks the
|
||||
// guard for RadioBrowser where Source is "URL".
|
||||
func TestResolveContentItem_SourceAccountGuard_RadioBrowserURLSource(t *testing.T) {
|
||||
// SourceAccount == "URL" is the echo value — must be dropped.
|
||||
// TestResolveContentItem_SourceAccountGuard_RadioBrowserEchoDropped checks the
|
||||
// guard for RadioBrowser where Source is "RADIO_BROWSER".
|
||||
func TestResolveContentItem_SourceAccountGuard_RadioBrowserEchoDropped(t *testing.T) {
|
||||
// SourceAccount == "RADIO_BROWSER" is the echo value — must be dropped.
|
||||
item := PlayItem{
|
||||
Provider: ProviderRadioBrowser,
|
||||
Location: "https://all.api.radio-browser.info/soundtouch/stations/byuuid/xyz",
|
||||
Location: "/stations/byuuid/xyz",
|
||||
Name: "Test",
|
||||
SourceAccount: "URL",
|
||||
SourceAccount: "RADIO_BROWSER",
|
||||
}
|
||||
|
||||
ci := ResolveContentItem(item)
|
||||
|
||||
if ci.SourceAccount != "" {
|
||||
t.Errorf("expected SourceAccount dropped for URL source, got %q", ci.SourceAccount)
|
||||
t.Errorf("expected SourceAccount dropped for RADIO_BROWSER source, got %q", ci.SourceAccount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user