Files
Tobias GesellchenandClaude Opus 4.8 bf5309f49f feat(cli): show bare station/episode id as its own column in station find
`station find` previously surfaced the id only inside the Location href
(e.g. /v1/playback/station/s228737). Render the bare id (s228737,
p1864248, or radiobrowser UUID) alone in a leading column so it is easy
to copy-paste, with the name beside it and the description plus full
Location indented below. The Location line stays because that path, not
the bare id, is what play/preset commands consume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:28:16 +02:00

682 lines
18 KiB
Go

package main
import (
"fmt"
"net/url"
"path"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
"github.com/urfave/cli/v2"
)
// searchStations handles searching for stations across different sources
func searchStations(c *cli.Context) error {
PrintDeprecation(
"station search",
"It asks the speaker to search, which fails when the speaker's cloud is gone.",
`soundtouch-cli station find --provider tunein --query "<your search>"`,
)
source := c.String("source")
sourceAccount := c.String("source-account")
searchTerm := c.String("query")
if searchTerm == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Searching %s for: %s", source, searchTerm), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check service availability for the source
checker := NewServiceAvailabilityChecker(client)
actionDescription := fmt.Sprintf("search %s stations", source)
if !checker.CheckSourceAvailable(source, actionDescription) {
return fmt.Errorf("source '%s' is not available for station search", source)
}
response, err := client.SearchStation(source, sourceAccount, searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search stations: %v", err))
return err
}
printSearchResults(response, searchTerm)
return nil
}
// searchTuneIn handles searching TuneIn specifically
func searchTuneIn(c *cli.Context) error {
PrintDeprecation(
"station search-tunein",
"It asks the speaker to search, which fails when the speaker's cloud is gone.",
`soundtouch-cli station find-tunein --query "<your search>"`,
)
searchTerm := c.String("query")
if searchTerm == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Searching TuneIn for: %s", searchTerm), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check TuneIn availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidateTuneInAvailable("search TuneIn stations") {
return fmt.Errorf("TuneIn is not available on this device")
}
response, err := client.SearchTuneInStations(searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search TuneIn: %v", err))
return err
}
printSearchResults(response, searchTerm)
return nil
}
// searchPandora handles searching Pandora specifically
func searchPandora(c *cli.Context) error {
PrintDeprecation(
"station search-pandora",
"There is no built-in Pandora search yet (it requires the speaker and your account).",
"",
)
sourceAccount := c.String("source-account")
searchTerm := c.String("query")
if sourceAccount == "" {
PrintError("Pandora source account is required")
return fmt.Errorf("source account required for Pandora")
}
if searchTerm == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Searching Pandora for: %s", searchTerm), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check Pandora availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidatePandoraAvailable("search Pandora stations") {
return fmt.Errorf("pandora is not available on this device")
}
response, err := client.SearchPandoraStations(sourceAccount, searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search Pandora: %v", err))
return err
}
printSearchResults(response, searchTerm)
return nil
}
// searchSpotify handles searching Spotify specifically
func searchSpotify(c *cli.Context) error {
PrintDeprecation(
"station search-spotify",
"There is no built-in Spotify search yet (it requires the speaker and your account).",
"",
)
sourceAccount := c.String("source-account")
searchTerm := c.String("query")
if sourceAccount == "" {
PrintError("Spotify source account is required")
return fmt.Errorf("source account required for Spotify")
}
if searchTerm == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Searching Spotify for: %s", searchTerm), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check Spotify availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidateSpotifyAvailable("search Spotify content") {
return fmt.Errorf("spotify is not available on this device")
}
response, err := client.SearchSpotifyContent(sourceAccount, searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search Spotify: %v", err))
return err
}
printSearchResults(response, searchTerm)
return nil
}
// addStation handles adding a station and playing it immediately
func addStation(c *cli.Context) error {
source := c.String("source")
sourceAccount := c.String("source-account")
token := c.String("token")
name := c.String("name")
if source == "" {
PrintError("Source is required")
return fmt.Errorf("source cannot be empty")
}
if token == "" {
PrintError("Station token is required")
return fmt.Errorf("token cannot be empty")
}
if name == "" {
PrintError("Station name is required")
return fmt.Errorf("name cannot be empty")
}
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Adding %s station: %s", source, name), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check service availability for the source
checker := NewServiceAvailabilityChecker(client)
actionDescription := fmt.Sprintf("add %s station", source)
if !checker.CheckSourceAvailable(source, actionDescription) {
return fmt.Errorf("source '%s' is not available for adding stations", source)
}
err = client.AddStation(source, sourceAccount, token, name)
if err != nil {
PrintError(fmt.Sprintf("Failed to add station: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Added and started playing station: %s", name))
return nil
}
// removeStation handles removing a station from collections
func removeStation(c *cli.Context) error {
source := c.String("source")
location := c.String("location")
itemType := c.String("type")
sourceAccount := c.String("source-account")
if source == "" {
PrintError("Source is required")
return fmt.Errorf("source cannot be empty")
}
if location == "" {
PrintError("Station location is required")
return fmt.Errorf("location cannot be empty")
}
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Removing %s station", source), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Create content item for the station to remove
contentItem := &models.ContentItem{
Source: source,
Location: location,
Type: itemType,
SourceAccount: sourceAccount,
}
err = client.RemoveStation(contentItem)
if err != nil {
PrintError(fmt.Sprintf("Failed to remove station: %v", err))
return err
}
PrintSuccess("Station removed successfully")
return nil
}
// printSearchResults formats and displays search results
func printSearchResults(response *models.SearchStationResponse, searchTerm string) {
fmt.Printf("Search Results for '%s':\n", searchTerm)
if response.IsEmpty() {
fmt.Printf(" No results found\n")
return
}
fmt.Printf(" Total results: %d\n", response.GetResultCount())
// Group results by type for better display
songs := response.GetSongs()
artists := response.GetArtists()
stations := response.GetStations()
printSongs(songs)
printArtists(artists)
printStations(stations)
printSearchHints(response, songs, artists, stations)
}
// printSongs prints song search results
func printSongs(songs []models.SearchResult) {
if len(songs) == 0 {
return
}
fmt.Printf("\n 🎵 Songs (%d):\n", len(songs))
for i := range songs {
song := &songs[i]
fmt.Printf(" %d. %s\n", i+1, song.GetDisplayName())
if song.Artist != "" {
fmt.Printf(" Artist: %s\n", song.Artist)
}
if song.Album != "" {
fmt.Printf(" Album: %s\n", song.Album)
}
if song.SourceAccount != "" {
fmt.Printf(" Account: %s\n", song.SourceAccount)
}
fmt.Printf(" Token: %s\n", song.Token)
fmt.Println()
}
}
// printArtists prints artist search results
func printArtists(artists []models.SearchResult) {
if len(artists) == 0 {
return
}
fmt.Printf(" 🎤 Artists (%d):\n", len(artists))
for i := range artists {
artist := &artists[i]
fmt.Printf(" %d. %s\n", i+1, artist.GetDisplayName())
if artist.SourceAccount != "" {
fmt.Printf(" Account: %s\n", artist.SourceAccount)
}
fmt.Printf(" Token: %s\n", artist.Token)
fmt.Println()
}
}
// printStations prints station search results
func printStations(stations []models.SearchResult) {
if len(stations) == 0 {
return
}
fmt.Printf(" 📻 Stations (%d):\n", len(stations))
for i := range stations {
station := &stations[i]
fmt.Printf(" %d. %s\n", i+1, station.GetDisplayName())
if station.SourceAccount != "" {
fmt.Printf(" Account: %s\n", station.SourceAccount)
}
fmt.Printf(" Token: %s\n", station.Token)
if station.Description != "" {
fmt.Printf(" Description: %s\n", station.Description)
}
fmt.Println()
}
}
// printSearchHints prints usage hints for search results
func printSearchHints(response *models.SearchStationResponse, songs, artists, stations []models.SearchResult) {
fmt.Printf("💡 Usage hints:\n")
fmt.Printf(" • To add a station and play it: station add --source %s --token <token> --name <name>\n", response.Source)
if hasAccountResults(response) {
fmt.Printf(" • Include --source-account <account> when adding stations that require it\n")
}
if len(songs) > 0 || len(artists) > 0 || len(stations) > 0 {
fmt.Printf(" • Copy the token from results above to use with 'station add'\n")
}
}
// hasAccountResults checks if any results have source accounts
func hasAccountResults(response *models.SearchStationResponse) bool {
allResults := response.GetAllResults()
for i := range allResults {
if allResults[i].SourceAccount != "" {
return true
}
}
return false
}
// listStations handles listing saved stations
func listStations(c *cli.Context) error {
source := c.String("source")
sourceAccount := c.String("source-account")
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Getting %s stations", source), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check service availability for the source
checker := NewServiceAvailabilityChecker(client)
actionDescription := fmt.Sprintf("list %s stations", source)
if !checker.CheckSourceAvailable(source, actionDescription) {
return fmt.Errorf("source '%s' is not available for listing stations", source)
}
var response *models.NavigateResponse
switch strings.ToUpper(source) {
case "TUNEIN":
response, err = client.GetTuneInStations(sourceAccount)
case "PANDORA":
if sourceAccount == "" {
PrintError("Pandora source account is required")
return fmt.Errorf("source account required for Pandora")
}
response, err = client.GetPandoraStations(sourceAccount)
default:
return fmt.Errorf("listing stations is not supported for source: %s", source)
}
if err != nil {
PrintError(fmt.Sprintf("Failed to get stations: %v", err))
return err
}
printStationList(response, source)
return nil
}
// printStationList formats and displays saved station results
func printStationList(response *models.NavigateResponse, source string) {
fmt.Printf("Saved %s Stations:\n", source)
if response.TotalItems == 0 {
fmt.Printf(" No stations found\n")
return
}
stations := response.GetStations()
fmt.Printf(" Total stations: %d\n", response.TotalItems)
fmt.Printf(" Showing: %d\n\n", len(stations))
for i, station := range stations {
fmt.Printf(" %d. %s\n", i+1, station.Name)
if station.ContentItem != nil {
if station.ContentItem.Location != "" {
fmt.Printf(" Location: %s\n", station.ContentItem.Location)
}
if station.ContentItem.SourceAccount != "" {
fmt.Printf(" Account: %s\n", station.ContentItem.SourceAccount)
}
if station.ContentItem.IsPresetable {
fmt.Printf(" Can be saved as preset: Yes\n")
}
}
if station.Type != "" {
fmt.Printf(" Type: %s\n", station.Type)
}
fmt.Println()
}
// Show usage hints
fmt.Printf("💡 Usage hints:\n")
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")
}
// playbackID returns the bare station/episode id from a playback href,
// e.g. "/v1/playback/station/s228737" -> "s228737" and
// "/v1/playback/episodes/p1864248?encoded_name=…" -> "p1864248".
// Returns "" when href is empty.
func playbackID(href string) string {
if href == "" {
return ""
}
if i := strings.IndexByte(href, '?'); i >= 0 {
href = href[:i]
}
return path.Base(href)
}
// printBmxNavResults renders a *models.BmxNavResponse to stdout.
// For each section it prints the section name as a header, then each item as a
// leading id column followed by the name, with subtitle and playback location
// indented below. The bare id sits alone in its own column so it is easy to
// copy-paste.
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
}
// Width of the leading id column = widest id in this section.
maxID := 0
for _, item := range section.Items {
if item.Links != nil && item.Links.BmxPlayback != nil {
maxID = max(maxID, len(playbackID(item.Links.BmxPlayback.Href)))
}
}
// Continuation lines align under the name: 4 leading spaces
// + id column + 2-space gap.
indent := strings.Repeat(" ", 4+maxID+2)
for _, item := range section.Items {
id := ""
if item.Links != nil && item.Links.BmxPlayback != nil {
id = playbackID(item.Links.BmxPlayback.Href)
}
fmt.Printf(" %-*s %s\n", maxID, id, item.Name)
if item.Subtitle != "" {
fmt.Printf("%s%s\n", indent, item.Subtitle)
}
if item.Links != nil && item.Links.BmxPlayback != nil {
fmt.Printf("%sLocation: %s\n", indent, 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")
}
// runFind performs a built-in station search for the given provider and
// prints the results. The search runs inside the CLI itself, querying the
// radio provider's public API directly — it needs neither the speaker's
// cloud nor a running soundtouch-service. When more is true it follows up
// to three additional result pages while a next cursor is available.
func runFind(provider stations.Provider, label, query string, more bool) error {
if query == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
fmt.Printf("Searching %s for: %s\n", label, 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
}
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
}
// findStations is the action for the unified `station find` with
// --provider / --query / --more.
func findStations(c *cli.Context) error {
providerStr := c.String("provider")
var (
provider stations.Provider
label string
)
switch strings.ToLower(providerStr) {
case "tunein":
provider, label = stations.ProviderTuneIn, "TuneIn"
case "radiobrowser":
provider, label = stations.ProviderRadioBrowser, "Radio Browser"
default:
PrintError(fmt.Sprintf("Unknown provider %q: must be 'tunein' or 'radiobrowser'", providerStr))
return fmt.Errorf("unknown provider: %s", providerStr)
}
return runFind(provider, label, c.String("query"), c.Bool("more"))
}
// findTuneIn is the action for `station find-tunein` (built-in TuneIn search).
func findTuneIn(c *cli.Context) error {
return runFind(stations.ProviderTuneIn, "TuneIn", c.String("query"), c.Bool("more"))
}
// findRadioBrowser is the action for `station find-radiobrowser`
// (built-in Radio Browser search).
func findRadioBrowser(c *cli.Context) error {
return runFind(stations.ProviderRadioBrowser, "Radio Browser", c.String("query"), c.Bool("more"))
}