Implement TuneIn URL conversion and metadata extraction for preset store

This commit is contained in:
Tobias Gesellchen
2026-02-01 21:26:59 +01:00
parent 4326c97c7a
commit de19cf1b0c
5 changed files with 237 additions and 4 deletions
+19
View File
@@ -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 == "" {
+96
View File
@@ -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: <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(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)
+114
View File
@@ -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 := `
<!doctype html>
<html>
<head>
<meta property="og:title" content="WDR 2 Rheinland, 100.4 FM, Köln | Free Internet Radio | TuneIn"/>
<meta property="og:image" content="https://cdn-radiotime-logos.tunein.com/s213886g.png"/>
</head>
<body></body>
</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)
}
})
}
}
+2 -3
View File
@@ -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,
+6 -1
View File
@@ -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.**
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.**