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:
Tobias Gesellchen
2026-04-29 20:30:06 +02:00
co-authored by Claude Sonnet 4.6
parent c8f280f9d4
commit 5fbad7d315
6 changed files with 256 additions and 66 deletions
+20 -24
View File
@@ -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())
+16 -1
View File
@@ -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
+118
View File
@@ -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
View File
@@ -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
}
+2
View File
@@ -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);
});
%}