diff --git a/cmd/soundtouch-cli/cmd_preset.go b/cmd/soundtouch-cli/cmd_preset.go
new file mode 100644
index 0000000..8bfc8dc
--- /dev/null
+++ b/cmd/soundtouch-cli/cmd_preset.go
@@ -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)
+}
diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go
index 14cccfd..bac2be6 100644
--- a/cmd/soundtouch-cli/main.go
+++ b/cmd/soundtouch-cli/main.go
@@ -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
{
diff --git a/cmd/websocket-demo/main.go b/cmd/websocket-demo/main.go
index a54bb27..26fe14d 100644
--- a/cmd/websocket-demo/main.go
+++ b/cmd/websocket-demo/main.go
@@ -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))
}
}
diff --git a/pkg/client/client.go b/pkg/client/client.go
index f373bf9..3f90e61 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -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) {
diff --git a/pkg/client/preset_test.go b/pkg/client/preset_test.go
new file mode 100644
index 0000000..73e90e6
--- /dev/null
+++ b/pkg/client/preset_test.go
@@ -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: `My Playlist`,
+ 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: `K-LOVE Radio`,
+ 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: `Custom Radio`,
+ 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: `Invalid preset`,
+ 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: ``,
+ 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: `Preset not found`,
+ 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: `
+
+
+ Test Track
+
+
+ Test Artist
+ PLAY_STATE
+`,
+ nowPlayingStatus: http.StatusOK,
+ storePresetStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "store_current_tunein_success",
+ presetID: 1,
+ nowPlayingResponse: `
+
+
+ K-LOVE Radio
+
+
+ PLAY_STATE
+`,
+ nowPlayingStatus: http.StatusOK,
+ storePresetStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "empty_now_playing",
+ presetID: 1,
+ nowPlayingResponse: `
+
+
+`,
+ nowPlayingStatus: http.StatusOK,
+ expectError: true,
+ errorContains: "no content currently playing",
+ },
+ {
+ name: "content_not_presetable",
+ presetID: 1,
+ nowPlayingResponse: `
+
+
+ Phone Audio
+
+
+ PLAY_STATE
+`,
+ nowPlayingStatus: http.StatusOK,
+ expectError: true,
+ errorContains: "current content cannot be saved as preset",
+ },
+ {
+ name: "no_content_item",
+ presetID: 1,
+ nowPlayingResponse: `
+
+
+ PLAY_STATE
+`,
+ 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: `
+
+
+ Test Track
+
+
+ PLAY_STATE
+`,
+ 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(``))
+ 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(``))
+ }))
+ 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{
+ `Test Playlist`,
+ `https://example.com/art.jpg`,
+ }
+
+ 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(``))
+ }))
+ 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{
+ ``))
+ }))
+ 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(``))
+ }))
+ 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")
+ }
+}
diff --git a/pkg/models/websocket.go b/pkg/models/websocket.go
index bd62251..49d9443 100644
--- a/pkg/models/websocket.go
+++ b/pkg/models/websocket.go
@@ -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