feat: implement comprehensive content selection with streamUrl format support

 New Features:
- Add SelectContentItem() method for direct ContentItem selection
- Add SelectLocalInternetRadio() with full streamUrl format support
- Add SelectLocalMusic() for SoundTouch App Media Server content
- Add SelectStoredMusic() for UPnP/DLNA media server content

📻 streamUrl Format Support:
- Full implementation of wiki specification for LOCAL_INTERNET_RADIO
- Support for proxy URLs: http://contentapi.gmuth.de/station.php?name=Station&streamUrl=ActualStream
- Direct stream URL support for simple internet radio
- Complete ContentItem structure with metadata and artwork

🖥️ CLI Commands:
- Add 'source internet-radio' command with streamUrl support
- Add 'source local-music' command for local media server content
- Add 'source stored-music' command for UPnP/DLNA content
- Add 'source content' command for advanced generic selection
- All commands include comprehensive flag support and validation

🧪 Testing:
- Add 17+ comprehensive unit tests covering all scenarios
- Test streamUrl format validation and parsing
- Test error handling and parameter validation
- Test default value assignment and ContentItem construction
- All tests passing with full coverage

📚 Documentation:
- Update CLI-REFERENCE.md with new command examples
- Add complete content-selection example with working code
- Add implementation summary document
- Include API documentation for all new methods
- Add usage examples for both API and CLI

🔗 References:
Implements features from SoundTouch WebServices API Wiki:
- https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format
- https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music

🎯 Benefits:
- Complete API coverage for advanced content selection
- Backward compatible with existing code
- Flexible design with both convenience and power-user methods
- Production-ready with comprehensive testing and documentation

