From d101e515a9c52a2962b0fe414a2daede0c2524c4 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 30 May 2026 13:34:50 +0200 Subject: [PATCH] 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) --- cmd/soundtouch-cli/cmd_station.go | 148 +++++++++++++++ cmd/soundtouch-cli/main.go | 35 ++++ pkg/service/bmx/radiobrowser.go | 78 ++++++-- pkg/service/bmx/radiobrowser_test.go | 137 ++++++++++++++ .../marge/classify_radiobrowser_test.go | 79 ++++++++ pkg/service/marge/marge.go | 17 ++ pkg/service/soundtouchweb/handler.go | 83 +++----- pkg/service/stations/stations.go | 169 +++++++++++++++++ pkg/service/stations/stations_test.go | 178 ++++++++++++++++++ 9 files changed, 849 insertions(+), 75 deletions(-) create mode 100644 pkg/service/marge/classify_radiobrowser_test.go create mode 100644 pkg/service/stations/stations.go create mode 100644 pkg/service/stations/stations_test.go diff --git a/cmd/soundtouch-cli/cmd_station.go b/cmd/soundtouch-cli/cmd_station.go index a47f191..9d56e01 100644 --- a/cmd/soundtouch-cli/cmd_station.go +++ b/cmd/soundtouch-cli/cmd_station.go @@ -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="; 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 +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 0de0ed2..2fdf0a4 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -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", diff --git a/pkg/service/bmx/radiobrowser.go b/pkg/service/bmx/radiobrowser.go index 5568a21..bc72698 100644 --- a/pkg/service/bmx/radiobrowser.go +++ b/pkg/service/bmx/radiobrowser.go @@ -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) +} diff --git a/pkg/service/bmx/radiobrowser_test.go b/pkg/service/bmx/radiobrowser_test.go index 1b2b3e0..72a12c7 100644 --- a/pkg/service/bmx/radiobrowser_test.go +++ b/pkg/service/bmx/radiobrowser_test.go @@ -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") diff --git a/pkg/service/marge/classify_radiobrowser_test.go b/pkg/service/marge/classify_radiobrowser_test.go new file mode 100644 index 0000000..0dca09f --- /dev/null +++ b/pkg/service/marge/classify_radiobrowser_test.go @@ -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) + } +} diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 0f8bc75..8388a96 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -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 diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index ebdcb86..5ff6b60 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -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 } diff --git a/pkg/service/stations/stations.go b/pkg/service/stations/stations.go new file mode 100644 index 0000000..031403a --- /dev/null +++ b/pkg/service/stations/stations.go @@ -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) +} diff --git a/pkg/service/stations/stations_test.go b/pkg/service/stations/stations_test.go new file mode 100644 index 0000000..4c67298 --- /dev/null +++ b/pkg/service/stations/stations_test.go @@ -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") + } +}