mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
3
Commits
i622
...
sptfy-connect
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59593a27e0 | ||
|
|
c817b4902f | ||
|
|
8a7f8010c9 |
@@ -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 := `<info deviceID="` + deviceID + `">
|
||||
<name>Bare Speaker</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID>` + accountID + `</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6</softwareVersion>
|
||||
<serialNumber>SN-PLACEHOLDER-TEST</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + deviceID + `</macAddress>
|
||||
<ipAddress>127.0.0.1</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,6 +719,10 @@ func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spot
|
||||
registered = true
|
||||
}
|
||||
|
||||
// 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
|
||||
// triggers a sync — which leaves storePreset failing with
|
||||
@@ -916,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)
|
||||
}
|
||||
|
||||
@@ -969,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)
|
||||
}
|
||||
|
||||
|
||||
+159
-20
@@ -1212,6 +1212,66 @@ func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
}
|
||||
|
||||
// UpdatePreset updates or creates a preset for the specified account and device.
|
||||
// findMatchingSourceForUpdatePreset implements the three-tier match for a
|
||||
// storePreset payload, in order of decreasing specificity:
|
||||
//
|
||||
// 1. Exact source ID match — used when the speaker echoes back an ID we
|
||||
// previously gave it.
|
||||
// 2. Exact (SourceKeyType, SourceKeyAccount) match — lets firmware-internal
|
||||
// sourceAccounts (e.g. "SpotifyConnectUserName" for Connect-initiated
|
||||
// playback) bind to their dedicated placeholder source rather than
|
||||
// collapsing onto an unrelated OAuth-brokered entry of the same type.
|
||||
// See marge.EnsurePlaceholderSources.
|
||||
// 3. SourceKeyType-only fallback for the well-known provider strings —
|
||||
// preserves legacy behavior when the speaker only sends a provider name
|
||||
// and no account.
|
||||
//
|
||||
// Returns nil if no tier matches; callers turn that into "invalid account/source".
|
||||
func findMatchingSourceForUpdatePreset(sources []models.ConfiguredSource, sourceID, source, sourceAccount string) *models.ConfiguredSource {
|
||||
if sourceID != "" {
|
||||
for i := range sources {
|
||||
if sources[i].ID == sourceID {
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if source != "" && sourceAccount != "" {
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == source && sources[i].SourceKeyAccount == sourceAccount {
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isWellKnownProviderID(sourceID) {
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == sourceID {
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWellKnownProviderID(sourceID string) bool {
|
||||
switch sourceID {
|
||||
case constants.ProviderInternetRadio,
|
||||
constants.ProviderTunein,
|
||||
constants.ProviderSpotify,
|
||||
constants.ProviderAmazon:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdatePreset handles a storePreset payload from a speaker: it parses the
|
||||
// preset XML, looks up the configured source it should bind to via
|
||||
// findMatchingSourceForUpdatePreset, persists the preset under the account's
|
||||
// device directory, and returns the parity XML body the speaker expects.
|
||||
// Returns an error with "invalid account/source" if no source matches.
|
||||
func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber int, sourceXML []byte) ([]byte, error) {
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
@@ -1226,6 +1286,8 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
var newPresetElem struct {
|
||||
Name string `xml:"name"`
|
||||
SourceID string `xml:"sourceid"`
|
||||
Source string `xml:"source"`
|
||||
SourceAccount string `xml:"sourceaccount"`
|
||||
Location string `xml:"location"`
|
||||
ContentItemType string `xml:"contentItemType"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
@@ -1234,31 +1296,14 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matchingSrc *models.ConfiguredSource
|
||||
|
||||
log.Printf("[Marge] Searching for source matching ID=%s in %d sources", newPresetElem.SourceID, len(sources))
|
||||
log.Printf("[Marge] Searching for source matching ID=%s source=%s sourceAccount=%s in %d sources",
|
||||
newPresetElem.SourceID, newPresetElem.Source, newPresetElem.SourceAccount, len(sources))
|
||||
|
||||
for i := range sources {
|
||||
log.Printf("[Marge] Source[%d]: ID=%s, Type=%s, SourceKeyType=%s, SourceKeyAccount=%s", i, sources[i].ID, sources[i].Type, sources[i].SourceKeyType, sources[i].SourceKeyAccount)
|
||||
|
||||
if sources[i].ID == newPresetElem.SourceID {
|
||||
matchingSrc = &sources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchingSrc == nil {
|
||||
if newPresetElem.SourceID == constants.ProviderInternetRadio || newPresetElem.SourceID == constants.ProviderTunein || newPresetElem.SourceID == constants.ProviderSpotify || newPresetElem.SourceID == constants.ProviderAmazon {
|
||||
// Find by SourceKeyType instead of ID if it's a default source
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == newPresetElem.SourceID {
|
||||
matchingSrc = &sources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matchingSrc := findMatchingSourceForUpdatePreset(sources, newPresetElem.SourceID, newPresetElem.Source, newPresetElem.SourceAccount)
|
||||
if matchingSrc == nil {
|
||||
return nil, fmt.Errorf("invalid account/source")
|
||||
}
|
||||
@@ -2038,3 +2083,97 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
|
||||
return sourceID, nil
|
||||
}
|
||||
|
||||
// PlaceholderSpotifyConnectAccount is the firmware-internal sourceAccount
|
||||
// slug the speaker uses for Spotify-Connect-initiated playback (where the
|
||||
// speaker handles the Spotify session itself, independent of any OAuth
|
||||
// account brokered by AfterTouch). When the speaker stores a preset for
|
||||
// such playback it sends this exact string as sourceAccount.
|
||||
const PlaceholderSpotifyConnectAccount = "SpotifyConnectUserName"
|
||||
|
||||
// placeholderSource describes a firmware-internal "identity" source. These
|
||||
// carry no credentials and exist purely as match anchors so storePreset
|
||||
// payloads naming a firmware-managed sourceAccount don't fall back to an
|
||||
// unrelated OAuth-brokered entry with the same SourceKeyType.
|
||||
type placeholderSource struct {
|
||||
SourceKeyType string
|
||||
SourceKeyAccount string
|
||||
SourceProviderID string
|
||||
}
|
||||
|
||||
func knownPlaceholderSources() []placeholderSource {
|
||||
return []placeholderSource{
|
||||
{
|
||||
SourceKeyType: constants.ProviderSpotify,
|
||||
SourceKeyAccount: PlaceholderSpotifyConnectAccount,
|
||||
SourceProviderID: strconv.Itoa(constants.SpotifyProviderID),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsurePlaceholderSources seeds the firmware-internal placeholder
|
||||
// ConfiguredSources for a single (account, device) pair. Idempotent — a
|
||||
// placeholder with the same (SourceKeyType, SourceKeyAccount) is left alone
|
||||
// if it already exists, so this is safe to call on every prime/discover
|
||||
// cycle.
|
||||
//
|
||||
// Placeholders are saved with empty credentials and Status="UNAVAILABLE" so
|
||||
// they don't masquerade as user-managed sources in the AfterTouch UI; their
|
||||
// only job is to give marge.UpdatePreset something concrete to match against
|
||||
// when the speaker sends e.g. sourceAccount="SpotifyConnectUserName".
|
||||
func EnsurePlaceholderSources(ds *datastore.DataStore, account, device string) error {
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
|
||||
for _, p := range knownPlaceholderSources() {
|
||||
if hasSourceWithKey(sources, p.SourceKeyType, p.SourceKeyAccount) {
|
||||
continue
|
||||
}
|
||||
|
||||
now := FormatTime(time.Now())
|
||||
|
||||
newSrc := models.ConfiguredSource{
|
||||
ID: "PLACEHOLDER_" + p.SourceKeyType + "_" + p.SourceKeyAccount,
|
||||
SourceProviderID: p.SourceProviderID,
|
||||
Username: p.SourceKeyAccount,
|
||||
Name: p.SourceKeyAccount,
|
||||
CreatedOn: now,
|
||||
UpdatedOn: now,
|
||||
Status: "UNAVAILABLE",
|
||||
}
|
||||
newSrc.SourceKey.Type = p.SourceKeyType
|
||||
newSrc.SourceKey.Account = p.SourceKeyAccount
|
||||
|
||||
PrepareConfiguredSource(&newSrc)
|
||||
|
||||
log.Printf("[Marge] Seeding placeholder source %s/%s for account=%s device=%s",
|
||||
p.SourceKeyType, p.SourceKeyAccount, account, device)
|
||||
|
||||
sources = append(sources, newSrc)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ds.SaveConfiguredSources(account, device, sources)
|
||||
}
|
||||
|
||||
func hasSourceWithKey(sources []models.ConfiguredSource, sourceKeyType, sourceKeyAccount string) bool {
|
||||
for i := range sources {
|
||||
// Match either the structured SourceKey or the legacy flat fields —
|
||||
// PrepareConfiguredSource keeps the two in sync but older datastore
|
||||
// records may have populated only one.
|
||||
if (sources[i].SourceKey.Type == sourceKeyType && sources[i].SourceKey.Account == sourceKeyAccount) ||
|
||||
(sources[i].SourceKeyType == sourceKeyType && sources[i].SourceKeyAccount == sourceKeyAccount) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestEnsurePlaceholderSources_SeedsSpotifyConnect verifies that the
|
||||
// SPOTIFY/SpotifyConnectUserName placeholder is created for a fresh device
|
||||
// and that subsequent calls are no-ops (idempotent).
|
||||
func TestEnsurePlaceholderSources_SeedsSpotifyConnect(t *testing.T) {
|
||||
ds, account, device := newMargeTestDatastore(t)
|
||||
|
||||
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
|
||||
t.Fatalf("EnsurePlaceholderSources: %v", err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources: %v", err)
|
||||
}
|
||||
|
||||
if !hasSourceWithKey(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount) {
|
||||
t.Fatalf("expected SPOTIFY/%s placeholder, got %d sources: %+v",
|
||||
PlaceholderSpotifyConnectAccount, len(sources), sourceSummary(sources))
|
||||
}
|
||||
|
||||
// Find it and assert the cosmetic fields look right (UNAVAILABLE, no creds).
|
||||
var placeholder *models.ConfiguredSource
|
||||
|
||||
for i := range sources {
|
||||
if sources[i].SourceKey.Type == "SPOTIFY" && sources[i].SourceKey.Account == PlaceholderSpotifyConnectAccount {
|
||||
placeholder = &sources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if placeholder == nil {
|
||||
t.Fatalf("could not locate placeholder source after seeding")
|
||||
}
|
||||
|
||||
// No credentials — placeholders are pure match anchors. (Status carries
|
||||
// xml:"-" so it doesn't round-trip through XML storage; not asserted.)
|
||||
if placeholder.Secret != "" || placeholder.Credential.Value != "" {
|
||||
t.Errorf("placeholder should carry no credential, got Secret=%q credential=%q",
|
||||
placeholder.Secret, placeholder.Credential.Value)
|
||||
}
|
||||
|
||||
if placeholder.SourceProviderID != "15" {
|
||||
t.Errorf("placeholder SourceProviderID = %q, want 15", placeholder.SourceProviderID)
|
||||
}
|
||||
|
||||
// Idempotency: second call must not add a duplicate.
|
||||
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
|
||||
t.Fatalf("EnsurePlaceholderSources (second call): %v", err)
|
||||
}
|
||||
|
||||
after, _ := ds.GetConfiguredSources(account, device)
|
||||
|
||||
connectCount := 0
|
||||
for i := range after {
|
||||
if after[i].SourceKey.Type == "SPOTIFY" && after[i].SourceKey.Account == PlaceholderSpotifyConnectAccount {
|
||||
connectCount++
|
||||
}
|
||||
}
|
||||
|
||||
if connectCount != 1 {
|
||||
t.Errorf("expected exactly 1 SPOTIFY/%s placeholder after second call, got %d",
|
||||
PlaceholderSpotifyConnectAccount, connectCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsurePlaceholderSources_CoexistsWithOAuth verifies that seeding the
|
||||
// placeholder does not collide with or overwrite an existing OAuth-brokered
|
||||
// SPOTIFY source under the same account.
|
||||
func TestEnsurePlaceholderSources_CoexistsWithOAuth(t *testing.T) {
|
||||
ds, account, device := newMargeTestDatastore(t)
|
||||
|
||||
// Pre-populate the OAuth-brokered Spotify source (same as bridgeSpotifyToMarge would).
|
||||
if _, err := AddSource(ds, account, "gesellix", "15", "bs-deadbeef", "token_version_3", "Gesell IX"); err != nil {
|
||||
t.Fatalf("AddSource: %v", err)
|
||||
}
|
||||
|
||||
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
|
||||
t.Fatalf("EnsurePlaceholderSources: %v", err)
|
||||
}
|
||||
|
||||
sources, _ := ds.GetConfiguredSources(account, device)
|
||||
|
||||
// Both must be present.
|
||||
if !hasSourceWithKey(sources, "SPOTIFY", "gesellix") {
|
||||
t.Errorf("OAuth source SPOTIFY/gesellix missing after placeholder seeding")
|
||||
}
|
||||
|
||||
if !hasSourceWithKey(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount) {
|
||||
t.Errorf("placeholder source SPOTIFY/%s missing after seeding", PlaceholderSpotifyConnectAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdatePreset_BindsToPlaceholderByExactAccount is the regression that
|
||||
// motivated this whole change: when the speaker sends storePreset with
|
||||
// source=SPOTIFY sourceAccount=SpotifyConnectUserName, marge must bind the
|
||||
// preset to the placeholder, NOT the OAuth-brokered "gesellix" entry which
|
||||
// the old type-only fallback would have picked.
|
||||
func TestUpdatePreset_BindsToPlaceholderByExactAccount(t *testing.T) {
|
||||
ds, account, device := newMargeTestDatastore(t)
|
||||
|
||||
// OAuth-brokered Spotify (the speaker should NOT bind to this for a
|
||||
// Connect-initiated preset).
|
||||
if _, err := AddSource(ds, account, "gesellix", "15", "bs-deadbeef", "token_version_3", "Gesell IX"); err != nil {
|
||||
t.Fatalf("AddSource: %v", err)
|
||||
}
|
||||
|
||||
// Placeholder for Spotify Connect.
|
||||
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
|
||||
t.Fatalf("EnsurePlaceholderSources: %v", err)
|
||||
}
|
||||
|
||||
// Look up the IDs of the two SPOTIFY sources so we can match on them
|
||||
// (the rendered preset XML doesn't echo Username back, so checking the
|
||||
// embedded <source id=...> attribute is the reliable signal).
|
||||
sources, _ := ds.GetConfiguredSources(account, device)
|
||||
|
||||
placeholderID := findSourceID(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount)
|
||||
oauthID := findSourceID(sources, "SPOTIFY", "gesellix")
|
||||
|
||||
if placeholderID == "" || oauthID == "" || placeholderID == oauthID {
|
||||
t.Fatalf("test setup wrong: placeholderID=%q oauthID=%q", placeholderID, oauthID)
|
||||
}
|
||||
|
||||
// Speaker payload modeled on what rhino sent: source/sourceAccount carry
|
||||
// the firmware-internal slug, sourceid is absent (the speaker doesn't
|
||||
// know our SRC_ IDs at storePreset time for a Connect-managed session).
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<preset>
|
||||
<name>Sand Castle Tapes</name>
|
||||
<source>SPOTIFY</source>
|
||||
<sourceaccount>SpotifyConnectUserName</sourceaccount>
|
||||
<location>/playback/container/c3BvdGlmeTphbGJ1bTo3M1I2YXlQS2VWWEV2SnR4UTR4SDNv</location>
|
||||
<contentItemType>tracklisturl</contentItemType>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273</containerArt>
|
||||
</preset>`)
|
||||
|
||||
resp, err := UpdatePreset(ds, account, device, 1, body)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePreset failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(resp)
|
||||
|
||||
if !strings.Contains(xmlStr, `id="`+placeholderID+`"`) {
|
||||
t.Errorf("preset bound to wrong source: expected embedded <source id=%q>, got:\n%s", placeholderID, xmlStr)
|
||||
}
|
||||
|
||||
if strings.Contains(xmlStr, `id="`+oauthID+`"`) {
|
||||
t.Errorf("preset unexpectedly bound to OAuth source <id=%q>; should have hit placeholder.\nResponse: %s", oauthID, xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdatePreset_FallsBackToOAuthWhenNoPlaceholderMatch verifies the
|
||||
// existing type-only fallback still works for presets whose sourceAccount
|
||||
// matches the OAuth user (or is absent), so OAuth-driven presets keep
|
||||
// binding correctly.
|
||||
func TestUpdatePreset_FallsBackToOAuthWhenNoPlaceholderMatch(t *testing.T) {
|
||||
ds, account, device := newMargeTestDatastore(t)
|
||||
|
||||
if _, err := AddSource(ds, account, "gesellix", "15", "bs-deadbeef", "token_version_3", "Gesell IX"); err != nil {
|
||||
t.Fatalf("AddSource: %v", err)
|
||||
}
|
||||
|
||||
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
|
||||
t.Fatalf("EnsurePlaceholderSources: %v", err)
|
||||
}
|
||||
|
||||
sources, _ := ds.GetConfiguredSources(account, device)
|
||||
|
||||
placeholderID := findSourceID(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount)
|
||||
oauthID := findSourceID(sources, "SPOTIFY", "gesellix")
|
||||
|
||||
if placeholderID == "" || oauthID == "" || placeholderID == oauthID {
|
||||
t.Fatalf("test setup wrong: placeholderID=%q oauthID=%q", placeholderID, oauthID)
|
||||
}
|
||||
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<preset>
|
||||
<name>OAuth Album</name>
|
||||
<source>SPOTIFY</source>
|
||||
<sourceaccount>gesellix</sourceaccount>
|
||||
<location>/playback/container/c3BvdGlmeTphbGJ1bTpvYXV0aA==</location>
|
||||
<contentItemType>tracklisturl</contentItemType>
|
||||
</preset>`)
|
||||
|
||||
resp, err := UpdatePreset(ds, account, device, 2, body)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePreset failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(resp)
|
||||
|
||||
if !strings.Contains(xmlStr, `id="`+oauthID+`"`) {
|
||||
t.Errorf("preset did not bind to OAuth source <id=%q>; got:\n%s", oauthID, xmlStr)
|
||||
}
|
||||
|
||||
if strings.Contains(xmlStr, `id="`+placeholderID+`"`) {
|
||||
t.Errorf("preset unexpectedly bound to placeholder <id=%q>; OAuth preset collapsed wrong direction.\nResponse: %s", placeholderID, xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func findSourceID(sources []models.ConfiguredSource, sourceKeyType, sourceKeyAccount string) string {
|
||||
for i := range sources {
|
||||
if (sources[i].SourceKey.Type == sourceKeyType && sources[i].SourceKey.Account == sourceKeyAccount) ||
|
||||
(sources[i].SourceKeyType == sourceKeyType && sources[i].SourceKeyAccount == sourceKeyAccount) {
|
||||
return sources[i].ID
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func newMargeTestDatastore(t *testing.T) (*datastore.DataStore, string, string) {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "marge-placeholder-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
const account = "1234567"
|
||||
|
||||
const device = "DEVPLACE"
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
Name: "Test Speaker",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(account, device, info); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// marge.AddSource walks accounts/{account}/devices and needs the
|
||||
// per-device dir present to write configuredsources.xml.
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(account), device), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
return ds, account, device
|
||||
}
|
||||
|
||||
func sourceSummary(sources []models.ConfiguredSource) []string {
|
||||
out := make([]string, 0, len(sources))
|
||||
for i := range sources {
|
||||
out = append(out, sources[i].SourceKey.Type+"/"+sources[i].SourceKey.Account+"#"+sources[i].ID)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user