diff --git a/pkg/service/handlers/placeholder_discovery_test.go b/pkg/service/handlers/placeholder_discovery_test.go new file mode 100644 index 0000000..b6cfa9e --- /dev/null +++ b/pkg/service/handlers/placeholder_discovery_test.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/marge" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" +) + +// TestHandleDiscoveredDevice_SeedsPlaceholdersWithoutSpotify guards the +// "Spotify-agnostic" property of placeholder seeding: a brand-new device +// being discovered must get its SPOTIFY/SpotifyConnectUserName placeholder +// even when no Spotify service is configured and no OAuth account is linked. +// That's the use case where the operator hasn't set anything up yet but the +// user pushes Spotify Connect from their phone and wants to preset it. +func TestHandleDiscoveredDevice_SeedsPlaceholdersWithoutSpotify(t *testing.T) { + tempDir, err := os.MkdirTemp("", "placeholder-discovery-*") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() + + const ( + deviceID = "AABBCCDDEEFF" + accountID = "7654321" + ) + + deviceInfoXML := ` +Bare Speaker +SoundTouch 20 +` + accountID + ` + + +SCM +27.0.6 +SN-PLACEHOLDER-TEST + + + +` + deviceID + ` +127.0.0.1 + +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/info" { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, deviceInfoXML) + + return + } + + http.NotFound(w, r) + })) + defer server.Close() + + deviceIP := server.URL[len("http://"):] + + ds := datastore.NewDataStore(tempDir) + sm := setup.NewManager(server.URL, ds, nil) + srv := NewServer(ds, sm, "http://localhost", false, false, false) + + // Deliberately NOT calling srv.SetSpotifyService — the whole point is + // that placeholder seeding must work without any music-service config. + if srv.spotifyService != nil { + t.Fatalf("test precondition: spotifyService should be nil for this scenario") + } + + srv.handleDiscoveredDevice(models.DiscoveredDevice{ + Host: deviceIP, + Name: "Bare Speaker", + DiscoveryMethod: "UPnP", + }) + + sources, err := ds.GetConfiguredSources(accountID, deviceID) + if err != nil { + t.Fatalf("GetConfiguredSources: %v", err) + } + + found := false + for i := range sources { + if sources[i].SourceKey.Type == "SPOTIFY" && sources[i].SourceKey.Account == marge.PlaceholderSpotifyConnectAccount { + found = true + break + } + } + + if !found { + t.Errorf("expected SPOTIFY/%s placeholder after discovery (without Spotify config), got %d sources", + marge.PlaceholderSpotifyConnectAccount, len(sources)) + for i := range sources { + t.Logf(" source[%d]: %s/%s (id=%s)", i, sources[i].SourceKey.Type, sources[i].SourceKey.Account, sources[i].ID) + } + } +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index b459237..01a47a6 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -719,14 +719,9 @@ func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spot registered = true } - // Seed firmware-internal placeholder sources (e.g. SPOTIFY/SpotifyConnectUserName) - // so storePreset payloads originating from Spotify-Connect playback bind to the - // Connect placeholder rather than the OAuth-brokered entry above. Idempotent. - if registered && deviceID != "" { - if err := marge.EnsurePlaceholderSources(s.ds, accountID, deviceID); err != nil { - log.Printf("[Spotify Watchdog] Failed to seed placeholder sources for account %s device %s: %v", accountID, deviceID, err) - } - } + // Note: firmware-internal placeholder sources (SPOTIFY/SpotifyConnectUserName, …) + // are seeded at device-discovery time by handleDiscoveredDevice — they live + // independently of whether any music service is OAuth-linked. // Tell the speaker its sources list changed so it re-fetches from marge. // Without this its on-device Sources.xml stays stale until something else @@ -925,6 +920,14 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { } } + // 9. Seed firmware-internal placeholder sources (e.g. SPOTIFY/SpotifyConnectUserName) + // so storePreset payloads carrying speaker-managed sourceAccounts bind to the + // right placeholder. Spotify-agnostic — runs even when no music service is + // linked yet, since Spotify Connect from a phone doesn't need our OAuth setup. + if err := marge.EnsurePlaceholderSources(s.ds, accountID, deviceID); err != nil { + log.Printf("Failed to seed placeholder sources for %s/%s: %v", accountID, deviceID, err) + } + log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", info.Name, d.Host, deviceID) } @@ -978,6 +981,11 @@ func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) { } } + // Seed firmware-internal placeholder sources — same as the live-info path. + if err := marge.EnsurePlaceholderSources(s.ds, accountID, deviceID); err != nil { + log.Printf("Failed to seed placeholder sources for %s/%s: %v", accountID, deviceID, err) + } + log.Printf("Successfully saved device %s (%s) with fallback deviceID: %s", info.Name, d.Host, deviceID) }