From b71a3830ec85a95c730dc32cf2e66339bce78e5c Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 22 Feb 2026 20:41:47 +0100 Subject: [PATCH] Add more routes to be handled by ourselves Group management is only implemented as placeholder --- cmd/soundtouch-service/main.go | 78 +++--- pkg/service/handlers/handlers_marge.go | 69 ++++- pkg/service/handlers/handlers_marge_test.go | 294 +++++++++++++++++++- pkg/service/handlers/main_test.go | 68 +++-- 4 files changed, 437 insertions(+), 72 deletions(-) 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), "