feat(marge): log GH-343-shaped source mismatch on UpdatePreset

The speaker's preset PUT carries only <sourceid> — 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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-20 22:41:32 +02:00
co-authored by Claude Sonnet 4.6
parent f0a63f19f4
commit 97238eb07a
2 changed files with 87 additions and 0 deletions
@@ -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)
}
})
}
}
+41
View File
@@ -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 <sourceid> — 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{