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:
Tobias Gesellchen
2026-06-08 20:44:31 +02:00
co-authored by Claude Opus 4.8
parent 6c8a50c049
commit 2dada5a61a
6 changed files with 46 additions and 19 deletions
+4 -2
View File
@@ -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,
+10
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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
+10 -10
View File
@@ -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)
}
}