diff --git a/cmd/soundtouch-cli/cmd_preset.go b/cmd/soundtouch-cli/cmd_preset.go index 8bfc8dc..20398de 100644 --- a/cmd/soundtouch-cli/cmd_preset.go +++ b/cmd/soundtouch-cli/cmd_preset.go @@ -80,6 +80,25 @@ func storePreset(c *cli.Context) error { itemType := c.String("type") artwork := c.String("artwork") + // Resolve location and source from potential URLs + resolvedSource, resolvedLocation := resolveLocation(source, location) + if resolvedLocation != location && (source == "" || source == "TUNEIN") { + // If location was a TuneIn URL, fetch metadata if name or artwork is missing + if name == "" || artwork == "" { + metadata, err := fetchTuneInMetadata(location) + if err == nil && metadata != nil { + if name == "" { + name = metadata.Name + } + if artwork == "" { + artwork = metadata.Artwork + } + } + } + } + source = resolvedSource + location = resolvedLocation + clientConfig := GetClientConfig(c) if source == "" { diff --git a/cmd/soundtouch-cli/common.go b/cmd/soundtouch-cli/common.go index 6a9c89f..8206d98 100644 --- a/cmd/soundtouch-cli/common.go +++ b/cmd/soundtouch-cli/common.go @@ -2,7 +2,9 @@ package main import ( "fmt" + "io" "net" + "net/http" "runtime" "strconv" "strings" @@ -133,6 +135,100 @@ 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 + } + } + + return source, location +} + +type TuneInMetadata struct { + Name string + Artwork string +} + +var httpClient = &http.Client{ + Timeout: 5 * time.Second, +} + +func fetchTuneInMetadata(url string) (*TuneInMetadata, error) { + if !strings.Contains(url, "tunein.com/radio/") { + return nil, nil + } + + 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 + } + + html := string(body) + metadata := &TuneInMetadata{} + + // Simple extraction of og:title and og:image + // Example: + // Example: + + titlePrefix := `property="og:title" content="` + if idx := strings.Index(html, titlePrefix); idx != -1 { + start := idx + len(titlePrefix) + end := strings.Index(html[start:], `"`) + if end != -1 { + title := html[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(html, imagePrefix); idx != -1 { + start := idx + len(imagePrefix) + end := strings.Index(html[start:], `"`) + if end != -1 { + metadata.Artwork = html[start : start+end] + } + } + + return metadata, nil +} + // PrintSuccess prints a standard success message func PrintSuccess(message string) { fmt.Printf("✓ %s\n", message) diff --git a/cmd/soundtouch-cli/common_test.go b/cmd/soundtouch-cli/common_test.go new file mode 100644 index 0000000..bd61a44 --- /dev/null +++ b/cmd/soundtouch-cli/common_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchTuneInMetadata(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + html := ` + + +
+ + + + + +` + w.WriteHeader(http.StatusOK) + w.Write([]byte(html)) + })) + defer ts.Close() + + // Temporarily override httpClient to use test server + oldClient := httpClient + httpClient = ts.Client() + defer func() { httpClient = oldClient }() + + metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/") + if err != nil { + t.Fatalf("fetchTuneInMetadata() error = %v", err) + } + + if metadata == nil { + t.Fatal("fetchTuneInMetadata() returned nil metadata") + } + + expectedName := "WDR 2 Rheinland" + if metadata.Name != expectedName { + t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName) + } + + expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png" + if metadata.Artwork != expectedArtwork { + t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork) + } +} + +func TestResolveLocation(t *testing.T) { + tests := []struct { + name string + source string + location string + expectedSource string + expectedLocation string + }{ + { + name: "Plain location", + source: "TUNEIN", + location: "/v1/playback/station/s213886", + expectedSource: "TUNEIN", + expectedLocation: "/v1/playback/station/s213886", + }, + { + name: "TuneIn URL", + source: "", + location: "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/", + expectedSource: "TUNEIN", + expectedLocation: "/v1/playback/station/s213886", + }, + { + name: "TuneIn URL with source", + source: "SOMETHING", + location: "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/", + expectedSource: "TUNEIN", + expectedLocation: "/v1/playback/station/s213886", + }, + { + name: "TuneIn URL without trailing slash", + source: "", + location: "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886", + expectedSource: "TUNEIN", + expectedLocation: "/v1/playback/station/s213886", + }, + { + name: "Non-TuneIn URL", + source: "OTHER", + location: "https://example.com/radio/s123", + expectedSource: "OTHER", + expectedLocation: "https://example.com/radio/s123", + }, + { + name: "TuneIn URL short form", + source: "", + location: "https://tunein.com/radio/s213886/", + expectedSource: "TUNEIN", + expectedLocation: "/v1/playback/station/s213886", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSource, gotLocation := resolveLocation(tt.source, tt.location) + if gotSource != tt.expectedSource { + t.Errorf("resolveLocation() gotSource = %v, want %v", gotSource, tt.expectedSource) + } + if gotLocation != tt.expectedLocation { + t.Errorf("resolveLocation() gotLocation = %v, want %v", gotLocation, tt.expectedLocation) + } + }) + } +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 5038bf2..5ecb617 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -98,14 +98,13 @@ func main() { app := &cli.App{ Name: "soundtouch-cli", Usage: "Command-line interface for controlling Bose SoundTouch devices", - Description: `A comprehensive CLI tool for interacting with Bose SoundTouch devices. + Description: `⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ A comprehensive CLI tool for interacting with Bose SoundTouch devices. Supports device discovery, playback control, volume/bass/balance adjustment, source selection, zone management, and more.`, Version: version, Authors: []*cli.Author{ { - Name: "SoundTouch CLI Contributors", - Email: "info@example.com", + Name: "Tobias Gesellchen, and the SoundTouch CLI Contributors", }, }, Flags: CommonFlags, diff --git a/docs/preset-store.md b/docs/preset-store.md index cdc7082..66772dd 100644 --- a/docs/preset-store.md +++ b/docs/preset-store.md @@ -91,6 +91,11 @@ soundtouch-cli --host 192.168.1.100 preset store \ --location "/v1/playback/station/s33828" \ --name "K-LOVE Radio" +# Store radio station using TuneIn URL (Name and Artwork are automatically fetched) +soundtouch-cli --host 192.168.1.100 preset store \ + --slot 6 \ + --location "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/" + # Remove preset soundtouch-cli --host 192.168.1.100 preset remove --slot 3 @@ -374,4 +379,4 @@ Key benefits: - ✅ **Low complexity**: Leverages existing code patterns and infrastructure - ✅ **Enhanced CLI**: Automatic location display makes it easy to capture preset data -This feature would enable SoundTouch users to fully utilize their device's preset capabilities programmatically, making it easier to manage and access their favorite content from any supported source. **Special thanks to the SoundTouch Plus community for documenting these working endpoints that weren't included in the official API documentation.** \ No newline at end of file +This feature would enable SoundTouch users to fully utilize their device's preset capabilities programmatically, making it easier to manage and access their favorite content from any supported source. **Special thanks to the SoundTouch Plus community for documenting these working endpoints that weren't included in the official API documentation.**