Files
Tobias GesellchenandClaude Opus 4.8 2dada5a61a 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>
2026-06-08 20:44:31 +02:00

179 lines
4.6 KiB
Go

package stations
import (
"testing"
)
func TestResolveContentItem_TuneIn(t *testing.T) {
item := PlayItem{
Provider: ProviderTuneIn,
Location: "/v1/playback/station/s123",
Name: "Jazz FM",
Type: "stationurl",
}
ci := ResolveContentItem(item)
if ci.Source != "TUNEIN" {
t.Errorf("expected Source TUNEIN, got %q", ci.Source)
}
if ci.Type != "stationurl" {
t.Errorf("expected Type stationurl, got %q", ci.Type)
}
if ci.Location != item.Location {
t.Errorf("expected Location %q, got %q", item.Location, ci.Location)
}
if ci.ItemName != "Jazz FM" {
t.Errorf("expected ItemName Jazz FM, got %q", ci.ItemName)
}
if !ci.IsPresetable {
t.Error("expected IsPresetable true")
}
}
func TestResolveContentItem_TuneIn_DefaultType(t *testing.T) {
// When Type is empty it should default to "stationurl".
item := PlayItem{
Provider: ProviderTuneIn,
Location: "/v1/playback/station/s456",
Name: "Rock Radio",
}
ci := ResolveContentItem(item)
if ci.Type != "stationurl" {
t.Errorf("expected default Type stationurl, got %q", ci.Type)
}
}
func TestResolveContentItem_TuneIn_ContainerArt(t *testing.T) {
item := PlayItem{
Provider: ProviderTuneIn,
Location: "/v1/playback/station/s789",
Name: "Pop Radio",
ContainerArt: "http://example.com/art.png",
}
ci := ResolveContentItem(item)
if ci.ContainerArt != "http://example.com/art.png" {
t.Errorf("expected ContainerArt set, got %q", ci.ContainerArt)
}
}
func TestResolveContentItem_RadioBrowser(t *testing.T) {
item := PlayItem{
Provider: ProviderRadioBrowser,
Location: "/stations/byuuid/abc-123",
Name: "Radio Paradise",
}
ci := ResolveContentItem(item)
if ci.Source != "RADIO_BROWSER" {
t.Errorf("expected Source RADIO_BROWSER, got %q", ci.Source)
}
if ci.Type != "stationurl" {
t.Errorf("expected Type stationurl, got %q", ci.Type)
}
if ci.Location != item.Location {
t.Errorf("expected Location %q, got %q", item.Location, ci.Location)
}
if ci.ItemName != "Radio Paradise" {
t.Errorf("expected ItemName Radio Paradise, got %q", ci.ItemName)
}
if !ci.IsPresetable {
t.Error("expected IsPresetable true")
}
}
// TestResolveContentItem_SourceAccountGuard_EchoDropped verifies that a
// SourceAccount equal to the ContentItem Source (the placeholder value
// speakers echo back) is NOT forwarded.
func TestResolveContentItem_SourceAccountGuard_EchoDropped(t *testing.T) {
// TuneIn: source name == "TUNEIN"; echoed SourceAccount must be dropped.
item := PlayItem{
Provider: ProviderTuneIn,
Location: "/v1/playback/station/s111",
Name: "Example",
SourceAccount: "TUNEIN", // echoed placeholder
}
ci := ResolveContentItem(item)
if ci.SourceAccount != "" {
t.Errorf("expected SourceAccount dropped, got %q", ci.SourceAccount)
}
}
// TestResolveContentItem_SourceAccountGuard_RealAccountKept verifies that a
// real (non-placeholder) SourceAccount is forwarded to the ContentItem.
func TestResolveContentItem_SourceAccountGuard_RealAccountKept(t *testing.T) {
item := PlayItem{
Provider: ProviderTuneIn,
Location: "/v1/playback/station/s222",
Name: "Example",
SourceAccount: "real-user-token-xyz",
}
ci := ResolveContentItem(item)
if ci.SourceAccount != "real-user-token-xyz" {
t.Errorf("expected SourceAccount kept, got %q", ci.SourceAccount)
}
}
// 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: "/stations/byuuid/xyz",
Name: "Test",
SourceAccount: "RADIO_BROWSER",
}
ci := ResolveContentItem(item)
if ci.SourceAccount != "" {
t.Errorf("expected SourceAccount dropped for RADIO_BROWSER source, got %q", ci.SourceAccount)
}
}
func TestSearch_UnknownProvider(t *testing.T) {
_, err := Search("bogus", "query")
if err == nil {
t.Error("expected error for unknown provider")
}
}
func TestSearchNext_UnknownProvider(t *testing.T) {
_, err := SearchNext("bogus", "cursor")
if err == nil {
t.Error("expected error for unknown provider")
}
}
func TestNavigate_RadioBrowserNotSupported(t *testing.T) {
_, err := Navigate(ProviderRadioBrowser, "")
if err == nil {
t.Error("expected error for RadioBrowser navigate")
}
}
func TestNavigate_UnknownProvider(t *testing.T) {
_, err := Navigate("bogus", "")
if err == nil {
t.Error("expected error for unknown provider")
}
}