Co-authored-by: SoundTouch WebServices API Wiki <https://github.com/thlucas1/homeassistantcomponent_soundtouchplus>
This commit is contained in:
Tobias Gesellchen
2026-02-02 16:44:25 +01:00
co-authored by lnx01
parent 7ec4ee67af
commit 0d5746a6a5
8 changed files with 1527 additions and 3 deletions
+212
View File
@@ -209,6 +209,218 @@ func selectAux(c *cli.Context) error {
return nil
}
// selectLocalInternetRadio handles selecting LOCAL_INTERNET_RADIO source
func selectLocalInternetRadio(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
sourceAccount := c.String("account")
itemName := c.String("name")
containerArt := c.String("artwork")
// Check LOCAL_INTERNET_RADIO availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select internet radio") {
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
}
PrintDeviceHeader("Selecting internet radio stream", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Station: %s\n", itemName)
}
fmt.Printf(" Location: %s\n", location)
err = client.SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select internet radio: %w", err)
}
PrintSuccess("Internet radio stream selected")
return nil
}
// selectLocalMusic handles selecting LOCAL_MUSIC source
func selectLocalMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
sourceAccount := c.String("account")
if sourceAccount == "" {
return fmt.Errorf("account is required for LOCAL_MUSIC (use --account)")
}
itemName := c.String("name")
containerArt := c.String("artwork")
// Check LOCAL_MUSIC availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_MUSIC", "select local music") {
return fmt.Errorf("LOCAL_MUSIC is not available")
}
PrintDeviceHeader("Selecting local music content", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Content: %s\n", itemName)
}
fmt.Printf(" Location: %s\n", location)
fmt.Printf(" Account: %s\n", sourceAccount)
err = client.SelectLocalMusic(location, sourceAccount, itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select local music: %w", err)
}
PrintSuccess("Local music content selected")
return nil
}
// selectStoredMusic handles selecting STORED_MUSIC source
func selectStoredMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
sourceAccount := c.String("account")
if sourceAccount == "" {
return fmt.Errorf("account is required for STORED_MUSIC (use --account)")
}
itemName := c.String("name")
containerArt := c.String("artwork")
// Check STORED_MUSIC availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("STORED_MUSIC", "select stored music") {
return fmt.Errorf("STORED_MUSIC is not available")
}
PrintDeviceHeader("Selecting stored music content", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Content: %s\n", itemName)
}
fmt.Printf(" Location: %s\n", location)
fmt.Printf(" Account: %s\n", sourceAccount)
err = client.SelectStoredMusic(location, sourceAccount, itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select stored music: %w", err)
}
PrintSuccess("Stored music content selected")
return nil
}
// selectContent handles selecting content using a ContentItem directly
func selectContent(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
// Required parameters
source := strings.ToUpper(c.String("source"))
if source == "" {
return fmt.Errorf("source is required (use --source)")
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
// Optional parameters
sourceAccount := c.String("account")
itemName := c.String("name")
containerArt := c.String("artwork")
itemType := c.String("type")
isPresetable := c.Bool("presetable")
// Create ContentItem
contentItem := &models.ContentItem{
Source: source,
Type: itemType,
Location: location,
SourceAccount: sourceAccount,
IsPresetable: isPresetable,
ItemName: itemName,
ContainerArt: containerArt,
}
// Set default type if not specified
if itemType == "" {
switch source {
case "SPOTIFY":
contentItem.Type = "uri"
case "TUNEIN", "LOCAL_INTERNET_RADIO":
contentItem.Type = "stationurl"
case "LOCAL_MUSIC":
contentItem.Type = "album" // default, could be track, artist, etc.
}
}
// Set default item name if not specified
if itemName == "" {
contentItem.ItemName = source
}
PrintDeviceHeader("Selecting content", clientConfig.Host, clientConfig.Port)
fmt.Printf(" Source: %s\n", source)
fmt.Printf(" Location: %s\n", location)
if sourceAccount != "" {
fmt.Printf(" Account: %s\n", sourceAccount)
}
if itemName != "" {
fmt.Printf(" Name: %s\n", itemName)
}
if itemType != "" {
fmt.Printf(" Type: %s\n", itemType)
}
err = client.SelectContentItem(contentItem)
if err != nil {
return fmt.Errorf("failed to select content: %w", err)
}
PrintSuccess("Content selected")
return nil
}
// getServiceAvailability handles displaying service availability information
func getServiceAvailability(c *cli.Context) error {
clientConfig := GetClientConfig(c)
+130
View File
@@ -896,6 +896,136 @@ func main() {
Action: selectAux,
Before: RequireHost,
},
{
Name: "internet-radio",
Usage: "Select internet radio stream (LOCAL_INTERNET_RADIO)",
Action: selectLocalInternetRadio,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "location",
Aliases: []string{"l"},
Usage: "Stream location URL (direct stream or streamUrl format)",
Required: true,
},
&cli.StringFlag{
Name: "account",
Aliases: []string{"a"},
Usage: "Source account (optional)",
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Station name",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Station artwork URL",
},
},
},
{
Name: "local-music",
Usage: "Select local music content (LOCAL_MUSIC)",
Action: selectLocalMusic,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "location",
Aliases: []string{"l"},
Usage: "Content location (e.g., album:983, track:2579)",
Required: true,
},
&cli.StringFlag{
Name: "account",
Aliases: []string{"a"},
Usage: "Source account GUID (required)",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Content name",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Content artwork URL",
},
},
},
{
Name: "stored-music",
Usage: "Select stored music content (STORED_MUSIC)",
Action: selectStoredMusic,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "location",
Aliases: []string{"l"},
Usage: "Content location ID (e.g., 6_a2874b5d_4f83d999)",
Required: true,
},
&cli.StringFlag{
Name: "account",
Aliases: []string{"a"},
Usage: "Source account GUID (required)",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Content name",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Content artwork URL",
},
},
},
{
Name: "content",
Usage: "Select content using ContentItem (advanced)",
Action: selectContent,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "source",
Aliases: []string{"s"},
Usage: "Content source (SPOTIFY, TUNEIN, LOCAL_INTERNET_RADIO, etc.)",
Required: true,
},
&cli.StringFlag{
Name: "location",
Aliases: []string{"l"},
Usage: "Content location",
Required: true,
},
&cli.StringFlag{
Name: "account",
Aliases: []string{"a"},
Usage: "Source account",
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Content name",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "Content type (uri, stationurl, album, track, etc.)",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Content artwork URL",
},
&cli.BoolFlag{
Name: "presetable",
Usage: "Mark content as presetable",
Value: true,
},
},
},
{
Name: "availability",
Usage: "Show service availability",