From e54738d367ec4bdedf44ee709b5748f027cb22dd Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Thu, 28 May 2026 23:42:32 +0200 Subject: [PATCH] fix(preset): wrap LOCAL_INTERNET_RADIO stream URL in Orion location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speaker's BMX module calls GET on the stored preset location and expects a BmxPlaybackResponse JSON from the AfterTouch Orion endpoint. Storing a bare stream URL (e.g. http://davefmradio.no-ip.org:8000/stream) causes BMX to receive raw ICY audio, which it cannot parse; playback silently stays on the previous source and no error is surfaced. Add --service-url / SOUNDTOUCH_SERVICE_URL to `preset set`. When set alongside --source LOCAL_INTERNET_RADIO and a raw HTTP(S) location, the CLI wraps the stream URL in the Orion station endpoint: /core02/svc-bmx-adapter-orion/prod/orion/station ?data= Without --service-url the command still works but prints a clear warning explaining why the saved preset is likely to not play, rather than saving a silently broken location. Co-Authored-By: Claude Sonnet 4.6 --- cmd/soundtouch-cli/cmd_preset.go | 53 ++++++++++++++++++++++++++++++++ cmd/soundtouch-cli/main.go | 5 +++ 2 files changed, 58 insertions(+) diff --git a/cmd/soundtouch-cli/cmd_preset.go b/cmd/soundtouch-cli/cmd_preset.go index 40167cb..9a7d92a 100644 --- a/cmd/soundtouch-cli/cmd_preset.go +++ b/cmd/soundtouch-cli/cmd_preset.go @@ -1,7 +1,10 @@ package main import ( + "encoding/base64" + "encoding/json" "fmt" + "net/url" "strings" "github.com/gesellix/bose-soundtouch/pkg/models" @@ -85,6 +88,7 @@ type presetParams struct { name string itemType string artwork string + serviceURL string } // extractPresetParams extracts parameters from CLI context @@ -97,9 +101,38 @@ func extractPresetParams(c *cli.Context) *presetParams { name: c.String("name"), itemType: c.String("type"), artwork: c.String("artwork"), + serviceURL: strings.TrimRight(c.String("service-url"), "/"), } } +// buildOrionLocation wraps a raw stream URL in the AfterTouch Orion station +// endpoint that the speaker's BMX module expects when playing LOCAL_INTERNET_RADIO +// content. The speaker calls GET on the stored preset location and expects a +// BmxPlaybackResponse JSON — not raw audio bytes — which is why direct stream +// URLs silently fail to start playback. +func buildOrionLocation(serviceURL, name, imageURL, streamURL string) string { + payload := struct { + Name string `json:"name"` + ImageURL string `json:"imageUrl"` + StreamURL string `json:"streamUrl"` + }{ + Name: name, + ImageURL: imageURL, + StreamURL: streamURL, + } + + data, _ := json.Marshal(payload) + encoded := url.QueryEscape(base64.StdEncoding.EncodeToString(data)) + + return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded +} + +// isOrionLocation reports whether location is already an Orion station URL so +// we don't double-wrap it. +func isOrionLocation(location string) bool { + return strings.Contains(location, "/core02/svc-bmx-adapter-orion/") +} + // resolveLocationAndMetadata resolves location and fetches metadata if needed func resolveLocationAndMetadata(params *presetParams) error { originalLocation := params.location @@ -108,6 +141,26 @@ func resolveLocationAndMetadata(params *presetParams) error { params.source = resolvedSource params.location = resolvedLocation + // For LOCAL_INTERNET_RADIO with a raw stream URL, the speaker's BMX module + // calls GET on the preset location expecting a BmxPlaybackResponse JSON (the + // Orion station format). A direct stream URL returns raw audio, which BMX + // cannot parse, so playback silently stays on the previous source. + // Wrap the stream URL in the Orion endpoint when --service-url is provided. + if params.source == "LOCAL_INTERNET_RADIO" && + params.serviceURL != "" && + !isOrionLocation(params.location) && + (strings.HasPrefix(params.location, "http://") || strings.HasPrefix(params.location, "https://")) { + params.location = buildOrionLocation(params.serviceURL, params.name, params.artwork, resolvedLocation) + fmt.Printf(" Wrapped stream URL in Orion location for LOCAL_INTERNET_RADIO\n") + } else if params.source == "LOCAL_INTERNET_RADIO" && + params.serviceURL == "" && + !isOrionLocation(params.location) && + (strings.HasPrefix(params.location, "http://") || strings.HasPrefix(params.location, "https://")) { + fmt.Printf(" ⚠️ --service-url not set: storing raw stream URL as location.\n") + fmt.Printf(" The speaker's BMX module expects an Orion station URL, not raw audio.\n") + fmt.Printf(" Re-run with --service-url to fix this.\n") + } + // If metadata (name or artwork) is missing, try to fetch it if params.name == "" || params.artwork == "" { var ( diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 776ffd7..0de0ed2 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -385,6 +385,11 @@ func main() { Name: "artwork", Usage: "Artwork URL", }, + &cli.StringFlag{ + Name: "service-url", + Usage: "AfterTouch service HTTPS URL (e.g. https://soundtouch.local). Required for LOCAL_INTERNET_RADIO: the speaker's BMX module calls GET on the preset location and expects an Orion JSON response, not raw audio. When provided, the stream URL is automatically wrapped in the Orion station endpoint.", + EnvVars: []string{"SOUNDTOUCH_SERVICE_URL"}, + }, }, Before: RequireHost, },