mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
feat: implement complete /storePreset and /removePreset functionality
- Add StorePreset, StoreCurrentAsPreset, and RemovePreset methods to client - Create comprehensive preset management CLI with subcommands: * preset store-current --slot N (store currently playing content) * preset store --slot N --source X --location Y (store specific content) * preset remove --slot N (remove preset) * preset select --slot N (select/play preset) * preset list (list all presets) - Fix WebSocket event handling for preset updates: * Correct event type from 'presetUpdated' to 'presetsUpdated' * Update event structure to handle complete preset list * Improve WebSocket demo display for preset events - Add comprehensive test coverage for all new client methods - Fix mock server URL mismatch in tests (/now_playing vs /nowPlaying) - Add proper input validation and error handling - Support all content sources: SPOTIFY, TUNEIN, LOCAL_INTERNET_RADIO, etc. Successfully tested with real SoundTouch device: - Storing Spotify content as presets ✅ - Removing presets ✅ - Real-time WebSocket events ✅ - CLI usability and error handling ✅ Resolves #14 - Complete /storePreset implementation
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// storeCurrentPreset handles storing currently playing content as preset
|
||||
func storeCurrentPreset(c *cli.Context) error {
|
||||
slot := c.Int("slot")
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Storing current content as preset %d", slot), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check what's currently playing
|
||||
nowPlaying, err := client.GetNowPlaying()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get current content: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if nowPlaying.IsEmpty() {
|
||||
PrintError("No content currently playing")
|
||||
return fmt.Errorf("no content currently playing")
|
||||
}
|
||||
|
||||
if nowPlaying.ContentItem == nil {
|
||||
PrintError("Current content has no preset information")
|
||||
return fmt.Errorf("current content cannot be saved as preset")
|
||||
}
|
||||
|
||||
if !nowPlaying.ContentItem.IsPresetable {
|
||||
PrintError("Current content cannot be saved as preset")
|
||||
fmt.Printf(" Content: %s\n", nowPlaying.Track)
|
||||
fmt.Printf(" Source: %s\n", nowPlaying.Source)
|
||||
return fmt.Errorf("current content cannot be preset")
|
||||
}
|
||||
|
||||
// Show what we're about to store
|
||||
fmt.Printf("Current Content:\n")
|
||||
fmt.Printf(" Track: %s\n", nowPlaying.Track)
|
||||
if nowPlaying.Artist != "" {
|
||||
fmt.Printf(" Artist: %s\n", nowPlaying.Artist)
|
||||
}
|
||||
if nowPlaying.Album != "" {
|
||||
fmt.Printf(" Album: %s\n", nowPlaying.Album)
|
||||
}
|
||||
fmt.Printf(" Source: %s\n", nowPlaying.Source)
|
||||
if nowPlaying.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", nowPlaying.ContentItem.Location)
|
||||
}
|
||||
|
||||
// Store as preset
|
||||
err = client.StoreCurrentAsPreset(slot)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to store preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stored current content as preset %d", slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// storePreset handles storing specific content as preset
|
||||
func storePreset(c *cli.Context) error {
|
||||
slot := c.Int("slot")
|
||||
source := c.String("source")
|
||||
location := c.String("location")
|
||||
sourceAccount := c.String("source-account")
|
||||
name := c.String("name")
|
||||
itemType := c.String("type")
|
||||
artwork := c.String("artwork")
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
if source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Storing %s content as preset %d", source, slot), 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
|
||||
contentItem := &models.ContentItem{
|
||||
Source: source,
|
||||
Type: itemType,
|
||||
Location: location,
|
||||
SourceAccount: sourceAccount,
|
||||
IsPresetable: true,
|
||||
ItemName: name,
|
||||
ContainerArt: artwork,
|
||||
}
|
||||
|
||||
// Set default type if not specified
|
||||
if itemType == "" {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
contentItem.Type = "uri"
|
||||
case "TUNEIN", "LOCAL_INTERNET_RADIO":
|
||||
contentItem.Type = "stationurl"
|
||||
default:
|
||||
contentItem.Type = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Show what we're storing
|
||||
fmt.Printf("Content to store:\n")
|
||||
fmt.Printf(" Name: %s\n", name)
|
||||
fmt.Printf(" Source: %s\n", source)
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
if sourceAccount != "" {
|
||||
fmt.Printf(" Source Account: %s\n", sourceAccount)
|
||||
}
|
||||
if itemType != "" {
|
||||
fmt.Printf(" Type: %s\n", itemType)
|
||||
}
|
||||
|
||||
// Store preset
|
||||
err = client.StorePreset(slot, contentItem)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to store preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stored content as preset %d", slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removePreset handles removing a preset
|
||||
func removePreset(c *cli.Context) error {
|
||||
slot := c.Int("slot")
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Removing preset %d", slot), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if preset exists first
|
||||
presets, err := client.GetPresets()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get presets: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
preset := presets.GetPresetByID(slot)
|
||||
if preset == nil || preset.IsEmpty() {
|
||||
PrintError(fmt.Sprintf("Preset %d is already empty", slot))
|
||||
return fmt.Errorf("preset %d does not exist", slot)
|
||||
}
|
||||
|
||||
// Show what we're removing
|
||||
fmt.Printf("Removing preset %d:\n", slot)
|
||||
fmt.Printf(" Name: %s\n", preset.GetDisplayName())
|
||||
fmt.Printf(" Source: %s\n", preset.GetSource())
|
||||
|
||||
// Remove preset
|
||||
err = client.RemovePreset(slot)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Removed preset %d", slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectPresetNew handles selecting a preset (new version that works with subcommands)
|
||||
func selectPresetNew(c *cli.Context) error {
|
||||
slot := c.Int("slot")
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Selecting preset %d", slot), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SelectPreset(slot)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to select preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Preset %d selected", slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listPresets handles listing all presets (alias for existing getPresets command)
|
||||
func listPresets(c *cli.Context) error {
|
||||
return getPresets(c)
|
||||
}
|
||||
@@ -237,17 +237,94 @@ func main() {
|
||||
},
|
||||
// Preset commands
|
||||
{
|
||||
Name: "preset",
|
||||
Usage: "Select preset by number",
|
||||
Action: selectPreset,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "preset",
|
||||
Usage: "Preset number (1-6)",
|
||||
Required: true,
|
||||
Name: "preset",
|
||||
Usage: "Preset management commands",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "store-current",
|
||||
Usage: "Store currently playing content as preset",
|
||||
Action: storeCurrentPreset,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "slot",
|
||||
Usage: "Preset slot number (1-6)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "store",
|
||||
Usage: "Store specific content as preset",
|
||||
Action: storePreset,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "slot",
|
||||
Usage: "Preset slot number (1-6)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Usage: "Content source (SPOTIFY, TUNEIN, LOCAL_INTERNET_RADIO, etc.)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "location",
|
||||
Usage: "Content location (URI, URL, or ID)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "source-account",
|
||||
Usage: "Source account (username, device ID, etc.)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Usage: "Display name for the preset",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Usage: "Content type (uri, stationurl, etc.)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Artwork URL",
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Usage: "Remove a preset",
|
||||
Action: removePreset,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "slot",
|
||||
Usage: "Preset slot number (1-6)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "select",
|
||||
Usage: "Select and play a preset",
|
||||
Action: selectPresetNew,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "slot",
|
||||
Usage: "Preset slot number (1-6)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List all presets",
|
||||
Action: listPresets,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
// Key commands
|
||||
{
|
||||
|
||||
@@ -342,17 +342,25 @@ func handleConnection(event *models.ConnectionStateUpdatedEvent) {
|
||||
}
|
||||
|
||||
func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
preset := &event.Preset
|
||||
fmt.Printf("\n📻 Preset Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 📻 Preset: %d\n", preset.ID)
|
||||
presets := &event.Presets
|
||||
deviceHeader := "\n📻 Presets Update"
|
||||
if event.DeviceID != "" {
|
||||
deviceHeader += fmt.Sprintf(" [%s]", event.DeviceID)
|
||||
}
|
||||
fmt.Printf("%s:\n", deviceHeader)
|
||||
fmt.Printf(" 📻 Total presets: %d\n", len(presets.Preset))
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" 🎵 %s\n", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" 📻 Source: %s\n", preset.ContentItem.Source)
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw preset data: ID=%d\n", preset.ID)
|
||||
fmt.Printf(" 📱 Raw presets data: %d total presets\n", len(presets.Preset))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +316,69 @@ func (c *Client) IsCurrentContentPresetable() (bool, error) {
|
||||
return nowPlaying.ContentItem.IsPresetable, nil
|
||||
}
|
||||
|
||||
// StorePreset saves content as a preset on the SoundTouch device
|
||||
func (c *Client) StorePreset(id int, contentItem *models.ContentItem) error {
|
||||
if id < 1 || id > 6 {
|
||||
return fmt.Errorf("preset ID must be between 1 and 6, got %d", id)
|
||||
}
|
||||
|
||||
if contentItem == nil {
|
||||
return fmt.Errorf("content item cannot be nil")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
preset := &models.Preset{
|
||||
ID: id,
|
||||
CreatedOn: &now,
|
||||
UpdatedOn: &now,
|
||||
ContentItem: contentItem,
|
||||
}
|
||||
|
||||
err := c.post("/storePreset", preset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store preset %d: %w", id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreCurrentAsPreset saves currently playing content as preset
|
||||
func (c *Client) StoreCurrentAsPreset(id int) error {
|
||||
if id < 1 || id > 6 {
|
||||
return fmt.Errorf("preset ID must be between 1 and 6, got %d", id)
|
||||
}
|
||||
|
||||
nowPlaying, err := c.GetNowPlaying()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current content: %w", err)
|
||||
}
|
||||
|
||||
if nowPlaying.IsEmpty() || nowPlaying.ContentItem == nil {
|
||||
return fmt.Errorf("no content currently playing")
|
||||
}
|
||||
|
||||
if !nowPlaying.ContentItem.IsPresetable {
|
||||
return fmt.Errorf("current content cannot be saved as preset")
|
||||
}
|
||||
|
||||
return c.StorePreset(id, nowPlaying.ContentItem)
|
||||
}
|
||||
|
||||
// RemovePreset deletes a preset from the SoundTouch device
|
||||
func (c *Client) RemovePreset(id int) error {
|
||||
if id < 1 || id > 6 {
|
||||
return fmt.Errorf("preset ID must be between 1 and 6, got %d", id)
|
||||
}
|
||||
|
||||
preset := &models.Preset{ID: id}
|
||||
err := c.post("/removePreset", preset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove preset %d: %w", id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendKey sends a key press command to the device (press followed by release)
|
||||
func (c *Client) SendKey(keyValue string) error {
|
||||
if !models.IsValidKey(keyValue) {
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_StorePreset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
presetID int
|
||||
contentItem *models.ContentItem
|
||||
serverResponse string
|
||||
serverStatus int
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "store_spotify_playlist_success",
|
||||
presetID: 1,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "uri",
|
||||
Location: "spotify:playlist:37i9dQZF1DX0XUsuxWHRQd",
|
||||
SourceAccount: "testuser",
|
||||
IsPresetable: true,
|
||||
ItemName: "My Playlist",
|
||||
ContainerArt: "https://example.com/art.jpg",
|
||||
},
|
||||
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="1"><ContentItem source="SPOTIFY" type="uri" location="spotify:playlist:37i9dQZF1DX0XUsuxWHRQd" sourceAccount="testuser" isPresetable="true"><itemName>My Playlist</itemName></ContentItem></preset></presets>`,
|
||||
serverStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "store_tunein_radio_success",
|
||||
presetID: 2,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playback/station/s33828",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
},
|
||||
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="2"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s33828" isPresetable="true"><itemName>K-LOVE Radio</itemName></ContentItem></preset></presets>`,
|
||||
serverStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "store_local_internet_radio_success",
|
||||
presetID: 3,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: "https://stream.example.com/radio",
|
||||
IsPresetable: true,
|
||||
ItemName: "Custom Radio",
|
||||
},
|
||||
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="3"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="https://stream.example.com/radio" isPresetable="true"><itemName>Custom Radio</itemName></ContentItem></preset></presets>`,
|
||||
serverStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_preset_id_too_low",
|
||||
presetID: 0,
|
||||
contentItem: &models.ContentItem{Source: "SPOTIFY", Location: "test"},
|
||||
expectError: true,
|
||||
errorContains: "preset ID must be between 1 and 6",
|
||||
},
|
||||
{
|
||||
name: "invalid_preset_id_too_high",
|
||||
presetID: 7,
|
||||
contentItem: &models.ContentItem{Source: "SPOTIFY", Location: "test"},
|
||||
expectError: true,
|
||||
errorContains: "preset ID must be between 1 and 6",
|
||||
},
|
||||
{
|
||||
name: "nil_content_item",
|
||||
presetID: 1,
|
||||
contentItem: nil,
|
||||
expectError: true,
|
||||
errorContains: "content item cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "server_error_response",
|
||||
presetID: 1,
|
||||
contentItem: &models.ContentItem{Source: "SPOTIFY", Location: "test"},
|
||||
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><error>Invalid preset</error>`,
|
||||
serverStatus: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
errorContains: "failed to store preset 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request method and endpoint
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/storePreset" {
|
||||
t.Errorf("Expected /storePreset endpoint, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Verify content type
|
||||
if r.Header.Get("Content-Type") != "application/xml" {
|
||||
t.Errorf("Expected Content-Type application/xml, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
if tt.serverStatus != 0 {
|
||||
w.WriteHeader(tt.serverStatus)
|
||||
}
|
||||
if tt.serverResponse != "" {
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
err := client.StorePreset(tt.presetID, tt.contentItem)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, but got nil")
|
||||
return
|
||||
}
|
||||
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
|
||||
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemovePreset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
presetID int
|
||||
serverResponse string
|
||||
serverStatus int
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "remove_preset_success",
|
||||
presetID: 3,
|
||||
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets></presets>`,
|
||||
serverStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_preset_id_too_low",
|
||||
presetID: 0,
|
||||
expectError: true,
|
||||
errorContains: "preset ID must be between 1 and 6",
|
||||
},
|
||||
{
|
||||
name: "invalid_preset_id_too_high",
|
||||
presetID: 7,
|
||||
expectError: true,
|
||||
errorContains: "preset ID must be between 1 and 6",
|
||||
},
|
||||
{
|
||||
name: "server_error_response",
|
||||
presetID: 1,
|
||||
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><error>Preset not found</error>`,
|
||||
serverStatus: http.StatusNotFound,
|
||||
expectError: true,
|
||||
errorContains: "failed to remove preset 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request method and endpoint
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/removePreset" {
|
||||
t.Errorf("Expected /removePreset endpoint, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Return mock response
|
||||
if tt.serverStatus != 0 {
|
||||
w.WriteHeader(tt.serverStatus)
|
||||
}
|
||||
if tt.serverResponse != "" {
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
err := client.RemovePreset(tt.presetID)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, but got nil")
|
||||
return
|
||||
}
|
||||
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
|
||||
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_StoreCurrentAsPreset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
presetID int
|
||||
nowPlayingResponse string
|
||||
nowPlayingStatus int
|
||||
storePresetStatus int
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "store_current_spotify_success",
|
||||
presetID: 2,
|
||||
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<nowPlaying deviceID="TEST123" source="SPOTIFY" sourceAccount="testuser">
|
||||
<ContentItem source="SPOTIFY" type="uri" location="spotify:track:123456789" sourceAccount="testuser" isPresetable="true">
|
||||
<itemName>Test Track</itemName>
|
||||
</ContentItem>
|
||||
<track>Test Track</track>
|
||||
<artist>Test Artist</artist>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
</nowPlaying>`,
|
||||
nowPlayingStatus: http.StatusOK,
|
||||
storePresetStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "store_current_tunein_success",
|
||||
presetID: 1,
|
||||
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<nowPlaying deviceID="TEST123" source="TUNEIN">
|
||||
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s33828" isPresetable="true">
|
||||
<itemName>K-LOVE Radio</itemName>
|
||||
</ContentItem>
|
||||
<track>K-LOVE Radio</track>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
</nowPlaying>`,
|
||||
nowPlayingStatus: http.StatusOK,
|
||||
storePresetStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty_now_playing",
|
||||
presetID: 1,
|
||||
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<nowPlaying deviceID="TEST123" source="STANDBY">
|
||||
<ContentItem source="STANDBY" isPresetable="false" />
|
||||
</nowPlaying>`,
|
||||
nowPlayingStatus: http.StatusOK,
|
||||
expectError: true,
|
||||
errorContains: "no content currently playing",
|
||||
},
|
||||
{
|
||||
name: "content_not_presetable",
|
||||
presetID: 1,
|
||||
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<nowPlaying deviceID="TEST123" source="BLUETOOTH">
|
||||
<ContentItem source="BLUETOOTH" isPresetable="false">
|
||||
<itemName>Phone Audio</itemName>
|
||||
</ContentItem>
|
||||
<track>Phone Audio</track>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
</nowPlaying>`,
|
||||
nowPlayingStatus: http.StatusOK,
|
||||
expectError: true,
|
||||
errorContains: "current content cannot be saved as preset",
|
||||
},
|
||||
{
|
||||
name: "no_content_item",
|
||||
presetID: 1,
|
||||
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<nowPlaying deviceID="TEST123" source="UNKNOWN">
|
||||
<track>Unknown Track</track>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
</nowPlaying>`,
|
||||
nowPlayingStatus: http.StatusOK,
|
||||
expectError: true,
|
||||
errorContains: "no content currently playing",
|
||||
},
|
||||
{
|
||||
name: "now_playing_request_fails",
|
||||
presetID: 1,
|
||||
nowPlayingStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
errorContains: "failed to get current content",
|
||||
},
|
||||
{
|
||||
name: "invalid_preset_id",
|
||||
presetID: 0,
|
||||
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<nowPlaying deviceID="TEST123" source="SPOTIFY">
|
||||
<ContentItem source="SPOTIFY" type="uri" location="spotify:track:123" isPresetable="true">
|
||||
<itemName>Test Track</itemName>
|
||||
</ContentItem>
|
||||
<track>Test Track</track>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
</nowPlaying>`,
|
||||
nowPlayingStatus: http.StatusOK,
|
||||
expectError: true,
|
||||
errorContains: "preset ID must be between 1 and 6",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/now_playing":
|
||||
if tt.nowPlayingStatus != 0 {
|
||||
w.WriteHeader(tt.nowPlayingStatus)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if tt.nowPlayingResponse != "" {
|
||||
w.Write([]byte(tt.nowPlayingResponse))
|
||||
}
|
||||
case "/storePreset":
|
||||
if tt.storePresetStatus != 0 {
|
||||
w.WriteHeader(tt.storePresetStatus)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
err := client.StoreCurrentAsPreset(tt.presetID)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, but got nil")
|
||||
return
|
||||
}
|
||||
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
|
||||
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_StorePreset_XMLGeneration(t *testing.T) {
|
||||
var capturedXML string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := make([]byte, r.ContentLength)
|
||||
r.Body.Read(body)
|
||||
capturedXML = string(body)
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "uri",
|
||||
Location: "spotify:playlist:37i9dQZF1DX0XUsuxWHRQd",
|
||||
SourceAccount: "testuser",
|
||||
IsPresetable: true,
|
||||
ItemName: "Test Playlist",
|
||||
ContainerArt: "https://example.com/art.jpg",
|
||||
}
|
||||
|
||||
err := client.StorePreset(3, contentItem)
|
||||
if err != nil {
|
||||
t.Fatalf("StorePreset failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify XML structure
|
||||
expectedElements := []string{
|
||||
`<preset id="3"`,
|
||||
`createdOn="`,
|
||||
`updatedOn="`,
|
||||
`<ContentItem source="SPOTIFY"`,
|
||||
`type="uri"`,
|
||||
`location="spotify:playlist:37i9dQZF1DX0XUsuxWHRQd"`,
|
||||
`sourceAccount="testuser"`,
|
||||
`isPresetable="true"`,
|
||||
`<itemName>Test Playlist</itemName>`,
|
||||
`<containerArt>https://example.com/art.jpg</containerArt>`,
|
||||
}
|
||||
|
||||
for _, element := range expectedElements {
|
||||
if !strings.Contains(capturedXML, element) {
|
||||
t.Errorf("Expected XML to contain '%s', but got:\n%s", element, capturedXML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemovePreset_XMLGeneration(t *testing.T) {
|
||||
var capturedXML string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := make([]byte, r.ContentLength)
|
||||
r.Body.Read(body)
|
||||
capturedXML = string(body)
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
err := client.RemovePreset(4)
|
||||
if err != nil {
|
||||
t.Fatalf("RemovePreset failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify XML structure - should only contain preset ID
|
||||
expectedElements := []string{
|
||||
`<preset id="4"`,
|
||||
}
|
||||
|
||||
for _, element := range expectedElements {
|
||||
if !strings.Contains(capturedXML, element) {
|
||||
t.Errorf("Expected XML to contain '%s', but got:\n%s", element, capturedXML)
|
||||
}
|
||||
}
|
||||
|
||||
// Should NOT contain content item for remove requests
|
||||
unexpectedElements := []string{
|
||||
`<ContentItem`,
|
||||
`createdOn=`,
|
||||
`updatedOn=`,
|
||||
}
|
||||
|
||||
for _, element := range unexpectedElements {
|
||||
if strings.Contains(capturedXML, element) {
|
||||
t.Errorf("Did not expect XML to contain '%s', but got:\n%s", element, capturedXML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_StorePreset_RealWorldScenarios(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
contentItem *models.ContentItem
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "spotify_daily_mix",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "uri",
|
||||
Location: "spotify:playlist:37i9dQZF1E35Ky0Qr5WjPT",
|
||||
SourceAccount: "testuser",
|
||||
IsPresetable: true,
|
||||
ItemName: "Daily Mix 1",
|
||||
ContainerArt: "https://dailymix-images.scdn.co/v2/img/ab6761610000e5eb1/1/en/default",
|
||||
},
|
||||
description: "User wants to save Spotify Daily Mix as preset",
|
||||
},
|
||||
{
|
||||
name: "internet_radio_station",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playback/station/s33828",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
ContainerArt: "http://cdn-profiles.tunein.com/s33828/images/logog.png",
|
||||
},
|
||||
description: "User wants to save favorite radio station",
|
||||
},
|
||||
{
|
||||
name: "nas_music_album",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "STORED_MUSIC",
|
||||
Location: "6_a2874b5d_4f83d999",
|
||||
SourceAccount: "d09708a1-5953-44bc-a413-123456789012/0",
|
||||
IsPresetable: true,
|
||||
ItemName: "MercyMe, It's Christmas!",
|
||||
},
|
||||
description: "User wants to save NAS album as preset",
|
||||
},
|
||||
{
|
||||
name: "pandora_station",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "PANDORA",
|
||||
Location: "126740707481236361",
|
||||
SourceAccount: "pandorauser",
|
||||
IsPresetable: true,
|
||||
ItemName: "Zach Williams Radio",
|
||||
ContainerArt: "https://content-images.p-cdn.com/images/68/88/0d/fb/aed34095a11118d2aa7b02a2/_500W_500H.jpg",
|
||||
},
|
||||
description: "User wants to save Pandora station as preset",
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Just return success for these scenario tests
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
err := client.StorePreset(1, scenario.contentItem)
|
||||
if err != nil {
|
||||
t.Errorf("Scenario '%s' failed: %s. Error: %v", scenario.name, scenario.description, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PresetTimestamps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Location: "spotify:track:test",
|
||||
IsPresetable: true,
|
||||
ItemName: "Test",
|
||||
}
|
||||
|
||||
startTime := time.Now().Unix()
|
||||
|
||||
err := client.StorePreset(1, contentItem)
|
||||
if err != nil {
|
||||
t.Fatalf("StorePreset failed: %v", err)
|
||||
}
|
||||
|
||||
endTime := time.Now().Unix()
|
||||
|
||||
// Timestamps should be set within the test timeframe
|
||||
// This is a basic check - in a real scenario, we'd inspect the XML or server response
|
||||
if endTime < startTime {
|
||||
t.Error("Timestamps appear to be incorrect")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// EventTypeConnectionState indicates a connection state change
|
||||
EventTypeConnectionState WebSocketEventType = "connectionStateUpdated"
|
||||
// EventTypePresetUpdated indicates a preset configuration change
|
||||
EventTypePresetUpdated WebSocketEventType = "presetUpdated"
|
||||
EventTypePresetUpdated WebSocketEventType = "presetsUpdated"
|
||||
// EventTypeZoneUpdated indicates a zone configuration change
|
||||
EventTypeZoneUpdated WebSocketEventType = "zoneUpdated"
|
||||
// EventTypeBassUpdated indicates a bass level change
|
||||
@@ -78,7 +78,7 @@ type WebSocketEvent struct {
|
||||
NowPlayingUpdated *NowPlayingUpdatedEvent `xml:"nowPlayingUpdated,omitempty"`
|
||||
VolumeUpdated *VolumeUpdatedEvent `xml:"volumeUpdated,omitempty"`
|
||||
ConnectionStateUpdated *ConnectionStateUpdatedEvent `xml:"connectionStateUpdated,omitempty"`
|
||||
PresetUpdated *PresetUpdatedEvent `xml:"presetUpdated,omitempty"`
|
||||
PresetUpdated *PresetUpdatedEvent `xml:"presetsUpdated,omitempty"`
|
||||
ZoneUpdated *ZoneUpdatedEvent `xml:"zoneUpdated,omitempty"`
|
||||
BassUpdated *BassUpdatedEvent `xml:"bassUpdated,omitempty"`
|
||||
ClockTimeUpdated *ClockTimeUpdatedEvent `xml:"clockTimeUpdated,omitempty"`
|
||||
@@ -195,9 +195,9 @@ func (cs *ConnectionState) GetSignalStrength() string {
|
||||
|
||||
// PresetUpdatedEvent represents a preset update event
|
||||
type PresetUpdatedEvent struct {
|
||||
XMLName xml.Name `xml:"presetUpdated"`
|
||||
XMLName xml.Name `xml:"presetsUpdated"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Preset Preset `xml:"preset"`
|
||||
Presets Presets `xml:"presets"`
|
||||
}
|
||||
|
||||
// ZoneUpdatedEvent represents a multiroom zone update event
|
||||
|
||||
Reference in New Issue
Block a user