mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Add Spotify primer
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}`))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user