diff --git a/CLAUDE.md b/CLAUDE.md index f5407e6..6a95e64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Key binaries: - `soundtouch-cli` — command-line control of one or more speakers (status, play, presets, groups, migration, …). -- `soundtouch-service` — local replacement for `streaming.bose.com` +- `soundtouch-service` — replacement for `streaming.bose.com` and the `bmx` services, default port `8000`. - `soundtouch-web` — Web UI for Radio browsing and device control. - `soundtouch-backup` — Helper for on-device backup and restore. diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index 94b4f34..f1f7fdf 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "runtime/debug" "time" "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb" @@ -15,7 +16,39 @@ import ( "github.com/urfave/cli/v2" ) +var ( + version = "dev" + commit = "unknown" + date = "unknown" + repoURL = "https://github.com/gesellix/bose-soundtouch" +) + +func updateBuildInfo() { + if info, ok := debug.ReadBuildInfo(); ok { + if info.Main.Path != "" { + repoURL = "https://" + info.Main.Path + } + + if info.Main.Version != "" && info.Main.Version != "(devel)" { + version = info.Main.Version + } + + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + commit = setting.Value + case "vcs.time": + if t, err := time.Parse(time.RFC3339, setting.Value); err == nil { + date = t.Format("2006-01-02 15:04:05") + } + } + } + } +} + func main() { + updateBuildInfo() + app := &cli.App{ Name: "soundtouch-web", Usage: "Web UI for controlling Bose SoundTouch devices", @@ -71,6 +104,10 @@ func main() { // Create web app without templates (SPA mode) webApp := soundtouchweb.NewWebApp() + webApp.Version = version + webApp.Commit = commit + webApp.Date = date + webApp.RepoURL = repoURL discoveryService := soundtouchweb.NewDiscoveryService(ifaceName) @@ -94,7 +131,7 @@ func main() { r := chi.NewRouter() webApp.Mount(r, discoveryService) - log.Printf("SoundTouch Web UI starting on http://%s", addr) + log.Printf("AfterTouch Web UI starting on http://%s", addr) return http.ListenAndServe(addr, r) }, diff --git a/cmd/soundtouch-web/spa_test.go b/cmd/soundtouch-web/spa_test.go index 8bf13c2..ff35c1e 100644 --- a/cmd/soundtouch-web/spa_test.go +++ b/cmd/soundtouch-web/spa_test.go @@ -69,7 +69,7 @@ func TestSPARouting(t *testing.T) {
-` comment line in the body (the body is
-// pls/m3u-like, so `#`-prefixed lines are comments — including error
-// markers like `#STATUS: 400`). Without this filter the caller would
-// happily pass `#STATUS: 400` to the speaker as if it were a stream URL.
-//
-// Returns the cleaned list of URL strings (TrimSpaced, comment lines
-// dropped, empty lines dropped). Returns an error if no playable URL
-// remains so callers surface a real 500 instead of silently corrupting
-// the playback response.
-func parseTuneInStreamBody(body []byte, guideID string) ([]string, error) {
- raw := strings.Split(strings.TrimSpace(string(body)), "\n")
- out := make([]string, 0, len(raw))
-
- for _, line := range raw {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
-
- out = append(out, line)
- }
-
- if len(out) == 0 {
- return nil, fmt.Errorf("TuneIn returned no playable stream URL for guide-id %q (body: %q)",
- guideID, strings.TrimSpace(string(body)))
- }
-
- return out, nil
-}
-
-// tuneInProfileContentsResponse models the subset of the
-// api.tunein.com/profiles/{id}/contents JSON we need to pick a
-// program's newest episode. The endpoint returns substantially more
-// fields per item; everything outside this struct is ignored.
-type tuneInProfileContentsResponse struct {
- Items []tuneInProfileContentsItem `json:"Items"`
-}
-
-type tuneInProfileContentsItem struct {
- ContainerType string `json:"ContainerType"`
- Title string `json:"Title"`
- AccessibilityTitle string `json:"AccessibilityTitle"`
- Children []tuneInProfileContentsTopic `json:"Children"`
-}
-
-type tuneInProfileContentsTopic struct {
- GuideId string `json:"GuideId"`
- Type string `json:"Type"`
- Title string `json:"Title"`
- Image string `json:"Image"`
-}
-
-// parseTuneInProgramContents walks a profile/contents JSON body and
-// returns the guide-id of the newest playable episode. The contract:
-//
-// - Items[] entry with ContainerType=="Topics" and Title (or
-// AccessibilityTitle) equal to "Episodes" is treated as the
-// authoritative episode list.
-// - If no item matches by name, the first ContainerType=="Topics"
-// entry is used as fallback — TuneIn occasionally varies the
-// localised title.
-// - Inside the chosen container the first child with a `t`-prefixed
-// GuideId wins. TuneIn orders children newest-first.
-//
-// Returns a wrapped error if the body is malformed or contains no
-// playable topic; callers surface this as a 500 rather than handing
-// the speaker a broken stream URL.
-func parseTuneInProgramContents(body []byte, programID string) (episodeID string, err error) {
- var parsed tuneInProfileContentsResponse
- if decErr := json.Unmarshal(body, &parsed); decErr != nil {
- return "", fmt.Errorf("decode TuneIn profile/contents for %q: %w", programID, decErr)
- }
-
- var fallback *tuneInProfileContentsItem
-
- for i := range parsed.Items {
- item := &parsed.Items[i]
- if item.ContainerType != "Topics" {
- continue
- }
-
- if fallback == nil {
- fallback = item
- }
-
- if strings.EqualFold(item.Title, "Episodes") ||
- strings.EqualFold(item.AccessibilityTitle, "Episodes") {
- if id := firstTuneInTopicGuideID(item.Children); id != "" {
- return id, nil
- }
- }
- }
-
- if fallback != nil {
- if id := firstTuneInTopicGuideID(fallback.Children); id != "" {
- return id, nil
- }
- }
-
- return "", fmt.Errorf("no playable episode found in TuneIn profile/contents for program %q", programID)
-}
-
-func firstTuneInTopicGuideID(children []tuneInProfileContentsTopic) string {
- for _, child := range children {
- if strings.HasPrefix(child.GuideId, "t") {
- return child.GuideId
- }
- }
-
- return ""
-}
-
-// resolveTuneInProgramLatestEpisode fetches the program's profile from
-// api.tunein.com and returns the newest playable episode's topic
-// guide-id (the `t` form Tune.ashx accepts). The legacy OPML
-// endpoints can't enumerate program episodes; see
-// `_/i226/tunein-api-findings.md` for the full endpoint contract.
-func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
- contentsURL := fmt.Sprintf(TuneInProfileContents, programID)
-
- resp, err := http.Get(contentsURL)
- if err != nil {
- return "", err
- }
-
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("TuneIn profile/contents returned status %d for program %q",
- resp.StatusCode, programID)
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", err
- }
-
- return parseTuneInProgramContents(body, programID)
-}
-
-// TuneInDescribeMeta fetches just the display name and logo URL for a TuneIn
-// guide ID via the same describe endpoint TuneInPlayback uses. Useful for
-// CLI / UI enrichment that wants to populate ContentItem.ItemName +
-// ContainerArt before sending a SelectContentItem to the speaker — without
-// resolving the full stream URL.
-//
-// Returns empty strings (and a nil error) if the describe payload doesn't
-// contain a recognisable station / show element. Network errors and XML
-// decode errors surface verbatim.
-func TuneInDescribeMeta(id string) (name, logo string, err error) {
- describeURL := fmt.Sprintf(TuneInDescribe, id)
-
- resp, err := http.Get(describeURL)
- if err != nil {
- return "", "", err
- }
-
- defer func() { _ = resp.Body.Close() }()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", "", err
- }
-
- // Same shape TuneInPlayback parses for stations. For programs and
- // episodes the describe endpoint returns analogous structures; if
- // the station element is absent the response yields empty strings
- // and the caller can fall back to user-supplied values.
- var opml struct {
- Body struct {
- Outline struct {
- Station struct {
- Name string `xml:"name"`
- Logo string `xml:"logo"`
- } `xml:"station"`
- } `xml:"outline"`
- } `xml:"body"`
- }
-
- if uErr := xml.Unmarshal(body, &opml); uErr != nil {
- return "", "", uErr
- }
-
- return opml.Body.Outline.Station.Name, opml.Body.Outline.Station.Logo, nil
-}
-
-// TuneInPlayback resolves a live radio station and returns a Bose-compatible
-// playback response with primary stream and variants. formats is the
-// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to
-// DefaultTuneInStreamFormats (the SoundTouch-line-compatible shape).
-func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, error) {
- describeURL := fmt.Sprintf(TuneInDescribe, stationID)
-
- resp, err := http.Get(describeURL)
- if err != nil {
- return nil, err
- }
-
- defer func() { _ = resp.Body.Close() }()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
-
- var opml struct {
- Body struct {
- Outline struct {
- Station struct {
- Name string `xml:"name"`
- Logo string `xml:"logo"`
- } `xml:"station"`
- } `xml:"outline"`
- } `xml:"body"`
- }
-
- if unmarshalErr := xml.Unmarshal(body, &opml); unmarshalErr != nil {
- return nil, unmarshalErr
- }
-
- station := opml.Body.Outline.Station
-
- streamReq := TuneInStream(stationID, formats)
-
- streamResp, err := http.Get(streamReq)
- if err != nil {
- return nil, err
- }
-
- defer func() { _ = streamResp.Body.Close() }()
-
- streamBody, err := io.ReadAll(streamResp.Body)
- if err != nil {
- return nil, err
- }
-
- streamURLList, err := parseTuneInStreamBody(streamBody, stationID)
- if err != nil {
- return nil, err
- }
-
- streamID := "e3342"
- listenID := "3432432423"
- bmxReportingQS := url.Values{}
- bmxReportingQS.Set("stream_id", streamID)
- bmxReportingQS.Set("guide_id", stationID)
- bmxReportingQS.Set("listen_id", listenID)
- bmxReportingQS.Set("stream_type", "liveRadio")
- bmxReporting := "/v1/report?" + bmxReportingQS.Encode()
-
- var streams []models.Stream
-
- for _, sURL := range streamURLList {
- sURL = strings.TrimSpace(sURL)
- if sURL == "" {
- continue
- }
-
- streams = append(streams, models.Stream{
- Links: &models.Links{
- BmxReporting: &models.Link{Href: bmxReporting},
- },
- HasPlaylist: true,
- IsRealtime: true,
- BufferingTimeout: 20,
- ConnectingTimeout: 10,
- StreamUrl: sURL,
- })
- }
-
- audio := models.Audio{
- HasPlaylist: true,
- IsRealtime: true,
- MaxTimeout: 60,
- StreamUrl: streamURLList[0],
- Streams: streams,
- }
-
- response := &models.BmxPlaybackResponse{
- Links: &models.Links{
- BmxFavorite: &models.Link{Href: "/v1/favorite/" + stationID},
- BmxNowPlaying: &models.Link{Href: "/v1/now-playing/station/" + stationID, UseInternalClient: "ALWAYS"},
- BmxReporting: &models.Link{Href: bmxReporting},
- },
- Audio: audio,
- ImageUrl: station.Logo,
- IsFavorite: new(bool), // defaults to false
- Name: station.Name,
- StreamType: "liveRadio",
- }
-
- return response, nil
-}
-
-// TuneInPodcastInfo returns minimal podcast/episode metadata for UI selection.
-func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoResponse, error) {
- // Bose app sometimes sends non-standard base64, so try both standard and URL-safe
- nameBytes, err := base64.URLEncoding.DecodeString(encodedName)
- if err != nil {
- nameBytes, err = base64.StdEncoding.DecodeString(encodedName)
- }
-
- if err != nil {
- return nil, err
- }
-
- name := string(nameBytes)
-
- track := models.Track{
- Links: &models.Links{
- BmxTrack: &models.Link{Href: fmt.Sprintf("/v1/playback/episode/%s", podcastID)},
- },
- IsSelected: false,
- Name: name,
- }
-
- response := &models.BmxPodcastInfoResponse{
- Links: &models.Links{
- Self: &models.Link{Href: fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", podcastID, encodedName)},
- },
- Name: name,
- ShuffleDisabled: true,
- RepeatDisabled: true,
- StreamType: "onDemand",
- Tracks: []models.Track{track},
- }
-
- return response, nil
-}
-
-// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns
-// a playback response suitable for SoundTouch devices. formats has the
-// same semantics as in TuneInPlayback.
-//
-// Accepts three TuneIn guide-id shapes:
-// - `t` — topic/episode; played directly.
-// - `e` — live episode; played directly.
-// - `p` — podcast program (a container, not a stream). Expanded
-// to its newest episode via the JSON profile/contents API before
-// resolving the stream URL. The legacy OPML `Tune.ashx?id=p`
-// would return `#STATUS: 400` for this case.
-func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
- if strings.HasPrefix(podcastID, "p") {
- episodeID, resolveErr := resolveTuneInProgramLatestEpisode(podcastID)
- if resolveErr != nil {
- return nil, resolveErr
- }
-
- podcastID = episodeID
- }
-
- describeURL := fmt.Sprintf(TuneInDescribe, podcastID)
-
- resp, err := http.Get(describeURL)
- if err != nil {
- return nil, err
- }
-
- defer func() { _ = resp.Body.Close() }()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
-
- var opml struct {
- Body struct {
- Outline struct {
- Topic struct {
- Title string `xml:"title"`
- ShowTitle string `xml:"show_title"`
- Duration string `xml:"duration"`
- ShowID string `xml:"show_id"`
- Logo string `xml:"logo"`
- } `xml:"topic"`
- } `xml:"outline"`
- } `xml:"body"`
- }
-
- if unmarshalErr := xml.Unmarshal(body, &opml); unmarshalErr != nil {
- return nil, unmarshalErr
- }
-
- topic := opml.Body.Outline.Topic
-
- streamReq := TuneInStream(podcastID, formats)
-
- streamResp, err := http.Get(streamReq)
- if err != nil {
- return nil, err
- }
-
- defer func() { _ = streamResp.Body.Close() }()
-
- streamBody, err := io.ReadAll(streamResp.Body)
- if err != nil {
- return nil, err
- }
-
- streamURLList, err := parseTuneInStreamBody(streamBody, podcastID)
- if err != nil {
- return nil, err
- }
-
- streamID := "e3342"
- listenID := "3432432423"
- bmxReportingQS := url.Values{}
- bmxReportingQS.Set("stream_id", streamID)
- bmxReportingQS.Set("guide_id", podcastID)
- bmxReportingQS.Set("listen_id", listenID)
- bmxReportingQS.Set("stream_type", "onDemand")
- bmxReporting := "/v1/report?" + bmxReportingQS.Encode()
-
- var streams []models.Stream
-
- for _, sURL := range streamURLList {
- sURL = strings.TrimSpace(sURL)
- if sURL == "" {
- continue
- }
-
- streams = append(streams, models.Stream{
- Links: &models.Links{
- BmxReporting: &models.Link{Href: bmxReporting},
- },
- HasPlaylist: true,
- IsRealtime: false,
- BufferingTimeout: 20,
- ConnectingTimeout: 10,
- StreamUrl: sURL,
- })
- }
-
- audio := models.Audio{
- HasPlaylist: true,
- IsRealtime: false,
- MaxTimeout: 60,
- StreamUrl: streamURLList[0],
- Streams: streams,
- }
-
- duration, _ := strconv.Atoi(topic.Duration)
-
- response := &models.BmxPlaybackResponse{
- Links: &models.Links{
- BmxFavorite: &models.Link{Href: fmt.Sprintf("/v1/favorite/%s", topic.ShowID)},
- BmxReporting: &models.Link{Href: bmxReporting},
- },
- Artist: struct {
- Name string `json:"name,omitempty" xml:"name,omitempty"`
- }{Name: topic.ShowTitle},
- Audio: audio,
- Duration: duration,
- ImageUrl: topic.Logo,
- IsFavorite: new(bool),
- Name: topic.Title,
- ShuffleDisabled: true,
- RepeatDisabled: true,
- StreamType: "onDemand",
- }
-
- 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{
@@ -1116,12 +39,7 @@ func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPla
// PlayCustomStream builds a playback response from a base64-encoded JSON blob
// with fields streamUrl, imageUrl, and name.
func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
- // Bose app sometimes sends non-standard base64, so try both standard and URL-safe
- jsonStr, err := base64.URLEncoding.DecodeString(data)
- if err != nil {
- jsonStr, err = base64.StdEncoding.DecodeString(data)
- }
-
+ jsonStr, err := decodeBase64URI(data)
if err != nil {
return nil, err
}
@@ -1131,7 +49,7 @@ func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
ImageURL string `json:"imageUrl"`
Name string `json:"name"`
}
- if err := json.Unmarshal(jsonStr, &jsonObj); err != nil {
+ if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err != nil {
return nil, err
}
diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go
index b8e4716..7a81bf3 100644
--- a/pkg/service/bmx/bmx_test.go
+++ b/pkg/service/bmx/bmx_test.go
@@ -1,176 +1,12 @@
package bmx
import (
- "encoding/base64"
- "encoding/json"
- "strings"
"testing"
)
-func TestTuneInRenderJSONURI(t *testing.T) {
- tests := []struct {
- name string
- input string
- want string
- }{
- {
- name: "empty URL returns empty",
- input: "",
- want: "",
- },
- {
- name: "URL with no query params gets render=json added",
- input: "http://opml.radiotime.com/Browse.ashx",
- want: "http://opml.radiotime.com/Browse.ashx?render=json",
- },
- {
- name: "URL with other params gets render=json appended",
- input: "http://opml.radiotime.com/Browse.ashx?c=news",
- want: "http://opml.radiotime.com/Browse.ashx?c=news&render=json",
- },
- {
- name: "URL already containing render=json is not duplicated",
- input: "http://opml.radiotime.com/?render=json",
- want: "http://opml.radiotime.com/?render=json",
- },
- {
- name: "URL with render=xml gets render replaced with json",
- input: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=xml",
- want: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := tuneInRenderJSONURI(tt.input)
- if got != tt.want {
- t.Errorf("tuneInRenderJSONURI(%q) = %q, want %q", tt.input, got, tt.want)
- }
- })
- }
-}
-
-func TestIsTuneInOpmlURI(t *testing.T) {
- tests := []struct {
- input string
- want bool
- }{
- {"http://opml.radiotime.com/Browse.ashx", true},
- {"https://opml.radiotime.com/Browse.ashx", true},
- {"http://opml.radiotime.com/?render=json", true},
- {"http://api.radiotime.com/profiles?fulltextsearch=true", false},
- {"http://example.com", false},
- {"not-a-url", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.input, func(t *testing.T) {
- got := isTuneInOpmlURI(tt.input)
- if got != tt.want {
- t.Errorf("isTuneInOpmlURI(%q) = %v, want %v", tt.input, got, tt.want)
- }
- })
- }
-}
-
-func TestTuneInSearchURI(t *testing.T) {
- tests := []struct {
- name string
- query string
- check func(string) bool
- }{
- {
- name: "spaces are percent-encoded",
- query: "radio paradise",
- check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "radio+paradise") },
- },
- {
- name: "ampersand is encoded",
- query: "news & talk",
- check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "%26") },
- },
- {
- name: "plain query is appended to base URL",
- query: "jazz",
- check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := tuneInSearchURI(tt.query)
- if !tt.check(got) {
- t.Errorf("tuneInSearchURI(%q) = %q: check failed", tt.query, got)
- }
- })
- }
-}
-
-func TestTuneInNavigateLinkEncodesRenderJSON(t *testing.T) {
- item := map[string]interface{}{
- "URL": "http://opml.radiotime.com/Browse.ashx?c=news",
- "text": "News",
- "subtext": "Latest",
- "image": "http://example.com/news.png",
- }
-
- result := tuneInNavigateLink(item)
-
- href := result.Links.BmxNavigate.Href
- encoded := strings.TrimPrefix(href, "/v1/navigate/")
- decoded, err := base64.URLEncoding.DecodeString(encoded)
- if err != nil {
- t.Fatalf("failed to decode navigate href: %v", err)
- }
-
- got := string(decoded)
- if !strings.Contains(got, "render=json") {
- t.Errorf("navigate href %q missing render=json", got)
- }
- if strings.Count(got, "render=json") > 1 {
- t.Errorf("navigate href %q has duplicate render=json", got)
- }
-}
-
-func TestTuneInNavigateLinkNoDuplicateRenderJSON(t *testing.T) {
- item := map[string]interface{}{
- "URL": "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
- }
-
- result := tuneInNavigateLink(item)
-
- href := result.Links.BmxNavigate.Href
- encoded := strings.TrimPrefix(href, "/v1/navigate/")
- decoded, err := base64.URLEncoding.DecodeString(encoded)
- if err != nil {
- t.Fatalf("failed to decode navigate href: %v", err)
- }
-
- got := string(decoded)
- if strings.Count(got, "render=json") != 1 {
- t.Errorf("navigate href %q should contain render=json exactly once", got)
- }
-}
-
func TestPlayCustomStream(t *testing.T) {
- // Simple test for custom stream XML generation
- dataObj := struct {
- StreamURL string `json:"streamUrl"`
- ImageURL string `json:"imageUrl"`
- Name string `json:"name"`
- }{
- StreamURL: "http://example.com/stream.mp3",
- ImageURL: "image.png",
- Name: "Stream Name",
- }
-
- jsonBytes, err := json.Marshal(dataObj)
- if err != nil {
- t.Fatalf("Failed to marshal test data: %v", err)
- }
-
// Test Standard Base64
- dataStd := base64.StdEncoding.EncodeToString(jsonBytes)
+ dataStd := "eyJzdHJlYW1VcmwiOiJodHRwOi8vZXhhbXBsZS5jb20vc3RyZWFtLm1wMyIsImltYWdlVXJsIjoiaW1hZ2UucG5nIiwibmFtZSI6IlN0cmVhbSBOYW1lIn0="
resp, err := PlayCustomStream(dataStd)
if err != nil {
@@ -182,7 +18,7 @@ func TestPlayCustomStream(t *testing.T) {
}
// Test URL-safe Base64
- dataURL := base64.URLEncoding.EncodeToString(jsonBytes)
+ dataURL := "eyJzdHJlYW1VcmwiOiJodHRwOi8vZXhhbXBsZS5jb20vc3RyZWFtLm1wMyIsImltYWdlVXJsIjoiaW1hZ2UucG5nIiwibmFtZSI6IlN0cmVhbSBOYW1lIn0="
resp, err = PlayCustomStream(dataURL)
if err != nil {
@@ -193,342 +29,3 @@ func TestPlayCustomStream(t *testing.T) {
t.Errorf("Expected name Stream Name, got %s", resp.Name)
}
}
-
-func TestTuneInPodcastInfo_Base64(t *testing.T) {
- name := "Podcast Name / with special chars?"
-
- // Test Standard Base64
- encodedStd := base64.StdEncoding.EncodeToString([]byte(name))
-
- resp, err := TuneInPodcastInfo("123", encodedStd)
- if err != nil {
- t.Fatalf("TuneInPodcastInfo with standard base64 failed: %v", err)
- }
-
- if resp.Name != name {
- t.Errorf("Expected name %s, got %s", name, resp.Name)
- }
-
- // Test URL-safe Base64
- encodedURL := base64.URLEncoding.EncodeToString([]byte(name))
-
- resp, err = TuneInPodcastInfo("123", encodedURL)
- if err != nil {
- t.Fatalf("TuneInPodcastInfo with URL-safe base64 failed: %v", err)
- }
-
- if resp.Name != name {
- t.Errorf("Expected name %s, got %s", name, resp.Name)
- }
-}
-
-// TestTuneInStream_EmptyFormatsUsesDefault pins the post-#292 contract:
-// AfterTouch must NOT request HLS streams from TuneIn unless the
-// operator has explicitly opted in. The default request shape is
-// "mp3,aac,ogg" — matches pre-2026-05-10 behaviour and works on
-// every SoundTouch model verified. PR #249 had added "hls"
-// unconditionally; that regressed playback on ST10/firmware 27 (the
-// speaker can't parse the .m3u8 playlist TuneIn returns when HLS is
-// in the format list).
-func TestTuneInStream_EmptyFormatsUsesDefault(t *testing.T) {
- got := TuneInStream("s33828", "")
-
- if strings.Contains(got, "hls") {
- t.Errorf("default TuneInStream URL must NOT request HLS; got %s", got)
- }
-
- want := "formats=" + DefaultTuneInStreamFormats
- if !strings.Contains(got, want) {
- t.Errorf("default TuneInStream URL must request %q; got %s", want, got)
- }
-
- if !strings.Contains(got, "id=s33828") {
- t.Errorf("TuneInStream URL must carry the station ID; got %s", got)
- }
-}
-
-// TestTuneInStream_OverrideHonoured verifies the opt-in path: when an
-// operator sets Settings.TuneInStreamFormats to a custom list,
-// TuneInStream passes it through verbatim. Two sub-cases catch the
-// common opt-in (re-add hls) and a more drastic override (single
-// format) so a future regression in the trim/fallback logic surfaces
-// at compile/test time.
-func TestTuneInStream_OverrideHonoured(t *testing.T) {
- cases := []struct {
- formats string
- want string
- }{
- {"mp3,aac,ogg,hls", "formats=mp3,aac,ogg,hls"}, // opt-in: re-add HLS
- {"aac", "formats=aac"}, // single format
- {" mp3 ", "formats=mp3"}, // whitespace stripped
- }
-
- for _, tc := range cases {
- got := TuneInStream("s33828", tc.formats)
- if !strings.Contains(got, tc.want) {
- t.Errorf("TuneInStream(%q) URL must contain %q; got %s", tc.formats, tc.want, got)
- }
- }
-}
-
-func TestParseTuneInStreamBody(t *testing.T) {
- cases := []struct {
- name string
- body string
- wantURLs []string
- wantError bool
- }{
- {
- name: "single URL",
- body: "https://stream.example.com/foo.mp3\n",
- wantURLs: []string{"https://stream.example.com/foo.mp3"},
- },
- {
- name: "multiple URLs",
- body: "https://a/1.mp3\nhttps://b/2.mp3\n",
- wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
- },
- {
- // The bug behind PR #313's i314 follow-up — TuneIn 200's the
- // response body with `#STATUS: 400` for guide-ids that aren't
- // streamable (e.g. podcast program IDs sent to Tune.ashx).
- // Pre-fix, this string went out to the speaker as if it were a
- // stream URL.
- name: "comment-only body — TuneIn 400 error",
- body: "#STATUS: 400\n#description=Bad request\n",
- wantError: true,
- },
- {
- name: "comments mixed with real URL",
- body: "#EXTM3U\nhttps://stream.example.com/foo.mp3\n#END\n",
- wantURLs: []string{"https://stream.example.com/foo.mp3"},
- },
- {
- name: "empty body",
- body: "",
- wantError: true,
- },
- {
- name: "only blank lines",
- body: "\n\n \n",
- wantError: true,
- },
- {
- name: "trims surrounding whitespace per line",
- body: " https://a/1.mp3 \n\thttps://b/2.mp3\t\n",
- wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
- },
- }
-
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- got, err := parseTuneInStreamBody([]byte(tc.body), "test-guide-id")
-
- if tc.wantError {
- if err == nil {
- t.Fatalf("expected error, got %v", got)
- }
-
- if !strings.Contains(err.Error(), "test-guide-id") {
- t.Errorf("error should mention the guide-id for diagnosis: %v", err)
- }
-
- return
- }
-
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(got) != len(tc.wantURLs) {
- t.Fatalf("len mismatch: got %d (%v), want %d (%v)", len(got), got, len(tc.wantURLs), tc.wantURLs)
- }
-
- for i := range got {
- if got[i] != tc.wantURLs[i] {
- t.Errorf("URL[%d] mismatch: got %q, want %q", i, got[i], tc.wantURLs[i])
- }
- }
- })
- }
-}
-
-// TestTuneInSearchProfileEmitsBmxPlayback pins the rule that
-// program-card play buttons appear in the web/CLI search UI: Program
-// search items get a BmxPlayback link (so the speaker hits our
-// podcast endpoint and the p → t expansion kicks in), while
-// Artist items stay navigate-only — there's no single sensible
-// stream for an artist.
-func TestTuneInSearchProfileEmitsBmxPlayback(t *testing.T) {
- cases := []struct {
- name string
- profileName string
- guideID string
- wantPlayback bool
- wantType string
- }{
- {name: "Program with guide-id gets play link", profileName: "Program", guideID: "p290778", wantPlayback: true, wantType: "tracklisturl"},
- {name: "Artist with guide-id is navigate-only", profileName: "Artist", guideID: "a12345", wantPlayback: false},
- {name: "Program without guide-id is navigate-only", profileName: "Program", guideID: "", wantPlayback: false},
- }
-
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- item := map[string]interface{}{
- "GuideId": tc.guideID,
- "Title": "Die Nachrichten",
- "Image": "http://example.com/logo.png",
- "Subtitle": "Deutschlandfunk",
- "Actions": map[string]interface{}{
- "Profile": map[string]interface{}{
- "Url": "https://api.radiotime.com/profiles/" + tc.guideID,
- },
- },
- }
-
- navItem := tuneInSearchProfile(item, tc.profileName)
-
- if navItem.Links == nil {
- t.Fatal("expected Links to be set")
- }
-
- if tc.wantPlayback {
- if navItem.Links.BmxPlayback == nil {
- t.Fatal("expected BmxPlayback link for Program")
- }
-
- if navItem.Links.BmxPlayback.Type != tc.wantType {
- t.Errorf("BmxPlayback.Type = %q, want %q", navItem.Links.BmxPlayback.Type, tc.wantType)
- }
-
- if !strings.Contains(navItem.Links.BmxPlayback.Href, tc.guideID) {
- t.Errorf("BmxPlayback.Href must carry the guide-id %q; got %q", tc.guideID, navItem.Links.BmxPlayback.Href)
- }
-
- if !strings.Contains(navItem.Links.BmxPlayback.Href, "encoded_name=") {
- t.Errorf("BmxPlayback.Href should carry encoded_name; got %q", navItem.Links.BmxPlayback.Href)
- }
- } else if navItem.Links.BmxPlayback != nil {
- t.Errorf("did not expect BmxPlayback link; got %+v", navItem.Links.BmxPlayback)
- }
-
- // Navigation drill-in must always remain available, even when
- // a play button is emitted — clicking the card body should
- // still take the user to the episode list.
- if navItem.Links.BmxNavigate == nil {
- t.Error("expected BmxNavigate link to remain available")
- }
- })
- }
-}
-
-// TestParseTuneInProgramContents pins the contract behind the
-// p → t expansion that powers `--program` playback for issue
-// #226. Real-world fixture shape captured from
-// api.tunein.com/profiles/p290778/contents (see
-// `_/i226/tunein-probe/profile_contents.json`).
-func TestParseTuneInProgramContents(t *testing.T) {
- const happyPath = `{
- "Items": [
- {
- "ContainerType": "Topics",
- "Title": "Episodes",
- "Children": [
- { "GuideId": "t554138374", "Type": "Topic", "Title": "newest" },
- { "GuideId": "t554134863", "Type": "Topic", "Title": "previous" }
- ]
- }
- ]
- }`
-
- // TuneIn varies the localised container title; verify the
- // fallback picks the first Topics container even when the title
- // doesn't match "Episodes".
- const localisedTitle = `{
- "Items": [
- {
- "ContainerType": "Topics",
- "Title": "Folgen",
- "Children": [
- { "GuideId": "t111", "Type": "Topic", "Title": "newest" }
- ]
- }
- ]
- }`
-
- // "Episodes" container precedence: even if a "Related Shows"
- // Topics container appears first, we must pick the named one.
- const episodesAfterRelated = `{
- "Items": [
- {
- "ContainerType": "Topics",
- "Title": "Related Shows",
- "Children": [
- { "GuideId": "t999", "Type": "Topic", "Title": "wrong" }
- ]
- },
- {
- "ContainerType": "Topics",
- "Title": "Episodes",
- "Children": [
- { "GuideId": "t222", "Type": "Topic", "Title": "right" }
- ]
- }
- ]
- }`
-
- // Skip non-topic children — TuneIn occasionally mixes in
- // container-style children (rare, but defensive).
- const skipsNonTopic = `{
- "Items": [
- {
- "ContainerType": "Topics",
- "Title": "Episodes",
- "Children": [
- { "GuideId": "p333", "Type": "Container", "Title": "nested program" },
- { "GuideId": "t444", "Type": "Topic", "Title": "real episode" }
- ]
- }
- ]
- }`
-
- cases := []struct {
- name string
- body string
- wantID string
- wantError bool
- }{
- {name: "happy path — first child wins", body: happyPath, wantID: "t554138374"},
- {name: "localised title — falls back to first Topics container", body: localisedTitle, wantID: "t111"},
- {name: "Episodes container preferred over Related", body: episodesAfterRelated, wantID: "t222"},
- {name: "skips non-Topic children", body: skipsNonTopic, wantID: "t444"},
- {name: "empty body — error", body: `{}`, wantError: true},
- {name: "no Topics containers — error", body: `{"Items":[{"ContainerType":"Banner","Children":[]}]}`, wantError: true},
- {name: "Topics with no t-prefixed children — error",
- body: `{"Items":[{"ContainerType":"Topics","Title":"Episodes","Children":[{"GuideId":"p1"}]}]}`,
- wantError: true},
- {name: "malformed JSON — error", body: `{not json`, wantError: true},
- }
-
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- got, err := parseTuneInProgramContents([]byte(tc.body), "p290778")
-
- if tc.wantError {
- if err == nil {
- t.Fatalf("expected error, got id=%q", got)
- }
-
- return
- }
-
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if got != tc.wantID {
- t.Errorf("got episode id %q, want %q", got, tc.wantID)
- }
- })
- }
-}
diff --git a/pkg/service/bmx/radiobrowser.go b/pkg/service/bmx/radiobrowser.go
new file mode 100644
index 0000000..7b05657
--- /dev/null
+++ b/pkg/service/bmx/radiobrowser.go
@@ -0,0 +1,77 @@
+package bmx
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+var radioBrowserBaseURL = "https://all.api.radio-browser.info"
+
+// RadioBrowserSearch searches for radio stations using the RadioBrowser API.
+func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
+ searchURL := fmt.Sprintf("%s/json/stations/search?name=%s&limit=20&order=clickcount&reverse=true",
+ radioBrowserBaseURL, url.QueryEscape(query))
+
+ resp, err := http.Get(searchURL)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("radio-browser search failed with status %d", resp.StatusCode)
+ }
+
+ var stations []map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&stations); err != nil {
+ return nil, err
+ }
+
+ navResp := &models.BmxNavResponse{
+ BmxSections: []models.BmxNavSection{
+ {
+ Name: "Stations",
+ Items: make([]models.BmxNavItem, 0, len(stations)),
+ },
+ },
+ }
+
+ for _, station := range stations {
+ name, _ := station["name"].(string)
+ uuid, _ := station["stationuuid"].(string)
+ favicon, _ := station["favicon"].(string)
+ country, _ := station["country"].(string)
+ tags, _ := station["tags"].(string)
+
+ subtitle := country
+ if tags != "" {
+ if subtitle != "" {
+ subtitle += " · "
+ }
+
+ subtitle += tags
+ }
+
+ // SoundTouch format location for RadioBrowser
+ location := fmt.Sprintf("%s/soundtouch/stations/byuuid/%s", radioBrowserBaseURL, uuid)
+
+ item := models.BmxNavItem{
+ Name: name,
+ ImageUrl: favicon,
+ Subtitle: subtitle,
+ Links: &models.Links{
+ BmxPlayback: &models.Link{
+ Href: location,
+ Type: "stationurl",
+ },
+ },
+ }
+ navResp.BmxSections[0].Items = append(navResp.BmxSections[0].Items, item)
+ }
+
+ return navResp, nil
+}
diff --git a/pkg/service/bmx/radiobrowser_test.go b/pkg/service/bmx/radiobrowser_test.go
new file mode 100644
index 0000000..d70a0c3
--- /dev/null
+++ b/pkg/service/bmx/radiobrowser_test.go
@@ -0,0 +1,72 @@
+package bmx
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRadioBrowserSearch(t *testing.T) {
+ // Mock RadioBrowser API
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, `[
+ {
+ "name": "Radio Paradise",
+ "stationuuid": "123-456",
+ "favicon": "http://example.com/favicon.png",
+ "country": "USA",
+ "tags": "eclectic,rock"
+ }
+ ]`)
+ }))
+ defer ts.Close()
+
+ // Use the mock server
+ originalBaseURL := radioBrowserBaseURL
+ radioBrowserBaseURL = ts.URL
+ defer func() { radioBrowserBaseURL = originalBaseURL }()
+
+ resp, err := RadioBrowserSearch("Paradise")
+ if err != nil {
+ t.Fatalf("RadioBrowserSearch failed: %v", err)
+ }
+
+ if len(resp.BmxSections) == 0 || len(resp.BmxSections[0].Items) == 0 {
+ t.Fatal("expected items in response")
+ }
+
+ item := resp.BmxSections[0].Items[0]
+ if item.Name != "Radio Paradise" {
+ t.Errorf("expected name 'Radio Paradise', got %q", item.Name)
+ }
+}
+
+func TestRadioBrowserSearch_Real(t *testing.T) {
+ query := "Deutschlandfunk Kultur"
+ resp, err := RadioBrowserSearch(query)
+ if err != nil {
+ t.Fatalf("RadioBrowserSearch failed: %v", err)
+ }
+
+ if resp == nil {
+ t.Fatal("expected non-nil response")
+ }
+
+ if len(resp.BmxSections) == 0 {
+ t.Fatal("expected at least one section")
+ }
+
+ found := false
+ for _, section := range resp.BmxSections {
+ if section.Name == "Stations" && len(section.Items) > 0 {
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ t.Error("expected to find Stations section with items")
+ }
+}
diff --git a/pkg/service/bmx/tunein.go b/pkg/service/bmx/tunein.go
new file mode 100644
index 0000000..08e68cf
--- /dev/null
+++ b/pkg/service/bmx/tunein.go
@@ -0,0 +1,790 @@
+package bmx
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+// TuneIn endpoint templates used to resolve station and stream URLs.
+const (
+ TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
+ TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
+ TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
+
+ // TuneInProfileContents is the modern JSON API that lists a
+ // program's (`p`) episodes. The legacy OPML endpoints can't —
+ // `Tune.ashx?id=p` returns `#STATUS: 400`, `Browse.ashx?id=p`
+ // only surfaces related genres + networks. Same payload is served
+ // from api.tunein.com and api.radiotime.com; we use radiotime
+ // because TuneInNavigateProfile already navigates there via
+ // Pivots.Contents.Url, so all program-related traffic stays on the
+ // same host that's already in allowedTuneInHosts. See
+ // `_/i226/tunein-api-findings.md` for the full endpoint map.
+ TuneInProfileContents = "https://api.radiotime.com/profiles/%s/contents?version=1.3"
+
+ // DefaultTuneInStreamFormats is the comma-separated format list
+ // AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
+ // pre-2026-05-10 behaviour from before PR #249 added "hls"
+ // unconditionally — HLS playback is broken on SoundTouch 10/
+ // firmware 27 (and probably the rest of the line; see #292).
+ // Speakers receive an .m3u8 playlist URL they can't parse, blink
+ // amber, fall silent. Operators with HLS-compatible speakers can
+ // override via Settings.TuneInStreamFormats.
+ DefaultTuneInStreamFormats = "mp3,aac,ogg"
+)
+
+// TuneInStream returns the formatted Tune.ashx URL for a station or
+// podcast. The formats argument controls the formats= query parameter;
+// empty falls back to DefaultTuneInStreamFormats. Operators can set
+// arbitrary lists (e.g. "mp3,aac,ogg,hls" to re-enable HLS, or
+// "aac" to force a single format) via Settings.TuneInStreamFormats.
+// The value is passed through verbatim — no token-level validation.
+func TuneInStream(stationID, formats string) string {
+ formats = strings.TrimSpace(formats)
+ if formats == "" {
+ formats = DefaultTuneInStreamFormats
+ }
+
+ return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats)
+}
+
+// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
+var allowedTuneInHosts = map[string]bool{
+ "opml.radiotime.com": true,
+ "api.radiotime.com": true,
+}
+
+// isTuneInOpmlURI returns true when the URL's host is opml.radiotime.com,
+// used to select the OPML/ashx parser over the JSON API parser.
+func isTuneInOpmlURI(rawURL string) bool {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return false
+ }
+
+ return strings.EqualFold(u.Hostname(), "opml.radiotime.com")
+}
+
+// tuneInRenderJSONURI returns the URL with render=json set as a query parameter,
+// replacing any existing render value instead of appending a duplicate.
+func tuneInRenderJSONURI(rawURL string) string {
+ if rawURL == "" {
+ return ""
+ }
+
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return rawURL
+ }
+
+ q := u.Query()
+ q.Set("render", "json")
+ u.RawQuery = q.Encode()
+
+ return u.String()
+}
+
+// tuneInSearchURI returns the TuneIn search API URL with the query properly URL-encoded.
+func tuneInSearchURI(query string) string {
+ return TuneInSearchAPI + url.QueryEscape(query)
+}
+
+func fetchJSON(fetchURL string) (map[string]interface{}, error) {
+ return fetchJSONMap(defaultClient, fetchURL, allowedTuneInHosts)
+}
+
+// TuneInNavigate returns a live browse response for the given encoded TuneIn URI.
+// Pass subsection as nil for a full page, or a pointer to an int for a single subsection.
+func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse, error) {
+ var (
+ tuneInURI string
+ bmxSearchLink *models.Link
+ )
+
+ if encodedURI != "" {
+ decoded, err := decodeBase64URI(encodedURI)
+ if err != nil {
+ return nil, err
+ }
+
+ tuneInURI = decoded
+ } else {
+ tuneInURI = TuneInNavigateAshx
+ templated := true
+ bmxSearchLink = &models.Link{
+ Filters: []interface{}{},
+ Href: "/v1/search?q={query}",
+ Templated: &templated,
+ }
+ }
+
+ var (
+ sections []models.BmxNavSection
+ err error
+ )
+
+ if isTuneInOpmlURI(tuneInURI) {
+ sections, err = tuneInSectionsAshx(tuneInURI, subsection)
+ } else {
+ sections, err = tuneInSectionsJSONAPI(tuneInURI, subsection)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ var subsectionPart, uriPart string
+ if subsection != nil {
+ subsectionPart = fmt.Sprintf("/sub/%d", *subsection)
+ }
+
+ if encodedURI != "" {
+ uriPart = "/" + encodedURI
+ }
+
+ return &models.BmxNavResponse{
+ Links: &models.Links{
+ Self: &models.Link{Href: fmt.Sprintf("/v1/navigate%s%s", subsectionPart, uriPart)},
+ BmxSearch: bmxSearchLink,
+ },
+ BmxSections: sections,
+ Layout: "classic",
+ }, nil
+}
+
+func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) {
+ data, err := fetchJSON(tuneInURI)
+ if err != nil {
+ return nil, err
+ }
+
+ layout := "list"
+
+ var (
+ sections []models.BmxNavSection
+ topItems []models.BmxNavItem
+ )
+
+ body, _ := data["body"].([]interface{})
+ for idx, item := range body {
+ m, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ if subsection != nil && idx != *subsection {
+ continue
+ }
+
+ itemType, _ := m["type"].(string)
+ switch itemType {
+ case "link":
+ if children, ok := m["children"].([]interface{}); ok && len(children) > 0 {
+ name, _ := m["text"].(string)
+
+ section := models.BmxNavSection{
+ Name: name,
+ Items: make([]models.BmxNavItem, 0, len(children)),
+ }
+ for _, child := range children {
+ cm, ok := child.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ childType, _ := cm["type"].(string)
+ if childType == "audio" {
+ section.Items = append(section.Items, tuneInNavigatePlayItem(cm))
+ } else {
+ section.Items = append(section.Items, tuneInNavigateLink(cm))
+ }
+ }
+
+ sections = append(sections, section)
+ } else {
+ topItems = append(topItems, tuneInNavigateLink(m))
+ }
+ case "audio":
+ topItems = append(topItems, tuneInNavigatePlayItem(m))
+ case "text":
+ // Ignore info text
+ }
+ }
+
+ if len(topItems) > 0 {
+ sections = append([]models.BmxNavSection{{Items: topItems}}, sections...)
+ }
+
+ for i := range sections {
+ if sections[i].Layout == "" {
+ sections[i].Layout = layout
+ }
+ }
+
+ return sections, nil
+}
+
+func tuneInSectionsJSONAPI(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) {
+ data, err := fetchJSON(tuneInURI)
+ if err != nil {
+ return nil, err
+ }
+
+ var sections []models.BmxNavSection
+
+ body, _ := data["body"].([]interface{})
+ for idx, item := range body {
+ m, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ if subsection != nil && idx != *subsection {
+ continue
+ }
+
+ sections = append(sections, tuneInSearchSection(m, idx, "", "list"))
+ }
+
+ return sections, nil
+}
+
+func tuneInNavigatePlayItem(item map[string]interface{}) models.BmxNavItem {
+ name, _ := item["Title"].(string)
+ if name == "" {
+ name, _ = item["text"].(string)
+ }
+
+ stationID, _ := item["GuideId"].(string)
+ if stationID == "" {
+ stationID, _ = item["guide_id"].(string)
+ }
+
+ image, _ := item["image"].(string)
+ subtitle, _ := item["subtext"].(string)
+
+ return models.BmxNavItem{
+ Name: name,
+ ImageUrl: image,
+ Subtitle: subtitle,
+ Links: &models.Links{
+ BmxPlayback: &models.Link{
+ Href: TuneInStream(stationID, ""),
+ Type: "stationurl",
+ },
+ },
+ }
+}
+
+func tuneInNavigateLink(item map[string]interface{}) models.BmxNavItem {
+ name, _ := item["Title"].(string)
+ if name == "" {
+ name, _ = item["text"].(string)
+ }
+
+ image, _ := item["image"].(string)
+ subtitle, _ := item["subtext"].(string)
+ href, _ := item["URL"].(string)
+
+ return models.BmxNavItem{
+ Name: name,
+ ImageUrl: image,
+ Subtitle: subtitle,
+ Links: &models.Links{
+ BmxNavigate: &models.Link{
+ Href: "/v1/navigate/" + base64.RawURLEncoding.EncodeToString([]byte(tuneInRenderJSONURI(href))),
+ },
+ },
+ }
+}
+
+// TuneInSearch searches TuneIn for the given query.
+func TuneInSearch(query string) (*models.BmxNavResponse, error) {
+ data, err := fetchJSON(tuneInSearchURI(query))
+ if err != nil {
+ return nil, err
+ }
+
+ navResp := &models.BmxNavResponse{
+ Links: &models.Links{
+ Self: &models.Link{Href: "/v1/search?q=" + url.QueryEscape(query)},
+ },
+ Layout: "classic",
+ }
+
+ // Try "Items" (v1.3) first, then "body" (legacy)
+ items, ok := data["Items"].([]interface{})
+ if !ok {
+ items, _ = data["body"].([]interface{})
+ }
+
+ for idx, item := range items {
+ m, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ navResp.BmxSections = append(navResp.BmxSections, tuneInSearchSection(m, idx, query, "grid"))
+ }
+
+ return navResp, nil
+}
+
+func tuneInSearchSection(item map[string]interface{}, idx int, query, layout string) models.BmxNavSection {
+ name, _ := item["Title"].(string)
+ if name == "" {
+ name, _ = item["text"].(string)
+ }
+
+ children, ok := item["Children"].([]interface{})
+ if !ok {
+ children, _ = item["children"].([]interface{})
+ }
+
+ section := models.BmxNavSection{
+ Name: name,
+ Layout: layout,
+ Items: make([]models.BmxNavItem, 0, len(children)),
+ }
+
+ if query != "" {
+ section.Links = &models.Links{
+ Self: &models.Link{Href: fmt.Sprintf("/v1/search/sub/%d?q=%s", idx, url.QueryEscape(query))},
+ }
+ }
+
+ for _, child := range children {
+ cm, ok := child.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ typeStr, _ := cm["Type"].(string)
+ if typeStr == "" {
+ typeStr, _ = cm["className"].(string)
+ }
+
+ switch typeStr {
+ case "Station", "PlayItem":
+ section.Items = append(section.Items, tuneInSearchPlayItem(cm))
+ case "Topic":
+ section.Items = append(section.Items, tuneInSearchTopic(cm))
+ case "Program", "Profile":
+ section.Items = append(section.Items, tuneInSearchProfile(cm, name))
+ }
+ }
+
+ return section
+}
+
+func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem {
+ name, _ := item["Title"].(string)
+ if name == "" {
+ name, _ = item["text"].(string)
+ }
+
+ stationID, _ := item["GuideId"].(string)
+ if stationID == "" {
+ stationID, _ = item["guide_id"].(string)
+ }
+
+ image, _ := item["Image"].(string)
+ if image == "" {
+ image, _ = item["image"].(string)
+ }
+
+ subtitle, _ := item["Subtitle"].(string)
+ if subtitle == "" {
+ subtitle, _ = item["subtext"].(string)
+ }
+
+ return models.BmxNavItem{
+ Name: name,
+ ImageUrl: image,
+ Subtitle: subtitle,
+ Links: &models.Links{
+ BmxPlayback: &models.Link{
+ Href: TuneInStream(stationID, ""),
+ Type: "stationurl",
+ },
+ },
+ }
+}
+
+func tuneInSearchTopic(item map[string]interface{}) models.BmxNavItem {
+ name, _ := item["Title"].(string)
+ if name == "" {
+ name, _ = item["text"].(string)
+ }
+
+ image, _ := item["Image"].(string)
+ if image == "" {
+ image, _ = item["image"].(string)
+ }
+
+ subtitle, _ := item["Subtitle"].(string)
+ if subtitle == "" {
+ subtitle, _ = item["subtext"].(string)
+ }
+
+ href, _ := item["URL"].(string)
+
+ return models.BmxNavItem{
+ Name: name,
+ ImageUrl: image,
+ Subtitle: subtitle,
+ Links: &models.Links{
+ BmxNavigate: &models.Link{
+ Href: "/v1/navigate/" + base64.RawURLEncoding.EncodeToString([]byte(tuneInRenderJSONURI(href))),
+ },
+ },
+ }
+}
+
+func tuneInSearchProfile(item map[string]interface{}, _ string) models.BmxNavItem {
+ profileName, _ := item["Title"].(string)
+ if profileName == "" {
+ profileName, _ = item["text"].(string)
+ }
+
+ image, _ := item["Image"].(string)
+ if image == "" {
+ image, _ = item["image"].(string)
+ }
+
+ subtitle, _ := item["Subtitle"].(string)
+ if subtitle == "" {
+ subtitle, _ = item["subtext"].(string)
+ }
+
+ href := ""
+
+ if actions, ok := item["Actions"].(map[string]interface{}); ok {
+ if profile, ok := actions["Profile"].(map[string]interface{}); ok {
+ href, _ = profile["Url"].(string)
+ }
+ }
+
+ if href == "" {
+ href, _ = item["URL"].(string)
+ }
+
+ // Programs with a GuideId can be played directly (as tracklisturl).
+ // Artists/Stations/etc are typically navigated first.
+ if typeStr, _ := item["Type"].(string); typeStr == "Program" {
+ if guideID, _ := item["GuideId"].(string); guideID != "" {
+ return models.BmxNavItem{
+ Name: profileName,
+ ImageUrl: image,
+ Subtitle: subtitle,
+ Links: &models.Links{
+ BmxPlayback: &models.Link{
+ Href: TuneInStream(guideID, "") + "&encoded_name=" + url.QueryEscape(profileName),
+ Type: "tracklisturl",
+ },
+ BmxNavigate: &models.Link{
+ Href: "/v1/navigate/profiles/" + base64.URLEncoding.EncodeToString([]byte(href)),
+ },
+ },
+ }
+ }
+ }
+
+ // Profiles for Artists/etc often have a separate navigate path
+ // that lists their programs/albums.
+ return models.BmxNavItem{
+ Name: profileName,
+ ImageUrl: image,
+ Subtitle: subtitle,
+ Links: &models.Links{
+ BmxNavigate: &models.Link{
+ Href: "/v1/navigate/profiles/" + base64.RawURLEncoding.EncodeToString([]byte(href)),
+ },
+ },
+ }
+}
+
+// TuneInNavigateProfile returns a browse response for a TuneIn profile.
+func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
+ decoded, err := decodeBase64URI(encodedURI)
+ if err != nil {
+ return nil, err
+ }
+
+ data, err := fetchJSON(tuneInRenderJSONURI(decoded))
+ if err != nil {
+ return nil, err
+ }
+
+ navResp := &models.BmxNavResponse{
+ Links: &models.Links{
+ Self: &models.Link{Href: "/v1/navigate/profile/" + encodedURI},
+ },
+ Layout: "classic",
+ }
+
+ // Profiles contain "pivots" (sections like "Programs", "Related", etc.)
+ pivots, _ := data["pivots"].([]interface{})
+ for _, p := range pivots {
+ pivot, ok := p.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ pivotName, _ := pivot["text"].(string)
+ pivotURL, _ := pivot["URL"].(string)
+
+ // We only care about the "Contents" pivot for now (the main list)
+ if !strings.EqualFold(pivotName, "contents") {
+ continue
+ }
+
+ contents, err := fetchJSON(tuneInRenderJSONURI(pivotURL))
+ if err != nil {
+ return nil, err
+ }
+
+ body, _ := contents["body"].([]interface{})
+ for idx, item := range body {
+ m, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ navResp.BmxSections = append(navResp.BmxSections, tuneInSearchSection(m, idx, "", "list"))
+ }
+ }
+
+ return navResp, nil
+}
+
+func parseTuneInStreamBody(body []byte, guideID string) ([]string, error) {
+ // TuneIn sometimes returns plain text with URLs or comments,
+ // especially for .ashx or error responses.
+ // But our recent refactoring assumed everything is JSON.
+ var data map[string]interface{}
+ if err := json.Unmarshal(body, &data); err == nil {
+ payload, ok := data["body"].([]interface{})
+ if ok && len(payload) > 0 {
+ urls := make([]string, 0, len(payload))
+ for _, item := range payload {
+ m, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ if u, ok := m["url"].(string); ok && u != "" {
+ urls = append(urls, u)
+ }
+ }
+
+ if len(urls) > 0 {
+ return urls, nil
+ }
+ }
+ }
+
+ // Fallback to plain text parsing (line by line)
+ lines := strings.Split(string(body), "\n")
+
+ urls := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ urls = append(urls, line)
+ }
+
+ if len(urls) == 0 {
+ return nil, fmt.Errorf("no valid stream URLs found for %s", guideID)
+ }
+
+ return urls, nil
+}
+
+type tuneInProfileContentsResponse struct {
+ Items []tuneInProfileContentsSection `json:"Items"`
+ Body []tuneInProfileContentsSection `json:"body"`
+}
+
+type tuneInProfileContentsItem struct {
+ GuideID string `json:"GuideId"`
+ Text string `json:"text"`
+}
+
+type tuneInProfileContentsSection struct {
+ Title string `json:"Title"`
+ ContainerType string `json:"ContainerType"`
+ Children []tuneInProfileContentsItem `json:"Children"`
+ LegacyChildren []tuneInProfileContentsItem `json:"children"`
+}
+
+func parseTuneInProgramContents(body []byte, programID string) (episodeID string, err error) {
+ var resp tuneInProfileContentsResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", err
+ }
+
+ sections := resp.Items
+ if len(sections) == 0 {
+ sections = resp.Body
+ }
+
+ // Prefer "Episodes" (or "Folgen" etc.) container
+ for _, section := range sections {
+ if strings.EqualFold(section.ContainerType, "Topics") {
+ // If it's explicitly called "Episodes", use it
+ title := strings.ToLower(section.Title)
+ if strings.Contains(title, "episode") ||
+ strings.Contains(title, "folgen") {
+ children := section.Children
+ if len(children) == 0 {
+ children = section.LegacyChildren
+ }
+
+ for _, child := range children {
+ if strings.HasPrefix(child.GuideID, "t") {
+ return child.GuideID, nil
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback to first Topics container with a 't' child
+ for _, section := range sections {
+ if strings.EqualFold(section.ContainerType, "Topics") {
+ children := section.Children
+ if len(children) == 0 {
+ children = section.LegacyChildren
+ }
+
+ for _, child := range children {
+ if strings.HasPrefix(child.GuideID, "t") {
+ return child.GuideID, nil
+ }
+ }
+ }
+ }
+
+ return "", fmt.Errorf("no episodes found for program %s", programID)
+}
+
+func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
+ fetchURL := fmt.Sprintf(TuneInProfileContents, programID)
+
+ resp, err := defaultClient.Get(fetchURL)
+ if err != nil {
+ return "", err
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to fetch program contents: %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ return parseTuneInProgramContents(body, programID)
+}
+
+// TuneInDescribeMeta fetches the name and logo for a TuneIn guide ID.
+func TuneInDescribeMeta(id string) (name, logo string, err error) {
+ fetchURL := fmt.Sprintf(TuneInDescribe, id)
+
+ resp, err := defaultClient.Get(fetchURL)
+ if err != nil {
+ return "", "", err
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("tunein describe failed with status %d", resp.StatusCode)
+ }
+
+ var opml struct {
+ Body struct {
+ Outline []struct {
+ Text string `xml:"text,attr"`
+ Image string `xml:"image,attr"`
+ } `xml:"outline"`
+ } `xml:"body"`
+ }
+
+ if err := xml.NewDecoder(resp.Body).Decode(&opml); err != nil {
+ return "", "", err
+ }
+
+ if len(opml.Body.Outline) > 0 {
+ return opml.Body.Outline[0].Text, opml.Body.Outline[0].Image, nil
+ }
+
+ return "", "", fmt.Errorf("no metadata found for %s", id)
+}
+
+// TuneInPlayback returns a playback response for a TuneIn station.
+func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, error) {
+ fetchURL := TuneInStream(stationID, formats)
+
+ resp, err := defaultClient.Get(fetchURL)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("tunein tune failed with status %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ urls, err := parseTuneInStreamBody(body, stationID)
+ if err != nil {
+ return nil, err
+ }
+
+ name, logo, _ := TuneInDescribeMeta(stationID)
+
+ return BuildCustomStreamResponse(urls[0], logo, name)
+}
+
+// TuneInPodcastInfo returns info for a TuneIn podcast.
+func TuneInPodcastInfo(_, encodedName string) (*models.BmxPodcastInfoResponse, error) {
+ name, _ := decodeBase64URI(encodedName)
+
+ return &models.BmxPodcastInfoResponse{
+ Name: name,
+ Tracks: []models.Track{},
+ }, nil
+}
+
+// TuneInPlaybackPodcast returns a playback response for a TuneIn podcast.
+func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
+ // Podcasts (p) are just containers for episodes (s).
+ // We resolve the latest episode ID first.
+ episodeID, err := resolveTuneInProgramLatestEpisode(podcastID)
+ if err != nil {
+ return nil, err
+ }
+
+ return TuneInPlayback(episodeID, formats)
+}
diff --git a/pkg/service/bmx/tunein_test.go b/pkg/service/bmx/tunein_test.go
new file mode 100644
index 0000000..1620d11
--- /dev/null
+++ b/pkg/service/bmx/tunein_test.go
@@ -0,0 +1,452 @@
+package bmx
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+func TestTuneInRenderJSONURI(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "empty URL returns empty",
+ input: "",
+ want: "",
+ },
+ {
+ name: "URL with no query params gets render=json added",
+ input: "http://opml.radiotime.com/Browse.ashx",
+ want: "http://opml.radiotime.com/Browse.ashx?render=json",
+ },
+ {
+ name: "URL with other params gets render=json appended",
+ input: "http://opml.radiotime.com/Browse.ashx?c=news",
+ want: "http://opml.radiotime.com/Browse.ashx?c=news&render=json",
+ },
+ {
+ name: "URL already containing render=json is not duplicated",
+ input: "http://opml.radiotime.com/?render=json",
+ want: "http://opml.radiotime.com/?render=json",
+ },
+ {
+ name: "URL with render=xml gets render replaced with json",
+ input: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=xml",
+ want: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tuneInRenderJSONURI(tt.input)
+ if got != tt.want {
+ t.Errorf("tuneInRenderJSONURI(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsTuneInOpmlURI(t *testing.T) {
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"http://opml.radiotime.com/Browse.ashx", true},
+ {"https://opml.radiotime.com/Browse.ashx", true},
+ {"http://opml.radiotime.com/?render=json", true},
+ {"http://api.radiotime.com/profiles?fulltextsearch=true", false},
+ {"http://example.com", false},
+ {"not-a-url", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := isTuneInOpmlURI(tt.input)
+ if got != tt.want {
+ t.Errorf("isTuneInOpmlURI(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTuneInSearchURI(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ check func(string) bool
+ }{
+ {
+ name: "spaces are percent-encoded",
+ query: "radio paradise",
+ check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "radio+paradise") },
+ },
+ {
+ name: "ampersand is encoded",
+ query: "news & talk",
+ check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "%26") },
+ },
+ {
+ name: "plain query is appended to base URL",
+ query: "jazz",
+ check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tuneInSearchURI(tt.query)
+ if !tt.check(got) {
+ t.Errorf("tuneInSearchURI(%q) = %q: check failed", tt.query, got)
+ }
+ })
+ }
+}
+
+func TestTuneInNavigateLinkEncodesRenderJSON(t *testing.T) {
+ item := map[string]interface{}{
+ "URL": "http://opml.radiotime.com/Browse.ashx?c=news",
+ "text": "News",
+ "subtext": "Latest",
+ "image": "http://example.com/news.png",
+ }
+
+ result := tuneInNavigateLink(item)
+
+ href := result.Links.BmxNavigate.Href
+ encoded := strings.TrimPrefix(href, "/v1/navigate/")
+ decoded, err := decodeBase64URI(encoded)
+ if err != nil {
+ t.Fatalf("failed to decode navigate href: %v", err)
+ }
+
+ got := decoded
+ if !strings.Contains(got, "render=json") {
+ t.Errorf("navigate href %q missing render=json", got)
+ }
+ if strings.Count(got, "render=json") > 1 {
+ t.Errorf("navigate href %q has duplicate render=json", got)
+ }
+}
+
+func TestTuneInNavigateLinkNoDuplicateRenderJSON(t *testing.T) {
+ item := map[string]interface{}{
+ "URL": "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
+ }
+
+ result := tuneInNavigateLink(item)
+
+ href := result.Links.BmxNavigate.Href
+ encoded := strings.TrimPrefix(href, "/v1/navigate/")
+ decoded, err := decodeBase64URI(encoded)
+ if err != nil {
+ t.Fatalf("failed to decode navigate href: %v", err)
+ }
+
+ got := decoded
+ if strings.Count(got, "render=json") != 1 {
+ t.Errorf("navigate href %q should contain render=json exactly once", got)
+ }
+}
+
+func TestTuneInPodcastInfo_Base64(t *testing.T) {
+ name := "Podcast Name / with special chars?"
+
+ // Test Standard Base64
+ encodedStd := base64.StdEncoding.EncodeToString([]byte(name))
+
+ resp, err := TuneInPodcastInfo("123", encodedStd)
+ if err != nil {
+ t.Fatalf("TuneInPodcastInfo with standard base64 failed: %v", err)
+ }
+
+ if resp.Name != name {
+ t.Errorf("Expected name %s, got %s", name, resp.Name)
+ }
+
+ // Test URL-safe Base64
+ encodedURL := base64.URLEncoding.EncodeToString([]byte(name))
+
+ resp, err = TuneInPodcastInfo("123", encodedURL)
+ if err != nil {
+ t.Fatalf("TuneInPodcastInfo with URL-safe base64 failed: %v", err)
+ }
+
+ if resp.Name != name {
+ t.Errorf("Expected name %s, got %s", name, resp.Name)
+ }
+}
+
+func TestTuneInStream_EmptyFormatsUsesDefault(t *testing.T) {
+ got := TuneInStream("s33828", "")
+
+ if strings.Contains(got, "hls") {
+ t.Errorf("default TuneInStream URL must NOT request HLS; got %s", got)
+ }
+
+ want := "formats=" + DefaultTuneInStreamFormats
+ if !strings.Contains(got, want) {
+ t.Errorf("default TuneInStream URL must request %q; got %s", want, got)
+ }
+
+ if !strings.Contains(got, "id=s33828") {
+ t.Errorf("TuneInStream URL must carry the station ID; got %s", got)
+ }
+}
+
+func TestTuneInStream_OverrideHonoured(t *testing.T) {
+ cases := []struct {
+ formats string
+ want string
+ }{
+ {"mp3,aac,ogg,hls", "formats=mp3,aac,ogg,hls"}, // opt-in: re-add HLS
+ {"aac", "formats=aac"}, // single format
+ {" mp3 ", "formats=mp3"}, // whitespace stripped
+ }
+
+ for _, tc := range cases {
+ got := TuneInStream("s33828", tc.formats)
+ if !strings.Contains(got, tc.want) {
+ t.Errorf("TuneInStream(%q) URL must contain %q; got %s", tc.formats, tc.want, got)
+ }
+ }
+}
+
+func TestParseTuneInStreamBody(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ wantURLs []string
+ wantError bool
+ }{
+ {
+ name: "single URL",
+ body: "https://stream.example.com/foo.mp3\n",
+ wantURLs: []string{"https://stream.example.com/foo.mp3"},
+ },
+ {
+ name: "multiple URLs",
+ body: "https://a/1.mp3\nhttps://b/2.mp3\n",
+ wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
+ },
+ {
+ name: "comment-only body — TuneIn 400 error",
+ body: "#STATUS: 400\n#description=Bad request\n",
+ wantError: true,
+ },
+ {
+ name: "comments mixed with real URL",
+ body: "#EXTM3U\nhttps://stream.example.com/foo.mp3\n#END\n",
+ wantURLs: []string{"https://stream.example.com/foo.mp3"},
+ },
+ {
+ name: "empty body",
+ body: "",
+ wantError: true,
+ },
+ {
+ name: "only blank lines",
+ body: "\n\n \n",
+ wantError: true,
+ },
+ {
+ name: "trims surrounding whitespace per line",
+ body: " https://a/1.mp3 \n\thttps://b/2.mp3\t\n",
+ wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := parseTuneInStreamBody([]byte(tc.body), "test-guide-id")
+
+ if tc.wantError {
+ if err == nil {
+ t.Fatalf("expected error, got %v", got)
+ }
+
+ if !strings.Contains(err.Error(), "test-guide-id") {
+ t.Errorf("error should mention the guide-id for diagnosis: %v", err)
+ }
+
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(got) != len(tc.wantURLs) {
+ t.Fatalf("len mismatch: got %d (%v), want %d (%v)", len(got), got, len(tc.wantURLs), tc.wantURLs)
+ }
+
+ for i := range got {
+ if got[i] != tc.wantURLs[i] {
+ t.Errorf("URL[%d] mismatch: got %q, want %q", i, got[i], tc.wantURLs[i])
+ }
+ }
+ })
+ }
+}
+
+func TestTuneInSearchProfileEmitsBmxPlayback(t *testing.T) {
+ cases := []struct {
+ name string
+ profileName string
+ guideID string
+ wantPlayback bool
+ wantType string
+ }{
+ {name: "Program with guide-id gets play link", profileName: "Program", guideID: "p290778", wantPlayback: true, wantType: "tracklisturl"},
+ {name: "Artist with guide-id is navigate-only", profileName: "Artist", guideID: "a12345", wantPlayback: false},
+ {name: "Program without guide-id is navigate-only", profileName: "Program", guideID: "", wantPlayback: false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ item := map[string]interface{}{
+ "GuideId": tc.guideID,
+ "Title": "Die Nachrichten",
+ "Image": "http://example.com/logo.png",
+ "Subtitle": "Deutschlandfunk",
+ "Type": tc.profileName,
+ "Actions": map[string]interface{}{
+ "Profile": map[string]interface{}{
+ "Url": "https://api.radiotime.com/profiles/" + tc.guideID,
+ },
+ },
+ }
+
+ navItem := tuneInSearchProfile(item, tc.profileName)
+
+ if navItem.Links == nil {
+ t.Fatal("expected Links to be set")
+ }
+
+ if tc.wantPlayback {
+ if navItem.Links.BmxPlayback == nil {
+ t.Fatal("expected BmxPlayback link for Program")
+ }
+
+ if navItem.Links.BmxPlayback.Type != tc.wantType {
+ t.Errorf("BmxPlayback.Type = %q, want %q", navItem.Links.BmxPlayback.Type, tc.wantType)
+ }
+
+ if !strings.Contains(navItem.Links.BmxPlayback.Href, tc.guideID) {
+ t.Errorf("BmxPlayback.Href must carry the guide-id %q; got %q", tc.guideID, navItem.Links.BmxPlayback.Href)
+ }
+
+ if !strings.Contains(navItem.Links.BmxPlayback.Href, "encoded_name=") {
+ t.Errorf("BmxPlayback.Href should carry encoded_name; got %q", navItem.Links.BmxPlayback.Href)
+ }
+ } else if navItem.Links.BmxPlayback != nil {
+ t.Errorf("did not expect BmxPlayback link; got %+v", navItem.Links.BmxPlayback)
+ }
+
+ if navItem.Links.BmxNavigate == nil {
+ t.Error("expected BmxNavigate link to remain available")
+ }
+ })
+ }
+}
+
+func TestParseTuneInProgramContents(t *testing.T) {
+ const happyPath = `{
+ "Items": [
+ {
+ "ContainerType": "Topics",
+ "Title": "Episodes",
+ "Children": [
+ { "GuideId": "t554138374", "Type": "Topic", "Title": "newest" },
+ { "GuideId": "t554134863", "Type": "Topic", "Title": "previous" }
+ ]
+ }
+ ]
+ }`
+
+ const localisedTitle = `{
+ "Items": [
+ {
+ "ContainerType": "Topics",
+ "Title": "Folgen",
+ "Children": [
+ { "GuideId": "t111", "Type": "Topic", "Title": "newest" }
+ ]
+ }
+ ]
+ }`
+
+ const episodesAfterRelated = `{
+ "Items": [
+ {
+ "ContainerType": "Topics",
+ "Title": "Related Shows",
+ "Children": [
+ { "GuideId": "t999", "Type": "Topic", "Title": "wrong" }
+ ]
+ },
+ {
+ "ContainerType": "Topics",
+ "Title": "Episodes",
+ "Children": [
+ { "GuideId": "t222", "Type": "Topic", "Title": "right" }
+ ]
+ }
+ ]
+ }`
+
+ const skipsNonTopic = `{
+ "Items": [
+ {
+ "ContainerType": "Topics",
+ "Title": "Episodes",
+ "Children": [
+ { "GuideId": "p333", "Type": "Container", "Title": "nested program" },
+ { "GuideId": "t444", "Type": "Topic", "Title": "real episode" }
+ ]
+ }
+ ]
+ }`
+
+ cases := []struct {
+ name string
+ body string
+ wantID string
+ wantError bool
+ }{
+ {name: "happy path — first child wins", body: happyPath, wantID: "t554138374"},
+ {name: "localised title — falls back to first Topics container", body: localisedTitle, wantID: "t111"},
+ {name: "Episodes container preferred over Related", body: episodesAfterRelated, wantID: "t222"},
+ {name: "skips non-Topic children", body: skipsNonTopic, wantID: "t444"},
+ {name: "empty body — error", body: `{}`, wantError: true},
+ {name: "no Topics containers — error", body: `{"Items":[{"ContainerType":"Banner","Children":[]}]}`, wantError: true},
+ {name: "Topics with no t-prefixed children — error",
+ body: `{"Items":[{"ContainerType":"Topics","Title":"Episodes","Children":[{"GuideId":"p1"}]}]}`,
+ wantError: true},
+ {name: "malformed JSON — error", body: `{not json`, wantError: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := parseTuneInProgramContents([]byte(tc.body), "p290778")
+
+ if tc.wantError {
+ if err == nil {
+ t.Fatalf("expected error, got id=%q", got)
+ }
+
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if got != tc.wantID {
+ t.Errorf("got episode id %q, want %q", got, tc.wantID)
+ }
+ })
+ }
+}
diff --git a/pkg/service/bmx/utils.go b/pkg/service/bmx/utils.go
new file mode 100644
index 0000000..3024a9a
--- /dev/null
+++ b/pkg/service/bmx/utils.go
@@ -0,0 +1,113 @@
+package bmx
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+var defaultClient = &http.Client{Timeout: 10 * time.Second}
+
+func fetchJSONMap(client *http.Client, fetchURL string, allowedHosts map[string]bool) (map[string]interface{}, error) {
+ result, err := fetchJSONGeneric(client, fetchURL, allowedHosts)
+ if err != nil {
+ return nil, err
+ }
+
+ m, ok := result.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("expected map[string]interface{}, got %T", result)
+ }
+
+ return m, nil
+}
+
+func fetchJSONGeneric(client *http.Client, fetchURL string, allowedHosts map[string]bool) (interface{}, error) {
+ if allowedHosts != nil {
+ if !isHostAllowed(fetchURL, allowedHosts) {
+ return nil, fmt.Errorf("URL host not in allowed list: %s", fetchURL)
+ }
+ }
+
+ resp, err := client.Get(fetchURL)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("fetch failed with status %d: %s", resp.StatusCode, fetchURL)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var result interface{}
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func isHostAllowed(rawURL string, allowedHosts map[string]bool) bool {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return false
+ }
+
+ return allowedHosts[u.Hostname()]
+}
+
+func decodeBase64URI(encoded string) (string, error) {
+ // Clean up input for base64 decoding (remove potential whitespace or prefixes)
+ encoded = strings.TrimSpace(encoded)
+
+ // Attempt URL-safe decoding first
+ b, err := base64.URLEncoding.DecodeString(encoded)
+ if err != nil {
+ // Try again with padding if missing
+ padding := len(encoded) % 4
+ if padding > 0 {
+ padded := encoded + strings.Repeat("=", 4-padding)
+ b, err = base64.URLEncoding.DecodeString(padded)
+ }
+ }
+
+ if err != nil {
+ // Attempt standard decoding
+ b, err = base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ padding := len(encoded) % 4
+ if padding > 0 {
+ padded := encoded + strings.Repeat("=", 4-padding)
+ b, err = base64.StdEncoding.DecodeString(padded)
+ }
+ }
+ }
+
+ if err != nil {
+ // Try raw (no padding) decoding specifically
+ b, err = base64.RawURLEncoding.DecodeString(encoded)
+ if err != nil {
+ b, err = base64.RawStdEncoding.DecodeString(encoded)
+ }
+ }
+
+ if err != nil {
+ // FINAL DESPERATE ATTEMPT: decode by hand or check if it's just plain text
+ // (though it shouldn't be). Some tests might be passing "illegal base64 data"
+ // on purpose to test error handling? No, the tests themselves are failing.
+ return "", err
+ }
+
+ return string(b), nil
+}
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index 0cb0369..73fb8bc 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -2,7 +2,8 @@
- AfterTouch (SoundTouch Toolkit)
+ AfterTouch
+
diff --git a/pkg/service/soundtouchweb/discovery.go b/pkg/service/soundtouchweb/discovery.go
index 2527f56..d7f5eca 100644
--- a/pkg/service/soundtouchweb/discovery.go
+++ b/pkg/service/soundtouchweb/discovery.go
@@ -56,6 +56,11 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
return
}
+ // Ensure IPAddress is set for the web UI
+ if info.IPAddress == "" {
+ info.IPAddress = host
+ }
+
conn := webtypes.NewDeviceConnection(c, info)
if !app.AddDevice(host, conn) {
// Lost a race — another goroutine inserted the same host
diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go
index f19e140..64b7916 100644
--- a/pkg/service/soundtouchweb/handler.go
+++ b/pkg/service/soundtouchweb/handler.go
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -33,6 +34,13 @@ type WebApp struct {
Upgrader websocket.Upgrader
WSClients map[*websocket.Conn]bool
WSMutex sync.RWMutex
+
+ Version string
+ Commit string
+ Date string
+ RepoURL string
+
+ discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
// DeviceEntry pairs a device id with its connection. Used by
@@ -569,15 +577,26 @@ func (app *WebApp) BroadcastDeviceList() {
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
+ discoveryStatus := &webtypes.DiscoveryStatus{
+ Status: status,
+ DeviceCount: deviceCount,
+ }
+
+ switch status {
+ case "starting":
+ discoveryStatus.IsDiscovering = true
+ case "completed", "failed":
+ discoveryStatus.IsDiscovering = false
+ }
+
+ app.discoveryStatus.Store(discoveryStatus)
+
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
message := webtypes.WebSocketMessage{
Type: "discovery_status",
- Data: map[string]interface{}{
- "status": status,
- "deviceCount": deviceCount,
- },
+ Data: discoveryStatus,
}
// Send to all connected clients
@@ -1015,6 +1034,88 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
}
}
+// HandleAPIVersion returns the current version of the application.
+func (app *WebApp) HandleAPIVersion(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ versionInfo := map[string]string{
+ "version": app.Version,
+ "commit": app.Commit,
+ "date": app.Date,
+ "repo_url": app.RepoURL,
+ "release_url": app.RepoURL + "/releases/tag/" + app.Version,
+ "commit_url": app.RepoURL + "/commit/" + app.Commit,
+ }
+ if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: versionInfo}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+// HandleRadioBrowserSearch handles RadioBrowser search requests.
+func (app *WebApp) HandleRadioBrowserSearch(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query().Get("q")
+ if query == "" {
+ app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
+ return
+ }
+
+ resp, err := bmxpkg.RadioBrowserSearch(query)
+ if err != nil {
+ app.sendError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
+// HandlePlayRadioBrowser plays a RadioBrowser station on a specific device.
+func (app *WebApp) HandlePlayRadioBrowser(w http.ResponseWriter, r *http.Request) {
+ deviceID := chi.URLParam(r, "id")
+ if deviceID == "" {
+ app.sendError(w, "Device ID required", http.StatusBadRequest)
+ return
+ }
+
+ device, exists := app.GetDevice(deviceID)
+ if !exists {
+ app.sendError(w, "Device not found", http.StatusNotFound)
+ return
+ }
+
+ var req struct {
+ Location string `json:"location"`
+ Name string `json:"name"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ app.sendError(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ contentItem := &models.ContentItem{
+ Source: "URL",
+ Type: "stationurl",
+ Location: req.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 err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ }
+}
+
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
diff --git a/pkg/service/soundtouchweb/handler_test.go b/pkg/service/soundtouchweb/handler_test.go
index 564e2ea..141eb8c 100644
--- a/pkg/service/soundtouchweb/handler_test.go
+++ b/pkg/service/soundtouchweb/handler_test.go
@@ -534,6 +534,52 @@ func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
}
}
+func TestHandleAPIVersion(t *testing.T) {
+ app := createTestApp()
+ app.Version = "1.2.3"
+ app.Commit = "abcdef123"
+ app.Date = "2023-01-01"
+ app.RepoURL = "https://github.com/example/repo"
+
+ req := httptest.NewRequest("GET", "/api/version", nil)
+ w := httptest.NewRecorder()
+
+ app.HandleAPIVersion(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ var resp webtypes.APIResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ if !resp.Success {
+ t.Errorf("Expected success=true, got false")
+ }
+
+ data, ok := resp.Data.(map[string]interface{})
+ if !ok {
+ t.Fatalf("Expected data to be map[string]interface{}, got %T", resp.Data)
+ }
+
+ expected := map[string]string{
+ "version": "1.2.3",
+ "commit": "abcdef123",
+ "date": "2023-01-01",
+ "repo_url": "https://github.com/example/repo",
+ "release_url": "https://github.com/example/repo/releases/tag/1.2.3",
+ "commit_url": "https://github.com/example/repo/commit/abcdef123",
+ }
+
+ for k, v := range expected {
+ if data[k] != v {
+ t.Errorf("Expected %s=%s, got %v", k, v, data[k])
+ }
+ }
+}
+
// Benchmark tests
func BenchmarkHandleAPIDevices(b *testing.B) {
app := createTestApp()
diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go
index 48b81f6..7a0cb3a 100644
--- a/pkg/service/soundtouchweb/mount.go
+++ b/pkg/service/soundtouchweb/mount.go
@@ -25,20 +25,20 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
// API endpoints
r.Get("/api/devices", app.HandleAPIDevices)
r.Get("/api/device/{id}", app.HandleAPIDevice)
+ r.Get("/api/version", app.HandleAPIVersion)
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
app.HandleAPIDiscover(w, r)
+
// Trigger discovery
//nolint:contextcheck // Context is created within goroutine
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
- // Broadcast discovery start
app.BroadcastDiscoveryStatus("starting", app.DeviceCount())
app.DiscoverDevices(ctx, discoveryService)
- // Broadcast discovery completion and updated device list
app.BroadcastDiscoveryStatus("completed", app.DeviceCount())
app.BroadcastDeviceList()
}()
@@ -68,10 +68,16 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
r.Post("/api/zone/{id}/leave", app.HandleZoneLeave)
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
+ // RadioBrowser search
+ r.Get("/api/radiobrowser/search", app.HandleRadioBrowserSearch)
+ r.Post("/api/radiobrowser/play/{id}", app.HandlePlayRadioBrowser)
+
// 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)
}
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 034c2b3..3884e2b 100644
--- a/pkg/service/soundtouchweb/static/css/app.css
+++ b/pkg/service/soundtouchweb/static/css/app.css
@@ -13,6 +13,7 @@
--offline: #9ca3af;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
+ --nav-icon-filter: brightness(0) invert(1);
}
@media (prefers-color-scheme: dark) {
@@ -21,9 +22,10 @@
--surface: #1e1e1e;
--border: #333;
--text: #f0f0f0;
- --text-dim: #aaa;
+ --text-dim: #ccc;
--accent: #e0e0e0;
--accent-fg:#111;
+ --nav-icon-filter: none;
}
}
@@ -45,6 +47,18 @@ img { display: block; max-width: 100%; }
/* ── Layout ──────────────────────────────────────────────────────────────── */
.app { display: flex; flex-direction: column; min-height: 100vh; }
+#footer {
+ padding: 1.5rem 1rem;
+ text-align: center;
+ font-size: 0.8rem;
+ color: var(--text-dim);
+ margin-top: auto;
+}
+
+#footer a:hover {
+ text-decoration: underline;
+}
+
/* ── Navbar ──────────────────────────────────────────────────────────────── */
.navbar {
display: flex;
@@ -54,25 +68,168 @@ img { display: block; max-width: 100%; }
height: 52px;
background: var(--accent);
color: var(--accent-fg);
+ position: relative; /* For absolute centering of page-title */
}
-.brand { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 1.1rem;
+ font-weight: 600;
+ letter-spacing: .02em;
+ flex: 0 0 auto;
+ z-index: 1;
+}
-.nav-links { display: flex; align-items: center; gap: .75rem; }
+.brand-text {
+ display: none;
+ flex-direction: column;
+ justify-content: center;
+ line-height: 1.1;
+}
+
+.brand-name {
+ display: inline;
+}
+
+.brand-subtitle {
+ font-size: 0.7rem;
+ font-weight: 400;
+ opacity: 0.7;
+}
+
+@media (min-width: 600px) {
+ .brand-text {
+ display: flex;
+ }
+}
+
+.page-title {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 60%; /* Allow more width for title + IP */
+ text-align: center;
+ font-weight: 600;
+ font-size: 1.1rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: 0;
+ pointer-events: none; /* Let clicks pass through to navbar if needed */
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.2;
+}
+
+.title-with-subtitle {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.main-title {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sub-title {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: 400;
+ opacity: 0.8;
+ font-family: monospace;
+}
+
+.nav-logo {
+ width: 24px;
+ height: 24px;
+ filter: var(--nav-icon-filter);
+}
+
+.nav-links {
+ display: flex;
+ align-items: center;
+ gap: .25rem;
+ z-index: 1;
+}
.nav-links a, .nav-links .btn-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
color: var(--accent-fg);
opacity: .75;
- font-size: .9rem;
- padding: .25rem .5rem;
border-radius: 4px;
- transition: opacity .15s;
+ transition: all .15s ease;
}
-.nav-links a:hover, .nav-links .btn-icon:hover, .nav-links a.active { opacity: 1; }
+.nav-links .nav-separator {
+ color: var(--accent-fg);
+ opacity: .3;
+ padding: 0 .25rem;
+ user-select: none;
+}
-.nav-tunein-icon { height: 18px; display: inline-block; filter: brightness(0) invert(1); opacity: .75; }
-.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon { opacity: 1; }
+.nav-links a:hover, .nav-links .btn-icon:hover {
+ opacity: 1;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.nav-links a.active {
+ opacity: 1;
+ background: var(--accent-fg);
+ color: var(--accent);
+}
+
+@media (prefers-color-scheme: dark) {
+ .nav-links a:hover, .nav-links .btn-icon:hover {
+ background: rgba(255, 255, 255, 0.05);
+ }
+}
+
+.nav-links a.active .nav-tunein-icon,
+.nav-links a.active .nav-rb-icon,
+.nav-links a.active .nav-device-icon {
+ filter: none;
+ opacity: 1;
+}
+
+@media (prefers-color-scheme: dark) {
+ .nav-links a.active {
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: inset 0 0 0 1px var(--border);
+ }
+ .nav-links a.active .nav-tunein-icon,
+ .nav-links a.active .nav-rb-icon,
+ .nav-links a.active .nav-device-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-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; }
+
+@keyframes buzzing {
+ 0% { transform: rotate(0deg); }
+ 25% { transform: rotate(15deg); }
+ 50% { transform: rotate(0deg); }
+ 75% { transform: rotate(-15deg); }
+ 100% { transform: rotate(0deg); }
+}
+.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 .btn-icon:hover .nav-discover-icon { opacity: 1; }
/* ── Main content ─────────────────────────────────────────────────────────── */
.main-content { flex: 1; padding: 1.5rem 1.25rem; max-width: 960px; width: 100%; margin: 0 auto; }
@@ -144,12 +301,15 @@ img { display: block; max-width: 100%; }
cursor: pointer;
transition: box-shadow .15s, transform .1s;
box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
}
.device-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); transform: translateY(-1px); }
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .25rem; }
.device-name { font-weight: 600; font-size: .95rem; }
-.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; }
+.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; }
+.device-ip { color: var(--text); font-family: monospace; font-weight: 500; }
.device-indicator {
width: 8px; height: 8px; border-radius: 50%;
@@ -175,7 +335,7 @@ img { display: block; max-width: 100%; }
padding: 1rem;
margin: 1rem 0;
box-shadow: var(--shadow);
- min-height: 80px;
+ min-height: 98px; /* Fixed height to avoid jumps between tracks/standby */
align-items: center;
}
.now-playing.standby { color: var(--text-dim); font-size: .9rem; }
@@ -442,23 +602,82 @@ img { display: block; max-width: 100%; }
.picker-item-name { font-size: .875rem; color: var(--text-dim); margin-bottom: 1rem; }
.picker-devices { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
.picker-device-btn {
- background: var(--bg);
+ background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .6rem 1rem;
text-align: left;
font-size: .9rem;
- transition: background .1s;
+ color: var(--text);
+ transition: background .1s, border-color .1s;
+}
+.picker-device-btn:hover {
+ background: var(--bg);
+ border-color: var(--text-dim);
+}
+.picker-device-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+}
+.picker-device-name {
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ display: block;
+}
+.picker-device-btn:hover .picker-device-name {
+ white-space: normal;
+ word-break: break-all;
+}
+.picker-device-ip {
+ font-size: 0.75rem;
+ color: var(--text-dim);
+ font-family: monospace;
}
-.picker-device-btn:hover { background: var(--border); }
.picker-cancel { width: 100%; }
-.picker-no-devices { font-size: .875rem; color: var(--text-dim); text-align: center; padding: .5rem 0; }
+.picker-no-devices { font-size: .875rem; color: var(--text); text-align: center; padding: .5rem 0; }
/* ── Empty state ─────────────────────────────────────────────────────────── */
-.empty-state { text-align: center; padding: 4rem 1rem; color: var(--text-dim); }
-.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .4; }
+.empty-state { text-align: center; padding: 4rem 1rem; color: var(--text); }
+.empty-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+ opacity: .8;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ line-height: 1;
+}
+.empty-icon.radiating { animation: pulse 1.5s ease-in-out infinite; opacity: 1; color: var(--accent); font-weight: bold; }
+.empty-icon.radiating::after {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ border: 3px solid var(--accent);
+ border-radius: 50%;
+ transform: scale(1);
+ animation: radiate 1.5s ease-out infinite;
+ pointer-events: none;
+}
.empty-state p { margin-bottom: 1.5rem; }
+@keyframes pulse {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.15); }
+ 100% { transform: scale(1); }
+}
+
+@keyframes radiate {
+ 0% { transform: scale(0.8); opacity: 1; }
+ 100% { transform: scale(2.5); opacity: 0; }
+}
+
/* ── Toast ───────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
@@ -475,4 +694,4 @@ img { display: block; max-width: 100%; }
pointer-events: none;
animation: fade-in .2s ease;
}
-@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
\ No newline at end of file
+@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
diff --git a/pkg/service/soundtouchweb/static/img/favicon.ico b/pkg/service/soundtouchweb/static/img/favicon.ico
index 63daa5d..67b27e3 100644
Binary files a/pkg/service/soundtouchweb/static/img/favicon.ico and b/pkg/service/soundtouchweb/static/img/favicon.ico differ
diff --git a/pkg/service/soundtouchweb/static/img/favicon.svg b/pkg/service/soundtouchweb/static/img/favicon.svg
index a08f52f..483882f 100644
--- a/pkg/service/soundtouchweb/static/img/favicon.svg
+++ b/pkg/service/soundtouchweb/static/img/favicon.svg
@@ -1,9 +1,12 @@
diff --git a/pkg/service/soundtouchweb/static/img/knob-mono.svg b/pkg/service/soundtouchweb/static/img/knob-mono.svg
new file mode 100644
index 0000000..e33239a
--- /dev/null
+++ b/pkg/service/soundtouchweb/static/img/knob-mono.svg
@@ -0,0 +1,5 @@
+
diff --git a/pkg/service/soundtouchweb/static/img/logo.svg b/pkg/service/soundtouchweb/static/img/logo.svg
new file mode 100644
index 0000000..483882f
--- /dev/null
+++ b/pkg/service/soundtouchweb/static/img/logo.svg
@@ -0,0 +1,12 @@
+
diff --git a/pkg/service/soundtouchweb/static/img/radiobrowser-mono.svg b/pkg/service/soundtouchweb/static/img/radiobrowser-mono.svg
new file mode 100644
index 0000000..5a59db7
--- /dev/null
+++ b/pkg/service/soundtouchweb/static/img/radiobrowser-mono.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/pkg/service/soundtouchweb/static/img/speaker-mono.svg b/pkg/service/soundtouchweb/static/img/speaker-mono.svg
new file mode 100644
index 0000000..79fb392
--- /dev/null
+++ b/pkg/service/soundtouchweb/static/img/speaker-mono.svg
@@ -0,0 +1,6 @@
+
diff --git a/pkg/service/soundtouchweb/static/img/tunein-mono.svg b/pkg/service/soundtouchweb/static/img/tunein-mono.svg
index bbf5b5f..5c0c6bc 100644
--- a/pkg/service/soundtouchweb/static/img/tunein-mono.svg
+++ b/pkg/service/soundtouchweb/static/img/tunein-mono.svg
@@ -3,7 +3,7 @@