feat: Add RadioBrowser integration alongside TuneIn support

- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
This commit is contained in:
Tobias Gesellchen
2026-05-18 22:34:26 +02:00
parent 8881adbede
commit b95bdae751
36 changed files with 2303 additions and 1718 deletions
+1 -1
View File
@@ -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.
+38 -1
View File
@@ -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)
},
+1 -1
View File
@@ -69,7 +69,7 @@ func TestSPARouting(t *testing.T) {
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>SoundTouch Control Center</title>
<title>AfterTouch Control Center</title>
</head>
<body>
<div id="app">SPA Content</div>
+5 -5
View File
@@ -7,7 +7,7 @@ This document serves as the entry point for understanding the comprehensive plan
## Project Objectives
### Primary Goal
Create a robust, local replacement for Bose's upstream services that can seamlessly handle the transition from cloud-dependent to fully autonomous operation while maintaining and improving upon the existing functionality.
Create a robust replacement for Bose's upstream services that can seamlessly handle the transition from cloud-dependent to fully autonomous operation while maintaining and improving upon the existing functionality.
### Key Outcomes
- **Zero-downtime transition** from Bose services to local management
@@ -21,7 +21,7 @@ Create a robust, local replacement for Bose's upstream services that can seamles
### Current State
The existing SoundTouch service provides:
- BMX service for TuneIn integration
- Marge service for account and device management
- Marge service for account and device management
- Basic mirroring of upstream Bose endpoints
- File-based persistence for device data
- Migration support for device directory structures
@@ -42,7 +42,7 @@ The enhanced system will add:
- **Mirror-Enhanced Setup**: Use upstream data to enrich account creation
- **Passive Data Collection**: Record account information during normal operations
### Case 1a: Fresh Device Registration
### Case 1a: Fresh Device Registration
- **Factory Reset Support**: Handle devices with no prior Bose association
- **Default Configuration**: Initialize devices with sensible presets and sources
- **Local-First Setup**: Complete registration without upstream dependencies
@@ -100,7 +100,7 @@ data/
- Basic API endpoints with comprehensive testing
- Integration with existing datastore patterns
### Phase 2: Device Lifecycle (2-3 weeks) - Build on Existing Systems
### Phase 2: Device Lifecycle (2-3 weeks) - Build on Existing Systems
- Event processing using existing WebSocket system
- Lifecycle integration with current discovery and migration
- Enhanced logging building on existing parity detection
@@ -178,4 +178,4 @@ This concept is detailed across several documents:
3. **Resource Planning**: Allocate development resources for the three-phase implementation
4. **Community Engagement**: Share plans with the community for feedback and contributions
This enhanced state management system represents a significant evolution of the SoundTouch service, transforming it from a basic cloud replacement into a comprehensive, future-proof device management platform that can serve users well beyond the Bose service shutdown timeline.
This enhanced state management system represents a significant evolution of the SoundTouch service, transforming it from a basic replacement into a comprehensive, future-proof device management platform that can serve users well beyond the Bose service shutdown timeline.
+3 -3
View File
@@ -2,7 +2,7 @@
## Overview
This document outlines the concept for simulating and replacing upstream Bose services with enhanced state management capabilities. The goal is to create a comprehensive local replacement that can handle device lifecycles, account management, and state synchronization while maintaining compatibility with existing SoundTouch devices.
This document outlines the concept for simulating and replacing upstream Bose services with enhanced state management capabilities. The goal is to create a comprehensive replacement that can handle device lifecycles, account management, and state synchronization while maintaining compatibility with existing SoundTouch devices.
## Use Cases
@@ -331,7 +331,7 @@ GET /api/v1/accounts/{account-id}/export
### Quality Assurance
- Complete test coverage for all new functionality
- Comprehensive linting with `golangci-lint run --fix`
- Comprehensive linting with `golangci-lint run --fix`
- Full test suite execution `go test ./...` for each milestone
- Integration tests with existing functionality
@@ -387,4 +387,4 @@ Future improvements should maintain the simplicity-first approach:
- Simple reporting mechanisms
- Clear documentation for community contributions
This concept provides a solid, maintainable foundation for replacing Bose's upstream services. The emphasis on simplicity, existing system reuse, and comprehensive testing ensures reliable functionality while maintaining the debugging capabilities needed for small hardware deployments.
This concept provides a solid, maintainable foundation for replacing Bose's upstream services. The emphasis on simplicity, existing system reuse, and comprehensive testing ensures reliable functionality while maintaining the debugging capabilities needed for small hardware deployments.
+1 -1
View File
@@ -1,6 +1,6 @@
# Migration Guide: From Bose Cloud to AfterTouch
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the local replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
For a shorter overview, see the [Survival Guide](SURVIVAL-GUIDE.md). For safety considerations and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
+1 -1
View File
@@ -12,7 +12,7 @@ What **continues to work** regardless:
- Bluetooth, AUX, and AirPlay inputs
- Multiroom zones (local, peer-to-peer)
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a local replacement for the Bose cloud on your own network.
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a replacement for the Bose cloud on your own network.
---
+20 -19
View File
@@ -7,33 +7,34 @@ import (
// DeviceInfo represents the response from GET /info endpoint
type DeviceInfo struct {
XMLName xml.Name `xml:"info"`
DeviceID string `xml:"deviceID,attr"`
Name string `xml:"name"`
Type string `xml:"type"`
MargeAccountUUID string `xml:"margeAccountUUID"`
Components []Component `xml:"components>component"`
MargeURL string `xml:"margeURL"`
NetworkInfo []NetworkInfo `xml:"networkInfo"`
ModuleType string `xml:"moduleType"`
Variant string `xml:"variant"`
VariantMode string `xml:"variantMode"`
CountryCode string `xml:"countryCode"`
RegionCode string `xml:"regionCode"`
XMLName xml.Name `xml:"info" json:"-"`
DeviceID string `xml:"deviceID,attr" json:"device_id"`
Name string `xml:"name" json:"name"`
Type string `xml:"type" json:"type"`
MargeAccountUUID string `xml:"margeAccountUUID" json:"marge_account_uuid,omitempty"`
Components []Component `xml:"components>component" json:"components,omitempty"`
MargeURL string `xml:"margeURL" json:"marge_url,omitempty"`
NetworkInfo []NetworkInfo `xml:"networkInfo" json:"network_info,omitempty"`
ModuleType string `xml:"moduleType" json:"module_type,omitempty"`
Variant string `xml:"variant" json:"variant,omitempty"`
VariantMode string `xml:"variantMode" json:"variant_mode,omitempty"`
CountryCode string `xml:"countryCode" json:"country_code,omitempty"`
RegionCode string `xml:"regionCode" json:"region_code,omitempty"`
IPAddress string `xml:"-" json:"ip_address,omitempty"`
}
// Component represents a device component
type Component struct {
ComponentCategory string `xml:"componentCategory"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
ComponentCategory string `xml:"componentCategory" json:"component_category"`
SoftwareVersion string `xml:"softwareVersion" json:"software_version"`
SerialNumber string `xml:"serialNumber" json:"serial_number"`
}
// NetworkInfo represents network information for the device
type NetworkInfo struct {
Type string `xml:"type,attr"`
MacAddress string `xml:"macAddress"`
IPAddress string `xml:"ipAddress"`
Type string `xml:"type,attr" json:"type"`
MacAddress string `xml:"macAddress" json:"mac_address"`
IPAddress string `xml:"ipAddress" json:"ip_address"`
}
// SourcesUpdatedNotification represents the notification XML sent to the device
+5 -1087
View File
File diff suppressed because it is too large Load Diff
+2 -505
View File
@@ -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<N> → t<N> 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<N> → t<N> 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)
}
})
}
}
+77
View File
@@ -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
}
+72
View File
@@ -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")
}
}
+790
View File
@@ -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<N>`) episodes. The legacy OPML endpoints can't —
// `Tune.ashx?id=p<N>` returns `#STATUS: 400`, `Browse.ashx?id=p<N>`
// 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<N>) are just containers for episodes (s<N>).
// We resolve the latest episode ID first.
episodeID, err := resolveTuneInProgramLatestEpisode(podcastID)
if err != nil {
return nil, err
}
return TuneInPlayback(episodeID, formats)
}
+452
View File
@@ -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)
}
})
}
}
+113
View File
@@ -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
}
+2 -1
View File
@@ -2,7 +2,8 @@
<html>
<head>
<meta charset="UTF-8"/>
<title>AfterTouch (SoundTouch Toolkit)</title>
<title>AfterTouch</title>
<meta name="description" content="AfterTouch Admin UI — Configure your SoundTouch replacement, migrate speakers, and manage your local account." />
<link rel="icon" href="/web/img/favicon-braille.svg" type="image/svg+xml"/>
<link rel="stylesheet" href="/web/css/style.css"/>
</head>
+5
View File
@@ -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
+105 -4
View File
@@ -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")
+46
View File
@@ -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()
+8 -2
View File
@@ -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) {
+237 -18
View File
@@ -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); } }
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,9 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Morse 'S' (drei Punkte) -->
<circle cx="8" cy="10" r="3" fill="#0055aa"/>
<circle cx="16" cy="10" r="3" fill="#0055aa"/>
<circle cx="24" cy="10" r="3" fill="#0055aa"/>
<!-- Braille Raster 2x3 (S + T kombiniert) -->
<!-- Spalte 1: Punkte 1, 2, 3 -->
<circle cx="10" cy="8" r="3" fill="#eee"/> <!-- Punkt 1 (inaktiv) -->
<circle cx="10" cy="16" r="3" fill="#0055aa"/> <!-- Punkt 2 (aktiv S/T) -->
<circle cx="10" cy="24" r="3" fill="#0055aa"/> <!-- Punkt 3 (aktiv S/T) -->
<!-- Morse 'T' (ein langer Strich) -->
<rect x="5" y="18" width="22" height="6" rx="2" fill="#ffcc00"/>
<!-- Spalte 2: Punkte 4, 5, 6 -->
<circle cx="22" cy="8" r="3" fill="#0055aa"/> <!-- Punkt 4 (aktiv S/T) -->
<circle cx="22" cy="16" r="3" fill="#ffcc00"/> <!-- Punkt 5 (Der "T"-Punkt, Akzent) -->
<circle cx="22" cy="24" r="3" fill="#eee"/> <!-- Punkt 6 (inaktiv) -->
</svg>

