diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 5656c58..07cf977 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -606,40 +606,57 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
+ streamingRoutes := func(r chi.Router) {
+ r.Get("/sourceproviders", server.HandleMargeSourceProviders)
+ r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
+ r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
+ r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
+ r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
+ r.Post("/support/power_on", server.HandleMargePowerOn)
+ r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
+ r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
+ r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
+ r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
+ r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
+ r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
+ r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
+ r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
+ r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
+ r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
+ r.Get("/account/{account}/full", server.HandleMargeAccountFull)
+ r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
+
+ r.Route("/stats", func(r chi.Router) {
+ r.Post("/usage", server.HandleUsageStats)
+ r.Post("/error", server.HandleErrorStats)
+ })
+ }
+
+ accountsRoutes := func(r chi.Router) {
+ r.Get("/{account}/full", server.HandleMargeAccountFull)
+ r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
+ r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
+ r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
+ r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
+ r.Post("/{account}/devices", server.HandleMargeAddDevice)
+ r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
+ r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
+ r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
+ r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
+ r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
+ }
+
r.Route("/marge", func(r chi.Router) {
- r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
- r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
- r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
+ r.Route("/streaming", streamingRoutes)
+ r.Route("/accounts", accountsRoutes)
+
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
- r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
- r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
- r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
- r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
- r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
- r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
- r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
- r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
- r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
- r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
- r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
})
// Legacy or direct domain calls without /marge prefix
- r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
- r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
- r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
+ r.Route("/streaming", streamingRoutes)
+ r.Route("/accounts", accountsRoutes)
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
- r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
- r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
- r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
- r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
- r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
- r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
- r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
- r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
- r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
- r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
- r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Route("/customer", func(r chi.Router) {
r.Get("/account/{account}", server.HandleMargeAccountProfile)
@@ -652,11 +669,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
})
- r.Route("/streaming/stats", func(r chi.Router) {
- r.Post("/usage", server.HandleUsageStats)
- r.Post("/error", server.HandleErrorStats)
- })
-
r.Route("/mgmt", func(r chi.Router) {
// Browser OAuth callback — no auth required (Spotify redirects the
// user's browser here directly). The authorization code is single-use,
diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go
index 5e3ad84..5eae924 100644
--- a/pkg/service/handlers/handlers_marge.go
+++ b/pkg/service/handlers/handlers_marge.go
@@ -28,7 +28,7 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header()["ETag"] = []string{etag}
_, _ = w.Write(data)
}
@@ -51,7 +51,7 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header()["ETag"] = []string{etag}
_, _ = w.Write(data)
}
@@ -146,7 +146,7 @@ func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Reque
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
@@ -165,7 +165,7 @@ func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Req
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
@@ -184,9 +184,16 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header()["ETag"] = []string{etag}
+ // For the account-specific firmware route, always return the software_update tag.
+ // This route is specifically used by firmware like Bose_Lisa/27.0.6.
+ if chi.URLParam(r, "account") != "" {
+ _, _ = w.Write([]byte(marge.SoftwareUpdateToXML()))
+ return
+ }
+
if len(swUpdateXML) > 0 {
_, _ = w.Write(swUpdateXML)
} else {
@@ -211,7 +218,7 @@ func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) {
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header()["ETag"] = []string{etag}
_, _ = w.Write(data)
}
@@ -244,7 +251,29 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
+ _, _ = w.Write(data)
+}
+
+// HandleMargeRecents returns the Marge recents for a device.
+func (s *Server) HandleMargeRecents(w http.ResponseWriter, r *http.Request) {
+ account := chi.URLParam(r, "account")
+ device := chi.URLParam(r, "device")
+
+ etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10)
+ if r.Header.Get("If-None-Match") == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ data, err := marge.RecentsToXML(s.ds, account, device)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
+ w.Header()["ETag"] = []string{etag}
_, _ = w.Write(data)
}
@@ -268,7 +297,7 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) {
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write(data)
}
@@ -288,7 +317,7 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
return
}
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write(data)
}
@@ -310,7 +339,7 @@ func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request)
func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
- w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write([]byte(marge.ProviderSettingsToXML(account)))
}
@@ -336,6 +365,26 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
_, _ = w.Write(data)
}
+// HandleMargeDeviceGroup returns grouping information for a device (empty group by default).
+func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request) {
+ // Native firmware expects vnd.bose.streaming content type
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(``))
+}
+
+// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
+func (s *Server) HandleMargeDeviceGroupServer(w http.ResponseWriter, r *http.Request) {
+ // Not in a group as server
+ http.NotFound(w, r)
+}
+
+// HandleMargeDeviceGroupMember returns grouping member information (404 by default if not a member).
+func (s *Server) HandleMargeDeviceGroupMember(w http.ResponseWriter, r *http.Request) {
+ // Not in a group as member
+ http.NotFound(w, r)
+}
+
// HandleMargeCustomerSupport handles Marge customer support uploads.
func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go
index 184ac5e..2faef2d 100644
--- a/pkg/service/handlers/handlers_marge_test.go
+++ b/pkg/service/handlers/handlers_marge_test.go
@@ -264,7 +264,7 @@ func TestMargeUpdatePreset(t *testing.T) {
}
}
-func TestMargeDeviceInfo(t *testing.T) {
+func TestMargeAddRecentRoute(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
@@ -331,6 +331,298 @@ func TestMargeDeviceInfo(t *testing.T) {
}
}
+func TestMargeNativeStreamingRoutes(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "st-test-native-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+
+ defer func() { _ = os.RemoveAll(tempDir) }()
+
+ ds := datastore.NewDataStore(tempDir)
+
+ account := "12345"
+ deviceID := "DEV1"
+
+ accountDir := filepath.Join(tempDir, "accounts", account)
+ deviceDir := filepath.Join(accountDir, "devices", deviceID)
+ err = os.MkdirAll(deviceDir, 0755)
+ if err != nil {
+ t.Fatalf("Failed to create device dir: %v", err)
+ }
+
+ // Mock Sources.xml for recent tests
+ if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
+
+
+
+
+
+ `), 0644); err != nil {
+ t.Fatalf("Failed to write Sources.xml: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(``), 0644); err != nil {
+ t.Fatalf("Failed to write Recents.xml: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(``), 0644); err != nil {
+ t.Fatalf("Failed to write Presets.xml: %v", err)
+ }
+
+ r, _ := setupRouter("http://localhost:8001", ds)
+ ts := httptest.NewServer(r)
+ defer ts.Close()
+
+ t.Run("POST /streaming/account/{account}/device/{device}/recent", func(t *testing.T) {
+ payload := `
+
+ New Route Recent
+ SRC1
+ /station/s999
+ station
+ `
+
+ res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
+ }
+
+ if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
+ t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
+ }
+
+ // Verify file was saved
+ recentData, _ := os.ReadFile(filepath.Join(deviceDir, "Recents.xml"))
+ if !strings.Contains(string(recentData), "New Route Recent") {
+ t.Error("Recent from native route was not saved to datastore")
+ }
+ })
+
+ t.Run("GET /streaming/account/{account}/full", func(t *testing.T) {
+ res, err := http.Get(ts.URL + "/streaming/account/" + account + "/full")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
+ }
+
+ if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
+ t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
+ }
+
+ fullData, _ := io.ReadAll(res.Body)
+ if !strings.Contains(string(fullData), account) {
+ t.Error("Account full response does not contain account ID")
+ }
+ })
+
+ t.Run("GET /streaming/software/update/account/{account}", func(t *testing.T) {
+ res, err := http.Get(ts.URL + "/streaming/software/update/account/" + account)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
+ }
+
+ if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
+ t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
+ }
+
+ swData, _ := io.ReadAll(res.Body)
+ if !strings.Contains(string(swData), "software_update") {
+ t.Errorf("Response missing software_update tag: %s", string(swData))
+ }
+ })
+
+ t.Run("GET /streaming/account/{account}/device/{device}/recent", func(t *testing.T) {
+ res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
+ }
+
+ if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
+ t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
+ }
+
+ etag := res.Header.Get("ETag")
+ if etag == "" {
+ t.Error("Expected ETag header")
+ }
+
+ recentData, _ := io.ReadAll(res.Body)
+ if !strings.Contains(string(recentData), "recents") {
+ t.Errorf("Response missing recents tag: %s", string(recentData))
+ }
+
+ // Test 304
+ req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", nil)
+ req.Header.Set("If-None-Match", etag)
+ res2, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res2.Body.Close()
+ if res2.StatusCode != http.StatusNotModified {
+ t.Errorf("Expected 304 Not Modified, got %v", res2.Status)
+ }
+ })
+
+ t.Run("GET /streaming/account/{account}/device/{device}/presets", func(t *testing.T) {
+ res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/presets")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
+ }
+
+ if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
+ t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
+ }
+
+ etag := res.Header.Get("ETag")
+ if etag == "" {
+ t.Error("Expected ETag header")
+ }
+
+ presetData, _ := io.ReadAll(res.Body)
+ if !strings.Contains(string(presetData), "presets") {
+ t.Errorf("Response missing presets tag: %s", string(presetData))
+ }
+
+ // Test 304
+ req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/presets", nil)
+ req.Header.Set("If-None-Match", etag)
+ res2, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res2.Body.Close()
+ if res2.StatusCode != http.StatusNotModified {
+ t.Errorf("Expected 304 Not Modified, got %v", res2.Status)
+ }
+ })
+
+ t.Run("POST /streaming/account/{account}/device/{device}/presets/{presetNumber}", func(t *testing.T) {
+ payload := `
+
+ New Native Preset
+ SRC1
+ /station/s777
+ station
+ `
+
+ res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/presets/1", "application/xml", strings.NewReader(payload))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
+ }
+
+ if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
+ t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
+ }
+
+ // Verify file was saved
+ presetData, _ := os.ReadFile(filepath.Join(deviceDir, "Presets.xml"))
+ if !strings.Contains(string(presetData), "New Native Preset") {
+ t.Error("Preset from native route was not saved to datastore")
+ }
+ })
+
+ t.Run("GET /streaming/account/{account}/device/{device}/group/", func(t *testing.T) {
+ res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/group/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
+ }
+
+ groupData, _ := io.ReadAll(res.Body)
+ if !strings.Contains(string(groupData), "