From 97238eb07a6689ed06b6a80ddddfd5e8d0890c03 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 20 May 2026 21:22:15 +0200 Subject: [PATCH] feat(marge): log GH-343-shaped source mismatch on UpdatePreset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speaker's preset PUT carries only — no symbolic source name — so we can't strict-match at write time the way we do on /full emission. Adds a diagnostic-only inference from the preset's location URL pattern (/v1/playback/station/sNNN -> TUNEIN, /playback/container/ -> SPOTIFY, /custom/v1/playback/ -> LOCAL_INTERNET_RADIO) and logs when the inference disagrees with the bound source's SourceKeyType. This is visibility, not enforcement: the binding still proceeds as the speaker requested (per "speaker wins"). The log gives the operator a concrete pointer — "the URL looks like TUNEIN but I bound to RADIOPLAYER, your Sources.xml may be stale, try setup.syncSources" — instead of leaving them to discover the drift via the consistency check days later. URL inference is deliberately fuzzy and one-way: it only triggers a log when confident, returns "" otherwise, and never feeds the binding decision. That keeps it from re-introducing the guesswork the user pushed back on for the actual GH-343 fix. Co-Authored-By: Claude Sonnet 4.6 --- .../marge/infer_source_keytype_test.go | 46 +++++++++++++++++++ pkg/service/marge/marge.go | 41 +++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 pkg/service/marge/infer_source_keytype_test.go diff --git a/pkg/service/marge/infer_source_keytype_test.go b/pkg/service/marge/infer_source_keytype_test.go new file mode 100644 index 0000000..b667a15 --- /dev/null +++ b/pkg/service/marge/infer_source_keytype_test.go @@ -0,0 +1,46 @@ +package marge + +import ( + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/constants" +) + +// TestInferSourceKeyTypeFromLocation pins the diagnostic URL-pattern +// inference. This function is intentionally fuzzy and only feeds a log +// line — it must never become load-bearing for binding decisions, so +// the unknown-pattern case returns "" rather than guessing. +func TestInferSourceKeyTypeFromLocation(t *testing.T) { + cases := []struct { + location string + want string + }{ + // TuneIn station ID (sNNN) and episode ID (tNNN) patterns. + {"/v1/playback/station/s166521", constants.ProviderTunein}, + {"/v1/playback/station/s6634", constants.ProviderTunein}, + {"/v1/playback/episodes/t544562099?encoded_name=...", constants.ProviderTunein}, + + // Spotify URI containers and tracks. + {"/playback/container/c3BvdGlmeTphbGJ1bToxRjh5MmJnOVY5blJveTh6dXhvM0p0", constants.ProviderSpotify}, + {"/playback/track/c3BvdGlmeTp0cmFjazoxbWd1OEhmSlAxNHU1Y3p3UkpsR1Zw", constants.ProviderSpotify}, + + // LocalInternetRadio via the BMX /custom/v1/playback/ proxy. + {"http://192.168.178.68/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==", constants.ProviderLocalInternetRadio}, + {"http://soundtouch.fritz.box/custom/v1/playback/abc", constants.ProviderLocalInternetRadio}, + + // Empty / unknown — must return "" so the diagnostic stays + // silent rather than printing a guess. + {"", ""}, + {"http://example.invalid/some/random/path.mp3", ""}, + {"19059", ""}, + {"some-random-string", ""}, + } + + for _, tc := range cases { + t.Run(tc.location, func(t *testing.T) { + if got := inferSourceKeyTypeFromLocation(tc.location); got != tc.want { + t.Errorf("inferSourceKeyTypeFromLocation(%q) = %q, want %q", tc.location, got, tc.want) + } + }) + } +} diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 8cab3e9..b75a118 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -793,6 +793,32 @@ func canonicalProviderIDByID(id string) string { return "" } +// inferSourceKeyTypeFromLocation makes a best-effort guess at the source +// type from a preset/recent's location URL. Used *only* for diagnostic +// logs — never to decide which source to bind to. URL-pattern matching +// is fragile (a location format can be reused across providers; tests +// can't enumerate every shape Bose's firmware ever emits), so the +// inference is one-way visibility: when it disagrees with the actually- +// bound source, surface a log line so an operator can investigate. +// When it can't make a confident inference, returns "" and the caller +// silently accepts the bound source as-is. +func inferSourceKeyTypeFromLocation(location string) string { + switch { + case location == "": + return "" + case strings.HasPrefix(location, "/v1/playback/station/s"), + strings.HasPrefix(location, "/v1/playback/episodes/t"): + return constants.ProviderTunein + case strings.HasPrefix(location, "/playback/container/"), + strings.HasPrefix(location, "/playback/track/"): + return constants.ProviderSpotify + case strings.Contains(location, "/custom/v1/playback/"): + return constants.ProviderLocalInternetRadio + } + + return "" +} + // canonicalSourceKeyTypeByProviderID is the inverse of the canonical // sourceproviderid mapping used in /full responses (25 = TuneIn, 11 = // LocalInternetRadio, …). Used by syncPresets/syncRecents to project the @@ -1473,6 +1499,21 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber return nil, fmt.Errorf("invalid account/source") } + // Visibility-only: the speaker's PUT carries only — no + // symbolic source name — so we can't strict-match at write time. + // But if the preset's location URL has a recognisable pattern + // (TuneIn /v1/playback/station/, Spotify /playback/container/, + // LocalInternetRadio /custom/v1/playback/, …) and that pattern + // disagrees with the matched source's SourceKeyType, the operator + // is probably looking at the GH-343 footprint: a stale Sources.xml + // entry shadowing the speaker's actual intent. Log so the operator + // can re-trigger setup.syncSources without us guessing on their + // behalf — the binding still happens as the speaker requested. + if inferred := inferSourceKeyTypeFromLocation(newPresetElem.Location); inferred != "" && matchingSrc.SourceKeyType != "" && inferred != matchingSrc.SourceKeyType { + log.Printf("[Marge] UpdatePreset(preset=%d): location URL %q suggests %s but matched source id=%s has SourceKeyType=%s — proceeding as the speaker requested, but the Sources.xml entry may be stale; consider re-running setup.syncSources from the speaker to refresh", + presetNumber, newPresetElem.Location, inferred, matchingSrc.ID, matchingSrc.SourceKeyType) + } + nowStr := strconv.FormatInt(time.Now().Unix(), 10) presetObj := models.ServicePreset{ ServiceContentItem: models.ServiceContentItem{