Before

Width:  |  Height:  |  Size: 381 B

After

Width:  |  Height:  |  Size: 681 B

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" stroke="black" stroke-width="2"/>
<circle cx="12" cy="12" r="3" fill="black"/>
<path d="M12 7V9M12 15V17M7 12H9M15 12H17" stroke="black" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

@@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Braille Raster 2x3 (S + T kombiniert) -->
<!-- Spalte 1: Punkte 1, 2, 3 -->
<circle cx="10" cy="8" r="3" fill="#eee"/> <!-- Punkt 1 (inaktiv) -->
<circle cx="10" cy="16" r="3" fill="#0055aa"/> <!-- Punkt 2 (aktiv S/T) -->
<circle cx="10" cy="24" r="3" fill="#0055aa"/> <!-- Punkt 3 (aktiv S/T) -->
<!-- Spalte 2: Punkte 4, 5, 6 -->
<circle cx="22" cy="8" r="3" fill="#0055aa"/> <!-- Punkt 4 (aktiv S/T) -->
<circle cx="22" cy="16" r="3" fill="#ffcc00"/> <!-- Punkt 5 (Der "T"-Punkt, Akzent) -->
<circle cx="22" cy="24" r="3" fill="#eee"/> <!-- Punkt 6 (inaktiv) -->
</svg>

