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",
|
||||
|
||||
Reference in New Issue
Block a user