From 5fbad7d315018d24e05db824581606c0ddc8e2ff Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 28 Apr 2026 21:25:26 +0200 Subject: [PATCH] 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 --- pkg/service/datastore/datastore.go | 44 +++--- pkg/service/handlers/handlers_marge.go | 17 ++- pkg/service/handlers/handlers_marge_test.go | 118 +++++++++++++++ pkg/service/marge/marge.go | 137 +++++++++++++----- pkg/service/marge/marge_test.go | 2 + .../http-client/get_full_account.http | 4 +- 6 files changed, 256 insertions(+), 66 deletions(-) diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 706afaf..9ee4032 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -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()) diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index e89f19f..e5f4a94 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -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 diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 3ed6b20..8e0f0bc 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -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(` + + A Sound Machine + SoundTouch 20 scm + + `), 0644); err != nil { + t.Fatalf("Failed to write first device DeviceInfo.xml: %v", err) + } + if err := os.WriteFile(filepath.Join(firstDir, "Sources.xml"), []byte(` + + + + `), 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(` + + Another Speaker + SoundTouch 300 + + `), 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 := ` + + + + + + + + + + + + + + + + + + + ` + 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, "20") { + 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 may hold a display name rather than the type string. + for _, wantProviderID := range []string{ + "9", // AUX + "2", // INTERNET_RADIO + "11", // LOCAL_INTERNET_RADIO + "25", // TUNEIN + "39", // RADIO_BROWSER + "15", // 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 { diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 0533393..df21fbd 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -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 } diff --git a/pkg/service/marge/marge_test.go b/pkg/service/marge/marge_test.go index 2c4f76c..661dd1f 100644 --- a/pkg/service/marge/marge_test.go +++ b/pkg/service/marge/marge_test.go @@ -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" { diff --git a/tests/integration/http-client/get_full_account.http b/tests/integration/http-client/get_full_account.http index bae1273..c228f4e 100644 --- a/tests/integration/http-client/get_full_account.http +++ b/tests/integration/http-client/get_full_account.http @@ -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); }); %}