mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
f0a63f19f4
commit
97238eb07a
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user