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",