From 8642ecfc5cfdb39c13109c20ac2b585d5007c358 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 21 Feb 2026 12:53:16 +0100 Subject: [PATCH] Prepare Spotify primer --- cmd/soundtouch-service/main.go | 3 + docs/SUMMARY.md | 1 + docs/guides/SURVIVAL-GUIDE.md | 2 +- docs/spotify-oauth.md | 137 ++++++ pkg/service/handlers/handlers_mgmt.go | 88 ++++ .../handlers_mgmt_install_primer_test.go | 48 ++ pkg/service/handlers/handlers_mgmt_test.go | 266 +++++++++++ pkg/service/setup/setup.go | 418 +++++++++++++++--- pkg/service/setup/setup_test.go | 18 +- scripts/spotify/INSTALL.md | 97 ++++ scripts/spotify/README.md | 6 + scripts/spotify/ZEROCONF-ANALYSIS.md | 318 +++++++++++++ scripts/spotify/rc.local | 4 + scripts/spotify/spotify-boot-primer.sh | 144 ++++++ scripts/spotify/spotify-prime-speaker.sh | 141 ++++++ scripts/spotify/spotify-primer.conf.example | 6 + 16 files changed, 1628 insertions(+), 69 deletions(-) create mode 100644 docs/spotify-oauth.md create mode 100644 pkg/service/handlers/handlers_mgmt_install_primer_test.go create mode 100644 pkg/service/handlers/handlers_mgmt_test.go create mode 100644 scripts/spotify/INSTALL.md create mode 100644 scripts/spotify/README.md create mode 100644 scripts/spotify/ZEROCONF-ANALYSIS.md create mode 100644 scripts/spotify/rc.local create mode 100644 scripts/spotify/spotify-boot-primer.sh create mode 100644 scripts/spotify/spotify-prime-speaker.sh create mode 100644 scripts/spotify/spotify-primer.conf.example diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 21cb152..7acf809 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -211,6 +211,8 @@ func main() { cm := initCertificateManager(config.dataDir) sm := setup.NewManager(config.serverURL, ds, cm) + sm.MgmtUsername = config.mgmtUsername + sm.MgmtPassword = config.mgmtPassword server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy) sm.GetDNSRunning = server.GetDNSRunning server.SetSoundcorkURL(config.soundcorkURL) @@ -666,6 +668,7 @@ 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/docs/SUMMARY.md b/docs/SUMMARY.md index 8d368de..eea0087 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -34,6 +34,7 @@ * [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) diff --git a/docs/guides/SURVIVAL-GUIDE.md b/docs/guides/SURVIVAL-GUIDE.md index f79ad63..fdd2acd 100644 --- a/docs/guides/SURVIVAL-GUIDE.md +++ b/docs/guides/SURVIVAL-GUIDE.md @@ -43,7 +43,7 @@ To migrate your speakers, the service needs SSH access. You can enable it by: 3. Rebooting the speaker (unplug/replug). **Verify SSH Access:** -- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@` +- Confirm the device responds to SSH without a password: `ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@` - Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH. Once enabled, you can log in as `root` (no password). diff --git a/docs/spotify-oauth.md b/docs/spotify-oauth.md new file mode 100644 index 0000000..66a33b7 --- /dev/null +++ b/docs/spotify-oauth.md @@ -0,0 +1,137 @@ +# Spotify OAuth Integration + +The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026). + +## OAuth Flows + +The service supports two primary OAuth flows: a browser-based flow and a mobile app-based flow (specifically for the [ueberboese](https://github.com/julius-d/ueberboese-app) app). + +### 1. Browser-based Flow + +The user initiates the flow, completes authorization in their browser, and is redirected back to the service. + +```mermaid +sequenceDiagram + participant Client as Client (curl/app) + participant Service as Service + participant Spotify as Spotify Auth Server + participant Browser as User's Browser + + Client->>Service: POST /mgmt/spotify/init [Basic Auth] + Service-->>Client: {"redirectUrl": "https://accounts.spotify.com/authorize?..."} + + Client->>Browser: User opens URL + Browser->>Spotify: User logs in & grants access + Spotify-->>Browser: Redirect to /mgmt/spotify/callback?code=abc + + Browser->>Service: GET /mgmt/spotify/callback?code=abc + Note over Service: No auth needed for callback + + Service->>Spotify: POST /api/token (exchange code) + Spotify-->>Service: {access_token, refresh_token} + + Service->>Spotify: GET /v1/me (fetch profile) + Spotify-->>Service: {id, display_name, email} + + Note over Service: Store account to disk + + Service-->>Browser: HTML: "Spotify Connected. You can close this window." +``` + +### 2. Mobile App Flow (ueberboese) + +The mobile app handles the redirect via a deep link and then confirms the authorization with the service. + +```mermaid +sequenceDiagram + participant App as ueberboese Flutter App + participant Service as Service + participant Spotify as Spotify Auth Server + + App->>Service: POST /mgmt/spotify/init [Basic Auth] + Service-->>App: {"redirectUrl": "https://..."} + + App->>Spotify: Open in-app browser (User authorizes) + Spotify-->>App: Deep link redirect: ueberboese-login://spotify?code=abc + + App->>Service: POST /mgmt/spotify/confirm?code=abc [Basic Auth] + + Service->>Spotify: POST /api/token (exchange code) + Spotify-->>Service: {access_token, refresh_token} + + Service->>Spotify: GET /v1/me (fetch profile) + Spotify-->>Service: {profile} + + Service-->>App: {"ok": true} +``` + +### 3. Token Retrieval (Boot Primer / Speaker Setup) + +Once an account is linked, access tokens can be retrieved for use with speakers (e.g., via the `addUser` ZeroConf command). + +```mermaid +sequenceDiagram + participant Primer as Boot Primer Script + participant Service as Service + participant Spotify as Spotify Token API + participant Speaker as Speaker (Bose ST 20) + + Primer->>Service: GET /mgmt/spotify/token [Basic Auth] + + alt Token expired + Service->>Spotify: POST /api/token (refresh) + Spotify-->>Service: new tokens + end + + Service-->>Primer: {"access_token": "...", "username": "..."} + + Note over Primer: Spotify Connect ZeroConf + Primer->>Speaker: POST /SpotifyConnect (addUser with token) + Speaker-->>Primer: OK + Note over Speaker: Speaker now has Spotify access +``` + +## 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). + +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 + +The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker: +`POST /mgmt/devices/{deviceId}/spotify/install-primer` + +### Automated Installation Steps +When you run the Spotify primer installation, the service performs the following: +1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker. +2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker. +3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials. +4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers. +5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH. + +- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content. +- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block. +- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`: + - `# --- Aftertouch Spotify hook START ---` + - `# --- Aftertouch Spotify hook END ---` +- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks. + +## Endpoints + +| Method | Path | Auth | Purpose | +|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------| +| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) | +| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) | +| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL | +| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) | +| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) | +| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) | +| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL | + +## Security + +- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server. +- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`. +- Tokens are persisted to disk as JSON with restricted file permissions (`0600`). +- The `GetAccounts` endpoint strips sensitive tokens from the response. diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go index 2e4f270..5888df5 100644 --- a/pkg/service/handlers/handlers_mgmt.go +++ b/pkg/service/handlers/handlers_mgmt.go @@ -5,6 +5,7 @@ import ( "io" "log" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -215,6 +216,93 @@ 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() diff --git a/pkg/service/handlers/handlers_mgmt_install_primer_test.go b/pkg/service/handlers/handlers_mgmt_install_primer_test.go new file mode 100644 index 0000000..0b4190c --- /dev/null +++ b/pkg/service/handlers/handlers_mgmt_install_primer_test.go @@ -0,0 +1,48 @@ +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/handlers/handlers_mgmt_test.go b/pkg/service/handlers/handlers_mgmt_test.go new file mode 100644 index 0000000..a21c7de --- /dev/null +++ b/pkg/service/handlers/handlers_mgmt_test.go @@ -0,0 +1,266 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/spotify" + "github.com/go-chi/chi/v5" +) + +func TestHandleMgmtSpotifyInit(t *testing.T) { + s := &Server{} + // No spotify service configured + req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil) + w := httptest.NewRecorder() + s.HandleMgmtSpotifyInit(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } + + // With spotify service + svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) + s.SetSpotifyService(svc) + + w = httptest.NewRecorder() + s.HandleMgmtSpotifyInit(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if !strings.Contains(resp["redirectUrl"], "client_id=cid") { + t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"]) + } +} + +func TestHandleMgmtSpotifyAccounts(t *testing.T) { + s := &Server{} + svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) + s.SetSpotifyService(svc) + + req := httptest.NewRequest("GET", "/mgmt/spotify/accounts", nil) + w := httptest.NewRecorder() + s.HandleMgmtSpotifyAccounts(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string][]spotify.Account + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if len(resp["accounts"]) != 0 { + t.Errorf("expected 0 accounts, got %d", len(resp["accounts"])) + } +} + +func TestHandleMgmtListSpeakers(t *testing.T) { + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + _, s := setupRouter("http://localhost:8000", ds) + + req := httptest.NewRequest("GET", "/mgmt/accounts/default/speakers", nil) + w := httptest.NewRecorder() + s.HandleMgmtListSpeakers(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if _, ok := resp["speakers"]; !ok { + t.Error("expected 'speakers' in response") + } +} + +func TestHandleMgmtSpotifyCallback(t *testing.T) { + s := &Server{} + svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) + s.SetSpotifyService(svc) + + // Mock Spotify token and profile endpoints + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "at", + "refresh_token": "rt", + "expires_in": 3600, + }) + })) + defer tokenServer.Close() + + profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "user123", + "display_name": "Test User", + }) + })) + defer profileServer.Close() + + // Use internal members to override URLs (available because we are in the same package) + // Actually we need to reach through s.spotifyService which is private. + // But s.spotifyService is *spotify.Service, which we have a handle to (svc). + // We can't access private fields of spotify.Service from handlers package. + // Wait, I can't override tokenURL from here if it's unexported in spotify package. + // Let's check service.go again. Yes, tokenURL and apiBase are unexported. + + // Since I can't easily mock the external Spotify API here without exported fields, + // I will test the error paths. + + t.Run("Missing code", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mgmt/spotify/callback", nil) + w := httptest.NewRecorder() + s.HandleMgmtSpotifyCallback(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "Missing authorization code") { + t.Errorf("expected missing code error message, got %s", w.Body.String()) + } + }) + + t.Run("Spotify error", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mgmt/spotify/callback?error=access_denied", nil) + w := httptest.NewRecorder() + s.HandleMgmtSpotifyCallback(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "access_denied") { + t.Errorf("expected access_denied error message, got %s", w.Body.String()) + } + }) +} + +func TestHandleMgmtSpotifyConfirm(t *testing.T) { + s := &Server{} + svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) + s.SetSpotifyService(svc) + + t.Run("Missing code", func(t *testing.T) { + req := httptest.NewRequest("POST", "/mgmt/spotify/confirm", nil) + w := httptest.NewRecorder() + s.HandleMgmtSpotifyConfirm(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) +} + +func TestHandleMgmtDeviceEvents(t *testing.T) { + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + _, s := setupRouter("http://localhost:8000", ds) + + r := chi.NewRouter() + r.Get("/mgmt/devices/{deviceId}/events", s.HandleMgmtDeviceEvents) + + req := httptest.NewRequest("GET", "/mgmt/devices/device123/events", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if _, ok := resp["events"]; !ok { + t.Error("expected 'events' in response") + } +} + +func TestBasicAuthMgmt(t *testing.T) { + s := &Server{} + s.SetMgmtConfig("admin", "secret123") + + handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + })) + + t.Run("Valid credentials", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil) + req.SetBasicAuth("admin", "secret123") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code) + } + if rr.Body.String() != "OK" { + t.Errorf("expected body 'OK', got %q", rr.Body.String()) + } + }) + + t.Run("Wrong username", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil) + req.SetBasicAuth("wrong", "secret123") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } + if rr.Header().Get("WWW-Authenticate") == "" { + t.Error("expected WWW-Authenticate header to be set") + } + }) + + t.Run("Wrong password", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil) + req.SetBasicAuth("admin", "wrongpass") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) + + t.Run("Missing auth header", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) + + t.Run("Empty credentials", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil) + req.SetBasicAuth("", "") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 04a580b..ae6b63c 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -86,6 +86,10 @@ type Manager struct { // GetDNSRunning is an optional callback to check the actual state of the DNS server. GetDNSRunning func() (bool, string) + + // Spotify management credentials for the boot primer + MgmtUsername string + MgmtPassword string } // NewManager creates a new Manager with the given base server URL. @@ -97,6 +101,8 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi NewSSH: func(host string) SSHClient { return ssh.NewClient(host) }, + MgmtUsername: "admin", + MgmtPassword: "change_me!", } } @@ -724,6 +730,20 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma logs += fmt.Sprintf("Warning: could not verify configuration on device: %v\n", err) } + // 3. Inject CA Certificate (optional but recommended) + summary := &MigrationSummary{} + m.checkCACertTrusted(summary, deviceIP) + + if !summary.CACertTrusted { + out, err := m.TrustCACert(deviceIP) + + logs += "Trusting CA:\n" + out + "\n" + + if err != nil { + fmt.Printf("Warning: failed to trust CA: %v\n", err) + } + } + return logs, nil } @@ -1117,18 +1137,18 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro hostIP := m.resolveIP(hostName, client) logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP) - // 2. Prepare /mnt/nv/aftertouch.resolv.conf content + // 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content resolvContent := fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP) - // 3. Upload /mnt/nv/aftertouch.resolv.conf - // Ensure /mnt/nv exists - _, _ = client.Run("mkdir -p /mnt/nv") + // 3. Upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf + // Ensure /mnt/nv/soundtouch-service exists + _, _ = client.Run("mkdir -p /mnt/nv/soundtouch-service") - if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); uploadErr != nil { - return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", uploadErr) + if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"); uploadErr != nil { + return logs, fmt.Errorf("failed to upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf: %w", uploadErr) } - logs += "Uploaded /mnt/nv/aftertouch.resolv.conf\n" + logs += "Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf\n" // 4. Update /mnt/nv/rc.local with idempotent patch patchOut, err := m.updateRcLocalWithDNSHook(client) @@ -1138,11 +1158,14 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro return logs, err } - // 5. Apply patch immediately to /etc/udhcpc.d/50default + // 5. Cleanup legacy file + _, _ = client.Run("rm -f /mnt/nv/aftertouch.resolv.conf") + + // 6. Apply patch immediately to /etc/udhcpc.d/50default rwOut, _ := client.Run(rwCmd) logs += rwCmd + ": " + rwOut + "\n" - hookMarker := "/mnt/nv/aftertouch.resolv.conf" + hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" targetDHCPFile := "/etc/udhcpc.d/50default" dhcpPatchOut, err := m.patchDHCPFile(client, targetDHCPFile, hookMarker) logs += dhcpPatchOut @@ -1180,12 +1203,141 @@ 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 rcLocalPath := "/mnt/nv/rc.local" targetDHCPFile := "/etc/udhcpc.d/50default" - hookMarker := "/mnt/nv/aftertouch.resolv.conf" + hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" // Check if rc.local exists and read it currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath)) @@ -1197,8 +1349,12 @@ func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) { return fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath), nil } + patchStartMarker := "# --- Aftertouch DNS hook START ---" + patchEndMarker := "# --- Aftertouch DNS hook END ---" + patchLogic := fmt.Sprintf(` -# Aftertouch DNS hook: prioritizes our custom nameserver if it exists +%s +# prioritizes our custom nameserver if it exists if [ -f "%s" ]; then if [ -f "%s" ] && ! grep -q "%s" "%s"; then logger -t "aftertouch" "Patching %s with Aftertouch DNS hook" @@ -1210,9 +1366,47 @@ if [ -f "%s" ]; then sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"%s"' ] && cat '"%s"' >> '"\$RESOLV_CONF"' && dns=""' "$targetScript" fi fi -`, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker) +%s +`, patchStartMarker, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker, patchEndMarker) newRcLocal := currentRcLocal + // Remove old-style DNS hook if it exists + if strings.Contains(newRcLocal, "# Aftertouch DNS hook") && !strings.Contains(newRcLocal, patchStartMarker) { + // Old removal: filter out lines between the marker and the first 'fi' + lines := strings.Split(newRcLocal, "\n") + + var filteredLines []string + + skip := false + + for _, line := range lines { + if strings.Contains(line, "# Aftertouch DNS hook") { + skip = true + continue + } + + if skip && strings.TrimSpace(line) == "fi" { + skip = false + continue + } + + if !skip { + filteredLines = append(filteredLines, line) + } + } + + newRcLocal = strings.Join(filteredLines, "\n") + } + + // Remove existing marker-based hook if it exists (for update) + if strings.Contains(newRcLocal, patchStartMarker) { + startIdx := strings.Index(newRcLocal, patchStartMarker) + + endIdx := strings.Index(newRcLocal, patchEndMarker) + if startIdx != -1 && endIdx != -1 { + newRcLocal = newRcLocal[:startIdx] + newRcLocal[endIdx+len(patchEndMarker):] + } + } // Remove "cat: can't open..." error message if it was accidentally saved in the file if strings.Contains(newRcLocal, "cat: can't open") { newRcLocal = "" @@ -1323,6 +1517,9 @@ 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) @@ -1392,59 +1589,21 @@ func (m *Manager) revertResolvConf(client SSHClient, rwCmd string) string { func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string { var logs string - aftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf" + aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" + legacyConfPath := "/mnt/nv/aftertouch.resolv.conf" rcLocalPath := "/mnt/nv/rc.local" targetDHCPFile := "/etc/udhcpc.d/50default" - if _, err := client.Run(fmt.Sprintf("[ -f %s ]", aftertouchConfPath)); err == nil { - logs += fmt.Sprintf("Removing %s\n", aftertouchConfPath) - fmt.Printf("Removing %s\n", aftertouchConfPath) - _, _ = client.Run(fmt.Sprintf("rm %s", aftertouchConfPath)) - } - - if currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath)); err == nil { - // Remove "cat: can't open..." error message if it was accidentally saved in the file - if strings.Contains(currentRcLocal, "cat: can't open") { - logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath) - _, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath)) - - return logs - } - - if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") { - logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath) - fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath) - - // Simple removal: filter out lines between the marker and the 'fi' - lines := strings.Split(currentRcLocal, "\n") - - var newLines []string - - skip := false - - for _, line := range lines { - if strings.Contains(line, "# Aftertouch DNS hook") { - skip = true - continue - } - - if skip && strings.TrimSpace(line) == "fi" { - skip = false - continue - } - - if !skip { - newLines = append(newLines, line) - } - } - - newRcLocal := strings.Join(newLines, "\n") - if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil { - fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err) - } + for _, p := range []string{aftertouchConfPath, legacyConfPath} { + if _, err := client.Run(fmt.Sprintf("[ -f %s ]", p)); err == nil { + logs += fmt.Sprintf("Removing %s\n", p) + fmt.Printf("Removing %s\n", p) + _, _ = client.Run(fmt.Sprintf("rm %s", p)) } } + logs += m.removeRcLocalHooks(client, rcLocalPath) + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err == nil { logs += fmt.Sprintf("Reverting %s from backup\n", targetDHCPFile) fmt.Printf("Reverting %s from backup\n", targetDHCPFile) @@ -1471,6 +1630,147 @@ func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string { return logs } +func (m *Manager) removeRcLocalHooks(client SSHClient, rcLocalPath string) string { + var logs string + + patchStartMarker := "# --- Aftertouch DNS hook START ---" + patchEndMarker := "# --- Aftertouch DNS hook END ---" + spotifyPatchStartMarker := "# --- Aftertouch Spotify hook START ---" + spotifyPatchEndMarker := "# --- Aftertouch Spotify hook END ---" + aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf" + legacyAftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf" + + currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath)) + if err != nil { + return "" + } + + // Remove "cat: can't open..." error message if it was accidentally saved in the file + if strings.Contains(currentRcLocal, "cat: can't open") { + logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath) + _, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath)) + + return logs + } + + modified := false + + if strings.Contains(currentRcLocal, patchStartMarker) { + logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath) + fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath) + + startIdx := strings.Index(currentRcLocal, patchStartMarker) + endIdx := strings.Index(currentRcLocal, patchEndMarker) + + if startIdx != -1 && endIdx != -1 { + currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(patchEndMarker):] + modified = true + } + } else if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, legacyAftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") { + logs += fmt.Sprintf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath) + fmt.Printf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath) + + lines := strings.Split(currentRcLocal, "\n") + + var newLines []string + + skip := false + + for _, line := range lines { + if strings.Contains(line, "# Aftertouch DNS hook") { + skip = true + continue + } + + if skip && strings.TrimSpace(line) == "fi" { + skip = false + continue + } + + if !skip { + newLines = append(newLines, line) + } + } + + currentRcLocal = strings.Join(newLines, "\n") + modified = true + } + + if strings.Contains(currentRcLocal, spotifyPatchStartMarker) { + logs += fmt.Sprintf("Removing Spotify hook logic from %s\n", rcLocalPath) + fmt.Printf("Removing Spotify hook logic from %s\n", rcLocalPath) + + startIdx := strings.Index(currentRcLocal, spotifyPatchStartMarker) + endIdx := strings.Index(currentRcLocal, spotifyPatchEndMarker) + + if startIdx != -1 && endIdx != -1 { + currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(spotifyPatchEndMarker):] + modified = true + } + } + + if modified { + if err := client.UploadContent([]byte(currentRcLocal), rcLocalPath); err != nil { + fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err) + } + } + + 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 diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 6b8743f..847a9cd 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -719,7 +719,7 @@ func TestRevertMigration(t *testing.T) { } // Mock file existence checks for .original files if strings.HasPrefix(command, "[ -f") { - if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") { + if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") { return "", nil // file exists } } @@ -1128,7 +1128,7 @@ func TestMigrateViaResolvConf(t *testing.T) { if command == "cat /mnt/nv/rc.local" { return "#!/bin/sh\n", nil } - if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") { + if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") { return "OK", nil } if strings.HasPrefix(command, "[ -f") { @@ -1149,11 +1149,11 @@ func TestMigrateViaResolvConf(t *testing.T) { } // Verify uploads - if !strings.Contains(uploads["/mnt/nv/aftertouch.resolv.conf"], "nameserver 192.168.1.100") { + if !strings.Contains(uploads["/mnt/nv/soundtouch-service/aftertouch.resolv.conf"], "nameserver 192.168.1.100") { t.Errorf("aftertouch.resolv.conf missing nameserver") } - if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/aftertouch.resolv.conf") { + if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") { t.Errorf("rc.local missing hook logic") } @@ -1193,7 +1193,7 @@ func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) { // Simulate corrupted file containing error message return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil } - if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") { + if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") { return "OK", nil } if strings.HasPrefix(command, "[ -f") { @@ -1221,7 +1221,7 @@ func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) { if !strings.HasPrefix(rcLocal, "#!/bin/sh") { t.Errorf("rc.local missing shebang: %s", rcLocal) } - if !strings.Contains(rcLocal, "/mnt/nv/aftertouch.resolv.conf") { + if !strings.Contains(rcLocal, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") { t.Errorf("rc.local missing hook logic: %s", rcLocal) } } @@ -1252,7 +1252,7 @@ func TestMigrateViaResolvConf_UdhcpcScript(t *testing.T) { if command == "cat /mnt/nv/rc.local" { return "#!/bin/sh\n", nil } - if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") { + if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") { return "OK", nil } if command == "[ -f "+targetScript+" ]" { @@ -1320,12 +1320,12 @@ func TestRevertMigration_ResolvConf(t *testing.T) { runFunc: func(command string) (string, error) { runCalls = append(runCalls, command) if command == "cat /mnt/nv/rc.local" { - return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil + return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil } if strings.Contains(command, ".original ]") { return "", nil // backup exists } - if strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") { + if strings.Contains(command, "[ -f /mnt/nv/soundtouch-service/aftertouch.resolv.conf ]") || strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") { return "", nil } return "", nil diff --git a/scripts/spotify/INSTALL.md b/scripts/spotify/INSTALL.md new file mode 100644 index 0000000..3a9ec08 --- /dev/null +++ b/scripts/spotify/INSTALL.md @@ -0,0 +1,97 @@ +# On-Speaker Spotify Boot Primer for Bose SoundTouch +Self-contained boot-time Spotify primer that runs directly on the speaker. +No Spotify credentials on the device — it fetches a fresh token from a +[Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server at boot. +No jq, no rootfs modification — just files on persistent storage. + +## How It Works +Bose SoundTouch speakers run embedded Linux with a persistent writable volume +at `/mnt/nv`. The init script `shelby_local` (S97) has a built-in hook: +``` +[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local +``` +This runs before SoundTouch itself (S99), so we background a primer script +that waits for the Spotify Connect ZeroConf endpoint (port 8200) to come up, +fetches a fresh Spotify token from the service, and primes the speaker — all +within ~30 seconds of boot. + +## File Layout +``` +/mnt/nv/ + rc.local boot hook (S97 checks this) + .profile PATH setup for interactive SSH + bin/ + spotify-boot-primer main script + BoseApp-Persistence/1/ + spotify-primer.conf service credentials (mode 600) + Sources.xml, Presets.xml, ... existing speaker data +``` +Scripts live in `/mnt/nv/bin/` (added to PATH via `.profile`), config lives +alongside the speaker's own persistence files in `/mnt/nv/BoseApp-Persistence/1/`. + +## Speaker Environment +Tested on SoundTouch 20. Other SoundTouch models likely similar. +| Item | Detail | +|------|--------| +| OS | Linux 3.14.43+ ARM (hostname `spotty`) | +| Root FS | Read-only ubifs (can be remounted rw) | +| Persistent storage | `/mnt/nv` — writable ubifs, ~24M free | +| curl | 7.50.3 with OpenSSL (HTTPS works) | +| bash/grep/sed/awk | Available via busybox | +| jq | **Not available** (not needed) | +| Init | SysV, runlevel 5 | +| Production mode | Yes — cron is disabled | + +## Prerequisites +1. **SSH access to the speaker**: + ``` + ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@SPEAKER_IP + ``` +2. **A running [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server** with: + - A linked Spotify account (via the management API OAuth flow) + - The `GET /mgmt/spotify/token` endpoint (returns `{accessToken, username}`) + - Management API credentials (HTTP Basic Auth) + +## Installation +SSH into the speaker and run: +```bash +# 1. Create bin directory +mkdir -p /mnt/nv/bin +# 2. Create the config file with your service connection info +cat > /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf << 'EOF' +SOUNDTOUCH_URL=https://soundtouch.example.com +SOUNDTOUCH_USER=admin +SOUNDTOUCH_PASS=secret +EOF +chmod 600 /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf +# 3. Copy spotify-boot-primer to the speaker +# From your local machine: +# cat scripts/spotify/spotify-boot-primer | ssh root@SPEAKER_IP "cat > /mnt/nv/bin/spotify-boot-primer" +chmod +x /mnt/nv/bin/spotify-boot-primer +# 4. Create the boot hook +cat > /mnt/nv/rc.local << 'EOF' +#!/bin/bash +/mnt/nv/bin/spotify-boot-primer & +EOF +chmod +x /mnt/nv/rc.local +# 5. Set up PATH for interactive SSH sessions (optional but convenient) +cat > /mnt/nv/.profile << 'EOF' +export PATH="/mnt/nv/bin:$PATH" +EOF +``` + +## Testing +```bash +# Manual test (speaker must be running): +/mnt/nv/bin/spotify-boot-primer +# Check logs: +logread | grep spotify-primer +# Full test — reboot the speaker: +reboot +# Wait ~30s, then SSH back in and check: +logread | grep spotify-primer +curl -s "http://localhost:8200/zc?action=getInfo" | grep activeUser +``` + +## Related +- [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) — Comprehensive Go toolkit with migration automation diff --git a/scripts/spotify/README.md b/scripts/spotify/README.md new file mode 100644 index 0000000..af36d4b --- /dev/null +++ b/scripts/spotify/README.md @@ -0,0 +1,6 @@ +# Spotify Scripts + +This directory contains scripts and configuration files for the Spotify OAuth integration, specifically for priming Bose SoundTouch speakers. + +These files were adapted from the community gist: +https://gist.github.com/timvw/84ef8768ff876ef6805012b3eb4015b0 diff --git a/scripts/spotify/ZEROCONF-ANALYSIS.md b/scripts/spotify/ZEROCONF-ANALYSIS.md new file mode 100644 index 0000000..59597bc --- /dev/null +++ b/scripts/spotify/ZEROCONF-ANALYSIS.md @@ -0,0 +1,318 @@ +# ZeroConf Analysis - Spotify Connect Integration for Bose SoundTouch + +## Overview + +This document provides a comprehensive analysis of the Spotify Connect ZeroConf protocol as implemented by Bose SoundTouch speakers. ZeroConf enables seamless integration between Spotify clients and SoundTouch hardware without requiring manual configuration. + +## What is ZeroConf in This Context? + +ZeroConf (Zero Configuration) in the Bose SoundTouch ecosystem is a **Spotify Connect integration protocol** that allows Spotify clients (mobile apps, desktop applications) to discover and control SoundTouch speakers automatically. The speakers expose an HTTP API on **port 8200** that implements Spotify's official ZeroConf specification. + +## Network Discovery + +### mDNS/Bonjour Advertisement + +SoundTouch speakers advertise themselves on the local network using: +- **Service Type**: `_spotify-connect._tcp` +- **Port**: 8200 +- **TXT Record**: `CPath=/zc` (points to the ZeroConf endpoint) + +This allows Spotify applications to automatically discover available speakers without manual configuration. + +### Endpoint Structure + +``` +http://[SPEAKER_IP]:8200/zc?action=[ACTION]&[PARAMETERS] +``` + +Example: `http://192.168.1.100:8200/zc?action=getInfo` + +## The getInfo Action + +### Purpose + +The `getInfo` action retrieves comprehensive device information and current status. This is the most commonly used ZeroConf action for: +- Device discovery and identification +- Checking Spotify authentication status +- Retrieving device capabilities +- Monitoring multiroom configurations + +### Request Format + +```http +GET http://[SPEAKER_IP]:8200/zc?action=getInfo&version=2.10.0 +``` + +The `version` parameter is optional but recommended for compatibility. + +### Response Properties + +#### Mandatory Fields (Present in All Responses) + +| Property | Type | Description | +|----------|------|-------------| +| `status` | Integer | Operation result code (101 = success) | +| `statusString` | String | Human-readable status description | +| `spotifyError` | Integer | Last Spotify SDK error code (0 = no error) | +| `responseSource` | String | Entity identifier (e.g., "Bose") | + +#### Device Information Fields + +| Property | Required | Type | Description | +|----------|----------|------|-------------| +| `version` | Yes | String | ZeroConf API version (e.g., "2.10.0") | +| `deviceID` | Yes | String | Unique device identifier (MAC-based) | +| `publicKey` | Yes | String | Device's public key for secure communication | +| `remoteName` | Yes | String | User-friendly device name shown in Spotify | +| `deviceType` | No | String | Device category (e.g., "SPEAKER") | +| `brandDisplayName` | Yes | String | Brand name displayed in Spotify apps | +| `modelDisplayName` | No | String | Model name for user display | +| `libraryVersion` | Yes | String | Spotify Connect library version | +| `resolverVersion` | Yes | String | DNS resolution version | +| `groupStatus` | Yes | String | Multiroom status: "NONE", "GROUP", or "SLAVE" | +| `tokenType` | Yes | String | Authentication token type ("accesstoken") | +| `clientID` | Yes | String | Spotify client identifier | +| `productID` | Yes | Integer | Spotify product identifier | +| `scope` | Yes | String | Permission scope (typically "streaming") | +| `availability` | Yes | String | Device availability status | + +#### Status Fields + +| Property | Required | Type | Description | +|----------|----------|------|-------------| +| `activeUser` | No | String | Currently logged-in Spotify username (if any) | + +#### Advanced Fields (Optional) + +| Property | Type | Description | +|----------|------|-------------| +| `aliases` | Array | Virtual devices for multiroom zones | +| `supported_drm_media_formats` | Array | Supported audio formats with DRM capabilities | +| `supported_capabilities` | Integer | Bitmasked device capabilities | + +### Example Response + +```json +{ + "status": 101, + "statusString": "OK", + "spotifyError": 0, + "responseSource": "Bose", + "version": "2.10.0", + "deviceID": "0007F537F5ED", + "deviceType": "SPEAKER", + "remoteName": "Living Room Speaker", + "publicKey": "BgIwVfz9ZXQG...", + "brandDisplayName": "Bose", + "modelDisplayName": "SoundTouch 30", + "libraryVersion": "master-v3.15.1-g7890abcd", + "resolverVersion": "1", + "groupStatus": "NONE", + "tokenType": "accesstoken", + "clientID": "65b708073fc0480ea92a077233ca87bd", + "productID": 0, + "scope": "streaming", + "availability": "", + "activeUser": "spotify_username", + "supported_drm_media_formats": [ + {"drm": 0, "formats": 35}, + {"drm": 1, "formats": 35}, + {"drm": 3, "formats": 1168} + ], + "supported_capabilities": 1 +} +``` + +## Key Properties Analysis + +### Critical Status Indicators + +- **`activeUser`**: Most important field for determining if Spotify is active + - Present and non-empty: Spotify is authenticated and ready + - Empty or missing: No active Spotify session + +- **`remoteName`**: The display name users see in Spotify Connect device lists + - Should be descriptive and user-friendly + - Can contain UTF-8 characters and special symbols + +### Device Identification + +- **`deviceID`**: Unique identifier for targeting specific speakers + - Typically derived from MAC address + - Used for device-specific API calls + +- **`groupStatus`**: Critical for multiroom functionality + - `"NONE"`: Standalone device + - `"GROUP"`: Multiroom master/coordinator + - `"SLAVE"`: Member of a multiroom group + +### Display Properties + +- **`brandDisplayName`** and **`modelDisplayName`**: Shown in Spotify client UIs + - Should be marketing-appropriate names + - Support UTF-8 for international markets + +## Practical Usage Examples + +### 1. Status Checking + +```bash +# Check if Spotify is active +curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \ + grep -o '"activeUser" *: *"[^"]*"' | \ + sed 's/"activeUser" *: *"//;s/"$//' +``` + +### 2. Device Discovery + +```bash +# Get device name and ID +info=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo") +device_name=$(echo "$info" | grep -o '"remoteName" *: *"[^"]*"' | sed 's/"remoteName" *: *"//;s/"$//') +device_id=$(echo "$info" | grep -o '"deviceID" *: *"[^"]*"' | sed 's/"deviceID" *: *"//;s/"$//') +``` + +### 3. Multiroom Detection + +```bash +# Check multiroom status +group_status=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \ + grep -o '"groupStatus" *: *"[^"]*"' | \ + sed 's/"groupStatus" *: *"//;s/"$//') +``` + +## Authentication Flow + +The ZeroConf API supports the `addUser` action for Spotify authentication: + +```bash +curl -X POST "http://192.168.1.100:8200/zc" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "action=addUser&userName=${SPOTIFY_USER}&blob=${ACCESS_TOKEN}&clientKey=&tokenType=accesstoken" +``` + +### Token Requirements + +- **Access Token**: Valid Spotify OAuth access token +- **Username**: Spotify username associated with the token +- **Token Type**: Always "accesstoken" for current implementations +- **Client Key**: Empty string for current protocol version + +### Token Lifecycle + +1. Tokens expire after 1 hour (3600 seconds) +2. Speakers must be re-primed after reboot +3. Use `getInfo` to verify successful authentication via `activeUser` field + +## Security Considerations + +### Communication Security + +- **Protocol**: HTTP (plain text) is standard, HTTPS supported but optional +- **Network Scope**: Local network only (port 8200 typically not exposed externally) +- **Authentication**: Token-based, no permanent credentials stored + +### Best Practices + +1. **Token Management**: + - Never store long-lived tokens on devices + - Implement token refresh mechanisms + - Use centralized token servers when possible + +2. **Network Security**: + - Ensure port 8200 is not accessible from external networks + - Consider HTTPS for enhanced security + - Implement proper firewall rules + +3. **Error Handling**: + - Always check `status` and `spotifyError` fields + - Implement retry mechanisms for network failures + - Log authentication failures for debugging + +## Integration Patterns + +### Boot-time Automation + +See `spotify-boot-primer.sh` for a complete example of: +1. Waiting for ZeroConf endpoint availability +2. Checking current authentication status +3. Fetching fresh tokens from a management server +4. Automatically priming speakers at startup + +### Manual Priming + +See `spotify-prime-speaker.sh` for standalone token injection: +1. Validate access tokens against Spotify API +2. Extract username from token metadata +3. Prime individual speakers +4. Verify successful authentication + +### Monitoring and Health Checks + +```bash +#!/bin/bash +# Health check script +SPEAKER_IP="192.168.1.100" +info=$(curl -sf --max-time 5 "http://${SPEAKER_IP}:8200/zc?action=getInfo" 2>/dev/null) + +if [ $? -eq 0 ]; then + active_user=$(echo "$info" | grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//') + if [ -n "$active_user" ]; then + echo "✅ Spotify active (user: $active_user)" + else + echo "⚠️ Speaker reachable but Spotify not active" + fi +else + echo "❌ Speaker unreachable" +fi +``` + +## Troubleshooting + +### Common Issues + +1. **Port 8200 Unreachable** + - Check network connectivity + - Verify speaker is powered on + - Confirm IP address is correct + +2. **Empty `activeUser` After Authentication** + - Wait 2-5 seconds after `addUser` request + - Verify access token is valid and not expired + - Check `spotifyError` field for SDK errors + +3. **Authentication Failures** + - Ensure token has correct scopes + - Verify username matches token owner + - Check token expiration time + +### Diagnostic Commands + +```bash +# Test basic connectivity +curl -sf --max-time 5 "http://192.168.1.100:8200/zc?action=getInfo" + +# Check detailed response +curl -s "http://192.168.1.100:8200/zc?action=getInfo" | jq . + +# Monitor authentication status +while true; do + active=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \ + grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//') + echo "$(date): activeUser = '$active'" + sleep 10 +done +``` + +## References + +- [Spotify ZeroConf API Documentation](https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf) +- [Bose SoundTouch Toolkit](https://github.com/gesellix/Bose-SoundTouch) +- Scripts in this directory: + - `spotify-boot-primer.sh`: Automated boot-time priming + - `spotify-prime-speaker.sh`: Manual speaker priming + - `spotify-primer.conf.example`: Configuration template + +--- + +*This analysis is based on Spotify's official ZeroConf specification and practical implementation experience with Bose SoundTouch speakers.* \ No newline at end of file diff --git a/scripts/spotify/rc.local b/scripts/spotify/rc.local new file mode 100644 index 0000000..8b37ffe --- /dev/null +++ b/scripts/spotify/rc.local @@ -0,0 +1,4 @@ +#!/bin/bash +# /mnt/nv/rc.local — runs at boot via shelby_local (S97) +# Launches Spotify boot primer in background since SoundTouch starts at S99 +/mnt/nv/bin/spotify-boot-primer & diff --git a/scripts/spotify/spotify-boot-primer.sh b/scripts/spotify/spotify-boot-primer.sh new file mode 100644 index 0000000..30a8064 --- /dev/null +++ b/scripts/spotify/spotify-boot-primer.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# +# spotify-boot-primer — Self-contained Spotify primer for Bose SoundTouch speakers +# +# Runs at boot (via /mnt/nv/rc.local), waits for the ZeroConf endpoint to +# come up, fetches a fresh Spotify token from a soundtouch-service server, and +# primes the speaker. No Spotify credentials stored on the device. +# +# Only needs: curl, grep, sed (all available on the speaker via busybox). +# +# Install: +# 1. mkdir -p /mnt/nv/soundtouch-service +# 2. Copy this script to /mnt/nv/soundtouch-service/spotify-boot-primer +# 3. Create /mnt/nv/soundtouch-service/spotify-primer.conf +# 4. Create /mnt/nv/rc.local that backgrounds this script +# 5. chmod +x /mnt/nv/rc.local /mnt/nv/soundtouch-service/spotify-boot-primer +# +# Config file format (/mnt/nv/soundtouch-service/spotify-primer.conf): +# SOUNDTOUCH_URL=https://soundtouch.example.com +# SOUNDTOUCH_USER=admin +# SOUNDTOUCH_PASS=secret +# +# Related: +# https://github.com/gesellix/Bose-SoundTouch +# +set -uo pipefail + +CONF="/mnt/nv/soundtouch-service/spotify-primer.conf" +LOG_TAG="spotify-primer[$$]" +ZC_URL="http://localhost:8200/zc" +MAX_WAIT=120 # max seconds to wait for port 8200 +RETRY_DELAY=3 # seconds between retries + +# --- Logging --- +log() { + logger -s -t "$LOG_TAG" -p "$1" "$2" +} + +# --- JSON parsing without jq --- +# Extract a string value: echo '{"key":"val"}' | json_str key +json_str() { + grep -o "\"$1\" *: *\"[^\"]*\"" | sed "s/\"$1\" *: *\"//;s/\"$//" +} + +# Extract a numeric value: echo '{"key":123}' | json_num key +json_num() { + grep -o "\"$1\" *: *[0-9]*" | sed "s/\"$1\" *: *//" +} + +# --- Load config --- +if [ ! -f "$CONF" ]; then + log err "Config not found: $CONF" + exit 1 +fi + +. "$CONF" + +for var in SOUNDTOUCH_URL SOUNDTOUCH_USER SOUNDTOUCH_PASS; do + if [ -z "${!var:-}" ]; then + log err "Missing $var in $CONF" + exit 1 + fi +done + +log info "Config loaded (server=${SOUNDTOUCH_URL})" + +# --- Wait for ZeroConf endpoint (port 8200) --- +log info "Waiting for ZeroConf endpoint (max ${MAX_WAIT}s)..." +waited=0 +while true; do + if curl -sf --max-time 2 "${ZC_URL}?action=getInfo" >/dev/null 2>&1; then + break + fi + waited=$((waited + RETRY_DELAY)) + if [ $waited -ge $MAX_WAIT ]; then + log err "ZeroConf endpoint not available after ${MAX_WAIT}s — giving up" + exit 1 + fi + sleep $RETRY_DELAY +done +log info "ZeroConf endpoint is up (waited ${waited}s)" + +# --- Check if already primed --- +info=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" 2>/dev/null) +active_user=$(echo "$info" | json_str activeUser) +device_name=$(echo "$info" | json_str remoteName) + +if [ -n "$active_user" ]; then + log info "Already primed (device=$device_name, activeUser=$active_user) — nothing to do" + exit 0 +fi + +log info "Speaker '$device_name' has no active Spotify user — priming..." + +# --- Get token from soundtouch-service server --- +log info "Requesting Spotify token from soundtouch-service..." +token_response=$(curl -sf --max-time 15 \ + -u "${SOUNDTOUCH_USER}:${SOUNDTOUCH_PASS}" \ + "${SOUNDTOUCH_URL}/mgmt/spotify/token" \ + 2>&1) + +if [ $? -ne 0 ] || [ -z "$token_response" ]; then + log err "Failed to get token from soundtouch-service (is the server reachable?)" + exit 1 +fi + +access_token=$(echo "$token_response" | json_str accessToken) +user=$(echo "$token_response" | json_str username) + +if [ -z "$access_token" ] || [ -z "$user" ]; then + error_msg=$(echo "$token_response" | json_str detail) + log err "soundtouch-service returned error: ${error_msg:-no token/username in response}" + exit 1 +fi + +log info "Got token for user $user (${access_token:0:10}...)" + +# --- Prime the speaker --- +result=$(curl -sf --max-time 10 -X POST "$ZC_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "action=addUser&userName=${user}&blob=${access_token}&clientKey=&tokenType=accesstoken" \ + 2>&1) + +status=$(echo "$result" | json_num status) +status_str=$(echo "$result" | json_str statusString) + +if [ "$status" != "101" ]; then + log err "addUser failed: status=$status ($status_str)" + exit 1 +fi + +# --- Verify (retry — speaker needs a few seconds after cold boot) --- +log info "addUser accepted (status 101) — verifying..." +for i in 1 2 3 4 5; do + sleep $((i * 2)) + active_user=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" | json_str activeUser) + if [ -n "$active_user" ]; then + log info "Speaker primed successfully (activeUser=$active_user)" + exit 0 + fi +done + +log warning "Speaker accepted addUser but activeUser still empty after 30s" +exit 1 diff --git a/scripts/spotify/spotify-prime-speaker.sh b/scripts/spotify/spotify-prime-speaker.sh new file mode 100644 index 0000000..e6cbcaa --- /dev/null +++ b/scripts/spotify/spotify-prime-speaker.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# spotify-prime-speaker — Prime a Bose SoundTouch speaker for Spotify playback +# +# Activates Spotify on a SoundTouch speaker by sending an access token +# via the Spotify Connect ZeroConf endpoint (port 8200). This is the +# same mechanism the Spotify desktop app uses internally. +# +# Works standalone — no soundtouch-service, ueberboese, or other server required. +# +# Requirements: curl, jq +# +# Usage: +# ./spotify-prime-speaker SPEAKER_IP ACCESS_TOKEN +# +# Example: +# ./spotify-prime-speaker 192.168.1.143 BQDj...your_token... +# +# How to get an access token: +# - Spotify Developer Console: https://developer.spotify.com +# (create an app, use the "Get Token" button) +# - Via soundtouch-service management API: POST /mgmt/spotify/auth/init +# - Via ueberboese management API: POST /mgmt/spotify/init +# - Any Spotify OAuth Authorization Code flow with user-read-email scope +# +# Notes: +# - Access tokens expire after 1 hour (3600 seconds) +# - The speaker must be on the same network and reachable on port 8200 +# - After priming, Spotify presets on the speaker should work immediately +# - Re-run after each speaker reboot (or use a server like soundtouch-service +# to automate this) +# +# How it works: +# The Bose SoundTouch speaker exposes a Spotify Connect ZeroConf API +# on port 8200. By sending an addUser request with a valid Spotify +# access token, the speaker activates its built-in Spotify Connect +# client. No encryption is needed — the token is sent as plain text, +# exactly like the Spotify desktop app does it. +# +# Related: +# - https://github.com/gesellix/Bose-SoundTouch (comprehensive toolkit) +set -euo pipefail + +# --- Argument parsing --- +if [ $# -lt 2 ]; then + echo "Usage: $0 SPEAKER_IP ACCESS_TOKEN" + echo "" + echo "Prime a Bose SoundTouch speaker for Spotify playback." + echo "" + echo "Arguments:" + echo " SPEAKER_IP IP address of the SoundTouch speaker" + echo " ACCESS_TOKEN Spotify access token (starts with BQ...)" + echo "" + echo "Get a token at https://developer.spotify.com or via a server's OAuth flow." + exit 1 +fi + +SPEAKER_IP="$1" +TOKEN="$2" +ZC_URL="http://${SPEAKER_IP}:8200/zc" + +# --- Dependency check --- +for cmd in curl jq; do + if ! command -v "$cmd" &>/dev/null; then + echo "Error: $cmd is required but not installed." >&2 + exit 1 + fi +done + +# --- Step 1: Discover Spotify username from token --- +echo "Discovering Spotify user from token..." +ME_RESPONSE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ + https://api.spotify.com/v1/me 2>&1) || { + echo "Error: Failed to call Spotify /me API. Is the token valid?" >&2 + echo " (tokens expire after 1 hour)" >&2 + exit 1 +} + +USER=$(echo "$ME_RESPONSE" | jq -r '.id // empty') +if [ -z "$USER" ]; then + echo "Error: Could not extract user ID from Spotify response." >&2 + echo "$ME_RESPONSE" >&2 + exit 1 +fi +echo " Spotify user: $USER" + +# --- Step 2: Check current speaker status --- +echo "Checking speaker at ${SPEAKER_IP}:8200..." +INFO=$(curl -sf "${ZC_URL}?action=getInfo" 2>&1) || { + echo "Error: Could not reach speaker at ${SPEAKER_IP}:8200." >&2 + echo " Is the speaker on and on the same network?" >&2 + exit 1 +} + +ACTIVE=$(echo "$INFO" | jq -r '.activeUser // empty') +DEVICE_NAME=$(echo "$INFO" | jq -r '.remoteName // empty') + +if [ -n "$DEVICE_NAME" ]; then + echo " Speaker: $DEVICE_NAME" +fi + +if [ -n "$ACTIVE" ]; then + echo " Already primed (activeUser=$ACTIVE)" + echo "Done — speaker is ready for Spotify playback." + exit 0 +fi + +echo " No active Spotify user — priming now..." + +# --- Step 3: Send addUser --- +RESULT=$(curl -sf -X POST "${ZC_URL}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "action=addUser&userName=${USER}&blob=${TOKEN}&clientKey=&tokenType=accesstoken" \ + 2>&1) || { + echo "Error: addUser request failed." >&2 + exit 1 +} + +STATUS=$(echo "$RESULT" | jq -r '.status // -1') +STATUS_STR=$(echo "$RESULT" | jq -r '.statusString // empty') + +if [ "$STATUS" != "101" ]; then + echo "Error: Speaker returned status $STATUS ($STATUS_STR)" >&2 + echo "$RESULT" | jq . >&2 + exit 1 +fi + +echo " Speaker accepted the token (status 101)." + +# --- Step 4: Verify --- +echo " Verifying (waiting 2 seconds)..." +sleep 2 +ACTIVE=$(curl -sf "${ZC_URL}?action=getInfo" | jq -r '.activeUser // empty') + +if [ -n "$ACTIVE" ]; then + echo "Done — speaker primed for Spotify (activeUser=$ACTIVE)" +else + echo "Warning: Speaker returned 101 but activeUser is still empty." + echo " The speaker may need more time. Try pressing a Spotify preset." + exit 1 +fi diff --git a/scripts/spotify/spotify-primer.conf.example b/scripts/spotify/spotify-primer.conf.example new file mode 100644 index 0000000..ff02b9e --- /dev/null +++ b/scripts/spotify/spotify-primer.conf.example @@ -0,0 +1,6 @@ +# /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf — service connection for boot primer +# The speaker fetches a fresh Spotify token from the service at boot. +# No Spotify credentials needed on the device. +SOUNDTOUCH_URL=https://soundtouch.example.com +SOUNDTOUCH_USER=admin +SOUNDTOUCH_PASS=secret