feat(cli): service-side station search for TuneIn + Radio Browser

Add a provider-neutral station orchestration layer and expose it in the
CLI so TuneIn and Radio Browser search work consistently without
depending on the speaker's (dead) cloud search. Substance of #338.

- pkg/service/stations: new package with Search/SearchNext/Navigate/
  ResolveContentItem/Play over both providers; centralises the
  SourceAccount placeholder guard.
- soundtouchweb: the six TuneIn/Radio Browser handlers become thin
  adapters over the new package (behaviour preserved; bmxpkg retained
  for HandlePlayURL).
- bmx/radiobrowser: add offset/cursor pagination
  (RadioBrowserSearchPage + RadioBrowserSearchNext) mirroring the
  TuneIn opaque-cursor pattern; BmxNext only on full pages.
- marge: classifyLearnedSource gains a provider-39 (RADIO_BROWSER)
  case + classifyAsRadioBrowser helper (candidate fix for #334
  INVALID_SOURCE; location-substring match still to be confirmed
  against a real recording).
- cli: new `station search-radiobrowser` sibling and unified
  `station find --provider tunein|radiobrowser [--more]`. The existing
  generic device-side `station search --source` is kept unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-30 20:53:40 +02:00
co-authored by Claude Opus 4.8
parent 8defb0b833
commit d101e515a9
9 changed files with 849 additions and 75 deletions
+148
View File
@@ -2,9 +2,11 @@ package main
import (
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
"github.com/urfave/cli/v2"
)
@@ -473,3 +475,149 @@ func printStationList(response *models.NavigateResponse, source string) {
fmt.Printf(" • To play a station: Use the location value with 'play content' command\n")
fmt.Printf(" • To save as preset: Use 'preset set' command with the location\n")
}
// printBmxNavResults renders a *models.BmxNavResponse to stdout.
// For each section it prints the section name as a header, then each item's
// name, subtitle, and playback location so the user can act on it.
func printBmxNavResults(resp *models.BmxNavResponse) {
if len(resp.BmxSections) == 0 {
fmt.Println(" No results found")
return
}
for _, section := range resp.BmxSections {
if section.Name != "" {
fmt.Printf("\n [%s]\n", section.Name)
}
if len(section.Items) == 0 {
fmt.Println(" (empty)")
continue
}
for i, item := range section.Items {
fmt.Printf(" %3d. %s\n", i+1, item.Name)
if item.Subtitle != "" {
fmt.Printf(" %s\n", item.Subtitle)
}
if item.Links != nil && item.Links.BmxPlayback != nil {
fmt.Printf(" Location: %s\n", item.Links.BmxPlayback.Href)
}
}
}
}
// bmxNavCursor extracts the opaque cursor value from a section's BmxNext link.
// The Href looks like "...?cursor=<value>"; this returns the cursor query param.
// Returns "" when no next link is present.
func bmxNavCursor(section *models.BmxNavSection) string {
if section == nil || section.Links == nil || section.Links.BmxNext == nil {
return ""
}
href := section.Links.BmxNext.Href
if href == "" {
return ""
}
// The cursor is the query parameter named "cursor".
parsed, err := url.Parse(href)
if err != nil {
return ""
}
return parsed.Query().Get("cursor")
}
// searchService is the action for `station search` with --provider / --query / --more.
// It uses the service-side stations package (works even when the speaker's cloud is dead).
func searchService(c *cli.Context) error {
providerStr := c.String("provider")
query := c.String("query")
more := c.Bool("more")
if query == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
var provider stations.Provider
switch strings.ToLower(providerStr) {
case "tunein":
provider = stations.ProviderTuneIn
case "radiobrowser":
provider = stations.ProviderRadioBrowser
default:
PrintError(fmt.Sprintf("Unknown provider %q: must be 'tunein' or 'radiobrowser'", providerStr))
return fmt.Errorf("unknown provider: %s", providerStr)
}
fmt.Printf("Searching %s for: %s\n", providerStr, query)
resp, err := stations.Search(provider, query)
if err != nil {
PrintError(fmt.Sprintf("Search failed: %v", err))
return err
}
printBmxNavResults(resp)
if !more {
return nil
}
// Follow up to 3 additional pages while a next cursor is available.
const maxExtraPages = 3
for page := 0; page < maxExtraPages; page++ {
// Find a cursor from any section that has one.
cursor := ""
for i := range resp.BmxSections {
cursor = bmxNavCursor(&resp.BmxSections[i])
if cursor != "" {
break
}
}
if cursor == "" {
break
}
fmt.Printf("\n -- page %d --\n", page+2)
resp, err = stations.SearchNext(provider, cursor)
if err != nil {
PrintError(fmt.Sprintf("Failed to fetch next page: %v", err))
return err
}
printBmxNavResults(resp)
}
return nil
}
// searchServiceRadioBrowser is the action for `station search-radiobrowser`.
// It searches via the service-side RadioBrowser backend.
func searchServiceRadioBrowser(c *cli.Context) error {
query := c.String("query")
if query == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
fmt.Printf("Searching Radio Browser for: %s\n", query)
resp, err := stations.Search(stations.ProviderRadioBrowser, query)
if err != nil {
PrintError(fmt.Sprintf("Search failed: %v", err))
return err
}
printBmxNavResults(resp)
return nil
}
+35
View File
@@ -612,6 +612,28 @@ func main() {
},
Before: RequireHost,
},
{
Name: "find",
Usage: "Find stations via the AfterTouch service (tunein or radiobrowser)",
Action: searchService,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "provider",
Usage: "Station provider: tunein or radiobrowser",
Value: "tunein",
},
&cli.StringFlag{
Name: "query",
Aliases: []string{"q"},
Usage: "Search query",
Required: true,
},
&cli.BoolFlag{
Name: "more",
Usage: "Follow up to 3 additional result pages when available",
},
},
},
{
Name: "search-tunein",
Usage: "Search TuneIn stations",
@@ -664,6 +686,19 @@ func main() {
},
Before: RequireHost,
},
{
Name: "search-radiobrowser",
Usage: "Search Radio Browser stations via the AfterTouch service",
Action: searchServiceRadioBrowser,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "query",
Aliases: []string{"q"},
Usage: "Search query",
Required: true,
},
},
},
{
Name: "add",
Usage: "Add station and play immediately",
+65 -13
View File
@@ -1,6 +1,7 @@
package bmx
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -11,12 +12,25 @@ import (
var radioBrowserBaseURL = "https://all.api.radio-browser.info"
// RadioBrowserSearch searches for radio stations using the RadioBrowser API.
func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
searchURL := fmt.Sprintf("%s/json/stations/search?name=%s&limit=20&order=clickcount&reverse=true",
radioBrowserBaseURL, url.QueryEscape(query))
const radioBrowserPageSize = 20
resp, err := http.Get(searchURL)
// radioBrowserCursor is the opaque pagination cursor for RadioBrowser search results.
type radioBrowserCursor struct {
Query string `json:"q"`
NextOffset int `json:"o"`
}
// RadioBrowserSearch searches for radio stations using the RadioBrowser API (first page).
func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
return RadioBrowserSearchPage(query, 0)
}
// RadioBrowserSearchPage searches for radio stations at a specific offset.
func RadioBrowserSearchPage(query string, offset int) (*models.BmxNavResponse, error) {
searchURL := fmt.Sprintf("%s/json/stations/search?name=%s&limit=%d&offset=%d&hidebroken=true&order=clickcount&reverse=true",
radioBrowserBaseURL, url.QueryEscape(query), radioBrowserPageSize, offset)
resp, err := http.Get(searchURL) //nolint:noctx
if err != nil {
return nil, err
}
@@ -32,13 +46,9 @@ func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
return nil, err
}
navResp := &models.BmxNavResponse{
BmxSections: []models.BmxNavSection{
{
Name: "Stations",
Items: make([]models.BmxNavItem, 0, len(stations)),
},
},
section := models.BmxNavSection{
Name: "Stations",
Items: make([]models.BmxNavItem, 0, len(stations)),
}
for _, station := range stations {
@@ -71,8 +81,50 @@ func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
},
},
}
navResp.BmxSections[0].Items = append(navResp.BmxSections[0].Items, item)
section.Items = append(section.Items, item)
}
// Attach a BmxNext link only when the page is full (more results likely exist).
if len(stations) == radioBrowserPageSize {
cursorData := radioBrowserCursor{Query: query, NextOffset: offset + radioBrowserPageSize}
cursorJSON, err := json.Marshal(cursorData)
if err == nil {
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
section.Links = &models.Links{
BmxNext: &models.Link{Href: "/v1/radiobrowser/search/next?cursor=" + encoded},
}
}
}
navResp := &models.BmxNavResponse{
BmxSections: []models.BmxNavSection{section},
}
return navResp, nil
}
// RadioBrowserSearchNext fetches the next page of RadioBrowser search results using the
// opaque cursor produced by RadioBrowserSearchPage.
func RadioBrowserSearchNext(encodedCursor string) (*models.BmxNavResponse, error) {
cursorBytes, err := base64.RawURLEncoding.DecodeString(encodedCursor)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
var cursor radioBrowserCursor
if err := json.Unmarshal(cursorBytes, &cursor); err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
if cursor.Query == "" {
return nil, fmt.Errorf("invalid cursor: missing query")
}
if cursor.NextOffset < 0 {
return nil, fmt.Errorf("invalid cursor: negative offset")
}
return RadioBrowserSearchPage(cursor.Query, cursor.NextOffset)
}
+137
View File
@@ -1,10 +1,13 @@
package bmx
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
@@ -44,6 +47,140 @@ func TestRadioBrowserSearch(t *testing.T) {
}
}
// makeStationsJSON returns a JSON array of n station objects.
func makeStationsJSON(n int) string {
stations := make([]string, n)
for i := 0; i < n; i++ {
stations[i] = fmt.Sprintf(`{"name":"Station %d","stationuuid":"uuid-%d","favicon":"","country":"DE","tags":"pop"}`, i, i)
}
return "[" + strings.Join(stations, ",") + "]"
}
func TestRadioBrowserSearchPage_FullPage_HasNext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, makeStationsJSON(radioBrowserPageSize))
}))
defer ts.Close()
originalBaseURL := radioBrowserBaseURL
radioBrowserBaseURL = ts.URL
defer func() { radioBrowserBaseURL = originalBaseURL }()
resp, err := RadioBrowserSearchPage("test", 0)
if err != nil {
t.Fatalf("RadioBrowserSearchPage failed: %v", err)
}
if len(resp.BmxSections) == 0 {
t.Fatal("expected sections")
}
section := resp.BmxSections[0]
if len(section.Items) != radioBrowserPageSize {
t.Errorf("expected %d items, got %d", radioBrowserPageSize, len(section.Items))
}
if section.Links == nil || section.Links.BmxNext == nil {
t.Fatal("expected BmxNext link on full page")
}
if !strings.Contains(section.Links.BmxNext.Href, "cursor=") {
t.Errorf("expected cursor in BmxNext href, got %q", section.Links.BmxNext.Href)
}
}
func TestRadioBrowserSearchPage_ShortPage_NoNext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, makeStationsJSON(5)) // fewer than radioBrowserPageSize
}))
defer ts.Close()
originalBaseURL := radioBrowserBaseURL
radioBrowserBaseURL = ts.URL
defer func() { radioBrowserBaseURL = originalBaseURL }()
resp, err := RadioBrowserSearchPage("test", 0)
if err != nil {
t.Fatalf("RadioBrowserSearchPage failed: %v", err)
}
if len(resp.BmxSections) == 0 {
t.Fatal("expected sections")
}
section := resp.BmxSections[0]
if section.Links != nil && section.Links.BmxNext != nil {
t.Errorf("expected no BmxNext link on short page, got %q", section.Links.BmxNext.Href)
}
}
func TestRadioBrowserSearchNext_CursorRoundTrip(t *testing.T) {
// Build a cursor manually to verify RadioBrowserSearchNext decodes it correctly.
cursorData := radioBrowserCursor{Query: "jazz", NextOffset: 20}
cursorJSON, err := json.Marshal(cursorData)
if err != nil {
t.Fatalf("marshal cursor: %v", err)
}
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the offset was forwarded in the URL.
if !strings.Contains(r.URL.RawQuery, "offset=20") {
t.Errorf("expected offset=20 in query, got %q", r.URL.RawQuery)
}
if !strings.Contains(r.URL.RawQuery, "name=jazz") {
t.Errorf("expected name=jazz in query, got %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, makeStationsJSON(3))
}))
defer ts.Close()
originalBaseURL := radioBrowserBaseURL
radioBrowserBaseURL = ts.URL
defer func() { radioBrowserBaseURL = originalBaseURL }()
resp, err := RadioBrowserSearchNext(encoded)
if err != nil {
t.Fatalf("RadioBrowserSearchNext failed: %v", err)
}
if len(resp.BmxSections) == 0 || len(resp.BmxSections[0].Items) != 3 {
t.Errorf("expected 3 items, got response: %+v", resp)
}
}
func TestRadioBrowserSearchNext_InvalidCursor(t *testing.T) {
_, err := RadioBrowserSearchNext("not-valid-base64!!!")
if err == nil {
t.Error("expected error for invalid cursor")
}
}
func TestRadioBrowserSearchNext_EmptyQueryCursor(t *testing.T) {
// A cursor with an empty query should be rejected.
cursorData := radioBrowserCursor{Query: "", NextOffset: 20}
cursorJSON, err := json.Marshal(cursorData)
if err != nil {
t.Fatalf("marshal cursor: %v", err)
}
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
_, err = RadioBrowserSearchNext(encoded)
if err == nil {
t.Error("expected error for cursor with empty query")
}
}
func TestRadioBrowserSearch_Real(t *testing.T) {
if os.Getenv("RADIOBROWSER_INTEGRATION") == "" {
t.Skip("skipping live network test; set RADIOBROWSER_INTEGRATION=1 to run")
@@ -0,0 +1,79 @@
package marge
import (
"strconv"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)
// TestClassifyAsRadioBrowser verifies that classifyAsRadioBrowser sets
// the expected fields on a ConfiguredSource.
func TestClassifyAsRadioBrowser(t *testing.T) {
src := &models.ConfiguredSource{}
classifyAsRadioBrowser(src)
if src.SourceKey.Type != constants.ProviderRadioBrowser {
t.Errorf("SourceKey.Type = %q, want %q", src.SourceKey.Type, constants.ProviderRadioBrowser)
}
if src.SourceKeyType != constants.ProviderRadioBrowser {
t.Errorf("SourceKeyType = %q, want %q", src.SourceKeyType, constants.ProviderRadioBrowser)
}
if src.Type != "Audio" {
t.Errorf("Type = %q, want Audio", src.Type)
}
if src.SecretType != constants.CredentialTypeToken {
t.Errorf("SecretType = %q, want %q", src.SecretType, constants.CredentialTypeToken)
}
if src.Secret == "" {
t.Error("expected Secret to be generated, got empty string")
}
if src.DisplayName != constants.ProviderRadioBrowser {
t.Errorf("DisplayName = %q, want %q", src.DisplayName, constants.ProviderRadioBrowser)
}
}
// TestClassifyAsRadioBrowser_PreservesExistingSecret verifies that a
// pre-existing secret is NOT overwritten.
func TestClassifyAsRadioBrowser_PreservesExistingSecret(t *testing.T) {
src := &models.ConfiguredSource{Secret: "existing-secret"}
classifyAsRadioBrowser(src)
if src.Secret != "existing-secret" {
t.Errorf("expected existing secret to be preserved, got %q", src.Secret)
}
}
// TestClassifyLearnedSource_RadioBrowserByProviderID verifies that the
// classifyLearnedSource dispatcher routes to classifyAsRadioBrowser when
// sourceProviderID matches RadioBrowserProviderID (39).
func TestClassifyLearnedSource_RadioBrowserByProviderID(t *testing.T) {
src := &models.ConfiguredSource{}
classifyLearnedSource(src, "", "", strconv.Itoa(constants.RadioBrowserProviderID))
if src.SourceKey.Type != constants.ProviderRadioBrowser {
t.Errorf("expected RADIO_BROWSER from providerID 39, got %q", src.SourceKey.Type)
}
}
// TestClassifyLearnedSource_RadioBrowserByLocation verifies that the dispatcher
// routes to classifyAsRadioBrowser when the location contains the RadioBrowser
// byuuid path segment (Source "URL" play path).
func TestClassifyLearnedSource_RadioBrowserByLocation(t *testing.T) {
src := &models.ConfiguredSource{}
classifyLearnedSource(src, "", "https://all.api.radio-browser.info/soundtouch/stations/byuuid/abc-123", "")
if src.SourceKey.Type != constants.ProviderRadioBrowser {
t.Errorf("expected RADIO_BROWSER from byuuid location, got %q", src.SourceKey.Type)
}
}
+17
View File
@@ -1809,6 +1809,8 @@ 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/"):
classifyAsRadioBrowser(src)
}
// If we can't classify, leave SourceKey.Type empty so the canonical-by-ID
// fallback in mapToFullResponseSource and the read-side applyCanonicalDefaults
@@ -1869,6 +1871,21 @@ func classifyAsAmazon(src *models.ConfiguredSource) {
}
}
func classifyAsRadioBrowser(src *models.ConfiguredSource) {
src.SourceKey.Type = constants.ProviderRadioBrowser
src.SourceKeyType = constants.ProviderRadioBrowser
src.Type = "Audio"
src.SecretType = constants.CredentialTypeToken
if src.Secret == "" {
src.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderRadioBrowser))
}
if src.DisplayName == "Other" || src.DisplayName == constants.ProviderRadioBrowser || src.DisplayName == "" {
src.DisplayName = constants.ProviderRadioBrowser
}
}
func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceName, sourceProviderID, createdOn, updatedOn string) bool {
learned := false
+21 -62
View File
@@ -15,6 +15,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/models"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
@@ -651,7 +652,7 @@ func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
return
}
resp, err := bmxpkg.TuneInSearch(query)
resp, err := stations.Search(stations.ProviderTuneIn, query)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
@@ -672,7 +673,7 @@ func (app *WebApp) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request
return
}
resp, err := bmxpkg.TuneInSearchNext(cursor)
resp, err := stations.SearchNext(stations.ProviderTuneIn, cursor)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
@@ -694,47 +695,7 @@ func (app *WebApp) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
wildcard := chi.URLParam(r, "*")
var (
resp interface{}
err error
)
if wildcard == "" {
resp, err = bmxpkg.TuneInNavigate("", nil)
} else {
firstSlash := strings.Index(wildcard, "/")
if firstSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
pfx := wildcard[:firstSlash]
rest := wildcard[firstSlash+1:]
switch pfx {
case "sub":
secondSlash := strings.Index(rest, "/")
if secondSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(rest, nil)
} else {
n, parseErr := strconv.Atoi(rest[:secondSlash])
if parseErr != nil {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
}
}
case "profiles":
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 3 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
}
default:
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
}
}
}
resp, err := stations.Navigate(stations.ProviderTuneIn, wildcard)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
@@ -1195,7 +1156,7 @@ func (app *WebApp) HandleRadioBrowserSearch(w http.ResponseWriter, r *http.Reque
return
}
resp, err := bmxpkg.RadioBrowserSearch(query)
resp, err := stations.Search(stations.ProviderRadioBrowser, query)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
@@ -1232,15 +1193,16 @@ func (app *WebApp) HandlePlayRadioBrowser(w http.ResponseWriter, r *http.Request
return
}
contentItem := &models.ContentItem{
Source: "URL",
Type: "stationurl",
Location: req.Location,
ItemName: req.Name,
IsPresetable: true,
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
if err := stations.Play(device.Client, stations.PlayItem{
Provider: stations.ProviderRadioBrowser,
Location: req.Location,
Name: req.Name,
}); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -1283,21 +1245,18 @@ func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
return
}
itemType := req.Type
if itemType == "" {
itemType = "stationurl"
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
contentItem := &models.ContentItem{
Source: "TUNEIN",
Type: itemType,
if err := stations.Play(device.Client, stations.PlayItem{
Provider: stations.ProviderTuneIn,
Location: req.Location,
ItemName: req.Name,
IsPresetable: true,
Name: req.Name,
Type: req.Type,
ContainerArt: req.ContainerArt,
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
}); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
+169
View File
@@ -0,0 +1,169 @@
// Package stations provides a provider-neutral surface for radio station
// search, navigation, and playback across TuneIn and Radio Browser.
package stations
import (
"fmt"
"strconv"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
)
// Provider identifies the radio station source backend.
type Provider string
const (
// ProviderTuneIn selects the TuneIn radio service.
ProviderTuneIn Provider = "tunein"
// ProviderRadioBrowser selects the Radio Browser service.
ProviderRadioBrowser Provider = "radiobrowser"
)
// Search returns the first page of search results for query from the given provider.
func Search(provider Provider, query string) (*models.BmxNavResponse, error) {
switch provider {
case ProviderTuneIn:
return bmx.TuneInSearch(query)
case ProviderRadioBrowser:
return bmx.RadioBrowserSearch(query)
default:
return nil, fmt.Errorf("unknown provider: %s", provider)
}
}
// SearchNext returns the next page of search results using an opaque cursor.
// The cursor is provider-specific and must not be passed across providers.
func SearchNext(provider Provider, cursor string) (*models.BmxNavResponse, error) {
switch provider {
case ProviderTuneIn:
return bmx.TuneInSearchNext(cursor)
case ProviderRadioBrowser:
return bmx.RadioBrowserSearchNext(cursor)
default:
return nil, fmt.Errorf("unknown provider: %s", provider)
}
}
// Navigate returns a browse response for the given path under the provider.
// The path is the chi wildcard tail from the /navigate/* route.
// For ProviderRadioBrowser, navigation is not supported.
func Navigate(provider Provider, path string) (*models.BmxNavResponse, error) {
switch provider {
case ProviderTuneIn:
return navigateTuneIn(path)
case ProviderRadioBrowser:
return nil, fmt.Errorf("radio browser navigation is not supported")
default:
return nil, fmt.Errorf("unknown provider: %s", provider)
}
}
// navigateTuneIn implements the path-parsing logic that was previously inline
// in HandleTuneInNavigate, dispatching to bmx.TuneInNavigate or
// bmx.TuneInNavigateProfile depending on the path prefix.
func navigateTuneIn(wildcard string) (*models.BmxNavResponse, error) {
if wildcard == "" {
return bmx.TuneInNavigate("", nil)
}
firstSlash := strings.Index(wildcard, "/")
if firstSlash == -1 {
return bmx.TuneInNavigate(wildcard, nil)
}
pfx := wildcard[:firstSlash]
rest := wildcard[firstSlash+1:]
switch pfx {
case "sub":
secondSlash := strings.Index(rest, "/")
if secondSlash == -1 {
return bmx.TuneInNavigate(rest, nil)
}
n, parseErr := strconv.Atoi(rest[:secondSlash])
if parseErr != nil {
return bmx.TuneInNavigate(wildcard, nil)
}
return bmx.TuneInNavigate(rest[secondSlash+1:], &n)
case "profiles":
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 3 {
return bmx.TuneInNavigate(wildcard, nil)
}
return bmx.TuneInNavigateProfile(parts[2])
default:
return bmx.TuneInNavigate(wildcard, nil)
}
}
// PlayItem holds all information needed to build a ContentItem and send it to a speaker.
type PlayItem struct {
Provider Provider
Location string
Name string
// Type is the ContentItem type; when empty a provider-appropriate default is used.
Type string
ContainerArt string
// SourceAccount is an optional real credential. Leave empty for anonymous access.
SourceAccount string
}
// ResolveContentItem builds a *models.ContentItem for the given PlayItem.
// It is pure (no network calls, no client dependency).
func ResolveContentItem(item PlayItem) *models.ContentItem {
var ci models.ContentItem
switch item.Provider {
case ProviderTuneIn:
ci.Source = "TUNEIN"
ci.Type = item.Type
if ci.Type == "" {
ci.Type = "stationurl"
}
ci.IsPresetable = true
ci.ItemName = item.Name
ci.Location = item.Location
ci.ContainerArt = item.ContainerArt
case ProviderRadioBrowser:
ci.Source = "URL"
ci.Type = "stationurl"
ci.IsPresetable = true
ci.ItemName = item.Name
ci.Location = item.Location
default:
// Best-effort fallback for unknown providers.
ci.Source = string(item.Provider)
ci.Type = item.Type
if ci.Type == "" {
ci.Type = "stationurl"
}
ci.IsPresetable = true
ci.ItemName = item.Name
ci.Location = item.Location
}
// Apply the SourceAccount placeholder guard: if SourceAccount is non-empty
// and is not just the source name echoed back by the speaker, pass it through.
if item.SourceAccount != "" && item.SourceAccount != ci.Source {
ci.SourceAccount = item.SourceAccount
}
return &ci
}
// Play builds a ContentItem via ResolveContentItem and sends it to the speaker via c.
func Play(c *client.Client, item PlayItem) error {
ci := ResolveContentItem(item)
return c.SelectContentItem(ci)
}
+178
View File
@@ -0,0 +1,178 @@
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: "https://all.api.radio-browser.info/soundtouch/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.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_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.
item := PlayItem{
Provider: ProviderRadioBrowser,
Location: "https://all.api.radio-browser.info/soundtouch/stations/byuuid/xyz",
Name: "Test",
SourceAccount: "URL",
}
ci := ResolveContentItem(item)
if ci.SourceAccount != "" {
t.Errorf("expected SourceAccount dropped for URL 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")
}
}