After

Width:  |  Height:  |  Size: 681 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<path d="M437,151.7c-5.3,0-10.3,1.9-14.2,5.2l-104-104c-3.9-3.9-10.2-3.9-14.1,0c-3.9,3.9-3.9,10.2,0,14.1l104,104
c-3.3,3.9-5.2,9-5.2,14.5c0,12.7,10.3,23,23,23s23-10.3,23-23S449.7,151.7,437,151.7z"/>
<path d="M451.5,197.7H60.5C27.1,197.7,0,224.8,0,258.2v146.1c0,33.4,27.1,60.5,60.5,60.5h391c33.4,0,60.5-27.1,60.5-60.5V258.2
C512,224.8,484.9,197.7,451.5,197.7z M123,412.2c-29.3,0-53-23.7-53-53s23.7-53,53-53s53,23.7,53,53S152.3,412.2,123,412.2z
M266,412.2c-5.5,0-10-4.5-10-10v-86.4c0-5.5,4.5-10,10-10s10,4.5,10,10v86.4C276,407.7,271.5,412.2,266,412.2z M316,412.2
c-5.5,0-10-4.5-10-10v-86.4c0-5.5,4.5-10,10-10s10,4.5,10,10v86.4C326,407.7,321.5,412.2,316,412.2z M366,412.2
c-5.5,0-10-4.5-10-10v-86.4c0-5.5,4.5-10,10-10s10,4.5,10,10v86.4C376,407.7,371.5,412.2,366,412.2z M442,412.2
c-29.3,0-53-23.7-53-53s23.7-53,53-53s53,23.7,53,53S471.3,412.2,442,412.2z"/>
<path d="M123,326.2c-18.2,0-33,14.8-33,33s14.8,33,33,33s33-14.8,33-33S141.2,326.2,123,326.2z"/>
<path d="M442,326.2c-18.2,0-33,14.8-33,33s14.8,33,33,33s33-14.8,33-33S460.2,326.2,442,326.2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="3" width="12" height="18" rx="2" stroke="black" stroke-width="2"/>
<circle cx="12" cy="8" r="3" stroke="black" stroke-width="1.5"/>
<circle cx="12" cy="16" r="4" stroke="black" stroke-width="1.5"/>
<circle cx="12" cy="16" r="1.5" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 371 B

