mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat: add Amazon Music source classification and fix ETag caching
- Recognize Amazon Music in learned sources (classifyAsAmazon) and AddSource dispatch, using CredentialTypeToken (cs1) not cs3 - Exclude Amazon from default sources: an empty-credential Amazon entry triggers the speaker's AmazonController to fail JSON parsing with MUSIC_SERVICE_ACCOUNT_LOGIN_FAILED; Amazon must only appear once a real OAuth token is present - Merge missing defaults into stored sources at request time so devices with older Sources.xml still receive all current defaults - Fix source providers ETag: was time.Now().UnixMilli() (always new), now a content hash so If-None-Match/304 works correctly - Include default sources fingerprint in GetETagForAccount so adding a new default invalidates cached /full responses on speakers - Refactor createLearnedSource into classifyLearnedSource + classifyAsX helpers to reduce cyclomatic complexity below linter limit - Add regression test for two-device scenario matching production setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c8f280f9d4
commit
5fbad7d315
@@ -1647,34 +1647,32 @@ func (ds *DataStore) GetETagForRecents(account, device string) int64 {
|
||||
return info.ModTime().UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
|
||||
// contentHashForFiles returns a SHA-256 hex digest over the concatenated contents of the given file paths.
|
||||
func contentHashForFiles(paths ...string) string {
|
||||
h := sha256.New()
|
||||
|
||||
for _, p := range paths {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = io.Copy(h, f)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// GetETagForAccount returns a content hash (SHA-256) over presets, sources, and recents for the account and device.
|
||||
// If device is empty, it hashes across all devices in the account.
|
||||
// The default sources fingerprint is always included so that newly added defaults (e.g. Amazon)
|
||||
// invalidate cached responses even when the stored Sources.xml has not changed.
|
||||
func (ds *DataStore) GetETagForAccount(account, device string) string {
|
||||
h := sha256.New()
|
||||
|
||||
// Include the default sources fingerprint so mergeDefaultSources changes are visible.
|
||||
defaults := ds.GetDefaultSources()
|
||||
for i := range defaults {
|
||||
_, _ = io.WriteString(h, defaults[i].ID+defaults[i].SourceKeyType+defaults[i].DisplayName)
|
||||
}
|
||||
|
||||
if device != "" {
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
|
||||
f, err := os.Open(filepath.Join(deviceDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return contentHashForFiles(
|
||||
filepath.Join(deviceDir, constants.PresetsFile),
|
||||
filepath.Join(deviceDir, constants.SourcesFile),
|
||||
filepath.Join(deviceDir, constants.RecentsFile),
|
||||
)
|
||||
_, _ = io.Copy(h, f)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
@@ -1684,8 +1682,6 @@ func (ds *DataStore) GetETagForAccount(account, device string) string {
|
||||
// If-None-Match header and return 304 on the first request.
|
||||
entries, _ := os.ReadDir(devicesDir)
|
||||
|
||||
h := sha256.New()
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
deviceDir := ds.AccountDeviceDir(account, entry.Name())
|
||||
|
||||
@@ -2,7 +2,9 @@ package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
@@ -17,6 +19,19 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// sourceProvidersETag returns a stable ETag for the source providers list,
|
||||
// derived from the serialized content so it only changes when the list changes.
|
||||
func sourceProvidersETag() string {
|
||||
data, err := marge.SourceProvidersToXML()
|
||||
if err != nil {
|
||||
return "source-providers-v1"
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
|
||||
return fmt.Sprintf("%x", sum[:8])
|
||||
}
|
||||
|
||||
// HandleMargeCreateAccount creates a new account from Stockholm (XML).
|
||||
func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
@@ -128,7 +143,7 @@ func (s *Server) HandleMargeLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleMargeSourceProviders returns the Marge source providers.
|
||||
func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) {
|
||||
etag := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
etag := sourceProvidersETag()
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
|
||||
@@ -278,6 +278,124 @@ func TestMargeAccountFull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMargeAccountFullExcludesEmptyAmazonSource is a regression test for the two-device scenario
|
||||
// observed in production: device A81B6A536A98 (alphabetically last, used as lastDeviceID)
|
||||
// has Sources.xml with 6 sources but no Amazon. The first device has Amazon with empty
|
||||
// credentials (written before OAuth was implemented). Amazon must NOT appear in /full —
|
||||
// an empty-credential Amazon causes the speaker's AmazonController to fail JSON parsing.
|
||||
func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-amazon-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
account := "3230304"
|
||||
|
||||
// First device (alphabetically): has Amazon in Sources.xml
|
||||
firstDeviceID := "08DF1F0BA325"
|
||||
firstDir := filepath.Join(tempDir, "accounts", account, "devices", firstDeviceID)
|
||||
if err := os.MkdirAll(firstDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create first device dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(firstDir, "DeviceInfo.xml"), []byte(`
|
||||
<info deviceID="08DF1F0BA325">
|
||||
<name>A Sound Machine</name>
|
||||
<type>SoundTouch 20 scm</type>
|
||||
</info>
|
||||
`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write first device DeviceInfo.xml: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(firstDir, "Sources.xml"), []byte(`<sources>
|
||||
<source id="10006" type="Audio" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
|
||||
<sourceKey type="AMAZON" account=""/>
|
||||
</source>
|
||||
</sources>`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write first device Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
// Second device (alphabetically last = lastDeviceID): 6 sources but NO Amazon.
|
||||
// This reproduces the real full.xml returned by the live service.
|
||||
lastDeviceID := "A81B6A536A98"
|
||||
lastDir := filepath.Join(tempDir, "accounts", account, "devices", lastDeviceID)
|
||||
if err := os.MkdirAll(lastDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create last device dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(lastDir, "DeviceInfo.xml"), []byte(`
|
||||
<info deviceID="A81B6A536A98">
|
||||
<name>Another Speaker</name>
|
||||
<type>SoundTouch 300</type>
|
||||
</info>
|
||||
`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write last device DeviceInfo.xml: %v", err)
|
||||
}
|
||||
// Sources.xml mirrors the real persisted file: AUX, INTERNET_RADIO, LOCAL_INTERNET_RADIO,
|
||||
// TUNEIN, RADIO_BROWSER, Spotify — no Amazon.
|
||||
lastSourcesXML := `<sources>
|
||||
<source id="10001" type="Audio" createdOn="2015-03-11T19:12:38.000+00:00" updatedOn="2015-03-11T19:12:38.000+00:00" displayName="AUX IN" secret="" secretType="token" sourceproviderid="9">
|
||||
<sourceKey type="AUX" account="AUX"/>
|
||||
</source>
|
||||
<source id="10002" type="Audio" createdOn="2015-03-11T19:12:38.000+00:00" updatedOn="2015-03-11T19:12:38.000+00:00" displayName="" secret="" secretType="token" sourceproviderid="2">
|
||||
<sourceKey type="INTERNET_RADIO" account=""/>
|
||||
</source>
|
||||
<source id="10003" type="Audio" createdOn="2019-01-24T08:18:37.000+00:00" updatedOn="2019-02-03T18:35:45.000+00:00" displayName="" secret="eyJzZXJpYWwiOiJsb2NhbC1pbnRlcm5ldC1yYWRpbyJ9" secretType="token" sourceproviderid="11">
|
||||
<sourceKey type="LOCAL_INTERNET_RADIO" account=""/>
|
||||
</source>
|
||||
<source id="10004" type="Audio" createdOn="2017-07-20T16:43:48.000+00:00" updatedOn="2017-07-20T16:43:48.000+00:00" displayName="" secret="eyJzZXJpYWwiOiJ0dW5laW4ifQ==" secretType="token" sourceproviderid="25">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
<source id="10005" type="Audio" createdOn="2026-02-16T01:01:01.000+00:00" updatedOn="2026-02-16T01:01:01.000+00:00" displayName="" secret="" secretType="token" sourceproviderid="39">
|
||||
<sourceKey type="RADIO_BROWSER" account=""/>
|
||||
</source>
|
||||
<source id="SRC_1776706409" type="Audio" createdOn="2026-04-20T17:33:29.483+00:00" updatedOn="2026-04-20T17:33:29.483+00:00" displayName="" secret="bs-6c58d056c2d35df85f57ad2334b0cdc4" secretType="token_version_3" sourceproviderid="15">
|
||||
<sourceKey type="SPOTIFY" account="gesellix"/>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(lastDir, "Sources.xml"), []byte(lastSourcesXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to write last device Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/full")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected 200 OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Amazon with empty credentials must not appear — the speaker's AmazonController
|
||||
// fails to parse an empty secret and returns MUSIC_SERVICE_ACCOUNT_LOGIN_FAILED.
|
||||
if strings.Contains(bodyStr, "<sourceproviderid>20</sourceproviderid>") {
|
||||
t.Errorf("/full response must not include an empty-credential Amazon source; body:\n%s", bodyStr)
|
||||
}
|
||||
|
||||
// The 6 sources from lastDeviceID's stored Sources.xml must all be present.
|
||||
// Checked by sourceproviderid since <name> may hold a display name rather than the type string.
|
||||
for _, wantProviderID := range []string{
|
||||
"<sourceproviderid>9</sourceproviderid>", // AUX
|
||||
"<sourceproviderid>2</sourceproviderid>", // INTERNET_RADIO
|
||||
"<sourceproviderid>11</sourceproviderid>", // LOCAL_INTERNET_RADIO
|
||||
"<sourceproviderid>25</sourceproviderid>", // TUNEIN
|
||||
"<sourceproviderid>39</sourceproviderid>", // RADIO_BROWSER
|
||||
"<sourceproviderid>15</sourceproviderid>", // Spotify
|
||||
} {
|
||||
if !strings.Contains(bodyStr, wantProviderID) {
|
||||
t.Errorf("/full response is missing source with %s; body:\n%s", wantProviderID, bodyStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAccountSources(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
|
||||
+97
-40
@@ -982,6 +982,9 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
|
||||
|
||||
if lastDeviceID != "" {
|
||||
sources, err = ds.GetConfiguredSources(account, lastDeviceID)
|
||||
if err == nil {
|
||||
sources = mergeDefaultSources(sources, ds.GetDefaultSources())
|
||||
}
|
||||
} else {
|
||||
sources = ds.GetDefaultSources()
|
||||
}
|
||||
@@ -1001,6 +1004,27 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
|
||||
return fullSources
|
||||
}
|
||||
|
||||
// mergeDefaultSources adds any default sources missing from stored that are not already present
|
||||
// (matched by SourceKeyType). It does not persist — initializeDefaultSources handles persistence at startup.
|
||||
func mergeDefaultSources(stored, defaults []models.ConfiguredSource) []models.ConfiguredSource {
|
||||
for i := range defaults {
|
||||
found := false
|
||||
|
||||
for j := range stored {
|
||||
if stored[j].SourceKeyType == defaults[i].SourceKeyType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
stored = append(stored, defaults[i])
|
||||
}
|
||||
}
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
// AccountSourcesToXML generates the account sources XML.
|
||||
func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
@@ -1163,7 +1187,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
}
|
||||
|
||||
if matchingSrc == nil {
|
||||
if newPresetElem.SourceID == constants.ProviderInternetRadio || newPresetElem.SourceID == constants.ProviderTunein || newPresetElem.SourceID == constants.ProviderSpotify {
|
||||
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 {
|
||||
@@ -1420,12 +1444,12 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
|
||||
|
||||
func createLearnedSource(sourceID, location, sourceName, credentialValue, sourceProviderID, createdOn, updatedOn string) *models.ConfiguredSource {
|
||||
displayName := sourceName
|
||||
// For TuneIn, we often see empty DisplayName/SourceName in recent items
|
||||
// if it's already a known source or if it's a generic TuneIn request.
|
||||
if displayName == "" && sourceID != "" {
|
||||
// Try to deduce from sourceID if it looks like a known service
|
||||
if sourceID == constants.ProviderSpotify {
|
||||
switch sourceID {
|
||||
case constants.ProviderSpotify:
|
||||
displayName = constants.ProviderSpotify
|
||||
case constants.ProviderAmazon:
|
||||
displayName = "Amazon Music"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1439,48 +1463,77 @@ func createLearnedSource(sourceID, location, sourceName, credentialValue, source
|
||||
UpdatedOn: updatedOn,
|
||||
}
|
||||
|
||||
classifyLearnedSource(src, sourceID, location, sourceProviderID)
|
||||
|
||||
return src
|
||||
}
|
||||
|
||||
func classifyLearnedSource(src *models.ConfiguredSource, sourceID, location, sourceProviderID string) {
|
||||
switch {
|
||||
case sourceProviderID == strconv.Itoa(constants.TuneinProviderID) || sourceID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/"):
|
||||
src.SourceKey.Type = constants.ProviderTunein
|
||||
src.SourceKeyType = constants.ProviderTunein
|
||||
src.Type = "Audio"
|
||||
src.SecretType = constants.CredentialTypeToken
|
||||
|
||||
if src.Secret == "" {
|
||||
src.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
|
||||
}
|
||||
|
||||
if src.DisplayName == "Other" || src.DisplayName == constants.ProviderTunein || src.DisplayName == "" {
|
||||
src.DisplayName = constants.ProviderTunein
|
||||
}
|
||||
classifyAsTuneIn(src)
|
||||
case sourceID == constants.ProviderLocalInternetRadio:
|
||||
src.SourceKey.Type = constants.ProviderLocalInternetRadio
|
||||
src.SourceKeyType = constants.ProviderLocalInternetRadio
|
||||
src.Type = "Audio"
|
||||
src.SecretType = constants.CredentialTypeToken
|
||||
|
||||
if src.Secret == "" {
|
||||
src.Secret = datastore.GenerateSerialSecret("local-internet-radio")
|
||||
}
|
||||
|
||||
if src.DisplayName == "Other" || src.DisplayName == "Local Internet Radio" || src.DisplayName == "" {
|
||||
src.DisplayName = "Local Internet Radio"
|
||||
}
|
||||
classifyAsLocalInternetRadio(src)
|
||||
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == constants.ProviderSpotify:
|
||||
src.SourceKey.Type = constants.ProviderSpotify
|
||||
src.SourceKeyType = constants.ProviderSpotify
|
||||
src.Type = "Audio"
|
||||
src.SecretType = constants.CredentialTypeTokenV3
|
||||
|
||||
if src.DisplayName == "Other" {
|
||||
src.DisplayName = constants.ProviderSpotify
|
||||
}
|
||||
classifyAsSpotify(src)
|
||||
case strings.Contains(location, "amazon") || sourceID == constants.ProviderAmazon || sourceProviderID == strconv.Itoa(constants.AmazonProviderID):
|
||||
classifyAsAmazon(src)
|
||||
default:
|
||||
src.SourceKey.Type = "INVALID"
|
||||
src.SourceKeyType = "INVALID"
|
||||
}
|
||||
}
|
||||
|
||||
return src
|
||||
func classifyAsTuneIn(src *models.ConfiguredSource) {
|
||||
src.SourceKey.Type = constants.ProviderTunein
|
||||
src.SourceKeyType = constants.ProviderTunein
|
||||
src.Type = "Audio"
|
||||
src.SecretType = constants.CredentialTypeToken
|
||||
|
||||
if src.Secret == "" {
|
||||
src.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
|
||||
}
|
||||
|
||||
if src.DisplayName == "Other" || src.DisplayName == constants.ProviderTunein || src.DisplayName == "" {
|
||||
src.DisplayName = constants.ProviderTunein
|
||||
}
|
||||
}
|
||||
|
||||
func classifyAsLocalInternetRadio(src *models.ConfiguredSource) {
|
||||
src.SourceKey.Type = constants.ProviderLocalInternetRadio
|
||||
src.SourceKeyType = constants.ProviderLocalInternetRadio
|
||||
src.Type = "Audio"
|
||||
src.SecretType = constants.CredentialTypeToken
|
||||
|
||||
if src.Secret == "" {
|
||||
src.Secret = datastore.GenerateSerialSecret("local-internet-radio")
|
||||
}
|
||||
|
||||
if src.DisplayName == "Other" || src.DisplayName == "Local Internet Radio" || src.DisplayName == "" {
|
||||
src.DisplayName = "Local Internet Radio"
|
||||
}
|
||||
}
|
||||
|
||||
func classifyAsSpotify(src *models.ConfiguredSource) {
|
||||
src.SourceKey.Type = constants.ProviderSpotify
|
||||
src.SourceKeyType = constants.ProviderSpotify
|
||||
src.Type = "Audio"
|
||||
src.SecretType = constants.CredentialTypeTokenV3
|
||||
|
||||
if src.DisplayName == "Other" {
|
||||
src.DisplayName = constants.ProviderSpotify
|
||||
}
|
||||
}
|
||||
|
||||
func classifyAsAmazon(src *models.ConfiguredSource) {
|
||||
src.SourceKey.Type = constants.ProviderAmazon
|
||||
src.SourceKeyType = constants.ProviderAmazon
|
||||
src.Type = "Audio"
|
||||
src.SecretType = constants.CredentialTypeToken
|
||||
|
||||
if src.DisplayName == "" || src.DisplayName == "Other" {
|
||||
src.DisplayName = "Amazon Music"
|
||||
}
|
||||
}
|
||||
|
||||
func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceName, sourceProviderID, createdOn, updatedOn string) bool {
|
||||
@@ -1828,9 +1881,13 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
}
|
||||
|
||||
newSrc.SourceKey.Account = username
|
||||
if providerID == strconv.Itoa(constants.SpotifyProviderID) {
|
||||
|
||||
switch providerID {
|
||||
case strconv.Itoa(constants.SpotifyProviderID):
|
||||
newSrc.SourceKey.Type = constants.ProviderSpotify
|
||||
} else {
|
||||
case strconv.Itoa(constants.AmazonProviderID):
|
||||
newSrc.SourceKey.Type = constants.ProviderAmazon
|
||||
default:
|
||||
newSrc.SourceKey.Type = providerID
|
||||
}
|
||||
|
||||
|
||||
@@ -681,6 +681,8 @@ func TestDefaultSources(t *testing.T) {
|
||||
if s.SourceKey.Account != "AUX" {
|
||||
t.Errorf("Expected AUX account 'AUX', got %s", s.SourceKey.Account)
|
||||
}
|
||||
case "AMAZON":
|
||||
t.Errorf("AMAZON must not appear in defaults — it requires real OAuth credentials")
|
||||
}
|
||||
|
||||
if s.Status != "READY" {
|
||||
|
||||
@@ -45,6 +45,8 @@ Authorization: Bearer {{token}}
|
||||
|
||||
var sources = account.getElementsByTagName("sources")[0];
|
||||
client.assert(sources !== null, "Missing 'sources' element");
|
||||
client.assert(sources.getElementsByTagName("source").length > 0, "No 'source' elements found in account sources");
|
||||
var sourceCount = sources.getElementsByTagName("source").length;
|
||||
client.assert(sourceCount > 0, "No 'source' elements found in account sources");
|
||||
client.assert(sourceCount === 6, "Expected 6 sources (AUX, INTERNET_RADIO, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify) but got " + sourceCount);
|
||||
});
|
||||
%}
|
||||
|
||||
Reference in New Issue
Block a user