Add Spotify primer

This commit is contained in:
Tobias Gesellchen
2026-02-21 23:40:45 +01:00
parent d18b67d80f
commit d68599896d
12 changed files with 740 additions and 181 deletions
+14 -13
View File
@@ -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)
+4 -1
View File
@@ -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)
@@ -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
+85
View File
@@ -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.
+11
View File
@@ -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")
+26
View File
@@ -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"}`))
}
+168 -39
View File
@@ -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
}
@@ -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 {
+8 -8
View File
@@ -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)
})
+100
View File
@@ -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)
}
+15 -2
View File
@@ -118,6 +118,12 @@
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Spotify Integration:</strong>
<div id="spotify-config-status" style="margin-top: 5px; font-size: 0.9em;">
Checking configuration...
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
@@ -134,7 +140,13 @@
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="spotify-status-header" style="background: #f0f0f0; padding: 5px 15px; border-radius: 20px; font-size: 0.9em; display: flex; align-items: center; gap: 10px;">
Spotify: <span id="spotify-account-name" style="font-weight: bold;">Not Linked</span>
<button id="link-spotify-btn" onclick="linkSpotify()" style="font-size: 0.8em; padding: 2px 8px; background: #1DB954; color: white; border: none; border-radius: 10px; cursor: pointer;">Link Account</button>
</div>
</div>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
@@ -179,8 +191,9 @@
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<h3>Migration Summary for <span id="summary-device-display"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<input type="hidden" id="summary-device-id">
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
+299 -116
View File
@@ -1,3 +1,117 @@
async function fetchSpotifyStatus() {
try {
const settingsResponse = await fetch('/setup/settings');
const settings = await settingsResponse.json();
const header = document.getElementById('spotify-status-header');
if (!settings.spotify_configured) {
if (header) header.style.display = 'none';
return;
}
if (header) header.style.display = 'flex';
const response = await fetch('/mgmt/spotify/accounts');
if (!response.ok) return;
const data = await response.json();
const nameEl = document.getElementById('spotify-account-name');
const linkBtn = document.getElementById('link-spotify-btn');
if (data.accounts && data.accounts.length > 0) {
header.style.background = '#e6ffed';
header.style.border = '1px solid #28a745';
nameEl.innerText = data.accounts[0].username;
if (linkBtn) linkBtn.style.display = 'none';
// Show Prime Spotify buttons on all devices
document.querySelectorAll('.btn-spotify').forEach(btn => {
btn.style.display = 'inline-block';
});
} else {
header.style.background = '#f0f0f0';
header.style.border = '1px solid #ccc';
nameEl.innerText = 'Not Linked';
if (linkBtn) linkBtn.style.display = 'inline-block';
document.querySelectorAll('.btn-spotify').forEach(btn => {
btn.style.display = 'none';
});
}
} catch (error) {
console.error('Failed to fetch Spotify status', error);
}
}
async function linkSpotify() {
try {
const response = await fetch('/mgmt/spotify/init', { method: 'POST' });
if (!response.ok) {
const err = await response.text();
alert('Failed to initialize Spotify link: ' + err);
return;
}
const data = await response.json();
if (data.redirectUrl) {
// Open in a new tab
const win = window.open(data.redirectUrl, '_blank');
if (win) {
win.focus();
// Start polling for status change
const pollInterval = setInterval(async () => {
const statusResponse = await fetch('/mgmt/spotify/accounts');
if (statusResponse.ok) {
const statusData = await statusResponse.json();
if (statusData.accounts && statusData.accounts.length > 0) {
clearInterval(pollInterval);
fetchSpotifyStatus();
}
}
}, 2000);
// Stop polling after 2 minutes
setTimeout(() => clearInterval(pollInterval), 120000);
} else {
alert('Please allow popups to link your Spotify account.');
}
}
} catch (error) {
alert('Error linking Spotify: ' + error.message);
}
}
async function primeSpotify(deviceId) {
const btn = document.getElementById('prime-spotify-' + deviceId);
const originalText = btn.innerText;
btn.innerText = 'Priming...';
btn.disabled = true;
try {
const response = await fetch(`/mgmt/spotify/prime?deviceId=${encodeURIComponent(deviceId)}`, {
method: 'POST'
});
if (response.ok) {
btn.innerText = '✅ Primed';
btn.style.background = '#28a745';
setTimeout(() => {
btn.innerText = originalText;
btn.style.background = '';
btn.disabled = false;
}, 3000);
} else {
const err = await response.text();
alert('Failed to prime Spotify: ' + err);
btn.innerText = '❌ Failed';
setTimeout(() => {
btn.innerText = originalText;
btn.disabled = false;
}, 3000);
}
} catch (error) {
alert('Error priming Spotify: ' + error.message);
btn.innerText = originalText;
btn.disabled = false;
}
}
async function fetchSettings() {
try {
const response = await fetch('/setup/settings');
@@ -26,7 +140,19 @@ async function fetchSettings() {
if (settings.enable_soundcork_proxy !== undefined) {
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
}
const spotifyStatus = document.getElementById('spotify-config-status');
if (spotifyStatus) {
if (settings.spotify_configured) {
spotifyStatus.innerHTML = '<span style="color: green;">✅ Configured</span> (Client ID present)';
} else {
spotifyStatus.innerHTML = '<span style="color: #666;">❌ Not Configured</span><br>' +
'<span style="font-size: 0.85em; color: #888;">To enable Spotify, provide <code>SPOTIFY_CLIENT_ID</code> and <code>SPOTIFY_CLIENT_SECRET</code> to the server.</span>';
}
}
fetchProxySettings();
fetchSpotifyStatus();
} catch (error) {
console.error('Failed to fetch settings', error);
}
@@ -127,33 +253,34 @@ async function fetchDevices() {
devices.forEach(d => {
const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto';
html += `
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
<tr id="device-row-${d.device_id}">
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || 'default'}</div></td>
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
<button onclick="prepareMigration('${d.ip_address}')">Migrate</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
`;
const optSync = document.createElement('option');
optSync.value = d.ip_address;
optSync.value = d.device_id;
optSync.textContent = `${d.name} (${d.ip_address})`;
syncSelector.appendChild(optSync);
const optMigrate = document.createElement('option');
optMigrate.value = d.ip_address;
optMigrate.value = d.device_id;
optMigrate.textContent = `${d.name} (${d.ip_address})`;
migrationSelector.appendChild(optMigrate);
if (eventSelector) {
const optEvent = document.createElement('option');
optEvent.value = d.device_id || d.ip_address;
optEvent.value = d.device_id;
optEvent.textContent = `${d.name} (${d.ip_address})`;
eventSelector.appendChild(optEvent);
}
@@ -166,22 +293,23 @@ async function fetchDevices() {
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
// Asynchronously fetch live info for each device
devices.forEach(d => updateDeviceInfo(d.ip_address));
devices.forEach(d => updateDeviceInfo(d.device_id, d.ip_address));
fetchSpotifyStatus();
}
} catch (error) {
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
}
}
function prepareSync(ip) {
document.getElementById('sync-device-list').value = ip;
function prepareSync(deviceId) {
document.getElementById('sync-device-list').value = deviceId;
openTab(null, 'tab-sync');
}
function prepareMigration(ip) {
document.getElementById('migration-device-list').value = ip;
function prepareMigration(deviceId) {
document.getElementById('migration-device-list').value = deviceId;
openTab(null, 'tab-migration');
showSummary(ip);
showSummary(deviceId);
}
function openTab(evt, tabId) {
@@ -220,9 +348,46 @@ function openTab(evt, tabId) {
}
}
function getDeviceDisplayName(deviceId) {
if (!deviceId) return "Unknown Device";
// 1. Try migration selector
const migrationSelector = document.getElementById('migration-device-list');
if (migrationSelector) {
for (let opt of migrationSelector.options) {
if (opt.value === deviceId && opt.textContent !== '-- Select a device --') {
return opt.textContent;
}
}
}
// 2. Try sync selector
const syncSelector = document.getElementById('sync-device-list');
if (syncSelector) {
for (let opt of syncSelector.options) {
if (opt.value === deviceId && opt.textContent !== '-- Select a device --') {
return opt.textContent;
}
}
}
// 3. Try table lookup
const rows = document.querySelectorAll('#device-list tr');
for (const row of rows) {
const idCell = row.querySelector('.col-deviceid');
if (idCell && idCell.innerText === deviceId) {
const name = row.querySelector('.col-name').innerText;
const ip = row.querySelector('.col-ip').innerText;
return `${name} (${ip})`;
}
}
return deviceId;
}
async function startSync() {
const ip = document.getElementById('sync-device-list').value;
if (!ip) {
const deviceId = document.getElementById('sync-device-list').value;
if (!deviceId) {
alert('Please select a device first');
return;
}
@@ -233,24 +398,25 @@ async function startSync() {
status.style.display = 'block';
status.style.backgroundColor = '#eef';
status.textContent = 'Syncing data from ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
status.textContent = 'Syncing data from ' + display + '...';
results.style.display = 'none';
log.innerHTML = '';
try {
const response = await fetch('/setup/sync/' + ip, { method: 'POST' });
const response = await fetch('/setup/sync/' + encodeURIComponent(deviceId), { method: 'POST' });
if (response.ok) {
status.style.backgroundColor = '#dfd';
status.textContent = '✅ Sync completed successfully!';
status.textContent = '✅ Sync completed successfully for ' + display + '!';
results.style.display = 'block';
log.innerHTML = 'Data fetched and saved to local datastore.\nPresets: OK\nRecents: OK\nSources: OK';
log.innerHTML = 'Data fetched and saved to local datastore for ' + display + '.\nPresets: OK\nRecents: OK\nSources: OK';
} else {
const err = await response.text();
throw new Error(err);
}
} catch (error) {
status.style.backgroundColor = '#fdd';
status.textContent = '❌ Sync failed: ' + error.message;
status.textContent = '❌ Sync failed for ' + display + ': ' + error.message;
}
}
@@ -729,13 +895,13 @@ async function pollDiscoveryStatus() {
}
}
async function updateDeviceInfo(ip) {
async function updateDeviceInfo(deviceId, ip) {
try {
const response = await fetch('/setup/info/' + ip);
const response = await fetch('/setup/info/' + encodeURIComponent(deviceId));
if (!response.ok) return;
const info = await response.json();
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const rowId = 'device-row-' + deviceId;
const row = document.getElementById(rowId);
if (row) {
const nameEl = row.querySelector('.col-name');
@@ -761,8 +927,8 @@ async function updateDeviceInfo(ip) {
}
}
async function showSummary(ip) {
if (!ip) {
async function showSummary(deviceId) {
if (!deviceId) {
document.getElementById('migration-summary').style.display = 'none';
return;
}
@@ -779,18 +945,20 @@ async function showSummary(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Fetching summary for ' + display + '...';
const outputBox = document.getElementById('command-output-box');
if (outputBox) outputBox.style.display = 'none';
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
query += '&' + k + '=' + encodeURIComponent(opts[k]);
}
const outputBox = document.getElementById('command-output-box');
if (outputBox) outputBox.style.display = 'none';
try {
const response = await fetch('/setup/summary/' + ip + query);
const response = await fetch('/setup/summary/' + encodeURIComponent(deviceId) + query);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
@@ -798,10 +966,15 @@ async function showSummary(ip) {
const summary = await response.json();
statusDiv.style.display = 'none';
document.getElementById('summary-ip').innerText = ip;
const ip = summary.ip_address || deviceId;
const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip;
document.getElementById('summary-device-display').innerText = finalDisplay;
// Keep deviceId hidden for subsequent calls
document.getElementById('summary-device-id').value = deviceId;
// Update table row if it exists
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const rowId = 'device-row-' + deviceId;
const row = document.getElementById(rowId);
if (row) {
const nameEl = row.querySelector('.col-name');
@@ -864,7 +1037,7 @@ async function showSummary(ip) {
caTrustStatus.innerText = summary.ca_cert_trusted ? '✅ Yes' : '❌ No';
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
document.getElementById('trust-ca-btn').style.display = summary.ca_cert_trusted ? 'none' : 'inline-block';
document.getElementById('trust-ca-btn').onclick = () => trustCA(ip);
document.getElementById('trust-ca-btn').onclick = () => trustCA(deviceId, ip);
} else {
remoteStatus.innerText = '❓ Unknown';
remoteStatus.style.color = 'gray';
@@ -894,51 +1067,51 @@ async function showSummary(ip) {
testResultDiv.style.display = 'none';
testResultDiv.innerText = '';
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(ip, true);
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(ip, false);
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(ip);
document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(ip);
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(deviceId, true);
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(deviceId, false);
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(deviceId);
document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(deviceId);
toggleMigrationMethod();
const migrateBtn = document.getElementById('confirm-migrate-btn');
migrateBtn.onclick = () => migrate(ip);
migrateBtn.onclick = () => migrate(deviceId, ip);
migrateBtn.disabled = !summary.ssh_success;
const revertBtn = document.getElementById('revert-migrate-btn');
revertBtn.onclick = () => revert(ip);
revertBtn.onclick = () => revert(deviceId, ip);
revertBtn.disabled = !summary.ssh_success;
revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.onclick = () => reboot(ip);
rebootBtn.onclick = () => reboot(deviceId, ip);
rebootBtn.disabled = !summary.ssh_success;
rebootBtn.style.border = 'none'; // Reset border if it was set during migration
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
remoteBtn.onclick = () => ensureRemoteServices(deviceId, ip);
remoteBtn.disabled = !summary.ssh_success;
const removeRemoteBtn = document.getElementById('remove-remote-btn');
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
removeRemoteBtn.onclick = () => removeRemoteServices(deviceId, ip);
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
const backupBtn = document.getElementById('backup-config-btn');
backupBtn.onclick = () => backupConfig(ip);
backupBtn.onclick = () => backupConfig(deviceId, ip);
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
document.getElementById('migration-summary').style.display = 'block';
document.getElementById('migration-summary').scrollIntoView();
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error fetching summary for ' + display + ': ' + error;
}
}
function refreshSummary() {
const ip = document.getElementById('summary-ip').innerText;
if (ip) {
showSummary(ip);
const deviceId = document.getElementById('summary-device-id').value;
if (deviceId) {
showSummary(deviceId);
}
}
@@ -953,12 +1126,13 @@ function showCommandOutput(result) {
}
}
async function revert(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function revert(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to revert ' + ip + ' to Bose cloud defaults?')) {
const display = getDeviceDisplayName(deviceId);
if (!confirm('Are you sure you want to revert ' + display + ' to Bose cloud defaults?')) {
return;
}
@@ -968,59 +1142,60 @@ async function revert(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Reverting ' + ip + ' to defaults...';
statusDiv.innerHTML = 'Reverting ' + display + ' to defaults...';
try {
const response = await fetch('/setup/revert/' + ip, { method: 'POST' });
const response = await fetch('/setup/revert/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started revert for ' + ip + '.';
statusDiv.innerHTML = 'Successfully started revert for ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Revert failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Revert failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error reverting ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error reverting ' + display + ': ' + error;
}
}
async function reboot(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function reboot(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to reboot the speaker at ' + ip + '?')) {
const display = getDeviceDisplayName(deviceId);
if (!confirm('Are you sure you want to reboot the speaker at ' + display + '?')) {
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Rebooting ' + ip + '...';
statusDiv.innerHTML = 'Rebooting ' + display + '...';
try {
const response = await fetch('/setup/reboot/' + ip, { method: 'POST' });
const response = await fetch('/setup/reboot/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started reboot for ' + ip + '.';
statusDiv.innerHTML = 'Successfully started reboot for ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Reboot failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Reboot failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error rebooting ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error rebooting ' + display + ': ' + error;
}
}
async function migrate(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function migrate(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const targetUrl = document.getElementById('target-domain').value;
@@ -1040,7 +1215,8 @@ async function migrate(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Migrating ' + ip + ' using ' + method + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Migrating ' + display + ' using ' + method + '...';
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
@@ -1048,12 +1224,12 @@ async function migrate(ip) {
}
try {
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
const response = await fetch('/setup/migrate/' + encodeURIComponent(deviceId) + query, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. <strong>Please reboot the device to activate the changes.</strong>';
statusDiv.innerHTML = 'Successfully started migration for ' + display + '. <strong>Please reboot the device to activate the changes.</strong>';
// Make reboot button available and prominent
const rebootBtn = document.getElementById('reboot-speaker-btn');
@@ -1065,45 +1241,46 @@ async function migrate(ip) {
summaryDiv.style.display = 'block';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Migration failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error migrating ' + display + ': ' + error;
}
}
async function trustCA(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function trustCA(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + display + '...';
try {
const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
const response = await fetch('/setup/trust-ca/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
showSummary(ip); // Refresh to update status
statusDiv.innerHTML = 'Successfully injected Root CA on ' + display + '.';
showSummary(deviceId); // Refresh to update status
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to trust CA on ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to trust CA on ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error trusting CA on ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error trusting CA on ' + display + ': ' + error;
}
}
async function ensureRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function ensureRemoteServices(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const summaryDiv = document.getElementById('migration-summary');
@@ -1112,31 +1289,33 @@ async function ensureRemoteServices(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Ensuring remote services for ' + display + '...';
try {
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
const response = await fetch('/setup/ensure-remote-services/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to ensure remote services for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error ensuring remote services for ' + display + ': ' + error;
}
}
async function removeRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function removeRemoteServices(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
const display = getDeviceDisplayName(deviceId);
if (!confirm('Are you sure you want to remove remote services from ' + display + '?')) {
return;
}
const summaryDiv = document.getElementById('migration-summary');
@@ -1145,65 +1324,67 @@ async function removeRemoteServices(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
statusDiv.innerHTML = 'Removing remote services for ' + display + '...';
try {
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
const response = await fetch('/setup/remove-remote-services/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
statusDiv.innerHTML = 'Successfully removed remote services from ' + display + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to remove remote services for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error removing remote services for ' + display + ': ' + error;
}
}
async function backupConfig(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function backupConfig(deviceId, ip) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
const display = getDeviceDisplayName(deviceId);
statusDiv.innerHTML = 'Creating backup for ' + display + '...';
try {
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
const response = await fetch('/setup/backup/' + encodeURIComponent(deviceId), { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
showSummary(ip); // Refresh
statusDiv.innerHTML = 'Successfully created backup for ' + display + '.';
showSummary(deviceId); // Refresh
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Backup failed for ' + display + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error creating backup for ' + display + ': ' + error;
}
}
async function testConnection(ip, useExplicitCA) {
async function testConnection(deviceId, useExplicitCA) {
const testUrl = document.getElementById('test-url').innerText;
const testResultDiv = document.getElementById('test-result');
const display = getDeviceDisplayName(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running connection test from ' + ip + '...\n(This may take a few seconds)';
testResultDiv.innerText = 'Running connection test from ' + display + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
const response = await fetch(`/setup/test-connection/${ip}${query}`, { method: 'POST' });
const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1219,18 +1400,19 @@ async function testConnection(ip, useExplicitCA) {
}
}
async function testHostsRedirection(ip) {
async function testHostsRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('hosts-test-result');
const display = getDeviceDisplayName(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running hosts redirection test from ' + ip + '...\n(This may take a few seconds)';
testResultDiv.innerText = 'Running hosts redirection test from ' + display + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-hosts/${ip}${query}`, { method: 'POST' });
const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1246,18 +1428,19 @@ async function testHostsRedirection(ip) {
}
}
async function testDNSRedirection(ip) {
async function testDNSRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('dns-test-result');
const display = getDeviceDisplayName(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running DNS redirection test from ' + ip + '...\n(This may take a few seconds)';
testResultDiv.innerText = 'Running DNS redirection test from ' + display + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-dns/${ip}${query}`, { method: 'POST' });
const response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {