From 8d95e170f63845d594fef0ff823a22e69fffe8d3 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 16 Mar 2026 23:17:50 +0100 Subject: [PATCH] Add a custom-radio url stream source (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on the descriptions at - https://gist.github.com/rody64/98a59990ff60ea962cac72cbe93edf56 - https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/discussions/37 Example usage: ``` go run ./cmd/soundtouch-cli --host 192.... source custom-radio --url https://stream.antenne.de/chillout/stream/aacp --service-url http://soundtouch.local:8000 Selecting custom radio stream from 192....:8090... URL: https://stream.antenne.de/chillout/stream/aacp Proxy: http://soundtouch.local:8000/bmx/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0uYW50ZW5uZS5kZS9jaGlsbG91dC9zdHJlYW0vYWFjcA== βœ“ Custom radio stream selected ``` Relates to https://github.com/gesellix/Bose-SoundTouch/issues/94 --- README.md | 59 ++++++++++++----------- cmd/soundtouch-cli/cmd_source.go | 57 ++++++++++++++++++++++ cmd/soundtouch-cli/main.go | 28 +++++++++++ cmd/soundtouch-service/main.go | 2 + docs/CONTENT-SELECTION-IMPLEMENTATION.md | 23 +++++++-- docs/guides/CLI-REFERENCE.md | 14 +++++- docs/guides/SOUNDTOUCH-SERVICE.md | 11 +++-- pkg/service/bmx/bmx.go | 51 +++++++++++--------- pkg/service/handlers/handlers_bmx.go | 40 +++++++++++++++ pkg/service/handlers/handlers_bmx_test.go | 42 ++++++++++++++++ pkg/service/handlers/main_test.go | 2 + 11 files changed, 267 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 64b47d7..65bc1e3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices, - ⚑ **Real-time Events**: WebSocket connection for live device state monitoring - πŸ” **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS - πŸ“» **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music +- πŸ“» **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service) - πŸ“» **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md) - πŸŽ™οΈ **Station Management**: Add and play radio stations without presets - πŸ–₯️ **CLI Tool**: Comprehensive command-line interface @@ -106,7 +107,7 @@ package main import ( "fmt" "log" - + "github.com/gesellix/bose-soundtouch/pkg/client" ) @@ -116,20 +117,20 @@ func main() { Host: "192.168.1.100", Port: 8090, }) - + // Get device information info, err := c.GetDeviceInfo() if err != nil { log.Fatal(err) } fmt.Printf("Device: %s\n", info.Name) - + // Control playback err = c.Play() if err != nil { log.Fatal(err) } - + // Set volume err = c.SetVolume(50) if err != nil { @@ -147,7 +148,7 @@ import ( "fmt" "log" "time" - + "github.com/gesellix/bose-soundtouch/pkg/discovery" ) @@ -158,9 +159,9 @@ func main() { if err != nil { log.Fatal(err) } - + for _, device := range devices { - fmt.Printf("Found: %s at %s:%d\n", + fmt.Printf("Found: %s at %s:%d\n", device.Name, device.Host, device.Port) } } @@ -174,7 +175,7 @@ import ( "context" "fmt" "log" - + "github.com/gesellix/bose-soundtouch/pkg/client" "github.com/gesellix/bose-soundtouch/pkg/models" ) @@ -184,13 +185,13 @@ func main() { Host: "192.168.1.100", Port: 8090, }) - + // Subscribe to device events events, err := c.SubscribeToEvents(context.Background()) if err != nil { log.Fatal(err) } - + for event := range events { switch e := event.(type) { case *models.NowPlayingUpdated: @@ -211,7 +212,7 @@ package main import ( "fmt" "log" - + "github.com/gesellix/bose-soundtouch/pkg/client" "github.com/gesellix/bose-soundtouch/pkg/models" ) @@ -221,21 +222,21 @@ func main() { Host: "192.168.1.100", Port: 8090, }) - + // Get current presets presets, err := c.GetPresets() if err != nil { log.Fatal(err) } - + fmt.Printf("Found %d presets\n", len(presets.Preset)) - + // Store currently playing content as preset 1 err = c.StoreCurrentAsPreset(1) if err != nil { log.Fatal(err) } - + // Store Spotify playlist as preset 2 spotifyContent := &models.ContentItem{ Source: "SPOTIFY", @@ -249,7 +250,7 @@ func main() { if err != nil { log.Fatal(err) } - + // Store radio station as preset 3 radioContent := &models.ContentItem{ Source: "TUNEIN", @@ -262,13 +263,13 @@ func main() { if err != nil { log.Fatal(err) } - + // Select preset 1 err = c.SelectPreset(1) if err != nil { log.Fatal(err) } - + fmt.Println("Preset management complete!") } ``` @@ -279,7 +280,7 @@ package main import ( "log" - + "github.com/gesellix/bose-soundtouch/pkg/client" "github.com/gesellix/bose-soundtouch/pkg/models" ) @@ -289,7 +290,7 @@ func main() { Host: "192.168.1.100", // Master speaker Port: 8090, }) - + // Create a multiroom zone zone := &models.Zone{ Master: "192.168.1.100", @@ -298,12 +299,12 @@ func main() { {IPAddress: "192.168.1.102"}, // Kitchen }, } - + err := master.SetZone(zone) if err != nil { log.Fatal(err) } - + fmt.Println("Multiroom zone created!") } ``` @@ -314,7 +315,7 @@ package main import ( "log" - + "github.com/gesellix/bose-soundtouch/pkg/client" ) @@ -323,13 +324,13 @@ func main() { Host: "192.168.1.100", Port: 8090, }) - + // Play Text-to-Speech message (language code "EN", "DE", etc.) err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70) if err != nil { log.Fatal(err) } - + // Play audio content from URL err = c.PlayURL( "https://example.com/doorbell.mp3", @@ -342,13 +343,13 @@ func main() { if err != nil { log.Fatal(err) } - + // Play notification beep err = c.PlayNotificationBeep() if err != nil { log.Fatal(err) } - + fmt.Println("Notifications sent!") } ``` @@ -358,7 +359,7 @@ func main() { This library supports all Bose SoundTouch-compatible devices, including: - SoundTouch 10, 20, 30 series -- SoundTouch Portable +- SoundTouch Portable - Wave SoundTouch music system - SoundTouch-enabled Bose speakers @@ -519,7 +520,7 @@ This project builds upon the excellent work of several community projects: These projects together form a comprehensive ecosystem for SoundTouch device management: - **This Project**: Go library + CLI + service for programmatic control and offline operation -- **SoundCork**: Python-based service interception and cloud replacement +- **SoundCork**: Python-based service interception and cloud replacement - **SoundTouch Plus**: Home Assistant integration with extensive device support - **ÜberBΓΆse**: API research and advanced endpoint discovery - **SoundTouch Hook**: Advanced reverse engineering and process instrumentation diff --git a/cmd/soundtouch-cli/cmd_source.go b/cmd/soundtouch-cli/cmd_source.go index 17f6763..5ae98bd 100644 --- a/cmd/soundtouch-cli/cmd_source.go +++ b/cmd/soundtouch-cli/cmd_source.go @@ -1,7 +1,9 @@ package main import ( + "encoding/base64" "fmt" + "net/url" "strings" "github.com/gesellix/bose-soundtouch/pkg/models" @@ -251,6 +253,61 @@ func selectLocalInternetRadio(c *cli.Context) error { return nil } +// selectCustomRadio handles selecting custom radio stream via soundtouch-service +func selectCustomRadio(c *cli.Context) error { + clientConfig := GetClientConfig(c) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + return err + } + + streamURL := c.String("url") + itemName := c.String("name") + containerArt := c.String("artwork") + serviceURL := c.String("service-url") + + encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL)) + location := fmt.Sprintf("%s/bmx/custom/v1/playback/%s", serviceURL, encodedURL) + + params := url.Values{} + if itemName != "" { + params.Add("name", itemName) + } + + if containerArt != "" { + params.Add("imageUrl", containerArt) + } + + if len(params) > 0 { + location += "?" + params.Encode() + } + + // Check LOCAL_INTERNET_RADIO availability + checker := NewServiceAvailabilityChecker(client) + if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") { + return fmt.Errorf("LOCAL_INTERNET_RADIO is not available") + } + + PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port) + + if itemName != "" { + fmt.Printf(" Station: %s\n", itemName) + } + + fmt.Printf(" URL: %s\n", streamURL) + fmt.Printf(" Proxy: %s\n", location) + + err = client.SelectLocalInternetRadio(location, "", itemName, containerArt) + if err != nil { + return fmt.Errorf("failed to select custom radio: %w", err) + } + + PrintSuccess("Custom radio stream selected") + + return nil +} + // selectLocalMusic handles selecting LOCAL_MUSIC source func selectLocalMusic(c *cli.Context) error { clientConfig := GetClientConfig(c) diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 4db02ce..adfaf1d 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -924,6 +924,34 @@ func main() { }, }, }, + { + Name: "custom-radio", + Usage: "Select custom radio stream via soundtouch-service", + Action: selectCustomRadio, + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "url", + Aliases: []string{"u"}, + Usage: "Stream URL", + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "Station name", + }, + &cli.StringFlag{ + Name: "artwork", + Usage: "Station artwork URL", + }, + &cli.StringFlag{ + Name: "service-url", + Usage: "URL of the soundtouch-service (default: http://localhost:8080)", + Value: "http://localhost:8080", + }, + }, + }, { Name: "local-music", Usage: "Select local music content (LOCAL_MUSIC)", diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 76080a0..ee62107 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -655,6 +655,7 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback) }) // Legacy or direct domain calls without /bmx prefix @@ -663,6 +664,7 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback) streamingRoutes := func(r chi.Router) { r.Get("/sourceproviders", server.HandleMargeSourceProviders) diff --git a/docs/CONTENT-SELECTION-IMPLEMENTATION.md b/docs/CONTENT-SELECTION-IMPLEMENTATION.md index a6e0155..7c8be0f 100644 --- a/docs/CONTENT-SELECTION-IMPLEMENTATION.md +++ b/docs/CONTENT-SELECTION-IMPLEMENTATION.md @@ -23,6 +23,14 @@ All content selection features from the [SoundTouch WebServices API Wiki](https: - Automatic defaults for missing parameters - **Use Cases**: Internet radio streams, proxy-based radio services +#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service` +- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy +- **Features**: + - Flexible stream URL encoding (Base64 or URL-escaped) + - Dynamic generation of Bose-compatible playback JSON + - Seamless integration with existing `LOCAL_INTERNET_RADIO` source +- **Use Case**: Playing any internet radio URL without external proxy dependencies + #### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error` - **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server - **Requirements**: SoundTouch App Media Server running on a computer @@ -47,6 +55,15 @@ soundtouch-cli --host source internet-radio \ --artwork "https://example.com/art.png" ``` +#### `soundtouch-cli source custom-radio` +```bash +soundtouch-cli --host source custom-radio \ + --url "https://stream.example.com/radio" \ + --name "My Station" \ + --artwork "https://example.com/art.png" \ + --service-url "http://localhost:8080" +``` + #### `soundtouch-cli source local-music` ```bash soundtouch-cli --host source local-music \ @@ -101,7 +118,7 @@ Comprehensive test suites implemented for all new functionality: ### Example Code Complete working example demonstrating: - LOCAL_INTERNET_RADIO with streamUrl proxy format -- LOCAL_INTERNET_RADIO with direct streams +- LOCAL_INTERNET_RADIO with direct streams - LOCAL_MUSIC content selection - STORED_MUSIC content selection - Generic ContentItem usage @@ -152,7 +169,7 @@ All convenience methods create properly structured `ContentItem` objects: Based on the wiki structure, these related features are also supported: 1. **LOCAL_MUSIC**: βœ… Fully implemented -2. **STORED_MUSIC**: βœ… Fully implemented +2. **STORED_MUSIC**: βœ… Fully implemented 3. **SPOTIFY**: βœ… Previously implemented 4. **TUNEIN**: βœ… Previously implemented 5. **BLUETOOTH**: βœ… Previously implemented @@ -169,7 +186,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "") // Direct ContentItem contentItem := &models.ContentItem{ Source: "LOCAL_INTERNET_RADIO", - Type: "stationurl", + Type: "stationurl", Location: location, ItemName: "My Station", IsPresetable: true, diff --git a/docs/guides/CLI-REFERENCE.md b/docs/guides/CLI-REFERENCE.md index bf3f26d..69f6d32 100644 --- a/docs/guides/CLI-REFERENCE.md +++ b/docs/guides/CLI-REFERENCE.md @@ -394,6 +394,9 @@ soundtouch-cli --host source spotify soundtouch-cli --host source bluetooth soundtouch-cli --host source aux +# Custom radio selection (via soundtouch-service) +soundtouch-cli --host source custom-radio --url [--name ] [--artwork ] [--service-url ] + # Advanced content selection soundtouch-cli --host source internet-radio --location [--name ] soundtouch-cli --host source local-music --location --account @@ -482,6 +485,7 @@ soundtouch-cli --host 192.168.1.10 source compare | Command | Description | Requirements | |---------|-------------|--------------| | `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL | +| `custom-radio` | Select custom radio stream via soundtouch-service | Stream URL and service URL | | `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server | | `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server | | `content` | Generic content selection (advanced) | Source and location | @@ -495,6 +499,12 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound soundtouch-cli --host 192.168.1.10 source internet-radio \ --location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \ --name "Antenne Chillout" + +# Using local soundtouch-service for custom streams +soundtouch-cli --host 192.168.1.10 source custom-radio \ + --url "https://stream.antenne.de/chillout/stream/aacp" \ + --name "Antenne Chillout" \ + --service-url "http://localhost:8080" ``` #### Service Introspection @@ -1044,7 +1054,7 @@ soundtouch-cli --host 192.168.1.10 speaker beep **Supported Languages for TTS:** - `EN` - English (default) -- `DE` - German +- `DE` - German - `ES` - Spanish - `FR` - French - `IT` - Italian @@ -1085,7 +1095,7 @@ soundtouch-cli --host events subscribe [flags] **Event Types:** - `nowPlaying` - Track changes, playback status -- `volume` - Volume and mute changes +- `volume` - Volume and mute changes - `connection` - Network connectivity status - `preset` - Preset configuration changes - `zone` - Multiroom zone changes diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md index 1abad6c..3c39734 100644 --- a/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/guides/SOUNDTOUCH-SERVICE.md @@ -26,10 +26,11 @@ The service consists of several key components: ### BMX Services (Bose Media eXchange) - **TuneIn Integration**: Direct playback of radio stations and podcasts +- **Custom Streams**: Flexible playback of any internet radio URL via dynamic proxy - **Service Registry**: Media service discovery and configuration - **Playback Control**: Stream URL resolution and audio metadata -### Marge Services (Account & Device Management) +### Marge Services (Account & Device Management) - **Account Management**: User account simulation and device association - **Preset Synchronization**: Cross-device preset storage and sync - **Recent Items**: Playback history tracking and management @@ -57,7 +58,7 @@ go build -o soundtouch-service ./cmd/soundtouch-service ### Docker Support -You can run the SoundTouch service using Docker or Docker Compose. +You can run the SoundTouch service using Docker or Docker Compose. > **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly. @@ -393,7 +394,7 @@ Migrates device to use local services. **Query Parameters:** - `target_url`: Custom service URL (optional) -- `proxy_url`: Proxy URL for fallback (optional) +- `proxy_url`: Proxy URL for fallback (optional) - `marge`: Set to "original" to proxy Marge requests (optional) - `stats`: Set to "original" to proxy stats requests (optional) - `sw_update`: Set to "original" to proxy update requests (optional) @@ -764,7 +765,7 @@ ls -la data/events/ This service implementation is based on and inspired by several excellent community projects: ### SoundCork -- **Project**: [SoundCork](https://github.com/deborahgu/soundcork) +- **Project**: [SoundCork](https://github.com/deborahgu/soundcork) - **Authors**: Deborah Gu and contributors - **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies. @@ -809,7 +810,7 @@ soundtouch: - host: 192.168.1.100 port: 8090 name: "Living Room Speaker" - + rest: - resource: "http://localhost:8000/setup/devices" scan_interval: 60 diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index 79bfe82..d33950c 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -280,6 +280,33 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error return response, nil } +// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name. +func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) { + streamList := []models.Stream{ + { + HasPlaylist: true, + IsRealtime: true, + StreamUrl: streamURL, + }, + } + + audio := models.Audio{ + HasPlaylist: true, + IsRealtime: true, + StreamUrl: streamURL, + Streams: streamList, + } + + response := &models.BmxPlaybackResponse{ + Audio: audio, + ImageUrl: imageURL, + Name: name, + StreamType: "liveRadio", + } + + return response, nil +} + // PlayCustomStream builds a playback response from a base64-encoded JSON blob // with fields streamUrl, imageUrl, and name. func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) { @@ -302,27 +329,5 @@ func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) { return nil, err } - streamList := []models.Stream{ - { - HasPlaylist: true, - IsRealtime: true, - StreamUrl: jsonObj.StreamURL, - }, - } - - audio := models.Audio{ - HasPlaylist: true, - IsRealtime: true, - StreamUrl: jsonObj.StreamURL, - Streams: streamList, - } - - response := &models.BmxPlaybackResponse{ - Audio: audio, - ImageUrl: jsonObj.ImageURL, - Name: jsonObj.Name, - StreamType: "liveRadio", - } - - return response, nil + return BuildCustomStreamResponse(jsonObj.StreamURL, jsonObj.ImageURL, jsonObj.Name) } diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 1770f35..59c43f5 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -2,8 +2,10 @@ package handlers import ( + "encoding/base64" "encoding/json" "net/http" + "net/url" "strings" "github.com/gesellix/bose-soundtouch/pkg/service/bmx" @@ -94,3 +96,41 @@ func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { return } } + +// HandleCustomPlayback returns custom playback information for a given stream URL. +func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) { + encodedURL := chi.URLParam(r, "encodedURL") + imageUrl := r.URL.Query().Get("imageUrl") + name := r.URL.Query().Get("name") + + // Decode URL if it's base64 encoded + var streamUrl string + + decoded, err := base64.URLEncoding.DecodeString(encodedURL) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(encodedURL) + } + + if err == nil { + streamUrl = string(decoded) + } else { + // Try unescaping if it's not base64 + streamUrl, err = url.PathUnescape(encodedURL) + if err != nil { + streamUrl = encodedURL + } + } + + resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} diff --git a/pkg/service/handlers/handlers_bmx_test.go b/pkg/service/handlers/handlers_bmx_test.go index 98b7128..47ab170 100644 --- a/pkg/service/handlers/handlers_bmx_test.go +++ b/pkg/service/handlers/handlers_bmx_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" ) @@ -107,3 +108,44 @@ func TestOrionPlayback(t *testing.T) { t.Errorf("Expected name Test Orion, got %v", resp["name"]) } } + +func TestCustomPlayback(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + + ts := httptest.NewServer(r) + defer ts.Close() + + // Base64 encoded: http://example.com/stream + encodedURL := "aHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbQ==" + imageUrl := "http://example.com/img.jpg" + name := "Test Custom" + + res, err := http.Get(ts.URL + "/bmx/custom/v1/playback/" + encodedURL + "?imageUrl=" + url.QueryEscape(imageUrl) + "&name=" + url.QueryEscape(name)) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + + var resp map[string]interface{} + _ = json.Unmarshal(body, &resp) + + if resp["name"] != "Test Custom" { + t.Errorf("Expected name Test Custom, got %v", resp["name"]) + } + + audio := resp["audio"].(map[string]interface{}) + if audio["streamUrl"] != "http://example.com/stream" { + t.Errorf("Expected streamUrl http://example.com/stream, got %v", audio["streamUrl"]) + } + + if resp["imageUrl"] != imageUrl { + t.Errorf("Expected imageUrl %s, got %v", imageUrl, resp["imageUrl"]) + } +} diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 2d1cdc2..3ac789f 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -27,6 +27,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback) }) // Legacy or direct domain calls without /bmx prefix @@ -35,6 +36,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback) streamingRoutes := func(r chi.Router) { r.Get("/sourceproviders", server.HandleMargeSourceProviders)