@@ -3,7 +3,7 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st0{fill:#000000;}
</style>
<title>Artboard Copy 9</title>
<desc>Created with Sketch.</desc>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+4 -2
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoundTouch Web</title>
<title>AfterTouch</title>
<meta name="description" content="AfterTouch — A replacement for Bose SoundTouch cloud services. Control your speakers after the cloud shutdown." />
<script type="importmap">
{
"imports": {
@@ -19,6 +20,7 @@
</head>
<body>
<div id="app"></div>
<p style="display:none">Bose SoundTouch Toolkit</p>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>
</html>
+7 -1
View File
@@ -37,4 +37,10 @@ export const api = {
headers: JSON_HEADERS,
body: JSON.stringify(item),
}),
};
radioBrowserSearch: (q) => req(`/api/radiobrowser/search?q=${encodeURIComponent(q)}`),
radioBrowserPlay: (deviceId, item) => req(`/api/radiobrowser/play/${deviceId}`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify(item),
}),
};
+98 -20
View File
@@ -9,6 +9,7 @@ import { Sources } from './components/Sources.js';
import { Zone } from './components/Zone.js';
import { Recents } from './components/Recents.js';
import { TuneInBrowser } from './components/TuneInBrowser.js';
import { RadioBrowser } from './components/RadioBrowser.js';
import { api } from './api.js';
const html = htm.bind(h);
@@ -29,7 +30,6 @@ function DeviceDetail({ deviceId, devices, onBack }) {
<div class="device-detail">
<div class="page-header">
<button class="back-btn" onClick=${onBack}> Back</button>
<h2>${device.info?.Name || deviceId}</h2>
<button class="btn-icon" onClick=${() => api.power(deviceId)} title="Power"></button>
</div>
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} />
@@ -47,8 +47,40 @@ function App() {
const [page, setPage] = useState('devices');
const [selectedId, setSelectedId] = useState(null);
const [toast, setToast] = useState(null);
const [version, setVersion] = useState(null);
const [isDiscovering, setIsDiscovering] = useState(false);
const getPageTitle = () => {
if (page === 'devices') return 'Devices';
if (page === 'device') {
const device = devices[selectedId];
const name = device?.info?.name || selectedId || 'Device Detail';
const ip = device?.info?.ip_address;
if (ip) {
return html`
<div class="title-with-subtitle">
<span class="main-title">${name}</span>
<span class="sub-title">${ip}</span>
</div>
`;
}
return name;
}
if (page === 'tunein') return 'TuneIn';
if (page === 'radiobrowser') return 'RadioBrowser';
return 'AfterTouch';
};
useEffect(() => {
fetch('/api/version')
.then(res => res.json())
.then(resp => {
if (resp.success) {
setVersion(resp.data);
}
})
.catch(err => console.error('Failed to fetch version:', err));
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
let reconnectTimer;
@@ -58,14 +90,26 @@ function App() {
if (msg.type === 'devices') {
setDevices(msg.data || {});
} else if (msg.type === 'discovery_status') {
console.log('[DEBUG_LOG] discovery_status:', msg.data);
if (msg.data?.isDiscovering !== undefined) {
setIsDiscovering(msg.data.isDiscovering);
} else if (msg.data?.status === 'starting') {
setIsDiscovering(true);
} else if (msg.data?.status === 'completed') {
setIsDiscovering(false);
}
if (msg.data?.status === 'completed') {
showToast(`Found ${msg.data.deviceCount} device(s)`);
}
} else if (msg.type === 'status_update' && msg.deviceId) {
setDevices(prev => ({
...prev,
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
}));
setDevices(prev => {
if (!prev[msg.deviceId]) return prev;
return {
...prev,
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
};
});
}
};
@@ -80,7 +124,8 @@ function App() {
}, []);
function showToast(msg) {
setToast(msg);
setToast(null);
setTimeout(() => setToast(msg), 10);
setTimeout(() => setToast(null), 3000);
}
@@ -98,44 +143,77 @@ function App() {
<div class="app">
<nav class="navbar">
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
SoundTouch
<img src="/static/img/logo.svg" alt="AfterTouch" class="nav-logo" />
<div class="brand-text">
<span class="brand-name">AfterTouch</span>
<span class="brand-subtitle">Bose SoundTouch Toolkit</span>
</div>
</a>
<div class="page-title">${getPageTitle()}</div>
<div class="nav-links">
<a href="#" class="${page === 'devices' || page === 'device' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
Devices
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}
title="Devices"
>
<img src="/static/img/speaker-mono.svg" alt="Devices" class="nav-device-icon" />
</a>
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}>
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}
title="TuneIn"
>
<img src="/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
</a>
<button class="btn-icon" onClick=${discover} title="Discover"></button>
<a href="#" class="${page === 'radiobrowser' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('radiobrowser'); }}
title="RadioBrowser"
>
<img src="/static/img/radiobrowser-mono.svg" alt="RadioBrowser" class="nav-rb-icon" />
</a>
<span class="nav-separator">|</span>
<button class="btn-icon" onClick=${discover} title="Discover">
<img src="/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
</button>
</div>
</nav>
<main class="main-content">
${page === 'devices' && html`
${page === 'devices' ? html`
<${DeviceList}
key="device-list"
devices=${devices}
isDiscovering=${isDiscovering}
onSelect=${(id) => navigate('device', id)}
onDiscover=${discover}
/>
`}
${page === 'device' && html`
` : page === 'device' ? html`
<${DeviceDetail}
key="device-detail"
deviceId=${selectedId}
devices=${devices}
onBack=${() => navigate('devices')}
/>
`}
${page === 'tunein' && html`
<${TuneInBrowser} devices=${devices} />
`}
` : page === 'tunein' ? html`
<${TuneInBrowser} key="tunein-browser" devices=${devices} />
` : page === 'radiobrowser' ? html`
<${RadioBrowser} key="radiobrowser-browser" devices=${devices} />
` : null}
</main>
${toast && html`<div class="toast">${toast}</div>`}
${version ? html`
<footer id="footer" key="footer">
<span>
AfterTouch <a href="${version.release_url || version.repo_url}" target="_blank">${version.version}</a>
${version.commit && version.commit !== 'unknown' ? html`
${' ('}<a href="${version.commit_url}" target="_blank">${version.commit.substring(0, 7)}</a>${')'}
` : null}
${version.date && version.date !== 'unknown' ? html`${version.date}` : null}
</span>
</footer>
` : null}
${toast ? html`<div class="toast" key="toast">${toast}</div>` : null}
</div>
`;
}
render(html`<${App} />`, document.getElementById('app'));
render(html`<${App} />`, document.getElementById('app'));
@@ -12,43 +12,46 @@ function DeviceCard({ id, device, onSelect }) {
return html`
<div class="device-card" onClick=${() => onSelect(id)}>
<div class="device-header">
<span class="device-name">${info?.Name || id}</span>
<span class="device-name">${info?.name || id}</span>
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
</div>
<div class="device-type">${info?.Type || ''}</div>
${!isStandby && html`
<div class="device-type">
${info?.type || ''}
${info?.ip_address ? html`<span class="device-ip">(${info.ip_address})</span>` : null}
</div>
${!isStandby ? html`
<div class="now-playing-mini">
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
${np.Artist && html`<span class="artist-mini"> — ${np.Artist}</span>`}
${np.Artist ? html`<span class="artist-mini"> — ${np.Artist}</span>` : null}
</div>
`}
${isStandby && html`<div class="standby-label">Standby</div>`}
` : null}
${isStandby ? html`<div class="standby-label">Standby</div>` : null}
</div>
`;
}
export function DeviceList({ devices, onSelect, onDiscover }) {
export function DeviceList({ devices, isDiscovering, onSelect, onDiscover }) {
const entries = Object.entries(devices);
return html`
<div class="page-header">
<h2>Devices</h2>
<button class="btn-secondary" onClick=${onDiscover}>Discover</button>
</div>
<div class="device-list-container">
${entries.length === 0
? html`
<div class="empty-state">
<div class="empty-icon"></div>
<p>No devices found on your network.</p>
<button class="btn-primary" onClick=${onDiscover}>Start Discovery</button>
<div class="empty-state" key="empty">
<div class="empty-icon ${isDiscovering ? 'radiating' : ''}"></div>
<p>${isDiscovering ? 'Searching for devices...' : 'No devices found on your network.'}</p>
<button class="btn-primary" onClick=${onDiscover} disabled=${isDiscovering}>
${isDiscovering ? 'Discovering...' : 'Start Discovery'}
</button>
</div>`
: html`
<div class="device-grid">
<div class="device-grid" key="grid">
${entries.map(([id, device]) => html`
<${DeviceCard} key=${id} id=${id} device=${device} onSelect=${onSelect} />
`)}
</div>`
}
</div>
`;
}
}
@@ -0,0 +1,105 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
function flattenSections(data) {
if (!data?.bmx_sections) return [];
return data.bmx_sections.flatMap(section =>
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
);
}
export function RadioBrowser({ devices }) {
const [items, setItems] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [pendingPlay, setPendingPlay] = useState(null);
async function search(q) {
if (!q.trim()) return;
setLoading(true);
const resp = await api.radioBrowserSearch(q);
setLoading(false);
if (resp.success) {
setItems(flattenSections(resp.data));
}
}
async function playOn(deviceId) {
await api.radioBrowserPlay(deviceId, {
location: pendingPlay.location,
type: pendingPlay.type,
name: pendingPlay.name
});
setPendingPlay(null);
}
const deviceEntries = Object.entries(devices);
return html`
<div class="tunein-browser">
<div class="tunein-toolbar">
<input
type="text"
class="tunein-search-input"
placeholder="Search RadioBrowser stations…"
value=${searchQuery}
onInput=${(e) => setSearchQuery(e.target.value)}
onKeyDown=${(e) => e.key === 'Enter' && search(searchQuery)}
/>
<button class="btn-primary" onClick=${() => search(searchQuery)}>Search</button>
</div>
${loading ? html`<div class="loading-bar"></div>` : null}
<ul class="tunein-list">
${items.length === 0 && !loading ? html`<li class="tunein-item" key="empty">No results yet. Try searching for a station.</li>` : null}
${items.map((item, i) => {
const play = item._links?.bmx_playback;
return html`
<li key=${item.stationuuid || i} class="tunein-item">
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
<div class="tunein-item-info">
<span class="tunein-item-name">${item.name}</span>
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
</div>
${play ? html`
<button
class="tunein-play-btn"
title="Play"
onClick=${() => {
setPendingPlay({ location: play.href, type: play.type, name: item.name });
}}
></button>
` : null}
</li>
`;
})}
</ul>
${pendingPlay ? html`
<div class="overlay" onClick=${() => setPendingPlay(null)}>
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
<h3 class="picker-title">Play on device</h3>
<p class="picker-item-name">${pendingPlay.name}</p>
<div class="picker-devices">
${deviceEntries.length === 0 ? html`<p class="picker-no-devices">No devices found. Try discovering first.</p>` : null}
${deviceEntries.map(([id, d]) => html`
<button class="picker-device-btn" key=${id} onClick=${() => playOn(id)}>
<div class="picker-device-info">
<span class="picker-device-name">${d.info?.name || id}</span>
<span class="picker-device-ip">${d.info?.ip_address || ''}</span>
</div>
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
</div>
</div>
` : null}
</div>
`;
}
@@ -97,38 +97,32 @@ export function TuneInBrowser({ devices }) {
}}>Browse</button>
</div>
${navStack.length > 1 && html`
${navStack.length > 1 ? html`
<nav class="breadcrumb">
${navStack.map((entry, i) => html`
${i > 0 && html`<span class="breadcrumb-sep"></span>`}
${i > 0 ? html`<span class="breadcrumb-sep"></span>` : null}
${i < navStack.length - 1
? html`<a class="breadcrumb-link" onClick=${() => navTo(i)}>${entry.label}</a>`
: html`<span class="breadcrumb-current">${entry.label}</span>`
}
`)}
</nav>
`}
` : null}
${loading && html`<div class="loading-bar"></div>`}
${loading ? html`<div class="loading-bar"></div>` : null}
<ul class="tunein-list">
${items.map((item, i) => {
const isNav = !!navPath(item);
const play = playbackInfo(item);
// Uniform play affordance: any item with a playback
// link gets the same pill button (stops propagation
// so it doesn't trigger the row's navigate). The
// arrow span carries only the drill-in chevron;
// stations no longer reuse it for ▶, which kept the
// two affordances visually distinct.
return html`
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
${item.imageUrl && html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />`}
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
<div class="tunein-item-info">
<span class="tunein-item-name">${item.name}</span>
${item.subtitle && html`<span class="tunein-item-desc">${item.subtitle}</span>`}
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
</div>
${play && html`
${play ? html`
<button
class="tunein-play-btn"
title="Play"
@@ -137,30 +131,33 @@ export function TuneInBrowser({ devices }) {
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
}}
></button>
`}
${isNav && html`<span class="tunein-item-arrow"></span>`}
` : null}
${isNav ? html`<span class="tunein-item-arrow"></span>` : null}
</li>
`;
})}
</ul>
${pendingPlay && html`
${pendingPlay ? html`
<div class="overlay" onClick=${() => setPendingPlay(null)}>
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
<h3 class="picker-title">Play on device</h3>
<p class="picker-item-name">${pendingPlay.name}</p>
<div class="picker-devices">
${deviceEntries.length === 0 && html`<p class="picker-no-devices">No devices found. Try discovering first.</p>`}
${deviceEntries.length === 0 ? html`<p class="picker-no-devices">No devices found. Try discovering first.</p>` : null}
${deviceEntries.map(([id, d]) => html`
<button class="picker-device-btn" onClick=${() => playOn(id)}>
${d.info?.name || id}
<button class="picker-device-btn" key=${id} onClick=${() => playOn(id)}>
<div class="picker-device-info">
<span class="picker-device-name">${d.info?.name || id}</span>
<span class="picker-device-ip">${d.info?.ip_address || ''}</span>
</div>
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
</div>
</div>
`}
` : null}
</div>
`;
}
}
+11 -1
View File
@@ -34,6 +34,17 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
app.WSClients[conn] = true
app.WSMutex.Unlock()
// Send current discovery status
if ds, ok := app.discoveryStatus.Load().(*webtypes.DiscoveryStatus); ok {
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "discovery_status",
Data: ds,
}); err != nil {
log.Printf("Failed to send initial discovery status: %v", err)
return
}
}
// Send initial device list
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
@@ -115,7 +126,6 @@ func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
return
}
// Discovery will be triggered by the main app
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
@@ -139,3 +139,10 @@ type WebSocketMessage struct {
DeviceID string `json:"deviceId,omitempty"`
Data interface{} `json:"data,omitempty"`
}
// DiscoveryStatus represents the status of device discovery
type DiscoveryStatus struct {
IsDiscovering bool `json:"isDiscovering"`
Status string `json:"status,omitempty"`
DeviceCount int `json:"deviceCount,omitempty"`
}