diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index aeb400a..cf7e1d9 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -759,9 +759,37 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc fullSource.Username = s.SourceKeyAccount } + // SourceProviderID is a required protobuf field inside recents/preset + // source blocks. A persisted source that lost its SourceKey.Type (e.g. + // poisoned by an older "INVALID" classification) lands here with an + // empty value, so fall back to the canonical default whose ID matches. + if fullSource.SourceProviderID == "" && s.ID != "" { + if def := canonicalProviderIDByID(s.ID); def != "" { + fullSource.SourceProviderID = def + } + } + return fullSource } +// canonicalProviderIDByID returns the canonical SourceProviderID for one of +// the well-known built-in source IDs (10001..10005), or "" if the ID isn't +// recognised. +func canonicalProviderIDByID(id string) string { + switch id { + case "10002": + return strconv.Itoa(constants.InternetRadioProviderID) + case "10003": + return strconv.Itoa(constants.LocalInternetRadioProviderID) + case "10004": + return strconv.Itoa(constants.TuneinProviderID) + case "10005": + return strconv.Itoa(constants.RadioBrowserProviderID) + } + + return "" +} + func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.ConfiguredSource) []models.FullResponsePreset { var fullPresets []models.FullResponsePreset @@ -1135,10 +1163,13 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { return nil, err } - // Parity: use self-closing tags for empty components and sourceSettings + // Parity: use self-closing tags for empty components and sourceSettings. + // NOTE: do NOT strip empty elements here — the speaker + // decodes /full into a protobuf message where recents>recent>source> + // sourceproviderid is a *required* field, so removing even an empty element + // trips "missing required field" and aborts the whole account sync. data = bytes.ReplaceAll(data, []byte(""), []byte("")) data = bytes.ReplaceAll(data, []byte(""), []byte("")) - data = bytes.ReplaceAll(data, []byte(""), []byte("")) return append([]byte(constants.XMLHeader), data...), nil } @@ -1482,16 +1513,18 @@ func classifyLearnedSource(src *models.ConfiguredSource, sourceID, location, sou switch { case sourceProviderID == strconv.Itoa(constants.TuneinProviderID) || sourceID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/"): classifyAsTuneIn(src) - case sourceID == constants.ProviderLocalInternetRadio: + case sourceProviderID == strconv.Itoa(constants.LocalInternetRadioProviderID) || sourceID == constants.ProviderLocalInternetRadio || strings.Contains(location, "/custom/v1/playback/"): classifyAsLocalInternetRadio(src) case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == 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" } + // If we can't classify, leave SourceKey.Type empty so the canonical-by-ID + // fallback in mapToFullResponseSource and the read-side applyCanonicalDefaults + // still have a chance to repair it. Writing a literal "INVALID" used to lock + // the source out of every repair path, producing a block with no + // and breaking the speaker's protobuf required-field check. } func classifyAsTuneIn(src *models.ConfiguredSource) { diff --git a/pkg/service/marge/recents_sourceproviderid_regression_test.go b/pkg/service/marge/recents_sourceproviderid_regression_test.go new file mode 100644 index 0000000..6be7352 --- /dev/null +++ b/pkg/service/marge/recents_sourceproviderid_regression_test.go @@ -0,0 +1,193 @@ +package marge + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// TestAccountFullToXML_RecentWithPoisonedSourceProviderID is a regression test +// for the production failure where the speaker's BoseApp rejected the +// /streaming/account/.../full response with: +// +// protobuf::FatalException - CHECK failed: IsInitialized(): +// Message of type "MargePB.account" is missing required fields: +// devices.device[1].recents.recent[0].source.sourceproviderid +// +// Trigger sequence reproduced here: +// +// 1. The device POSTs a "laut.fm" recent (location "/custom/v1/playback/...") +// against an account that has no Sources.xml yet. +// 2. classifyLearnedSource fails to recognise /custom/v1/playback/ and the +// numeric source id "10003", and historically wrote sourceKey type="INVALID" +// with an empty sourceproviderid. +// 3. The persisted Sources.xml then re-appears in /full with an empty +// element inside recents>recent>source, which the +// post-marshal cleanup stripped entirely — making the speaker's protobuf +// decode fail on a required field. +// +// The fix combines three things, all exercised below: +// +// - classifyLearnedSource recognises LocalInternetRadio via the +// /custom/v1/playback/ URL pattern and via sourceProviderID == "11". +// - mapToFullResponseSource falls back to the canonical SourceProviderID +// keyed by source ID, so already-poisoned data on disk still renders a +// non-empty providerid. +// - AccountFullToXML no longer strips empty elements +// inside recents/preset source blocks. +func TestAccountFullToXML_RecentWithPoisonedSourceProviderID(t *testing.T) { + tempDir, err := os.MkdirTemp("", "marge-recent-provid-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + account := "1234567" + device := "ABCDEF012345" + + deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("Failed to create device dir: %v", err) + } + + if err := os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(` + + Kitchen + SoundTouch + 10 sm2 +`), 0644); err != nil { + t.Fatalf("Failed to write DeviceInfo.xml: %v", err) + } + + // Sources.xml reproduces the poisoned entry observed in the user's + // backup (May 11): id="10003" with sourceKey type="INVALID" and no + // sourceproviderid attribute. Older repair paths (applyCanonicalDefaults, + // ensureSourceProviderID) all key off sourceKey.type, so the entry stays + // broken at load time. + sourcesXML := ` + + + + + +` + if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil { + t.Fatalf("Failed to write Sources.xml: %v", err) + } + + // Recents.xml references the poisoned source via 10003. + // The location is a laut.fm stream proxied through /custom/v1/playback/ — + // exactly the URL pattern the old classifier failed to recognise. + recentsXML := ` + + + + Smooth Jazz Instrumental 24/7 + + 2026-05-05T20:56:49.305+00:00 + 2026-05-05T20:56:49.305+00:00 + 10003 + +` + if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil { + t.Fatalf("Failed to write Recents.xml: %v", err) + } + + ds := datastore.NewDataStore(tempDir) + + fullXML, err := AccountFullToXML(ds, account) + if err != nil { + t.Fatalf("AccountFullToXML failed: %v", err) + } + + body := string(fullXML) + + // Locate the recents block and assert every inside it carries a + // non-empty . Without the fix, the post-marshal + // strip-empty step deletes the empty element and the speaker rejects + // the message with "missing required field". + recentsRE := regexp.MustCompile(`(?s)(.*?)`) + matches := recentsRE.FindAllStringSubmatch(body, -1) + if len(matches) == 0 { + t.Fatalf("Expected at least one block; body:\n%s", body) + } + + sourceInRecentRE := regexp.MustCompile(`(?s)]*)?>(.*?)`) + + for _, recentsBlock := range matches { + for _, src := range sourceInRecentRE.FindAllStringSubmatch(recentsBlock[1], -1) { + inner := src[1] + if !strings.Contains(inner, "") { + t.Errorf(" inside has no element; block:\n%s", src[0]) + continue + } + + if strings.Contains(inner, "") { + t.Errorf(" inside has empty ; block:\n%s", src[0]) + } + } + } + + // And spot-check the canonical fallback fired for the laut.fm recent. + if !strings.Contains(body, "11") { + t.Errorf("Expected 11 (LocalInternetRadio) in /full; body:\n%s", body) + } +} + +// TestClassifyLearnedSource_LocalInternetRadioCustomPlayback locks in the +// classifier behaviour: a recent POSTed with a /custom/v1/playback/ URL must +// classify as LocalInternetRadio. Previously this fell into the "INVALID" +// default and poisoned Sources.xml — see the regression test above. +func TestClassifyLearnedSource_LocalInternetRadioCustomPlayback(t *testing.T) { + cases := []struct { + name string + sourceID string + location string + sourceProviderID string + }{ + { + name: "laut.fm /custom/v1/playback URL", + sourceID: "10003", + location: "http://192.168.123.123/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==", + }, + { + name: "sourceProviderID==11 alone", + sourceID: "999999", + location: "http://example.invalid/whatever", + sourceProviderID: "11", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := createLearnedSource(tc.sourceID, tc.location, "", "", tc.sourceProviderID, "", "") + + if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" { + t.Errorf("classifier wrote INVALID for %s; src=%+v", tc.name, src) + } + + if src.SourceKey.Type != "LOCAL_INTERNET_RADIO" { + t.Errorf("expected SourceKey.Type=LOCAL_INTERNET_RADIO, got %q", src.SourceKey.Type) + } + }) + } +} + +// TestClassifyLearnedSource_UnknownLeavesKeyEmpty verifies the new default +// branch leaves SourceKey.Type empty instead of writing the literal "INVALID" +// sentinel that locks the source out of every downstream repair path. +func TestClassifyLearnedSource_UnknownLeavesKeyEmpty(t *testing.T) { + src := createLearnedSource("SOMETHING_UNKNOWN", "http://example.invalid/nothing", "", "", "", "", "") + + if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" { + t.Errorf("classifier still writes INVALID sentinel; src=%+v", src) + } + + if src.SourceKey.Type != "" { + t.Errorf("expected SourceKey.Type empty for an unrecognised source, got %q", src.SourceKey.Type) + } +}