diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 7acf809..2ee65d8 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -668,7 +668,6 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Use(server.BasicAuthMgmt()) r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers) r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents) - r.Post("/devices/{deviceId}/spotify/install-primer", server.HandleMgmtInstallSpotifyPrimer) r.Post("/spotify/init", server.HandleMgmtSpotifyInit) r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm) r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts) diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go index 5888df5..288e50d 100644 --- a/pkg/service/handlers/handlers_mgmt.go +++ b/pkg/service/handlers/handlers_mgmt.go @@ -5,7 +5,6 @@ import ( "io" "log" "net/http" - "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -216,94 +215,6 @@ func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Reques } } -// HandleMgmtInstallSpotifyPrimer installs the Spotify boot primer on a device identified by deviceId. -// The deviceId can be either a known device ID/serial stored in the DataStore or a raw IP address. -func (s *Server) HandleMgmtInstallSpotifyPrimer(w http.ResponseWriter, r *http.Request) { - 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 ID is required"}); err != nil { - log.Printf("[Mgmt] Failed to encode error: %v", err) - } - - return - } - - targetURL := r.URL.Query().Get("target_url") - if targetURL == "" { - s.mu.RLock() - targetURL = s.sm.ServerURL - s.mu.RUnlock() - } - - // Resolve deviceID to IP if possible - deviceIP := "" - - // If the ID looks like an IP, use it directly - if strings.Count(deviceID, ".") == 3 || strings.Contains(deviceID, ":") { // IPv4 or IPv6 - deviceIP = deviceID - } else { - // Look up in datastore - all, err := s.ds.ListAllDevices() - if err == nil { - for i := range all { - d := &all[i] - if d.DeviceID == deviceID || d.DeviceSerialNumber == deviceID { - deviceIP = d.IPAddress - break - } - } - } - } - - if deviceIP == "" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device not found or missing IP"}); err != nil { - log.Printf("[Mgmt] Failed to encode error: %v", err) - } - - return - } - - s.mu.RLock() - mgr := s.sm - s.mu.RUnlock() - - if mgr == nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusServiceUnavailable) - - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "setup manager unavailable"}); err != nil { - log.Printf("[Mgmt] Failed to encode error: %v", err) - } - - return - } - - output, err := mgr.InstallSpotifyPrimer(deviceIP, targetURL) - if err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - - if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil { - log.Printf("[Mgmt] Failed to encode error: %v", encodeErr) - } - - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Spotify primer installed", "output": output}); err != nil { - log.Printf("[Mgmt] Failed to encode response: %v", err) - } -} - -// HandleMgmtSpotifyToken returns a fresh Spotify access token and username. func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() svc := s.spotifyService diff --git a/pkg/service/handlers/handlers_mgmt_install_primer_test.go b/pkg/service/handlers/handlers_mgmt_install_primer_test.go deleted file mode 100644 index 0b4190c..0000000 --- a/pkg/service/handlers/handlers_mgmt_install_primer_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/gesellix/bose-soundtouch/pkg/service/setup" - "github.com/go-chi/chi/v5" -) - -func TestMgmtInstallSpotifyPrimer(t *testing.T) { - // Prepare server with a mocked SSH to avoid real connections - tmpDir := t.TempDir() - ds := datastore.NewDataStore(tmpDir) - _ = ds.Initialize() - - s := &Server{ds: ds} - - sm := setup.NewManager("http://localhost:8000", ds, nil) - sm.NewSSH = func(host string) setup.SSHClient { return &mockSSH{host: host} } - s.sm = sm - - r := chi.NewRouter() - r.Post("/mgmt/devices/{deviceId}/spotify/install-primer", s.HandleMgmtInstallSpotifyPrimer) - - req := httptest.NewRequest(http.MethodPost, "/mgmt/devices/192.168.1.10/spotify/install-primer", nil) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - var result map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if ok, _ := result["ok"].(bool); !ok { - t.Errorf("expected ok=true, got %v", result["ok"]) - } - if _, ok := result["output"]; !ok { - t.Errorf("expected output field in response") - } -} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index ae6b63c..d3322ed 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -1203,135 +1203,6 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro return logs, nil } -// InstallSpotifyPrimer installs all components required for the Spotify boot primer on the speaker. -func (m *Manager) InstallSpotifyPrimer(deviceIP, targetURL string) (string, error) { - client := m.NewSSH(deviceIP) - - var logs string - - // 1. Create the necessary directories - atDir := "/mnt/nv/soundtouch-service" - _, _ = client.Run(fmt.Sprintf("mkdir -p %s", atDir)) - - // 2. Upload spotify-boot-primer script - primerSource := "scripts/spotify/spotify-boot-primer.sh" - - primerContent, err := os.ReadFile(primerSource) - if err != nil { - // Fallback for different environments (e.g. tests) - primerSource = "../../../scripts/spotify/spotify-boot-primer.sh" - primerContent, err = os.ReadFile(primerSource) - } - - if err == nil { - remotePrimerPath := "/mnt/nv/soundtouch-service/spotify-boot-primer" - if uploadErr := client.UploadContent(primerContent, remotePrimerPath); uploadErr != nil { - logs += fmt.Sprintf("Warning: failed to upload %s: %v\n", remotePrimerPath, uploadErr) - } else { - logs += fmt.Sprintf("Uploaded %s\n", remotePrimerPath) - _, _ = client.Run(fmt.Sprintf("chmod +x %s", remotePrimerPath)) - } - } else { - logs += fmt.Sprintf("Warning: could not find spotify-boot-primer.sh locally (%s): %v\n", primerSource, err) - } - - // 3. Create the config file - confPath := "/mnt/nv/soundtouch-service/spotify-primer.conf" - - confContent := fmt.Sprintf("SOUNDTOUCH_URL=%s\nSOUNDTOUCH_USER=%s\nSOUNDTOUCH_PASS=%s\n", - targetURL, m.MgmtUsername, m.MgmtPassword) - - if uploadErr := client.UploadContent([]byte(confContent), confPath); uploadErr != nil { - logs += fmt.Sprintf("Warning: failed to upload %s: %v\n", confPath, uploadErr) - } else { - logs += fmt.Sprintf("Uploaded %s\n", confPath) - _, _ = client.Run(fmt.Sprintf("chmod 600 %s", confPath)) - } - - // 4. Update rc.local with the hook - hookLogs, hookErr := m.updateRcLocalWithSpotifyHook(client) - logs += hookLogs - - // 5. Cleanup legacy files - _, _ = client.Run("rm -f /mnt/nv/bin/spotify-boot-primer /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf") - - // 6. Set up .profile for PATH (optional but recommended in INSTALL.md) - profilePath := "/mnt/nv/.profile" - profileContent := "export PATH=\"/mnt/nv/soundtouch-service:$PATH\"\n" - - if strings.Contains(targetURL, ".local") || strings.Contains(targetURL, "192.168.") { - // Only add if not already there to be idempotent - existingProfile, _ := client.Run(fmt.Sprintf("cat %s", profilePath)) - if !strings.Contains(existingProfile, "/mnt/nv/soundtouch-service") { - newProfile := existingProfile - if !strings.HasSuffix(newProfile, "\n") && newProfile != "" { - newProfile += "\n" - } - - newProfile += profileContent - _ = client.UploadContent([]byte(newProfile), profilePath) - logs += "Updated /mnt/nv/.profile with PATH\n" - } - } - - return logs, hookErr -} - -func (m *Manager) updateRcLocalWithSpotifyHook(client SSHClient) (string, error) { - var logs string - - rcLocalPath := "/mnt/nv/rc.local" - primerPath := "/mnt/nv/soundtouch-service/spotify-boot-primer" - patchStartMarker := "# --- Aftertouch Spotify hook START ---" - patchEndMarker := "# --- Aftertouch Spotify hook END ---" - - // Check if rc.local exists and read it - currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath)) - if rcErr != nil { - currentRcLocal = "" - } - - if strings.Contains(currentRcLocal, patchStartMarker) { - return fmt.Sprintf("%s already contains Spotify hook logic\n", rcLocalPath), nil - } - - patchLogic := fmt.Sprintf(` -%s -# Launches Spotify boot primer in background since SoundTouch starts at S99 -if [ -f "%s" ]; then - %s & -fi -%s -`, patchStartMarker, primerPath, primerPath, patchEndMarker) - - newRcLocal := currentRcLocal - // Remove "cat: can't open..." error message if it was accidentally saved in the file - if strings.Contains(newRcLocal, "cat: can't open") { - newRcLocal = "" - } - - if !strings.HasPrefix(newRcLocal, "#!/bin/sh") { - newRcLocal = "#!/bin/sh\n" + strings.TrimPrefix(newRcLocal, "#!/bin/sh") - } - - if !strings.HasSuffix(newRcLocal, "\n") { - newRcLocal += "\n" - } - - newRcLocal += patchLogic - - if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil { - return logs, fmt.Errorf("failed to update %s: %w", rcLocalPath, err) - } - - logs += fmt.Sprintf("Updated %s with Spotify hook logic\n", rcLocalPath) - - // Make it executable - _, _ = client.Run(fmt.Sprintf("chmod +x %s", rcLocalPath)) - - return logs, nil -} - func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) { var logs string @@ -1517,9 +1388,6 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) { // 2c. Revert Aftertouch DNS Hook logs += m.revertAftertouchHook(client, rwCmd) - // 2d. Revert Spotify Primer components - logs += m.revertSpotifyPrimer(client, rwCmd) - // 3. Remove CA certificate from trust store if it exists logs += m.revertCACert(client, rwCmd) @@ -1718,59 +1586,6 @@ func (m *Manager) removeRcLocalHooks(client SSHClient, rcLocalPath string) strin return logs } -func (m *Manager) revertSpotifyPrimer(client SSHClient, rwCmd string) string { - var logs string - - // 1. Remove binary and config - primerPath := "/mnt/nv/soundtouch-service/spotify-boot-primer" - confPath := "/mnt/nv/soundtouch-service/spotify-primer.conf" - legacyPrimerPath := "/mnt/nv/bin/spotify-boot-primer" - legacyConfPath := "/mnt/nv/BoseApp-Persistence/1/spotify-primer.conf" - - out, _ := client.Run(fmt.Sprintf("%s && rm -f %s %s %s %s", rwCmd, primerPath, confPath, legacyPrimerPath, legacyConfPath)) - if out != "" { - logs += fmt.Sprintf("Removing primer files: %s\n", out) - } else { - logs += "Requested removal of Spotify primer binary and config\n" - } - - // 2. Remove rc.local hook (already handled by revertAftertouchHook if it uses markers, - // but let's be explicit if we want to clean up specifically) - // Actually revertAftertouchHook already removes blocks with "# --- Aftertouch Spotify hook START ---" - - // 3. Optional: cleanup .profile PATH? - // Probably better to leave it as it might contain other things, or just remove the specific line - profilePath := "/mnt/nv/.profile" - if content, err := client.Run(fmt.Sprintf("cat %s", profilePath)); err == nil && (strings.Contains(content, "/mnt/nv/soundtouch-service") || strings.Contains(content, "/mnt/nv/bin")) { - lines := strings.Split(content, "\n") - - var newLines []string - - for _, line := range lines { - if !strings.Contains(line, "export PATH=\"/mnt/nv/soundtouch-service:$PATH\"") && - !strings.Contains(line, "export PATH=\"/mnt/nv/bin:$PATH\"") && - strings.TrimSpace(line) != "" { - newLines = append(newLines, line) - } - } - - newContent := strings.Join(newLines, "\n") - if len(newLines) > 0 { - newContent += "\n" - } - - if newContent != content { - _ = client.UploadContent([]byte(newContent), profilePath) - logs += "Cleaned up PATH in /mnt/nv/.profile\n" - } - } - - // 4. Cleanup consolidated directory if empty - _, _ = client.Run("rmdir /mnt/nv/soundtouch-service 2>/dev/null") - - return logs -} - func (m *Manager) revertCACert(client SSHClient, rwCmd string) string { var logs string