diff --git a/cmd/soundtouch-cli/cmd_preset.go b/cmd/soundtouch-cli/cmd_preset.go index f438fab..b27fa8d 100644 --- a/cmd/soundtouch-cli/cmd_preset.go +++ b/cmd/soundtouch-cli/cmd_preset.go @@ -1,12 +1,11 @@ package main import ( - "encoding/base64" - "encoding/json" "fmt" - "net/url" "strings" + bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/urfave/cli/v2" ) @@ -105,28 +104,6 @@ func extractPresetParams(c *cli.Context) *presetParams { } } -// 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 { @@ -141,25 +118,22 @@ 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. + // For LOCAL_INTERNET_RADIO, the speaker's BMX module calls GET on the stored + // 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. 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) + if params.serviceURL != "" { + params.location = bmxpkg.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") + fmt.Printf(" Wrapped stream URL in Orion location for LOCAL_INTERNET_RADIO\n") + } else { + 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 diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index f97408e..f887cad 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "runtime/debug" + "strings" "time" "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb" @@ -75,6 +76,11 @@ func main() { Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)", EnvVars: []string{"SOUNDTOUCH_DEVICES"}, }, + &cli.StringFlag{ + Name: "service-url", + Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO", + EnvVars: []string{"SERVICE_URL"}, + }, }, Action: func(c *cli.Context) error { port := c.String("port") @@ -108,6 +114,7 @@ func main() { webApp.Commit = commit webApp.Date = date webApp.RepoURL = repoURL + webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/") discoveryService := soundtouchweb.NewDiscoveryService(ifaceName) diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index 7528ec8..b1b4f21 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -4,11 +4,38 @@ package bmx import ( + "encoding/base64" "encoding/json" + "net/url" "github.com/gesellix/bose-soundtouch/pkg/models" ) +// 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 location expecting a +// BmxPlaybackResponse JSON — not raw audio bytes. +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, err := json.Marshal(payload) + if err != nil { + return "" + } + + encoded := url.QueryEscape(base64.StdEncoding.EncodeToString(data)) + + return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded +} + // BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name. func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) { streamList := []models.Stream{ diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index ef7b758..ebdcb86 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -35,10 +35,11 @@ type WebApp struct { WSClients map[*websocket.Conn]bool WSMutex sync.RWMutex - Version string - Commit string - Date string - RepoURL string + Version string + Commit string + Date string + RepoURL string + ServiceURL string discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus } @@ -1084,6 +1085,81 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) { } } +// HandlePlayURL plays a custom stream URL on a device. When ServiceURL is +// configured the stream is wrapped in the Orion location format so the +// speaker's BMX module receives JSON instead of raw audio bytes. This also +// ensures that the ★ preset save flow stores a working location. +func (app *WebApp) HandlePlayURL(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "id") + + device, exists := app.GetDevice(deviceID) + if !exists { + app.sendError(w, "Device not found", http.StatusNotFound) + return + } + + if device.Client == nil { + app.sendError(w, "Device client not available", http.StatusInternalServerError) + return + } + + var req struct { + URL string `json:"url"` + Name string `json:"name"` + ImageURL string `json:"imageUrl"` + ServiceURL string `json:"serviceUrl"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + app.sendError(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.URL == "" { + app.sendError(w, "url is required", http.StatusBadRequest) + return + } + + // Server-side --service-url wins; fall back to client-supplied value. + serviceURL := app.ServiceURL + if serviceURL == "" { + serviceURL = strings.TrimRight(req.ServiceURL, "/") + } + + if serviceURL == "" { + app.sendError(w, + "AfterTouch service URL is required for LOCAL_INTERNET_RADIO playback. "+ + "Start soundtouch-web with --service-url or enter it in the Play URL settings.", + http.StatusBadRequest) + + return + } + + location := bmxpkg.BuildOrionLocation(serviceURL, req.Name, req.ImageURL, req.URL) + + contentItem := &models.ContentItem{ + Source: "LOCAL_INTERNET_RADIO", + Type: "stationurl", + Location: location, + ItemName: req.Name, + IsPresetable: true, + } + + if err := device.Client.SelectContentItem(contentItem); err != nil { + app.sendError(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{ + Success: true, + Data: map[string]string{"message": "Playing " + req.Name}, + }); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + // HandleAPIVersion returns the current version of the application. func (app *WebApp) HandleAPIVersion(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -1095,6 +1171,7 @@ func (app *WebApp) HandleAPIVersion(w http.ResponseWriter, _ *http.Request) { "repo_url": app.RepoURL, "release_url": app.RepoURL + "/releases/tag/" + app.Version, "commit_url": app.RepoURL + "/commit/" + app.Commit, + "service_url": app.ServiceURL, } if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: versionInfo}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index 00ba657..b8a8f31 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -76,12 +76,16 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov r.Get("/api/radiobrowser/search", app.HandleRadioBrowserSearch) r.Post("/api/radiobrowser/play/{id}", app.HandlePlayRadioBrowser) + // Custom URL playback + r.Post("/api/play-url/{id}", app.HandlePlayURL) + // SPA routes — serve index.html for client-side routing r.Get("/", app.serveIndex) r.Get("/devices", app.serveIndex) r.Get("/device/*", app.serveIndex) r.Get("/tunein", app.serveIndex) r.Get("/radiobrowser", app.serveIndex) + r.Get("/playurl", app.serveIndex) } func (app *WebApp) serveIndex(w http.ResponseWriter, _ *http.Request) { diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index b203b62..23518c6 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -197,7 +197,8 @@ img { display: block; max-width: 100%; } .nav-links a.active .nav-tunein-icon, .nav-links a.active .nav-rb-icon, -.nav-links a.active .nav-device-icon { +.nav-links a.active .nav-device-icon, +.nav-links a.active .nav-url-icon { filter: none; opacity: 1; } @@ -210,12 +211,13 @@ img { display: block; max-width: 100%; } } .nav-links a.active .nav-tunein-icon, .nav-links a.active .nav-rb-icon, - .nav-links a.active .nav-device-icon { + .nav-links a.active .nav-device-icon, + .nav-links a.active .nav-url-icon { filter: invert(1); } } -.nav-tunein-icon, .nav-rb-icon, .nav-device-icon { height: 20px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; } +.nav-tunein-icon, .nav-rb-icon, .nav-device-icon, .nav-url-icon { height: 20px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; } .nav-discover-icon { height: 24px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; } .nav-discover-icon.buzzing { animation: buzzing 0.3s linear infinite; opacity: 1; } @@ -229,6 +231,7 @@ img { display: block; max-width: 100%; } .nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon, .nav-links a:hover .nav-rb-icon, .nav-links a.active .nav-rb-icon, .nav-links a:hover .nav-device-icon, .nav-links a.active .nav-device-icon, +.nav-links a:hover .nav-url-icon, .nav-links a.active .nav-url-icon, .nav-links .btn-icon:hover .nav-discover-icon { opacity: 1; } /* ── Main content ─────────────────────────────────────────────────────────── */ diff --git a/pkg/service/soundtouchweb/static/img/link-mono.svg b/pkg/service/soundtouchweb/static/img/link-mono.svg new file mode 100644 index 0000000..c693c24 --- /dev/null +++ b/pkg/service/soundtouchweb/static/img/link-mono.svg @@ -0,0 +1,4 @@ + + + + diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index 34c3615..291bba5 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -45,4 +45,9 @@ export const api = { headers: JSON_HEADERS, body: JSON.stringify(item), }), + playURL: (deviceId, url, name, imageUrl, serviceUrl) => req(`/api/play-url/${deviceId}`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ url, name, imageUrl, serviceUrl }), + }), }; diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index df07be8..910a280 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -10,6 +10,7 @@ import { Zone } from './components/Zone.js'; import { Recents } from './components/Recents.js'; import { TuneInBrowser } from './components/TuneInBrowser.js'; import { RadioBrowser } from './components/RadioBrowser.js'; +import { PlayURL } from './components/PlayURL.js'; import { api } from './api.js'; const html = htm.bind(h); @@ -68,6 +69,7 @@ function App() { } if (page === 'tunein') return 'TuneIn'; if (page === 'radiobrowser') return 'RadioBrowser'; + if (page === 'playurl') return 'Play URL'; return 'AfterTouch'; }; @@ -169,6 +171,12 @@ function App() { > RadioBrowser + { e.preventDefault(); navigate('playurl'); }} + title="Play URL" + > + Play URL + | + +
+ onServiceUrlChange(e.target.value)} + title="AfterTouch service base URL — required for LOCAL_INTERNET_RADIO playback and preset save" + /> +
+ ${status && html`
${status}
`} + + ${pendingPlay ? html` +
setPendingPlay(null)}> +
e.stopPropagation()}> +

Play on device

+

${pendingPlay.name}

+
+ ${deviceEntries.length === 0 ? html`

No devices found. Try discovering first.

` : null} + ${deviceEntries.map(([id, d]) => html` + + `)} +
+ +
+
+ ` : null} + + `; +}