diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 2ee65d8..3446e71 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -673,6 +673,7 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts) r.Get("/spotify/token", server.HandleMgmtSpotifyToken) r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity) + r.Post("/spotify/prime", server.HandleMgmtPrimeDevice) }) }) @@ -686,19 +687,19 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/discovery-status", server.HandleGetDiscoveryStatus) r.Get("/settings", server.HandleGetSettings) r.Post("/settings", server.HandleUpdateSettings) - r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo) - r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary) - r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice) - r.Post("/revert/{deviceIP}", server.HandleRevertMigration) - r.Post("/reboot/{deviceIP}", server.HandleRebootDevice) - r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert) - r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices) - r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices) - r.Post("/backup/{deviceIP}", server.HandleBackupConfig) - r.Post("/sync/{deviceIP}", server.HandleInitialSync) - r.Post("/test-connection/{deviceIP}", server.HandleTestConnection) - r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection) - r.Post("/test-dns/{deviceIP}", server.HandleTestDNSRedirection) + r.Get("/info/{deviceId}", server.HandleGetDeviceInfo) + r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary) + r.Post("/migrate/{deviceId}", server.HandleMigrateDevice) + r.Post("/revert/{deviceId}", server.HandleRevertMigration) + r.Post("/reboot/{deviceId}", server.HandleRebootDevice) + r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert) + r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices) + r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices) + r.Post("/backup/{deviceId}", server.HandleBackupConfig) + r.Post("/sync/{deviceId}", server.HandleInitialSync) + r.Post("/test-connection/{deviceId}", server.HandleTestConnection) + r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection) + r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection) r.Get("/ca.crt", server.HandleGetCACert) r.Get("/proxy-settings", server.HandleGetProxySettings) r.Post("/proxy-settings", server.HandleUpdateProxySettings) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index eea0087..9b1dd6e 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -34,11 +34,14 @@ * [Source Selection](reference/SOURCE-SELECTION.md) * [Volume Controls](reference/VOLUME-CONTROLS.md) * [RadioBrowser](reference/radio-browser.md) -* [Spotify OAuth](spotify-oauth.md) * [Bass Controls](reference/BASS-CONTROLS.md) * [Key Controls](reference/KEY-CONTROLS.md) * [Feature Mapping](reference/FEATURE-MAPPING.md) +## Concepts +* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md) +* [Spotify OAuth](concepts/spotify-oauth.md) + ## Analysis & Research * [API Coverage Analysis](analysis/API-COVERAGE.md) * [Supported URLs](analysis/SUPPORTED-URLS.md) diff --git a/docs/spotify-oauth.md b/docs/concepts/spotify-oauth.md similarity index 97% rename from docs/spotify-oauth.md rename to docs/concepts/spotify-oauth.md index 66a33b7..6486f03 100644 --- a/docs/spotify-oauth.md +++ b/docs/concepts/spotify-oauth.md @@ -93,9 +93,9 @@ sequenceDiagram ## Boot Primer Script -A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../scripts/spotify/spotify-boot-primer.sh). +A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh). -This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../scripts/spotify/README.md) and [INSTALL.md](../scripts/spotify/INSTALL.md) for instructions. +This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions. ### Automated Installation via Service diff --git a/docs/concepts/spotify-priming-strategy.md b/docs/concepts/spotify-priming-strategy.md new file mode 100644 index 0000000..fdcf167 --- /dev/null +++ b/docs/concepts/spotify-priming-strategy.md @@ -0,0 +1,85 @@ +# Spotify Priming Strategy + +This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem. + +## Overview + +To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token. + +AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing. + +## Core Principles + +### 1. User Intent (Opt-in) +AfterTouch replicates the native Bose "Add Source" experience. No Spotify priming occurs until a user explicitly links their Spotify account through the AfterTouch Management Dashboard. This ensures privacy and respects users who do not wish to use Spotify. + +### 2. Device Cleanliness (Minimalist Footprint) +We avoid invasive modifications to the speaker's filesystem. +- **No On-Device Scripts:** We deprecate the use of internal boot-primer scripts. +- **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: + +- **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. + +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. + +### 5. Decoupling +The logic for account management and device discovery 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. + +## Workflow + +### Initial Setup (The "Add Source" UX) +1. User opens the AfterTouch Dashboard. +2. User selects "Link Spotify Account." +3. OAuth flow completes; AfterTouch stores the token. +4. AfterTouch immediately triggers a discovery run to find and prime all compatible speakers. + +### Maintenance (The "Watchdog" UX) +1. A speaker reboots or loses its token. +2. A discovery event occurs (periodic or triggered by UI). +3. AfterTouch detects the "Empty" user state on the speaker. +4. AfterTouch pushes a fresh token from the Spotify Service. +5. UI reflects that the device is "Managed by AfterTouch" and healthy. + +### Manual Override +Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device. + +## Network Topology & Deployment Scenarios + +The strategy adapts based on where the AfterTouch server is deployed: + +### Local Deployment (Home Server / Docker) +- **Mechanism:** Both "Pull" (Marge) and "Push" (ZeroConf side-channel) are used. +- **Advantage:** The server can proactively fix the speaker's state via port 8200 as soon as it sees a "Liveness Signal." + +### External Deployment (Cloud VPS) +- **Mechanism:** Primarily relies on "Pull" (Marge). +- **Constraint:** The server cannot reach port 8200 on the speaker due to NAT/Firewall. +- **Strategy:** In this scenario, AfterTouch acts as a passive token provider. The speaker must initiate the connection to our intercepted Bose endpoints to receive its Spotify configuration. If the speaker completely loses its user state and stops "pulling," a manual re-prime from a local machine or a temporary local discovery run might be required. + +## Transition & Cleanup + +As AfterTouch moves to the Server-Centric model, we will: +1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers. +2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts. +3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text. + +## Implementation Roadmap (Conceptual) + +1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks. +2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API. +3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state. +4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons. diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 8324f01..05ab16b 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -4,6 +4,7 @@ import ( "encoding/xml" "io" "log" + "net" "net/http" "strconv" "time" @@ -15,6 +16,11 @@ 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) @@ -34,6 +40,11 @@ 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") diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go index 288e50d..d13aa10 100644 --- a/pkg/service/handlers/handlers_mgmt.go +++ b/pkg/service/handlers/handlers_mgmt.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "fmt" "io" "log" "net/http" @@ -215,6 +216,7 @@ func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Reques } } +// HandleMgmtSpotifyToken returns a fresh Spotify access token for the linked account. func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() svc := s.spotifyService @@ -285,3 +287,27 @@ func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request) log.Printf("[Mgmt] Failed to encode entity: %v", err) } } + +// HandleMgmtPrimeDevice triggers a Spotify priming for a specific device. +func (s *Server) HandleMgmtPrimeDevice(w http.ResponseWriter, r *http.Request) { + deviceID := r.URL.Query().Get("deviceId") + + if deviceID == "" { + http.Error(w, `{"error":"missing deviceId"}`, http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + log.Printf("[Mgmt] Prime failed: %v", err) + http.Error(w, fmt.Sprintf(`{"error":"%v"}`, err), http.StatusNotFound) + + return + } + + // Trigger priming + go s.PrimeDeviceWithSpotify(deviceIP) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"Priming triggered"}`)) +} diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 866f177..539fed4 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -156,6 +156,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { enableSoundcorkProxy := s.enableSoundcorkProxy redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled shortcuts := s.shortcuts + spotifyConfigured := s.spotifyService != nil s.mu.RUnlock() dnsRunning, actualBind := s.GetDNSRunning() @@ -176,6 +177,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "log_bodies": logBody, "record_interactions": record, "shortcuts": shortcuts, + "spotify_configured": spotifyConfigured, }); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -280,9 +282,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { // HandleGetDeviceInfo returns live information for a device. func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) return } @@ -302,9 +310,15 @@ func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) { // HandleGetMigrationSummary returns a summary of the migration plan for a device. func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) return } @@ -335,12 +349,25 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques // HandleMigrateDevice starts the migration process for a device. func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -383,12 +410,25 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { // HandleRevertMigration reverts the migration for a device. func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -519,12 +559,25 @@ func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Reques // HandleTrustCACert injects the local Root CA into the device's shared trust store. func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -555,12 +608,25 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) { // HandleEnsureRemoteServices ensures that remote services are configured on a device. func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -591,12 +657,25 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque // HandleRemoveRemoteServices removes remote services configuration from a device. func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -627,12 +706,25 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque // HandleBackupConfig creates a backup of the device configuration. func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -752,9 +844,15 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques // HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection. func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) return } @@ -792,9 +890,15 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque // HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service. func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) return } @@ -832,9 +936,15 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request // HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore. func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Missing deviceIP", http.StatusBadRequest) + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Missing deviceId", http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) return } @@ -849,12 +959,25 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) { // HandleRebootDevice reboots a device. func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } @@ -885,9 +1008,15 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) { // HandleTestConnection performs a connection check from the device to the server. func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) { - deviceIP := chi.URLParam(r, "deviceIP") - if deviceIP == "" { - http.Error(w, "Device IP is required", http.StatusBadRequest) + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) return } diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index 0199d73..00abb5e 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/setup" @@ -150,6 +151,13 @@ func TestMigrationAndCA(t *testing.T) { ts := httptest.NewServer(r) defer ts.Close() + // Add device to datastore for resolution + _ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{ + DeviceID: "192.168.1.10", + IPAddress: "192.168.1.10", + AccountID: "default", + }) + // 1. Test GET /setup/ca.crt res, err := http.Get(ts.URL + "/setup/ca.crt") if err != nil { diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index f010ad3..4c7faa0 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -87,14 +87,14 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Post("/settings", server.HandleUpdateSettings) r.Get("/proxy-settings", server.HandleGetProxySettings) r.Post("/proxy-settings", server.HandleUpdateProxySettings) - r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices) - r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices) - r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice) - r.Post("/revert/{deviceIP}", server.HandleRevertMigration) - r.Post("/reboot/{deviceIP}", server.HandleRebootDevice) - r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert) - r.Post("/test-connection/{deviceIP}", server.HandleTestConnection) - r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection) + r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices) + r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices) + r.Post("/migrate/{deviceId}", server.HandleMigrateDevice) + r.Post("/revert/{deviceId}", server.HandleRevertMigration) + r.Post("/reboot/{deviceId}", server.HandleRebootDevice) + r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert) + r.Post("/test-connection/{deviceId}", server.HandleTestConnection) + r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection) r.Get("/ca.crt", server.HandleGetCACert) }) diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index b47078c..1151386 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -2,7 +2,10 @@ package handlers import ( "context" + "fmt" + "io" "log" + "net" "net/http" "net/url" "sync" @@ -265,6 +268,14 @@ func (s *Server) GetSettings() (string, string, string) { return s.serverURL, s.soundcorkURL, s.httpsServerURL } +// IsSpotifyConfigured returns whether Spotify integration is configured. +func (s *Server) IsSpotifyConfigured() bool { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.spotifyService != nil +} + // GetProxySettings returns the current proxy settings. func (s *Server) GetProxySettings() (bool, bool, bool, bool) { s.mu.RLock() @@ -308,6 +319,75 @@ func (s *Server) DiscoverDevices(ctx context.Context) { s.mergeOverlappingDevices() } +// PrimeDeviceWithSpotify triggers a Spotify priming of the speaker if a Spotify account is linked. +func (s *Server) PrimeDeviceWithSpotify(deviceIP string) { + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + return + } + + accounts := svc.GetAccounts() + if len(accounts) == 0 { + return + } + + // We'll use the first linked account. In the future, we might want to let the user + // pick or map accounts to speakers, but for now, we follow the "One linked account" model. + accessToken, username, err := svc.GetFreshToken() + if err != nil { + log.Printf("[Spotify Watchdog] Failed to get fresh token for %s: %v", deviceIP, err) + return + } + + log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username) + + if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil { + log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err) + } else { + log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP) + } +} + +func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error { + // ZeroConf API endpoint on the speaker + var zcURL string + if _, _, err := net.SplitHostPort(deviceIP); err == nil { + // If port is specified (e.g. in tests), keep it but usually it's just IP + zcURL = fmt.Sprintf("http://%s/zc", deviceIP) + } else { + // If no port specified, default to 8200 + zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP) + } + + data := url.Values{} + data.Set("action", "addUser") + data.Set("userName", username) + data.Set("blob", accessToken) + data.Set("clientKey", "") + data.Set("tokenType", "accesstoken") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.PostForm(zcURL, data) + if err != nil { + return fmt.Errorf("POST to %s failed: %w", zcURL, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("POST to %s returned status %d: %s", zcURL, resp.StatusCode, string(body)) + } + + return nil +} + func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo) @@ -375,6 +455,9 @@ 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() { @@ -470,3 +553,20 @@ func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.Servi return nil } + +func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + // 1. Try to find in Datastore + devices, err := s.ds.ListAllDevices() + if err == nil { + for i := range devices { + if devices[i].DeviceID == deviceID { + return devices[i].IPAddress, nil + } + } + } + + return "", fmt.Errorf("device not found: %s", deviceID) +} diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 869fff1..f59f305 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -118,6 +118,12 @@ +