Files
Tobias GesellchenandClaude Opus 4.8 c7eda7ed7b feat(cli)!: deprecate speaker-based station search in favour of find
The `find` family runs the search inside the CLI, querying the radio
provider's public API directly (no speaker cloud, no soundtouch-service).
Make it the canonical path and deprecate the speaker-based search family.

- Add `find-tunein` and `find-radiobrowser` siblings; refactor the find
  actions onto a shared `runFind` helper (all support `--more`).
- Rename the unreleased `search-radiobrowser` to `find-radiobrowser`.
- Deprecate `search`, `search-tunein`, `search-pandora`, `search-spotify`:
  they keep working but print a stderr deprecation notice (new
  `PrintDeprecation` helper) pointing at the `find*` replacement. Pandora
  and Spotify have no built-in equivalent yet (they need the speaker +
  account), so their notices say so.
- Docs: lead with the `find` family as recommended; mark the speaker-based
  search commands deprecated; drop the misleading "service-side" wording
  in favour of "built-in / queries the provider directly".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:53:40 +02:00

369 lines
10 KiB
Go

package main
import (
"encoding/base64"
"fmt"
"html"
"io"
"net"
"net/http"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/urfave/cli/v2"
)
// CloudCommonFlags defines flags for commands that talk to the AfterTouch service.
var CloudCommonFlags = []cli.Flag{
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service URL",
Required: true,
EnvVars: []string{"AFTERTOUCH_URL"},
},
}
// CommonFlags defines flags that are shared across multiple commands
var CommonFlags = []cli.Flag{
&cli.StringFlag{
Name: "host",
Usage: "SoundTouch device host/IP address (can include port like host:8090)",
EnvVars: []string{"SOUNDTOUCH_HOST"},
},
&cli.IntFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "SoundTouch device port",
Value: 8090,
EnvVars: []string{"SOUNDTOUCH_PORT"},
},
&cli.DurationFlag{
Name: "timeout",
Aliases: []string{"t"},
Usage: "Request timeout",
Value: 10 * time.Second,
},
}
// ClientConfig holds configuration for creating a SoundTouch client
type ClientConfig struct {
Host string
Port int
Timeout time.Duration
}
// GetClientConfig extracts client configuration from CLI context
func GetClientConfig(c *cli.Context) *ClientConfig {
host := c.String("host")
port := c.Int("port")
timeout := c.Duration("timeout")
// Parse host:port if host contains a port
if host != "" {
if finalHost, finalPort := parseHostPort(host, port); finalHost != "" {
host = finalHost
port = finalPort
}
}
return &ClientConfig{
Host: host,
Port: port,
Timeout: timeout,
}
}
// RequireHost validates that a host is provided for commands that need it
func RequireHost(c *cli.Context) error {
if c.String("host") == "" {
return fmt.Errorf("host is required. Use --host flag or set SOUNDTOUCH_HOST environment variable")
}
return nil
}
// CreateSoundTouchClient creates a configured SoundTouch client
func CreateSoundTouchClient(config *ClientConfig) (*client.Client, error) {
cfg, err := loadConfig(config.Timeout)
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
clientConfig := &client.Config{
Host: config.Host,
Port: config.Port,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}
return client.NewClient(clientConfig), nil
}
// loadConfig loads the application configuration with optional timeout override
func loadConfig(timeout time.Duration) (*config.Config, error) {
cfg, err := config.LoadFromEnv()
if err != nil {
return nil, err
}
// Override timeout if provided
if timeout > 0 {
cfg.HTTPTimeout = timeout
}
return cfg, nil
}
// parseHostPort splits a host:port string into separate host and port components
// If no port is specified, returns the original host and the provided default port
func parseHostPort(hostPort string, defaultPort int) (string, int) {
// Check if host contains a port (has a colon)
if strings.Contains(hostPort, ":") {
host, portStr, err := net.SplitHostPort(hostPort)
if err != nil {
// If parsing fails, return original host and default port
return hostPort, defaultPort
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
// If port parsing fails or is invalid, return host and default port
return host, defaultPort
}
return host, port
}
// No port specified, return original host and default port
return hostPort, defaultPort
}
// PrintDeviceHeader prints a standard header for device commands
func PrintDeviceHeader(operation, host string, port int) {
fmt.Printf("%s from %s:%d...\n", operation, host, port)
}
// resolveLocation converts potential URLs to SoundTouch locations
func resolveLocation(source, location string) (string, string) {
// If it's not a URL, return as is
if !strings.HasPrefix(location, "http://") && !strings.HasPrefix(location, "https://") {
return source, location
}
// TuneIn URL conversion
// Example: https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/
if strings.Contains(location, "tunein.com/radio/") {
trimmed := strings.TrimSuffix(location, "/")
parts := strings.Split(trimmed, "-")
if len(parts) > 0 {
lastPart := parts[len(parts)-1]
if strings.HasPrefix(lastPart, "s") {
return "TUNEIN", "/v1/playback/station/" + lastPart
}
}
// Fallback for URLs like https://tunein.com/radio/s213886/
parts = strings.Split(trimmed, "/")
lastPart := parts[len(parts)-1]
if strings.HasPrefix(lastPart, "s") {
return "TUNEIN", "/v1/playback/station/" + lastPart
}
}
// Spotify URL conversion
// Example: https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD?si=YhDPWL9LRGO5whz1wLsteA
if strings.Contains(location, "open.spotify.com/") {
re := regexp.MustCompile(`https://open\.spotify\.com/([^/]+)/([^?]+)`)
matches := re.FindStringSubmatch(location)
if len(matches) >= 3 {
contentType := matches[1]
contentID := matches[2]
uri := fmt.Sprintf("spotify:%s:%s", contentType, contentID)
encodedURI := base64.StdEncoding.EncodeToString([]byte(uri))
return "SPOTIFY", "/playback/container/" + encodedURI
}
}
return source, location
}
type Metadata struct {
Name string
Artwork string
}
var httpClient = &http.Client{
Timeout: 5 * time.Second,
}
func fetchTuneInMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
return nil, fmt.Errorf("url is not a TuneIn radio URL")
}
resp, err := httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // Limit to 100KB
if err != nil {
return nil, err
}
rawHTML := string(body)
metadata := &Metadata{}
// Simple extraction of og:title and og:image
// Example: <meta data-react-helmet="true" property="og:title" content="WDR 2 Rheinland, 100.4 FM, Köln | Free Internet Radio | TuneIn"/>
// Example: <meta data-react-helmet="true" property="og:image" content="https://cdn-radiotime-logos.tunein.com/s213886g.png"/>
titlePrefix := `property="og:title" content="`
if idx := strings.Index(rawHTML, titlePrefix); idx != -1 {
start := idx + len(titlePrefix)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
title := html.UnescapeString(rawHTML[start : start+end])
// Clean up title (remove ", 100.4 FM, Köln | Free Internet Radio | TuneIn")
if pipeIdx := strings.Index(title, " | "); pipeIdx != -1 {
title = title[:pipeIdx]
}
if commaIdx := strings.Index(title, ", "); commaIdx != -1 {
title = title[:commaIdx]
}
metadata.Name = title
}
}
imagePrefix := `property="og:image" content="`
if idx := strings.Index(rawHTML, imagePrefix); idx != -1 {
start := idx + len(imagePrefix)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
metadata.Artwork = rawHTML[start : start+end]
}
}
return metadata, nil
}
func fetchSpotifyMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
return nil, fmt.Errorf("url is not a Spotify URL")
}
resp, err := httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*200)) // Spotify pages can be larger
if err != nil {
return nil, err
}
rawHTML := string(body)
metadata := &Metadata{}
// Simple extraction of og:title and og:image
// Example: <meta property="og:title" content="Terminal Caribe - Album by Santi &amp; Tuğçe | Spotify"
titlePrefix := `property="og:title" content="`
if idx := strings.Index(rawHTML, titlePrefix); idx != -1 {
start := idx + len(titlePrefix)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
title := html.UnescapeString(rawHTML[start : start+end])
// Clean up title (remove " | Spotify")
if pipeIdx := strings.Index(title, " | "); pipeIdx != -1 {
title = title[:pipeIdx]
}
// Spotify often has "- Album by ..." or "- Playlist by ..."
// We might want to keep it or clean it up.
// User's TuneIn example cleaned it up.
// For now let's just keep what Spotify provides as title minus the " | Spotify" part.
metadata.Name = title
}
}
imagePrefix := `property="og:image" content="`
if idx := strings.Index(rawHTML, imagePrefix); idx != -1 {
start := idx + len(imagePrefix)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
metadata.Artwork = rawHTML[start : start+end]
}
}
return metadata, nil
}
// PrintSuccess prints a standard success message
func PrintSuccess(message string) {
fmt.Printf("✓ %s\n", message)
}
// PrintError prints a standard error message
func PrintError(message string) {
fmt.Printf("✗ %s\n", sanitizeLog(message))
}
// PrintWarning prints a standard warning message
func PrintWarning(message string) {
fmt.Printf("⚠️ %s\n", message)
}
// PrintDeprecation prints a deprecation notice to stderr (so it does not
// pollute piped stdout output). reason explains why the command is going
// away; newUsage is an optional replacement example — pass "" when there
// is no replacement yet.
func PrintDeprecation(command, reason, newUsage string) {
fmt.Fprintf(os.Stderr, "⚠️ '%s' is deprecated and will be removed in a future release.\n", command)
if reason != "" {
fmt.Fprintf(os.Stderr, " %s\n", reason)
}
if newUsage != "" {
fmt.Fprintf(os.Stderr, " Use instead:\n %s\n", newUsage)
}
}
// showVersionInfo displays detailed version information including build details
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("%s version %s\n", os.Args[0], version)
fmt.Printf("Build commit: %s\n", commit)
fmt.Printf("Build date: %s\n", date)
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
return nil
}