mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.8
parent
8defb0b833
commit
d101e515a9
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user