diff --git a/docs/concepts/spotify-priming-strategy.md b/docs/concepts/spotify-priming-strategy.md index fdcf167..3b3d901 100644 --- a/docs/concepts/spotify-priming-strategy.md +++ b/docs/concepts/spotify-priming-strategy.md @@ -19,22 +19,21 @@ We avoid invasive modifications to the speaker's filesystem. - **Native Communication:** We rely on the speaker's native ability to talk to Bose services, which are intercepted via DNS to point to the AfterTouch server. ### 3. Triggers for Priming -Priming does not strictly depend on a *periodic* loop. Instead, AfterTouch uses multiple **Liveness Signals** to identify when a speaker needs attention: +Priming is triggered when the speaker signals it is active and ready, specifically: -- **Incoming "Pull" Requests:** When the speaker reaches out to AfterTouch endpoints (e.g., `/marge`, `/bmx`, or `/api`), it signals that the device is active. AfterTouch can use this as a trigger to ensure the device's ZeroConf state is correctly primed. -- **Discovery Events:** Background scans (mDNS/UPnP) or manual refreshes in the UI serve as checkpoints. -- **Server Startup:** When AfterTouch starts, it can proactively check all known devices from its database. +- **Power On:** When the speaker calls the `/marge/streaming/support/power_on` endpoint, AfterTouch ensures the device's ZeroConf state is correctly primed. This is the primary trigger. +- **Manual Override:** Users can manually trigger a "Prime Spotify" from the device list in the UI if needed. During any of these events, the server: 1. Checks if a Spotify account is linked in AfterTouch. 2. Checks the device's current priming status (via ZeroConf). 3. If unprimed and an account is linked, it pushes the priming command. -### 4. Automated Self-Healing (Default) -By default, AfterTouch acts as the "Watchdog." It ensures that if a speaker loses its session (due to a crash, power loss, or token expiry), it is automatically re-primed during the next discovery checkpoint. +### 4. Automated Recovery +AfterTouch ensures that if a speaker loses its session (due to a crash or power loss), it is re-primed when it next powers on and reaches out to the service. ### 5. Decoupling -The logic for account management and device discovery remains decoupled: +The logic for account management and device interaction remains decoupled: - **Spotify Service:** Manages OAuth tokens and account state. - **Discovery Service:** Finds devices and tracks their network presence. - **Orchestrator:** Connects the two, deciding when to push tokens to discovered devices based on the current link status. diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 05ab16b..5e3ad84 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -16,11 +16,6 @@ import ( // HandleMargeSourceProviders returns the Marge source providers. func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) { - // Trigger Spotify priming as this is a common liveness signal - if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { - go s.PrimeDeviceWithSpotify(host) - } - etag := strconv.FormatInt(time.Now().UnixMilli(), 10) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) @@ -40,11 +35,6 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque // HandleMargeAccountFull returns the full Marge account information. func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) { - // Trigger Spotify priming as this is a common liveness signal - if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { - go s.PrimeDeviceWithSpotify(host) - } - account := chi.URLParam(r, "account") device := r.URL.Query().Get("device") @@ -67,7 +57,43 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) } // HandleMargePowerOn handles the Marge power on request. -func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) { +func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("[Marge] Failed to read power_on body: %v", err) + w.WriteHeader(http.StatusOK) // Silent failure is usually better for device requests + + return + } + + var req models.CustomerSupportRequest + if err := xml.Unmarshal(body, &req); err != nil { + log.Printf("[Marge] Failed to parse power_on body: %v", err) + + // Fallback to remote address if body parsing fails + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + go s.PrimeDeviceWithSpotify(host) + } + + w.WriteHeader(http.StatusOK) + + return + } + + deviceID := req.Device.ID + deviceIP := req.DiagnosticData.DeviceLandscape.IPAddress + + log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP) + + if deviceIP != "" { + go s.PrimeDeviceWithSpotify(deviceIP) + } else { + // Fallback to remote address if IP is missing from XML + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + go s.PrimeDeviceWithSpotify(host) + } + } + w.WriteHeader(http.StatusOK) } diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index e2d9dcb..184ac5e 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -414,16 +414,28 @@ func TestMargePowerOn(t *testing.T) { ts := httptest.NewServer(r) defer ts.Close() - res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte(""))) - if err != nil { - t.Fatal(err) - } + t.Run("EmptyBody", func(t *testing.T) { + res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte(""))) + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + }) - defer func() { _ = res.Body.Close() }() - - if res.StatusCode != http.StatusOK { - t.Errorf("Expected status OK, got %v", res.Status) - } + t.Run("FullBody", func(t *testing.T) { + payload := `I633252770373934200002027.0.6.46330069231P63364828AEExcellent192.168.1.1A81B6A536A98192.168.1.100Wireless` + res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + }) } func TestMargeAdvancedFeatures(t *testing.T) { diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 1151386..015f260 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -455,9 +455,6 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil { log.Printf("Failed to save device info: %v", err) } - - // Proactively prime with Spotify if a link exists - go s.PrimeDeviceWithSpotify(d.Host) } func (s *Server) mergeOverlappingDevices() {