Compare commits

..
4 Commits
Author SHA1 Message Date
Tobias Gesellchen 49ec2d58db wip 2026-03-07 19:51:19 +01:00
Tobias Gesellchen c78664ee59 wip 2026-03-07 19:51:19 +01:00
Tobias Gesellchen c17ba0e839 Add Stockholm Mini (3)
This is also a refactoring of our api paths
2026-03-07 19:51:19 +01:00
Tobias Gesellchen 17f052308f wip 2026-03-07 19:51:19 +01:00
203 changed files with 4972 additions and 19109 deletions
-9
View File
@@ -28,15 +28,6 @@
},
{
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
},
{
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
},
{
"pattern": "https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
},
{
"pattern": "https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
}
],
"replacementPatterns": [
+11 -15
View File
@@ -43,14 +43,8 @@ jobs:
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Build service
run: make build-service
- name: Run HTTP client integration tests
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v5
with:
file: ./coverage.out
flags: unittests
@@ -147,9 +141,11 @@ jobs:
uses: actions/checkout@v6
- name: Check documentation links
run: |
npm install -g markdown-link-check
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: "yes"
use-verbose-mode: "yes"
config-file: ".github/markdown-link-check.json"
- name: Warn on pending images
run: |
@@ -260,11 +256,11 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -272,7 +268,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -280,7 +276,7 @@ jobs:
type=ref,event=pr
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
@@ -324,7 +320,7 @@ jobs:
- name: Update commit status
if: always()
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
try {
+3 -3
View File
@@ -22,16 +22,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@v5
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@v4
with:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@v4
+6 -6
View File
@@ -440,7 +440,7 @@ jobs:
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
@@ -470,7 +470,7 @@ jobs:
path: ./release-assets
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
@@ -492,10 +492,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -503,7 +503,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -512,7 +512,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine AS builder
# Declare automatic platform ARGs to make them available in build stage
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
+2 -56
View File
@@ -20,8 +20,6 @@ EXAMPLE_UPNP_NAME=example-upnp
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
SCANNER_NAME=mdns-scanner
SCANNER_PATH=./cmd/$(SCANNER_NAME)
FAVICON_GEN_NAME=favicon-gen
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
BUILD_DIR=./build
# Version info
@@ -29,7 +27,7 @@ BUILD_DIR=./build
all: check build
build: build-cli build-service build-examples build-favicon-gen
build: build-cli build-service build-examples
build-cli:
@echo "Building $(BINARY_NAME)..."
@@ -50,11 +48,6 @@ build-examples:
@echo "Building $(SCANNER_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
build-favicon-gen:
@echo "Building $(FAVICON_GEN_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
build-all: build-linux build-darwin build-windows build-examples-all
build-linux:
@@ -103,53 +96,7 @@ test-coverage:
$(GOCMD) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
check: fmt vet test test-http-client
test-http-client:
@echo "Starting services with docker compose..."
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
@echo "Waiting for services to start..."
@sleep 10
@echo "Running .http tests..."
@docker run --rm --network soundtouch-test-net \
-v "$(PWD)/tests/integration/http-client:/workdir" \
jetbrains/intellij-http-client:2026.1 \
--env-file /workdir/http-client.env.json \
--env ci \
/workdir/spotify_registration.http \
/workdir/create_account.http \
/workdir/register_device.http \
/workdir/spotify_full_flow.http \
/workdir/customer_support.http \
/workdir/power_on.http \
/workdir/get_bmx_services.http \
/workdir/get_sourceproviders.http \
/workdir/get_software_update.http \
/workdir/get_soundtouch_updates.http \
/workdir/get_streaming_token.http \
/workdir/post_oauth_token.http \
/workdir/get_provider_settings.http \
/workdir/tunein_playback_station.http \
/workdir/set_preset_6.http \
/workdir/get_presets.http \
/workdir/delete_preset_6.http \
/workdir/set_preset_5.http \
/workdir/post_recent.http \
/workdir/get_recents.http \
/workdir/get_account_presets.http \
/workdir/get_account_devices.http \
/workdir/get_account_sources.http \
/workdir/get_api_versions.http \
/workdir/post_musicprovider_is_eligible.http \
/workdir/get_full_account.http \
/workdir/get_group.http \
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
exit $$EXIT_CODE
check: fmt vet test
fmt:
@echo "Formatting code..."
@@ -279,7 +226,6 @@ help:
@echo " build - Build the CLI tool, service, and examples"
@echo " build-cli - Build only the CLI tool"
@echo " build-service - Build only the service"
@echo " build-favicon-gen - Build the favicon generator"
@echo " build-examples - Build only the example programs"
@echo " build-all - Build for all platforms"
@echo " test - Run tests"
+31 -31
View File
@@ -17,7 +17,6 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
-**Real-time Events**: WebSocket connection for live device state monitoring
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
- 🎙️ **Station Management**: Add and play radio stations without presets
- 🖥️ **CLI Tool**: Comprehensive command-line interface
@@ -79,6 +78,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
- **🔧 Device Migration**: Seamlessly transition devices to local control
- **🌐 Web Management UI**: Easy browser-based setup and management
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
@@ -107,7 +107,7 @@ package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
@@ -117,20 +117,20 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Get device information
info, err := c.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\n", info.Name)
// Control playback
err = c.Play()
if err != nil {
log.Fatal(err)
}
// Set volume
err = c.SetVolume(50)
if err != nil {
@@ -148,7 +148,7 @@ import (
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
)
@@ -159,9 +159,9 @@ func main() {
if err != nil {
log.Fatal(err)
}
for _, device := range devices {
fmt.Printf("Found: %s at %s:%d\n",
fmt.Printf("Found: %s at %s:%d\n",
device.Name, device.Host, device.Port)
}
}
@@ -175,7 +175,7 @@ import (
"context"
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -185,13 +185,13 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Subscribe to device events
events, err := c.SubscribeToEvents(context.Background())
if err != nil {
log.Fatal(err)
}
for event := range events {
switch e := event.(type) {
case *models.NowPlayingUpdated:
@@ -212,7 +212,7 @@ package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -222,21 +222,21 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Get current presets
presets, err := c.GetPresets()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d presets\n", len(presets.Preset))
// Store currently playing content as preset 1
err = c.StoreCurrentAsPreset(1)
if err != nil {
log.Fatal(err)
}
// Store Spotify playlist as preset 2
spotifyContent := &models.ContentItem{
Source: "SPOTIFY",
@@ -250,7 +250,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Store radio station as preset 3
radioContent := &models.ContentItem{
Source: "TUNEIN",
@@ -263,13 +263,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Select preset 1
err = c.SelectPreset(1)
if err != nil {
log.Fatal(err)
}
fmt.Println("Preset management complete!")
}
```
@@ -280,7 +280,7 @@ package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -290,7 +290,7 @@ func main() {
Host: "192.168.1.100", // Master speaker
Port: 8090,
})
// Create a multiroom zone
zone := &models.Zone{
Master: "192.168.1.100",
@@ -299,12 +299,12 @@ func main() {
{IPAddress: "192.168.1.102"}, // Kitchen
},
}
err := master.SetZone(zone)
if err != nil {
log.Fatal(err)
}
fmt.Println("Multiroom zone created!")
}
```
@@ -315,7 +315,7 @@ package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
@@ -324,13 +324,13 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Play Text-to-Speech message (language code "EN", "DE", etc.)
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
if err != nil {
log.Fatal(err)
}
// Play audio content from URL
err = c.PlayURL(
"https://example.com/doorbell.mp3",
@@ -343,13 +343,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Play notification beep
err = c.PlayNotificationBeep()
if err != nil {
log.Fatal(err)
}
fmt.Println("Notifications sent!")
}
```
@@ -359,7 +359,7 @@ func main() {
This library supports all Bose SoundTouch-compatible devices, including:
- SoundTouch 10, 20, 30 series
- SoundTouch Portable
- SoundTouch Portable
- Wave SoundTouch music system
- SoundTouch-enabled Bose speakers
@@ -480,7 +480,7 @@ SoundTouch is a trademark of Bose Corporation.
This Go library will continue to work as it uses the local Web API for direct device control, which is unaffected by the cloud service discontinuation. The local preset management functionality implemented in this library (discovered through the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)) provides an alternative to the cloud-based preset features that will be discontinued.
**Community Alternatives**: See the [Related Projects & Credits](#related-projects--credits) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
**Community Alternatives**: See the [Related Projects](#related-projects) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
## Related Projects & Credits
@@ -520,7 +520,7 @@ This project builds upon the excellent work of several community projects:
These projects together form a comprehensive ecosystem for SoundTouch device management:
- **This Project**: Go library + CLI + service for programmatic control and offline operation
- **SoundCork**: Python-based service interception and cloud replacement
- **SoundCork**: Python-based service interception and cloud replacement
- **SoundTouch Plus**: Home Assistant integration with extensive device support
- **ÜberBöse**: API research and advanced endpoint discovery
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
-152
View File
@@ -1,152 +0,0 @@
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
package main
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
func main() {
mediaDir := "pkg/service/handlers/web/img"
files := []string{"favicon-braille", "favicon-morse"}
for _, name := range files {
svgPath := filepath.Join(mediaDir, name+".svg")
pngPath := filepath.Join(mediaDir, name+".png")
icoPath := filepath.Join(mediaDir, name+".ico")
fmt.Printf("Processing %s...\n", name)
// 1. Render SVG to PNG
img, err := renderSVG(svgPath, 32, 32)
if err != nil {
log.Fatalf("Failed to render %s: %v", svgPath, err)
}
f, err := os.Create(pngPath)
if err != nil {
log.Fatalf("Failed to create %s: %v", pngPath, err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
}
f.Close()
fmt.Printf("Created %s\n", pngPath)
// 2. Create ICO (containing multiple sizes)
sizes := []int{16, 32, 48}
var images []image.Image
for _, s := range sizes {
m, err := renderSVG(svgPath, s, s)
if err != nil {
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
}
images = append(images, m)
}
if err := writeICO(icoPath, images); err != nil {
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
}
fmt.Printf("Created %s\n", icoPath)
}
}
func renderSVG(path string, w, h int) (image.Image, error) {
in, err := os.Open(path)
if err != nil {
return nil, err
}
defer in.Close()
icon, err := oksvg.ReadIconStream(in)
if err != nil {
return nil, err
}
icon.SetTarget(0, 0, float64(w), float64(h))
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
dasher := rasterx.NewDasher(w, h, gv)
icon.Draw(dasher, 1.0)
return rgba, nil
}
// Simple ICO encoder that wraps PNGs
func writeICO(path string, images []image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
bw := bufio.NewWriter(f)
defer bw.Flush()
// ICONDIR header
// Reserved (2), Type (2), Count (2)
binary.Write(bw, binary.LittleEndian, uint16(0))
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
var pngData [][]byte
for _, img := range images {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
pngData = append(pngData, buf.Bytes())
}
offset := uint32(6 + len(images)*16)
for i, img := range images {
b := img.Bounds()
width := uint8(b.Dx())
if b.Dx() >= 256 {
width = 0
}
height := uint8(b.Dy())
if b.Dy() >= 256 {
height = 0
}
// ICONDIRENTRY
bw.WriteByte(width)
bw.WriteByte(height)
bw.WriteByte(0) // Color count
bw.WriteByte(0) // Reserved
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
binary.Write(bw, binary.LittleEndian, offset)
offset += uint32(len(pngData[i]))
}
for _, data := range pngData {
bw.Write(data)
}
return nil
}
-23
View File
@@ -1,23 +0,0 @@
// Package main provides a mock Spotify server for testing purposes.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
)
func main() {
port := flag.Int("port", 8080, "Port to listen on")
flag.Parse()
log.Printf("Starting mock Spotify server on port %d", *port)
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
log.Fatal(err)
}
}
-67
View File
@@ -652,73 +652,6 @@ func listMusicServiceAccounts(c *cli.Context) error {
return nil
}
// pairDevice triggers the Stockholm registration flow via WebSocket
func pairDevice(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
accountID := c.String("id")
token := c.String("token")
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
fmt.Printf(" Account ID: %s\n", accountID)
// We need a WebSocket client for this
ws := client.NewWebSocketClient(nil)
err = ws.Connect()
if err != nil {
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
}
defer func() { _ = ws.Disconnect() }()
err = ws.PairWithAccount(accountID, token)
if err != nil {
return fmt.Errorf("failed to send pairing request: %w", err)
}
PrintSuccess("Pairing request sent successfully")
fmt.Println("💡 The device will now register itself with the cloud service.")
return nil
}
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
func unpairDevice(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
// We need a WebSocket client for this
ws := client.NewWebSocketClient(nil)
err = ws.Connect()
if err != nil {
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
}
defer func() { _ = ws.Disconnect() }()
err = ws.UnPairFromAccount()
if err != nil {
return fmt.Errorf("failed to send unpairing request: %w", err)
}
PrintSuccess("Unpairing request sent successfully")
return nil
}
// getServiceDisplayName returns a user-friendly display name for a service
func getServiceDisplayName(source string) string {
switch source {
-57
View File
@@ -1,9 +1,7 @@
package main
import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -253,61 +251,6 @@ func selectLocalInternetRadio(c *cli.Context) error {
return nil
}
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
func selectCustomRadio(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
streamURL := c.String("url")
itemName := c.String("name")
containerArt := c.String("artwork")
serviceURL := c.String("service-url")
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
params := url.Values{}
if itemName != "" {
params.Add("name", itemName)
}
if containerArt != "" {
params.Add("imageUrl", containerArt)
}
if len(params) > 0 {
location += "?" + params.Encode()
}
// Check LOCAL_INTERNET_RADIO availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
}
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Station: %s\n", itemName)
}
fmt.Printf(" URL: %s\n", streamURL)
fmt.Printf(" Proxy: %s\n", location)
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select custom radio: %w", err)
}
PrintSuccess("Custom radio stream selected")
return nil
}
// selectLocalMusic handles selecting LOCAL_MUSIC source
func selectLocalMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
+2 -2
View File
@@ -196,7 +196,7 @@ var httpClient = &http.Client{
}
func fetchTuneInMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
if !strings.Contains(url, "tunein.com/radio/") {
return nil, fmt.Errorf("url is not a TuneIn radio URL")
}
@@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) {
}
func fetchSpotifyMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
if !strings.Contains(url, "open.spotify.com/") {
return nil, fmt.Errorf("url is not a Spotify URL")
}
+22 -22
View File
@@ -7,7 +7,7 @@ import (
)
func TestFetchTuneInMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
html := `
<!doctype html>
<html>
@@ -30,23 +30,23 @@ func TestFetchTuneInMetadata(t *testing.T) {
defer func() { httpClient = oldClient }()
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/")
if err != nil {
t.Fatalf("fetchTuneInMetadata() error = %v", err)
}
if metadata == nil {
t.Fatal("fetchTuneInMetadata() returned nil metadata")
} else {
expectedName := "WDR 2 Rheinland"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
}
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
expectedName := "WDR 2 Rheinland"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
}
@@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) {
}
func TestFetchSpotifyMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
html := `
<!doctype html>
<html>
@@ -185,22 +185,22 @@ func TestFetchSpotifyMetadata(t *testing.T) {
defer func() { httpClient = oldClient }()
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
if err != nil {
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
}
if metadata == nil {
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
} else {
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
}
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
}
-52
View File
@@ -924,34 +924,6 @@ func main() {
},
},
},
{
Name: "custom-radio",
Usage: "Select custom radio stream via soundtouch-service",
Action: selectCustomRadio,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Usage: "Stream URL",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Station name",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Station artwork URL",
},
&cli.StringFlag{
Name: "service-url",
Usage: "URL of the soundtouch-service (default: http://localhost:8080)",
Value: "http://localhost:8080",
},
},
},
{
Name: "local-music",
Usage: "Select local music content (LOCAL_MUSIC)",
@@ -2038,30 +2010,6 @@ func main() {
},
},
},
{
Name: "pair",
Usage: "Pair the device with a Marge cloud account (Stockholm registration)",
Action: pairDevice,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Usage: "Marge account ID (e.g., 1234567)",
Required: true,
},
&cli.StringFlag{
Name: "token",
Usage: "User authorization token",
Required: true,
},
},
},
{
Name: "unpair",
Usage: "Unpair the device from its Marge cloud account",
Action: unpairDevice,
Before: RequireHost,
},
},
},
// Token commands
+107 -220
View File
@@ -34,15 +34,10 @@ 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
}
@@ -53,54 +48,7 @@ func updateBuildInfo() {
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 initializeDefaultSources(ds *datastore.DataStore) {
// Ensure default sources exist for all known devices on startup
allDevices, _ := ds.ListAllDevices()
for i := range allDevices {
dev := &allDevices[i]
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
// Find default sources and merge them if missing or outdated tokens
defaults := ds.GetDefaultSources()
modified := false
for i := range defaults {
def := defaults[i]
found := false
for j := range sources {
if sources[j].SourceKeyType == def.SourceKeyType {
found = true
if sources[j].Secret == "" && def.Secret != "" {
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
sources[j].Secret = def.Secret
sources[j].SecretType = def.SecretType
modified = true
}
break
}
}
if !found {
log.Printf("Adding missing default source %s to device %s", def.SourceKeyType, dev.DeviceID)
sources = append(sources, def)
modified = true
}
}
if modified {
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
date = t.Format("2006-01-02_15:04:05")
}
}
}
@@ -214,16 +162,6 @@ func main() {
Value: "ueberboese-login://spotify",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "spotify-token-url",
Usage: "Spotify OAuth token URL (for testing)",
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
},
&cli.StringFlag{
Name: "spotify-api-base",
Usage: "Spotify API base URL (for testing)",
EnvVars: []string{"SPOTIFY_API_BASE"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
@@ -251,11 +189,6 @@ func main() {
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "skip-mirror-endpoints",
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
@@ -302,13 +235,13 @@ func main() {
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.migrationEnabled, config.migrationDryRun)
sm.GetDNSRunning = server.GetDNSRunning
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
@@ -320,14 +253,6 @@ func main() {
config.spotifyRedirectURI,
config.dataDir,
)
if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" {
spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase)
}
if err := spotifyService.Load(); err != nil {
log.Printf("[Spotify] Failed to load accounts: %v", err)
}
server.SetSpotifyService(spotifyService)
clientIDPrefix := config.spotifyClientID
@@ -391,8 +316,6 @@ func main() {
server.SetRecorder(recorder)
initializeDefaultSources(ds)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
@@ -451,15 +374,12 @@ type serviceConfig struct {
dnsBind string
mirrorEnabled bool
mirrorEndpoints []string
skipMirrorEndpoints []string
internalPaths []string
discoveryInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyTokenURL string
spotifyAPIBase string
mgmtUsername string
mgmtPassword string
migrationEnabled bool
@@ -524,13 +444,10 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
spotifyTokenURL := c.String("spotify-token-url")
spotifyAPIBase := c.String("spotify-api-base")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
mirrorEndpoints := c.StringSlice("mirror-endpoints")
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
@@ -552,15 +469,12 @@ func loadConfig(c *cli.Context) serviceConfig {
dnsBind: dnsBind,
mirrorEnabled: mirrorEnabled,
mirrorEndpoints: mirrorEndpoints,
skipMirrorEndpoints: skipMirrorEndpoints,
internalPaths: internalPaths,
discoveryInterval: discoveryInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
spotifyTokenURL: spotifyTokenURL,
spotifyAPIBase: spotifyAPIBase,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
@@ -651,7 +565,6 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.mirrorEnabled = persisted.MirrorEnabled
config.mirrorEndpoints = persisted.MirrorEndpoints
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
config.preferredSource = persisted.PreferredSource
config.internalPaths = persisted.InternalPaths
@@ -660,21 +573,20 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
SkipMirrorEndpoints: config.skipMirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -739,109 +651,69 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Route("/bmx", func(r chi.Router) {
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
r.Route("/tunein", func(r chi.Router) {
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/v1/token", server.HandleTuneInToken)
r.Post("/v1/report", server.HandleTuneInReport)
r.Get("/v1/navigate", server.HandleTuneInNavigate)
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
r.Get("/v1/search", server.HandleTuneInSearch)
})
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
})
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
// Legacy or direct domain calls without /bmx prefix
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Route("/streaming", func(r chi.Router) {
streamingRoutes := func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
r.Post("/account/{account}/source", server.HandleMargeAddSource)
r.Route("/account/{account}", func(r chi.Router) {
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Get("/presets", server.HandleMargeAccountPresets)
r.Get("/presets/all", server.HandleMargeAccountPresets)
r.Get("/provider_settings", server.HandleMargeProviderSettings)
r.Route("/device", func(r chi.Router) {
r.Post("/", server.HandleMargeAddDevice)
r.Post("/{device}", server.HandleMargeAddDevice)
})
r.Route("/device/{device}", func(r chi.Router) {
r.Get("/presets", server.HandleMargePresets)
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
r.Get("/recent", server.HandleMargeRecents)
r.Get("/recents", server.HandleMargeRecents)
r.Post("/recent", server.HandleMargeAddRecent)
r.Get("/group", server.HandleMargeDeviceGroup)
r.Get("/group/", server.HandleMargeDeviceGroup)
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
})
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
})
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/support/power_on", server.HandleMargePowerOn)
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
r.Route("/support", func(r chi.Router) {
r.Post("/power_on", server.HandleMargePowerOn)
r.Post("/customersupport", server.HandleMargeCustomerSupport)
})
r.Route("/stats", func(r chi.Router) {
r.Post("/usage", server.HandleUsageStats)
r.Post("/error", server.HandleErrorStats)
})
}
r.Route("/music", func(r chi.Router) {
r.Route("/musicprovider/{providerID}", func(r chi.Router) {
r.Post("/is_eligible", server.HandleMusicProviderIsEligible)
})
})
accountsRoutes := func(r chi.Router) {
r.Get("/{account}/full", server.HandleMargeAccountFull)
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
r.Post("/{account}/devices", server.HandleMargeAddDevice)
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
}
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
})
r.Route("/marge", func(r chi.Router) {
r.Route("/streaming", streamingRoutes)
r.Route("/accounts", accountsRoutes)
r.Route("/accounts", func(r chi.Router) {
r.Route("/{account}", func(r chi.Router) {
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Post("/devices", server.HandleMargeAddDevice)
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
})
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
})
// Legacy or direct domain calls without /marge prefix
r.Route("/streaming", streamingRoutes)
r.Route("/accounts", accountsRoutes)
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
r.Route("/customer", func(r chi.Router) {
@@ -851,19 +723,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
r.Route("/oauth", func(r chi.Router) {
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
r.HandleFunc("/*", server.HandleBoseProxy)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
// Return 405 Method Not Allowed as the upstream behavior also returns 405
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
})
})
r.Route("/mgmt", func(r chi.Router) {
@@ -875,34 +740,45 @@ func setupRouter(server *handlers.Server) *chi.Mux {
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
})
})
r.Get("/proxy/*", server.HandleProxyRequest)
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Get("/devices/{deviceId}/info", server.HandleGetStockholmDeviceInfo)
r.Post("/devices/{deviceId}/key/{key}", server.HandleDeviceKey)
r.Post("/devices/{deviceId}/volume/{level}", server.HandleDeviceVolume)
// Stockholm Mini app
r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini"))))
r.Route("/devices", func(r chi.Router) {
r.Get("/", server.HandleListDiscoveredDevices)
r.Post("/", server.HandleAddManualDevice)
r.Route("/{deviceId}", func(r chi.Router) {
r.Delete("/", server.HandleRemoveDevice)
r.Get("/events", server.HandleGetDeviceEvents)
r.Get("/info", server.HandleGetDeviceInfo)
r.Get("/ws", server.HandleDeviceWebSocket)
r.Post("/key/{key}", server.HandleDeviceKey)
r.Post("/volume/{level}", server.HandleDeviceVolume)
r.Post("/reboot", server.HandleRebootDevice)
})
})
r.Get("/version", server.HandleGetVersionInfo)
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
@@ -923,7 +799,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
@@ -937,7 +812,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Route("/devices/{deviceId}", func(r chi.Router) {
r.Get("/summary", server.HandleGetMigrationSummary)
r.Post("/migrate", server.HandleMigrateDevice)
r.Post("/revert", server.HandleRevertMigration)
r.Post("/trust-ca", server.HandleTrustCACert)
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
r.Post("/backup", server.HandleBackupConfig)
r.Post("/sync", server.HandleInitialSync)
r.Post("/test-connection", server.HandleTestConnection)
r.Post("/test-hosts", server.HandleTestHostsRedirection)
r.Post("/test-dns", server.HandleTestDNSRedirection)
})
})
r.NotFound(server.HandleNotFound)
-104
View File
@@ -1,104 +0,0 @@
package main
import (
"fmt"
"net/http"
"os"
"reflect"
"runtime"
"sort"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/go-chi/chi/v5"
)
func TestPrintRoutes(t *testing.T) {
// Initialize a minimal server to get the router
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
r := setupRouter(server)
var routes []string
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
route = strings.ReplaceAll(route, "/*/", "/")
handlerName := runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()
// Clean up the handler name (remove package path)
// For example, "github.com/gesellix/bose-soundtouch/cmd/soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
parts := strings.Split(handlerName, "/")
if len(parts) > 0 {
handlerName = parts[len(parts)-1]
}
// Now we might have "soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
// Let's remove the first part if it's a known varying package name
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
handlerName = handlerName[idx:]
}
// In case it's not setupRouter but still has a package prefix
for {
dotIdx := strings.Index(handlerName, ".")
if dotIdx == -1 {
break
}
prefix := handlerName[:dotIdx]
if prefix == "main" || prefix == "command-line-arguments" || strings.Contains(prefix, "soundtouch-service") {
handlerName = handlerName[dotIdx+1:]
} else {
break
}
}
// Also remove any ".funcN" suffix if it's an anonymous function
if idx := strings.Index(handlerName, ".func"); idx != -1 {
handlerName = handlerName[:idx]
}
routes = append(routes, fmt.Sprintf("%-8s %-60s %s", method, route, handlerName))
return nil
}
if err := chi.Walk(r, walkFunc); err != nil {
t.Fatalf("Failed to walk routes: %v", err)
}
sort.Strings(routes)
output := strings.Join(routes, "\n") + "\n"
// Define snapshot path
snapshotPath := "testdata/router_routes.txt"
actualPath := "testdata/router_routes.actual.txt"
// Always write the current (actual) routes to a file
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
t.Fatalf("Failed to write actual routes: %v", err)
}
// Check if snapshot exists
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
// Create testdata directory if it doesn't exist
if err := os.MkdirAll("testdata", 0755); err != nil {
t.Fatalf("Failed to create testdata directory: %v", err)
}
// Initial snapshot creation
if err := os.WriteFile(snapshotPath, []byte(output), 0644); err != nil {
t.Fatalf("Failed to write snapshot: %v", err)
}
t.Logf("Initial snapshot created at %s", snapshotPath)
return
}
// Read existing snapshot
existingOutput, err := os.ReadFile(snapshotPath)
if err != nil {
t.Fatalf("Failed to read snapshot: %v", err)
}
if string(existingOutput) != output {
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
}
}
@@ -1 +0,0 @@
*.actual.txt
-134
View File
@@ -1,134 +0,0 @@
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
GET /docs/* handlers.(*Server).HandleDocs-fm
GET /favicon.ico setupRouter
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm
GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
-10
View File
@@ -1,10 +0,0 @@
services:
soundtouch-service:
build: .
volumes:
- ./tests/integration/testdata:/app/data
environment:
- SPOTIFY_CLIENT_ID=mock-id
- SPOTIFY_CLIENT_SECRET=mock-secret
- SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
- SPOTIFY_API_BASE=http://spotify-mock:8080
-18
View File
@@ -8,8 +8,6 @@ services:
ports:
- "8000:8000"
- "8443:8443"
networks:
- soundtouch-test-net
environment:
- PORT=8000
- HTTPS_PORT=8443
@@ -37,22 +35,6 @@ services:
cpus: '0.25'
memory: 128M
spotify-mock:
image: golang:1.26.2-alpine
container_name: spotify-mock
working_dir: /app
volumes:
- .:/app
command: go run ./cmd/mock-spotify/main.go -port 8080
ports:
- "8081:8080"
networks:
- soundtouch-test-net
networks:
soundtouch-test-net:
name: soundtouch-test-net
volumes:
soundtouch-data:
# Named volumes are preferred in Swarm. For multi-node persistence,
Binary file not shown.
+3 -20
View File
@@ -23,14 +23,6 @@ All content selection features from the [SoundTouch WebServices API Wiki](https:
- Automatic defaults for missing parameters
- **Use Cases**: Internet radio streams, proxy-based radio services
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
- **Features**:
- Flexible stream URL encoding (Base64 or URL-escaped)
- Dynamic generation of Bose-compatible playback JSON
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
- **Use Case**: Playing any internet radio URL without external proxy dependencies
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
- **Requirements**: SoundTouch App Media Server running on a computer
@@ -55,15 +47,6 @@ soundtouch-cli --host <device> source internet-radio \
--artwork "https://example.com/art.png"
```
#### `soundtouch-cli source custom-radio`
```bash
soundtouch-cli --host <device> source custom-radio \
--url "https://stream.example.com/radio" \
--name "My Station" \
--artwork "https://example.com/art.png" \
--service-url "http://localhost:8080"
```
#### `soundtouch-cli source local-music`
```bash
soundtouch-cli --host <device> source local-music \
@@ -118,7 +101,7 @@ Comprehensive test suites implemented for all new functionality:
### Example Code
Complete working example demonstrating:
- LOCAL_INTERNET_RADIO with streamUrl proxy format
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_MUSIC content selection
- STORED_MUSIC content selection
- Generic ContentItem usage
@@ -169,7 +152,7 @@ All convenience methods create properly structured `ContentItem` objects:
Based on the wiki structure, these related features are also supported:
1. **LOCAL_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
3. **SPOTIFY**: ✅ Previously implemented
4. **TUNEIN**: ✅ Previously implemented
5. **BLUETOOTH**: ✅ Previously implemented
@@ -186,7 +169,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "")
// Direct ContentItem
contentItem := &models.ContentItem{
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
Type: "stationurl",
Location: location,
ItemName: "My Station",
IsPresetable: true,
-114
View File
@@ -1,114 +0,0 @@
# Bose SoundTouch Device Setup Flow
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
## 1. Local Coordination Stage (WebSocket)
Before a device can be controlled, it must be configured on the local network and named. These actions occur via a WebSocket connection to the device on port 8080.
### 1.1 Language Configuration (Optional)
If the device is in a factory-reset state, the UI typically ensures the device language matches the user's choice.
- **WebSocket Action**: `set_language`
- **Internal Logic**: `SetupWizard.js` handles this via `set_device_language`.
### 1.2 Network Configuration (WiFi)
Configures the device to connect to a specific wireless access point.
- **File Reference**: `setup/js/workflow_wifi_setup.js`
- **Logic**: Triggers a site survey, then sends SSID and credentials.
- **WebSocket Command**: `set_WIFI_OLED` or similar internal method calls to configure the network profile.
### 1.3 Device Naming (Rename Step)
Assigns a user-friendly name (e.g., "Living Room") to the device.
- **File Reference**: `setup/js/workflow_rename.js`
- **WebSocket Action**: `name`
- **XML Payload**:
```xml
<name>Living Room</name>
```
- **Implementation**: The `RenameDevices.do_rename_devices()` function sends this to the device. The device then updates its local name and mDNS/SSDP broadcasts.
## 2. Cloud Interaction Stage (HTTP)
The device needs to be linked to a Bose "Marge" account to enable cloud-based features and music services.
### 2.1 Account Creation (Registration)
If a user doesn't have an account, the setup client creates one.
- **File Reference**: `setup/js/workflow_marge.js`
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account`
- **Payload**: XML containing name, email, password, and country.
- **Content-Type**: `application/vnd.bose.customer-v1.0+xml`
### 2.2 Cloud Authentication (Login)
The setup client must obtain a valid `accountId` and `userAuthToken` to pair the device.
- **File Reference**: `setup/js/workflow_marge.js`
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account/login`
- **Payload**: XML containing username and password.
- **Content-Type**: `application/vnd.bose.streaming-v1.2+xml`
- **Result**: Returns a session token in the `Credentials` response header and the user's `account ID` in the XML body.
## 3. Registration Bridge (WebSocket to Cloud)
This is the final "pairing" step where the client tells the device which account it belongs to.
### 3.1 Device Registration (The "Pair" Step)
The client sends the user's credentials to the device, which then registers itself with the cloud.
- **File Reference**: `setup/js/workflow_add_devices.js`
- **WebSocket Action**: `setMargeAccount`
- **XML Payload**:
```xml
<PairDeviceWithAccount>
<accountId>12345</accountId>
<userAuthToken>jGwE... (truncated)</userAuthToken>
</PairDeviceWithAccount>
```
- **Device Reaction**: Upon receiving this, the device makes its own outbound HTTP POST to the Marge service:
`POST https://streaming.bose.com/{accountId}/devices`
## 4. Finalization
Once the registration is complete, the setup application (Stockholm) performs final cleanup. It's important to distinguish between **App State** (the Stockholm UI's persistent settings) and **Device State** (the physical speaker's configuration).
### 4.1 Exiting Setup Mode (App Settings)
The Stockholm app communicates with its "native container" (the WebView bridge on iOS/Android/Windows/macOS) using a `setData` command in **JSON format**. This is an internal message to the application's persistent storage, **not a network command sent to the physical speaker**.
This command tells the Stockholm app which page to load on startup, effectively marking the setup as complete in the UI.
- **Internal Command**: `setData`
- **Parameter**: `startupPage`
- **Normal Value**: `index.html` (Normal mode)
- **Setup Value**: `setup/index.html` (Setup mode)
**JSON Payload (Internal to Stockholm App)**:
```json
{
"method": "setData",
"params": {
"name": "startupPage",
"value": "index.html"
}
}
```
**Other Common Internal Parameters**:
- `changeStartupPage`: Set to `false` after a successful setup or update.
- `tipsEnabled`: Set to `false` to suppress the "Getting Started" tutorials.
- `promptUpdate`: Set to `true` if a firmware update was deferred during setup.
### 4.2 Device Finalization
The physical speaker considers the setup "done" once it successfully processes the `<PairDeviceWithAccount>` XML message and completes its own handshake with the Marge cloud. There is no specific "Finalize" XML command sent to the speaker; the successful registration is the signal.
The `SetupWizard.js` calls `single_device_setup_done()` to trigger the internal `setData` updates described above. If these are not saved in the app's local storage, the Stockholm UI may return to the setup flow on next launch, even if the speaker is already paired.
---
## Summary of Scriptable Requirements
To automate a device setup using a custom tool (like `soundtouch-cli`), you must perform the following:
1. **Configure WiFi**: (Assumed if device is reachable over IP).
2. **Set Name**: Send the `<name>` WebSocket message (XML) to update the device identity.
3. **Obtain Token**: Authenticate against the cloud service (Marge) via HTTP.
4. **Pair Device**: Send the `<PairDeviceWithAccount>` WebSocket message (XML) with the account ID and token.
**Note**: The JSON `setData` commands are only necessary if you are building/controlling a version of the Stockholm UI itself. They are not required to configure the physical hardware.
-66
View File
@@ -1,66 +0,0 @@
# Technical Proposal: External Service Provider Abstraction
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
## 1. Problem Statement
Currently, content handling for BMX (Bose Media Exchange) services like TuneIn or RadioBrowser is deeply intertwined with the HTTP handlers and XML models. Adding a new content provider (e.g., Local Media, Podcast RSS) requires modifying several files and duplicating boilerplate code for HTTP requests and error handling.
## 2. Proposed Architecture
### 2.1 The Provider Interface
We define a generic `ContentProvider` interface that abstracts away the source-specific logic (API calls, data parsing).
```go
package provider
import "github.com/gesellix/bose-soundtouch/pkg/models"
type ContentProvider interface {
// ID returns the unique identifier for this provider (e.g. "RADIO_BROWSER")
ID() string
// Resolve returns playback details for a given content identifier
Resolve(id string) (*models.BmxPlaybackResponse, error)
// Search allows finding content within this provider
Search(query string) ([]models.ContentItem, error)
}
```
### 2.2 Provider Registry
A central registry in `soundtouch-service` manages the lifecycle and selection of providers.
```go
type Registry struct {
providers map[string]ContentProvider
}
func (r *Registry) Register(p ContentProvider) { ... }
func (r *Registry) Get(id string) ContentProvider { ... }
```
## 3. Implementation Plan
### 3.1 Phase 1: Modularize RadioBrowser
1. **Extract Logic**: Move current RadioBrowser logic from `bmx.go` into a new package `pkg/service/providers/radiobrowser`.
2. **Add Failover**: Implement the **API Failover** logic inspired by OpenCloudTouch.
- Maintain a list of active RadioBrowser mirrors (e.g., `de1.api.radio-browser.info`, `nl1.api.radio-browser.info`).
- Implement a round-robin or health-based selection strategy.
3. **Implements Interface**: Ensure the new package satisfies the `ContentProvider` interface.
### 3.2 Phase 2: Refactor BMX Handlers
- Update `HandleTuneInPlayback` and `HandleOrionPlayback` to use the registry.
- The handlers will look up the provider based on the request context or URL parameters and delegate the resolution.
### 3.3 Phase 3: Dynamic Service Advertising
- Modify `HandleBMXRegistry` to dynamically generate the `bmx_services.json` content based on the currently registered and enabled providers.
## 4. Benefits
- **Resilience**: Centralized error handling and failover strategies for all external APIs.
- **Extensibility**: New services can be added by simply implementing the interface and registering them at startup.
- **Testability**: Providers can be unit-tested in isolation without mocking the entire HTTP server stack.
- **Unified UI**: A future Web UI can query the registry to show available content sources and their statuses.
## 5. Next Steps
1. Refine the `ContentProvider` interface to include metadata (icons, user-friendly names).
2. Create a prototype for the `radiobrowser` provider with failover support.
-88
View File
@@ -1,88 +0,0 @@
### Overview of Recent Improvements and Next Steps
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
#### ✅ Completed Improvements (Marge Service)
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
* **Attribute-level Parity**:
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
* **Credential Preservation**: Improved `AddRecent` to correctly extract and echo back base64 tokens/credentials provided in the incoming request, improving source learning.
* **XML Formatting Parity**:
* Added `standalone="yes"` to the XML declaration for all Marge responses, including `recent`, `presets`, `full account`, `software update`, and `sourceproviders`.
* Enforced self-closing `<sourceSettings/>` tags for parity.
* Standardized date formatting to UTC with milliseconds (`.000+00:00`).
* Fixed casing for `/streaming/sourceproviders`: Root element is `<sourceProviders>`, but child elements are `<sourceprovider>` (all lowercase), matching upstream behavior.
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
---
#### 🛠️ Open Issues and Next Steps
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
#### 1. BMX / TuneIn Playback Parity (Medium)
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
#### 2. `/full` Account Response Data Gaps (Medium)
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
#### 3. OAuth / Spotify Token Noise (Low/Medium)
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
* **The Issue**: This creates "noise" in your parity reports that isn't actually a bug.
* **Action**: Update the parity detection logic (or the handler) to selectively ignore the `access_token` field while still verifying that the rest of the JSON structure (expires_in, scope, token_type) matches.
#### 4. Large IDs for Other Models (Medium)
While we fixed IDs for `recents`, other models like `presets` or `sources` might still use small auto-incrementing integers.
* **Action**: Evaluate if other endpoints should also transition to the timestamp-based ID schema to further reduce diff noise.
#### 5. Improved Data Persistence (Continuous)
Continue the "learning" approach for other services. For example, if we see a new `sourceproviderid` in a Spotify or TuneIn request, we should ensure it is stored and reused.
#### 6. Local Reboot & Device State Management (Continuous)
Analysis of device reboot logs revealed several data requirements:
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
Structural and value gaps in the `/full` account response have been addressed:
**Key Fixes:**
* **Structural**:
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
-45
View File
@@ -1,45 +0,0 @@
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
- **Bose-SoundTouch (Go)**: A high-performance, strongly typed backend with a CLI and background service. Focuses on full API coverage, parity testing, and robust hardware control (DSP, zones).
- **OpenCloudTouch (OCT)**: A modern full-stack application (FastAPI + React/TypeScript). Prioritizes user experience with a web-based setup wizard and a clean abstraction for internet radio.
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | OpenCloudTouch (Python) |
|:------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------|
| **Setup Experience** | CLI-driven or manual API calls for migration (SSH, XML). | Web-based **Setup Wizard** guides through SSH, backup, and redirection. |
| **Radio Support** | Static integration of **RadioBrowser** and TuneIn. | Dynamic **RadioBrowserAdapter** with automatic **API Failover**. |
| **Commercial Services** | Deep integration (Spotify priming, Pandora, Deezer, etc.). | Basic support, focus is on local content and radio. |
| **Hardware Control** | Extensive (Bass, Treble, Soundbar levels, Clock display). | Basic playback and zone controls. |
| **Cloud Emulation** | High-fidelity parity (mirroring, discrepancy logging). | Functional emulation for local preset/recent persistence. |
| **Notifications** | Built-in **TTS** and custom URL audio alerts. | Not a primary focus. |
## 3. Key Strengths of OpenCloudTouch
- **Guided Onboarding**: The setup wizard reduces the entry barrier for non-technical users significantly.
- **Resilient Radio**: The API failover for RadioBrowser ensures continuous service even if specific community-hosted API instances go offline.
- **Modern API Stack**: Uses OpenAPI and generated TypeScript types for a seamless frontend integration.
- **Provider Abstraction**: A cleaner internal separation between the "Bose World" (XML/BMX) and external content providers (RadioBrowser).
## 4. Suggested Improvements for Bose-SoundTouch
### A. Web-based Setup Wizard (High Priority)
- Implement a state-driven wizard in the `soundtouch-service` to handle:
- SSH activation (checking `/remote_services` via USB).
- Automated backup of speaker configuration.
- Verification of DNS/Hosts redirection.
- Expose this via a simple embedded Web UI (using Go's `embed` package).
### B. RadioBrowser Failover (Medium Priority)
- Adapt the failover logic from OCT:
- Periodically refresh the list of available RadioBrowser API servers.
- Implement a retry mechanism that switches servers on 5xx errors or timeouts.
### C. External Service Abstraction (Medium Priority)
- Refactor the hardcoded BMX logic into a more modular **Provider System** (see `EXTERNAL-SERVICES-ABSTRACTION.md`).
- This will allow easier addition of new sources (e.g., local DLNA, generic M3U playlists) without touching the core BMX handlers.
## 5. Summary
While our Go project provides the most complete technical coverage of SoundTouch hardware and commercial services, OpenCloudTouch sets a higher standard for **user onboarding** and **service resilience** for community-driven content. Integrating a setup wizard and a more robust radio backend would make our project significantly more accessible and reliable.
-41
View File
@@ -1,41 +0,0 @@
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|:---------------------|:-------------------------------------------------|:----------------------------------------------------------------------------------------|
| **Group Management** | Placeholder handlers (return `<group/>` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
## 3. Key Strengths of SoundCork
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
- **Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).
## 4. Suggested Implementation Steps for Bose-SoundTouch
### A. Implement Full Group Support (High Priority)
- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`.
- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones.
### B. Modularize BMX Registry (Medium Priority)
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
- Allow users to customize which mocked services are advertised to the speaker.
### C. Enhanced Source Management (Medium Priority)
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
### D. Basic Admin Web UI (Low Priority)
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
## 5. Summary
While our Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`), SoundCork provides better coverage of multi-device coordination (Groups) and service emulation (BMX) that we should adopt for a more complete offline experience.
+44 -44
View File
@@ -21,7 +21,7 @@ This document describes the most important patterns for the Bose SoundTouch API
**Key Aspects:**
- **Native Builds**: Full API functionality for CLI and server
- **WASM Builds**: Browser-compatible subset functionality
- **WASM Builds**: Browser-compatible subset functionality
- **Cross-Platform**: Linux, macOS, Windows support
- **Embedded Assets**: Web UI directly embedded in binary
@@ -66,7 +66,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
return nil, err
}
defer resp.Body.Close()
var nowPlaying models.NowPlaying
err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
return &nowPlaying, err
@@ -77,7 +77,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
```go
func (c *Client) SendKey(key models.Key) error {
keyXML := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, key)
resp, err := c.httpClient.Post(
c.baseURL+"/key",
"application/xml",
@@ -117,14 +117,14 @@ func (d *DiscoveryService) DiscoverDevices() ([]Device, error) {
return nil, err
}
defer conn.Close()
// Send M-SEARCH request
searchRequest := "M-SEARCH * HTTP/1.1\r\n" +
"HOST: 239.255.255.250:1900\r\n" +
"MAN: \"ssdp:discover\"\r\n" +
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" +
"MX: 3\r\n\r\n"
// Implementation details...
return devices, nil
}
@@ -158,13 +158,13 @@ func (e *EventClient) Subscribe(eventType string, handler EventHandler) {
func (e *EventClient) Start() error {
u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return err
}
e.conn = conn
go e.eventLoop()
return nil
}
@@ -184,7 +184,7 @@ func (e *EventClient) eventLoop() {
}
return
}
if handler, exists := e.handlers[event.Type]; exists {
go handler(event)
}
@@ -220,7 +220,7 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
go func() {
devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices()
result := make(map[string]interface{})
if err != nil {
result["error"] = err.Error()
@@ -228,13 +228,13 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
devicesJSON, _ := json.Marshal(devices)
result["devices"] = string(devicesJSON)
}
// Call JavaScript callback
args[0].Invoke(js.ValueOf(result))
}()
return nil
})
return handler
}
```
@@ -280,7 +280,7 @@ func main() {
if err != nil {
return err
}
for i, device := range devices {
fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host)
}
@@ -300,7 +300,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
@@ -311,7 +311,7 @@ func getClientFromContext(c *cli.Context) *client.Client {
devices, _ := discovery.DiscoverDevices()
deviceHost = selectDeviceInteractive(devices)
}
return client.NewClient(deviceHost, 8090)
}
```
@@ -327,34 +327,34 @@ var webAssets embed.FS
func main() {
mux := http.NewServeMux()
// Embedded web assets
webFS, err := fs.Sub(webAssets, "web")
if err != nil {
log.Fatal(err)
}
// SPA routing
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
return
}
data, err := webAssets.ReadFile("web/index.html")
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(data)
})
// API endpoints
mux.HandleFunc("/api/devices", handleDeviceDiscovery)
mux.HandleFunc("/api/client/", handleClientProxy)
log.Println("SoundTouch Web UI starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
@@ -370,36 +370,36 @@ func handleClientProxy(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
deviceIP := pathParts[3]
apiPath := "/" + strings.Join(pathParts[4:], "/")
// Proxy request to SoundTouch device
targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath)
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Copy headers
for k, v := range r.Header {
proxyReq.Header[k] = v
}
resp, err := http.DefaultClient.Do(proxyReq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Enable CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// Copy response
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
@@ -448,7 +448,7 @@ func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
switch s {
case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped):
*p = PlayStatus(s)
@@ -469,41 +469,41 @@ type Config struct {
// Server configuration
WebPort int `env:"WEB_PORT" default:"8080"`
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
// Discovery configuration
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
// CORS configuration (for web proxy)
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
}
func Load() Config {
var cfg Config
// Load from .env file
loadDotEnv()
// Parse environment variables with reflection
parseEnvVars(&cfg)
return cfg
}
func parseEnvVars(cfg interface{}) {
v := reflect.ValueOf(cfg).Elem()
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
envTag := fieldType.Tag.Get("env")
defaultTag := fieldType.Tag.Get("default")
if envTag != "" {
if envValue := os.Getenv(envTag); envValue != "" {
setFieldValue(field, envValue)
@@ -545,11 +545,11 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
if err, exists := m.errors["now_playing"]; exists {
return nil, err
}
if resp, exists := m.responses["now_playing"]; exists {
return resp.(*models.NowPlaying), nil
}
return &models.NowPlaying{
Track: "Mock Track",
Artist: "Mock Artist",
@@ -577,8 +577,8 @@ CMD ["go", "test", "-v", "./..."]
```bash
# Makefile test target
test-integration:
docker compose -f test/docker-compose.yml up --build --abort-on-container-exit
docker compose -f test/docker-compose.yml down
docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit
docker-compose -f test/docker-compose.yml down
```
## Recommended Project Structure
@@ -741,7 +741,7 @@ type APIError struct {
Message string `xml:",innerxml"`
}
// pkg/models/device.go
// pkg/models/device.go
type DeviceInfo struct {
XMLResponse
Name string `xml:"name"`
@@ -773,7 +773,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
```
@@ -802,4 +802,4 @@ func main() {
## Conclusion
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
+2 -3
View File
@@ -8,7 +8,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
- **[Complete Migration Guide](guides/MIGRATION-GUIDE.md)** - Step-by-step guide from Bose Cloud to local control
- **[Getting Started](guides/GETTING-STARTED.md)** - Quick introduction to the toolkit
### For Existing Users
### For Existing Users
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
@@ -17,7 +17,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
The documentation is organized into three main categories:
### 1. **User Guides** - For everyday users migrating and managing devices
### 2. **Technical Reference** - For developers and advanced configuration
### 2. **Technical Reference** - For developers and advanced configuration
### 3. **Concept Documentation** - For contributors and system architects
## 🗂 Documentation Structure
@@ -47,7 +47,6 @@ The documentation is organized into three main categories:
### API Documentation
- [API Endpoints](reference/API-ENDPOINTS.md) - REST API reference
- [Spotify Account Addition](reference/spotify-account-addition.md) - Technical requests for Spotify
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md) - Real-time events
- [Zone Management](reference/ZONE-MANAGEMENT.md) - Multi-room control
- [Preset Management](reference/PRESET-MANAGEMENT.md) - Preset operations
+1 -1
View File
@@ -119,7 +119,7 @@ soundtouch-service
```go
// Build custom applications on top of local services
client := &http.Client{}
resp, _ := client.Get("http://localhost:8000/setup/devices")
resp, _ := client.Get("http://localhost:8000/devices")
```
### Privacy-Conscious Users
+1 -12
View File
@@ -9,7 +9,6 @@
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [Device Setup Flow](DEVICE-SETUP.md)
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
@@ -29,7 +28,6 @@
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [Spotify Account Addition](reference/spotify-account-addition.md)
* [Cloud API Emulation](reference/CLOUD-API.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
@@ -55,19 +53,12 @@
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
## Parity Analysis
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
## Appendix (Other Documents)
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
@@ -90,5 +81,3 @@
* [Device Lifecycle Summary](device-lifecycle-summary.md)
* [Power On Implementation Guide](power-on-implementation-guide.md)
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
-777
View File
@@ -1,777 +0,0 @@
# Bose SoundTouch Traffic Analysis Runbook
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
---
## Prerequisites
| Component | Details |
|--------------------|------------------------------------------------------------------|
| Raspberry Pi | Pi 3 or newer, Raspberry Pi OS (Bullseye, Bookworm, Trixie) |
| Network interfaces | `eth0` → LAN cable to FritzBox, `wlan0` → own Access Point |
| FritzBox | Unchanged, assigns an IP to the Pi via DHCP on eth0 |
| Custom DNS Server | Already present (or see Appendix A), incl. custom CA certificate |
| Phone | Android, connects to the Pi's Wi-Fi |
### Network Architecture
```
Internet
FritzBox (existing, unchanged)
↓ LAN cable (eth0)
Raspberry Pi
├── DNS Server → selective logging / redirection
├── hostapd → custom Wi-Fi Access Point ("Bose-Lab")
├── dnsmasq → DHCP for clients, DNS to custom server
├── iptables → NAT, Forwarding eth0 ↔ wlan0
├── tcpdump → full traffic capture
└── (optional) mitmproxy → HTTPS decryption
↓ Wi-Fi ("Bose-Lab")
Android Phone
└── Bose SoundTouch App
```
---
## Step 1 Install Packages
```bash
sudo apt update && sudo apt install -y \
hostapd \ # Wi-Fi Access Point daemon
dnsmasq \ # DHCP + DNS forwarding
nftables \ # Modern NAT / firewall / forwarding
tcpdump \ # Packet capture at all levels
wireshark-common # tshark CLI (optional, for live analysis)
```
---
## Step 2 Enable IP Forwarding
The Pi must forward packets between `wlan0` (phone) and `eth0` (FritzBox).
```bash
# Active immediately (no reboot required)
sudo sysctl -w net.ipv4.ip_forward=1
# Permanent (survives reboots)
# On modern Debian, using a dedicated file in sysctl.d/ is more reliable:
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-ip-forward.conf
# Apply changes immediately
sudo sysctl --system
```
**Verify:**
```bash
# After a reboot, ensure it is still '1'
cat /proc/sys/net/ipv4/ip_forward
```
---
## Step 3 Static IP on wlan0 (systemd-networkd)
On modern Debian (Bookworm/Trixie), `dhcpcd` is replaced by `systemd-networkd`.
```bash
# Create network configuration
sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
[Match]
Name=wlan0
[Network]
Address=192.168.10.1/24
IPForward=yes
ConfigureWithoutCarrier=yes
DHCP=no
IPv6AcceptRA=no
EOF
# Restart service
sudo systemctl enable systemd-networkd
sudo systemctl restart systemd-networkd
# Ensure wpa_supplicant and NetworkManager don't interfere
sudo nmcli device set wlan0 managed no
sudo systemctl stop wpa_supplicant@wlan0
sudo systemctl mask wpa_supplicant@wlan0
```
**Verify:**
```bash
ip addr show wlan0
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
```
---
## Step 4 hostapd (Access Point)
```bash
sudo tee /etc/hostapd/hostapd.conf << 'EOF'
interface=wlan0
driver=nl80211
ssid=Bose-Lab
hw_mode=b
#hw_mode=g
channel=1
#channel=6
wmm_enabled=0
auth_algs=1
wpa=2
wpa_passphrase=secret123
wpa_key_mgmt=WPA-PSK
wpa_pairwise=CCMP
EOF
# The modern way is to just use hostapd.service which defaults to /etc/hostapd/hostapd.conf
sudo systemctl unmask hostapd
sudo systemctl enable --now hostapd
```
**Verify:**
```bash
sudo systemctl status hostapd
# Expected: active (running)
```
---
## Step 5 dnsmasq (DHCP + DNS)
dnsmasq gives the phone an IP and forwards DNS queries to the custom DNS server.
```bash
# Back up original config
sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
sudo tee /etc/dnsmasq.conf << 'EOF'
interface=wlan0
dhcp-range=192.168.10.100,192.168.10.200,24h
dhcp-option=3,192.168.10.1
dhcp-option=6,192.168.10.1
# DNS Upstream: custom server on localhost (adjust port if necessary)
server=127.0.0.1#5353 # Example: custom server on port 5353
# Alternatively: server=1.1.1.1 if DNS server runs directly on port 53
# Log all DNS queries (for initial analysis)
log-queries
log-facility=/var/log/dnsmasq.log
EOF
sudo systemctl restart dnsmasq
```
**Observe DNS log live:**
```bash
sudo tail -f /var/log/dnsmasq.log
```
---
## Step 6 NAT and Forwarding (nftables)
On modern Debian (Bookworm/Trixie), `nftables` is the default and recommended way to manage NAT and traffic forwarding.
```bash
# Define the NAT and Forwarding rules
sudo tee /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain forward {
type filter hook forward priority 0; policy drop;
# Allow traffic from phone (wlan0) to internet (eth0)
iifname "wlan0" oifname "eth0" accept
# Allow established/related traffic back to the phone
iifname "eth0" oifname "wlan0" ct state established,related accept
}
}
table ip nat {
chain posterouting {
type nat hook postrouting priority 100; policy accept;
# MASQUERADE outgoing packets on eth0
oifname "eth0" masquerade
}
}
EOF
# Enable and start nftables
sudo systemctl enable nftables
sudo systemctl restart nftables
```
**Verify:**
```bash
sudo nft list ruleset
# Expected: ruleset showing the forward and nat chains
```
### WiFi "Bose-Lab" not visible?
If you cannot see the `Bose-Lab` SSID on your phone:
1. **Check hostapd status:** `sudo systemctl status hostapd`. If it failed with "nl80211: Driver does not support configured mode", try changing `hw_mode=g` to `hw_mode=b`.
2. **Interface blocking:** Ensure `rfkill` hasn't blocked WiFi: `sudo rfkill unblock wlan`.
3. **Country Code:** Some systems require a country code in `hostapd.conf` to enable the radio. Add `country_code=DE` (or your country) to the top of `/etc/hostapd/hostapd.conf` and restart hostapd: `sudo systemctl restart hostapd`.
4. **Local Radio Check:** You can verify that the radio is actually configured as an AP: `iw dev wlan0 info`. Look for `type AP` and your SSID.
> **Note:** Do NOT rely on `iw dev wlan0 scan` for your own SSID; many WiFi drivers cannot "scan" and "broadcast" simultaneously.
5. **Debug Mode:** If the scan still returns nothing, stop the service and run hostapd in the foreground to see real-time errors:
```bash
sudo systemctl stop hostapd
sudo hostapd -dd /etc/hostapd/hostapd.conf
```
Look for messages like `nl80211: Failed to set interface wlan0 into AP mode`. This usually means the hardware is busy or doesn't support the current `hw_mode` / `channel` combination.
6. **Conflicting Services:** Ensure nothing else is managing `wlan0`. NetworkManager is common on modern Debian:
```bash
sudo nmcli device set wlan0 managed no
```
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.168.178.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
```bash
sudo nmcli device set wlan0 managed no
# If the ghost IP is still there, remove it manually:
sudo ip addr del 192.168.178.X/24 dev wlan0
```
---
## Step 7 Install Custom CA Certificate on the Phone
Since a custom DNS server with a custom CA certificate is used, it must be trusted on the phone otherwise, the app will block HTTPS connections to redirected domains.
### Copy CA Certificate to the Pi (if not already there)
If you haven't created a CA yet, follow **Appendix A** first.
```bash
# Certificate is located e.g. at /etc/my-dns-ca/ca.crt
# Temporarily make reachable via HTTP for easy download:
cd /etc/my-dns-ca/
python3 -m http.server 8080
# → Reachable at http://192.168.10.1:8080/ca.crt
```
### Install on Android
1. Connect phone to `Bose-Lab`
2. Open browser → `http://192.168.10.1:8080/ca.crt`
3. Download certificate
4. **Settings → Security → Credentials → Install CA Certificate**
5. Select certificate and confirm
> **Note:** Android distinguishes between system CAs and user CAs. User-installed CAs are accepted by many apps, but apps with certificate pinning (hardcoded certificate hashes) ignore them. Whether Bose uses pinning will be visible in the capture (Connection Reset after TLS ClientHello).
### Android 14+ Special Case
From Android 14 onwards, apps do not trust user CAs by default unless explicitly declared in the manifest. If the Bose app rejects the CA certificate:
```bash
# Option A: Root + Magisk module "MagiskTrustUserCerts"
# → moves user CAs to the system store
# Option B: Root + manually copy to system CA directory
adb push ca.crt /system/etc/security/cacerts/
adb shell chmod 644 /system/etc/security/cacerts/ca.crt
```
---
## Step 8 Capture Traffic
### All at once (recommended)
```bash
# Full capture of all protocols on wlan0
# Filename with timestamp for multiple sessions
sudo tcpdump -i wlan0 \
-w /tmp/bose-$(date +%Y%m%d-%H%M%S).pcap \
-s 0 # full packet length (no truncation)
# End session: Ctrl+C
```
### Targeted by protocol
```bash
# DNS only (Port 53) shows if app uses standard DNS
sudo tcpdump -i wlan0 -n port 53
# HTTPS only TLS connections to Bose Cloud
sudo tcpdump -i wlan0 -n 'tcp port 443'
# mDNS (ZeroConf) device discovery in LAN
# Multicast group 224.0.0.1, Port 5353
sudo tcpdump -i wlan0 -n 'udp port 5353'
# SSDP/UPnP alternative device discovery
sudo tcpdump -i wlan0 -n 'udp port 1900'
# Everything except DNS (reduces noise)
sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
# Traffic of a specific host only (filter by phone IP)
# Read phone IP from dnsmasq.leases beforehand (see below)
sudo tcpdump -i wlan0 -n host 192.168.10.101
```
### Read SNI from TLS Traffic (without decryption)
```bash
# Extract domains from TLS ClientHello (SNI is unencrypted)
sudo tcpdump -i wlan0 -n 'tcp port 443' -A 2>/dev/null \
| grep -oP '(?<=\x00)([a-zA-Z0-9.-]+\.(?:com|net|io|cloud|bose\.com))'
```
### Readable mDNS Announcements output
```bash
# tshark decodes mDNS directly
sudo tshark -i wlan0 -f 'udp port 5353' -T fields \
-e dns.qry.name \
-e dns.resp.name \
-e dns.a
```
---
## Step 9 Analysis with Wireshark (on PC)
Transfer `.pcap` files from the Pi to the PC:
```bash
# From the PC (scp)
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
```
**Important Wireshark Filters:**
```
# DNS only
dns
# HTTPS only
tcp.port == 443
# WebSocket connections (HTTP Upgrade)
websocket
# mDNS
mdns
# TLS Handshakes (SNI visible)
tls.handshake.extensions_server_name
# Traffic of a specific domain (resolve by IP)
http.host contains "bose"
# WebSocket frames
websocket.payload
```
> **Tip:** Wireshark decodes WebSocket frames automatically if it sees the HTTP Upgrade handshake in the same capture. For the pairing flow: filtering for `tls.handshake.extensions_server_name` shows all domains the app contacts, even without decryption.
---
## Step 10 mitmproxy (optional, for HTTPS content)
Only useful if the CA certificate on the phone is trusted and no certificate pinning is active. `mitmproxy` acts as a Man-in-the-Middle by generating fake, on-the-fly certificates for any domain (e.g., `global.api.bose.io`) using your custom CA.
### 1. Configure mitmproxy to use your Custom CA
By default, `mitmproxy` creates its own CA in `~/.mitmproxy/`. To ensure the phone (which already trusts your `ca.crt`) accepts the traffic, you must tell `mitmproxy` to use your existing CA:
```bash
# mitmproxy expects the CA in a specific PEM format (cert + key in one file)
sudo mkdir -p ~/.mitmproxy
sudo cat /etc/my-dns-ca/ca.crt /etc/my-dns-ca/ca.key | sudo tee ~/.mitmproxy/mitmproxy-ca.pem > /dev/null
```
### 2. Install and Start mitmproxy
```bash
# Install mitmproxy binary (stable version for aarch64)
cd /tmp
wget https://downloads.mitmproxy.org/12.2.1/mitmproxy-12.2.1-linux-aarch64.tar.gz
tar -xzf mitmproxy-12.2.1-linux-aarch64.tar.gz
sudo mv mitmproxy mitmdump mitmweb /usr/local/bin/
rm mitmproxy-12.2.1-linux-aarch64.tar.gz
mitmproxy --version
# Transparent proxy on port 8080
# It will now use the CA from ~/.mitmproxy/mitmproxy-ca.pem
mitmproxy --mode transparent --listen-port 8080
# Alternatively: mitmdump for automatic logging to file
# mitmdump --mode transparent --listen-port 8080 -w /tmp/bose-https.mitm
```
### 3. Troubleshooting: TLS Handshake Failures
If you see `Client TLS handshake failed. The client does not trust the proxy's certificate for www.google.com` (or other domains) in the `mitmproxy` logs:
1. **HSTS and Pre-installed Pinning:** High-security sites like `www.google.com` use **HSTS (HTTP Strict Transport Security)** and have their certificates hardcoded (pinned) into browsers like Chrome and the Android system. **These will always fail with a User-installed CA.**
2. **User vs. System CA Store:** On Android 7.0+, apps **do not trust User-installed CAs by default**. They only trust the "System" store.
* **The Bose app:** If it fails, it's because it only trusts the System store or uses its own certificate pinning.
* **The Fix (Rooted Phone):** Use a Magisk module like `AlwaysTrustUserCerts` or manually move your `ca.crt` to `/system/etc/security/cacerts/` (see Step 7).
3. **The "Golden Rule" - Verify the Proxy is Working:**
To confirm your CA and `mitmproxy` are correctly configured, test with a non-HSTS site on the phone's browser (e.g., `http://neverssl.com`). Once redirected to HTTPS, **inspect the certificate**. It should say it was issued by your "Bose-Lab Root CA" (or "SoundTouch Root CA").
* **If this works:** Your "factory" (mitmproxy + CA) is 100% correct. Any failure in the Bose app is due to its own security policy (ignore User Store or Pinning).
* **If this fails:** Your CA is not trusted by the browser or `mitmproxy` is not using your PEM file.
Alternatively, use `curl` from a terminal emulator on the phone:
```bash
# This should work if the CA is in the user store and curl is told to use it
curl -v --cacert /path/to/ca.crt https://example.com
```
4. **Check mitmproxy CA:** Ensure `mitmproxy` is actually using your CA. When it starts, it should NOT generate a new CA in `~/.mitmproxy/mitmproxy-ca.pem` if you've already placed yours there.
---
**nftables rule: redirect HTTPS traffic to mitmproxy**
```bash
# Create a temporary file for the redirection rule
sudo nft add table ip mitm
sudo nft add chain ip mitm prerouting { type nat hook prerouting priority -100 \; }
sudo nft add rule ip mitm prerouting iifname "wlan0" tcp dport 443 redirect to :8080
```
**Remove rule when no longer needed:**
```bash
sudo nft delete table ip mitm
```
> **Detecting Certificate Pinning:** If the app immediately disconnects after mitmproxy redirection (connection reset directly after TLS ClientHello), pinning is active. In this case, Frida + root is needed to patch the pinning.
---
## Step 11 Bypassing Android Trust Restrictions
If `neverssl.com` works in the browser but the Bose app shows `TLS handshake failed` in `mitmproxy`, the app is either ignoring the **User CA store** (common on Android 7+) or using **Certificate Pinning**.
### Option A: Move CA to System Store (Requires Root/Magisk)
This is the most reliable way to make apps trust your CA without modifying the app itself.
1. **Using Magisk (Recommended):**
Install the **"AlwaysTrustUserCerts"** or **"Move Certificates"** module in Magisk. It automatically mirrors all certificates from the User store to the System store on every boot.
2. **Manual Move (via ADB):**
Android system certificates are stored in `/system/etc/security/cacerts/` and must be named using the hash of the certificate.
```bash
# 1. Get the hash of your certificate
hash=$(openssl x509 -inform PEM -subject_hash_old -in ca.crt | head -1)
# 2. Rename the certificate locally
cp ca.crt ${hash}.0
# 3. Push to the phone (requires remounting /system as read-write)
adb push ${hash}.0 /sdcard/
adb shell
su
mount -o rw,remount /
cp /sdcard/${hash}.0 /system/etc/security/cacerts/
chmod 644 /system/etc/security/cacerts/${hash}.0
chown root:root /system/etc/security/cacerts/${hash}.0
reboot
```
### Option B: Patching the App (No Root Required)
If you cannot root your phone, you can modify the app's APK to trust user-installed certificates. This involves obtaining the APK, decompiling it, adding a network security configuration, and then repackaging and signing it.
#### 0. How to get the .apk file?
You have two main ways to get the official Bose SoundTouch APK:
**Method 1: Extract from your phone (Safest)**
If the app is already installed on your phone, you can pull it using `adb`:
```bash
# 1. Find the package name (usually com.bose.soundtouch)
adb shell pm list packages | grep bose
# 2. Get the full path to the APK on the phone
adb shell pm path com.bose.soundtouch
# Output: package:/data/app/~~...==/com.bose.soundtouch-.../base.apk
# 3. Pull the file to your computer
adb pull /data/app/~~...==/com.bose.soundtouch-.../base.apk Bose-SoundTouch.apk
```
**Method 2: Download from a Mirror (Easiest)**
You can download the APK from reputable third-party sites.
> **Warning:** Always verify the site's reputation.
* [APKMirror](https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/)
* [APKPure](https://apkpure.com/bose-soundtouch/com.bose.soundtouch)
#### 1. Automated Method: apk-mitm (Recommended)
The easiest way is to use `apk-mitm`, which automates the entire process including fixing common certificate pinning libraries.
```bash
# Requires Node.js installed on your PC
npx apk-mitm Bose-SoundTouch.apk
```
This will produce a `Bose-SoundTouch-patched.apk` which you can install on your phone.
#### 2. Manual Method: Network Security Config
If you prefer to do it manually:
1. **Decompile the APK:**
```bash
apktool d Bose-SoundTouch.apk
```
2. **Create/Modify `res/xml/network_security_config.xml`:**
```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
```
3. **Update `AndroidManifest.xml`:**
Ensure the `<application>` tag includes: `android:networkSecurityConfig="@xml/network_security_config"`.
4. **Repackage and Sign:**
```bash
apktool b Bose-SoundTouch -o Bose-SoundTouch-patched.apk
# Sign with your own key
# 1. Generate a keystore (if you don't have one)
# Note: You can use ANY name/values here. The phone does not need to "know" or "trust" this key beforehand.
# It only needs the APK to be digitally signed so the Android installer accepts it.
keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
# 2. Sign the APK
apksigner sign --ks my-release-key.keystore --out Bose-SoundTouch-patched-signed.apk Bose-SoundTouch-patched.apk
# Alternatively, use uber-apk-signer (recommended for simplicity)
# It handles zipalign and signing automatically.
java -jar uber-apk-signer.jar --apk Bose-SoundTouch-patched.apk
```
#### 3. Install the Patched APK
Once you have your `Bose-SoundTouch-patched.apk` (and it is signed), you need to install it on your phone.
**Important:** You must **uninstall the original Bose app first**. Android will not allow you to "update" the official app with your patched version because the digital signatures won't match.
**Method 1: via ADB (Recommended)**
```bash
# 1. Uninstall the original app
adb uninstall com.bose.soundtouch
# 2. Install your patched version
adb install Bose-SoundTouch-patched.apk
```
**Method 2: Manual Transfer**
1. Copy the `Bose-SoundTouch-patched.apk` to your phone's storage (via USB, Google Drive, or the Pi's HTTP server).
2. On the phone, use a File Manager to open the APK.
3. If prompted, allow "Install from Unknown Sources" for your File Manager.
### Option C: Patching the App with Frida (Requires Root)
If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA to the System store won't work. You must disable the pinning check in the app's code.
1. **Install Frida** on your PC and `frida-server` on the rooted phone.
2. **Use a universal bypass script:**
```bash
frida -U -f com.bose.soundtouch -l https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/ --no-pause
```
*(Replace `com.bose.soundtouch` with the actual package name if different).*
## Step 12 Alternative: Regular HTTP Proxy Mode
If the **Transparent AP** setup (Steps 16) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
### 1. How it works
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
* **Pros:** No complex `nftables` or NAT rules required.
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
### 2. Start mitmproxy in Regular Mode
```bash
# Stop transparent mode first if it's running
# No special flags needed for regular mode
mitmproxy --listen-port 8080
```
### 3. Configure the Phone
1. Go to **Settings → Wi-Fi → Bose-Lab**.
2. Select **Modify Network** (or the "i" icon).
3. Set **Proxy** to **Manual**.
4. **Proxy hostname:** `192.168.10.1`
5. **Proxy port:** `8080`
6. Save and try to browse a site.
---
## Step 13 Extracting for soundtouch-service
```bash
# Which IPs did the phone receive?
cat /var/lib/misc/dnsmasq.leases
# Is the access point active?
sudo systemctl status hostapd
# Is dnsmasq active?
sudo systemctl status dnsmasq
# Check interfaces and IPs
ip addr show
# Check routing table
ip route show
# Show active nftables rules
sudo nft list ruleset
# All running tcpdump processes
pgrep -a tcpdump
# Test the Pi's own DNS resolution
dig @127.0.0.1 -p 5353 global.api.bose.io
# Check network connectivity from the phone (from the Pi)
ping 192.168.10.101 # Phone IP from dnsmasq.leases
```
---
## Restart Sequence
After a Pi reboot, everything should come up automatically. If not:
```bash
# Restart and enable all core services
sudo systemctl restart systemd-networkd
sudo systemctl enable --now hostapd
sudo systemctl enable --now dnsmasq
sudo systemctl restart nftables
# Verify the unmanaged state of wlan0 (nmcli)
sudo nmcli device set wlan0 managed no
```
---
## What to Expect
| Protocol | Port | Tool | Visibility |
|----------------------|------------|--------------------------|------------------------------------------------|
| DNS (Standard) | UDP 53 | tcpdump, dnsmasq log | Full, plaintext |
| HTTPS / REST | TCP 443 | tcpdump (SNI), mitmproxy | SNI without decryption, content with mitmproxy |
| WebSockets | TCP 443/80 | Wireshark | Frames decoded if TLS is broken |
| mDNS / ZeroConf | UDP 5353 | tcpdump, tshark | Full, plaintext |
| SSDP / UPnP | UDP 1900 | tcpdump | Full, plaintext |
| SoundTouch local API | TCP 8090 | tcpdump | Full, plaintext (no TLS) |
> **Expectation for Bose SoundTouch:** The app likely uses standard DNS (older app generation), REST/HTTPS for the pairing flow with the cloud, WebSockets for push events from the device, and mDNS for local device discovery. The local device API on port 8090 is HTTP without TLS this traffic is always readable.
---
## Next Steps After Analysis
1. Extract domains from DNS log and SNI → List of all Bose endpoints
2. HTTP methods and paths from mitmproxy log → Reconstruct API structure
3. Document auth flow (OAuth2? Proprietary? Token format?)
4. Build a minimal mock server simulating the critical endpoints
5. Testing: App against mock server → does pairing work offline?
---
## Appendix A Generating a Custom CA Certificate
If you don't have a custom DNS server with a CA yet, you can create one directly on the Pi. Alternatively, if you are already using the `soundtouch-service` from this repository, you can reuse its CA certificate located in the `data/certs/` directory.
### 0. (Optional) Copy an Existing CA from another host
If you are already using the `soundtouch-service` on another machine (e.g., your notebook), you can copy the existing CA to the Pi instead of generating a new one:
```bash
# On your Pi:
sudo mkdir -p /etc/my-dns-ca
sudo chown $USER:$USER /etc/my-dns-ca
# Run this on your notebook (replace hostnames and paths):
# Note: This is easiest if your SSH key is added to the Pi and soundtouch-service host.
# If you run into permission issues with sudo, ensure the source user has passwordless sudo for 'cat'.
# Step A: Download from source to your notebook
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.crt" > ca.crt
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.key" > ca.key
# Step B: Upload from notebook to the Pi
scp ca.crt ca.key soundtouch-access-point:/tmp/
ssh soundtouch-access-point "sudo mv /tmp/ca.crt /tmp/ca.key /etc/my-dns-ca/ && sudo chown root:root /etc/my-dns-ca/ca.*"
rm ca.crt ca.key
```
### 1. Create CA Key and Certificate
```bash
sudo mkdir -p /etc/my-dns-ca
cd /etc/my-dns-ca
# Generate CA private key
sudo openssl genrsa -out ca.key 4096
# Generate Root CA certificate
# Note: we explicitly add basicConstraints=CA:TRUE for modern TLS clients
sudo openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-out ca.crt \
-subj "/C=DE/O=Bose-Lab/CN=Bose-Lab Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
```
### 2. Generate a Certificate for Interception (Example)
To intercept `global.api.bose.io`, you need a certificate for it, signed by your CA:
```bash
# Generate server key
sudo openssl genrsa -out bose.key 2048
# Create CSR (Certificate Signing Request) configuration
sudo tee bose.ext << 'EOF'
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = global.api.bose.io
DNS.2 = *.bose.io
EOF
# Generate CSR
sudo openssl req -new -key bose.key -out bose.csr \
-subj "/C=DE/O=Bose-Lab/CN=global.api.bose.io"
# Sign the certificate with your CA
sudo openssl x509 -req -in bose.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out bose.crt -days 365 -sha256 -extfile bose.ext
```
### 3. Usage in your DNS/HTTPS Server
Your custom server (e.g., a small Go or Python script) would then use `bose.crt` and `bose.key` to serve HTTPS traffic for those domains.
-43
View File
@@ -1,43 +0,0 @@
# Spotify Account Addition Implementation Status
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
## 1. OAuth Token Exchange (Bose Cloud)
The Stockholm background worker (in `worker_common.js` and `spotify_worker.js`) performs a token exchange using an authorization code.
* **Route**: `POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs`
* **Purpose**: To exchange the Spotify authorization code for a Bose-mediated token.
* **Implementation**: `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go`.
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/oauth` route group.
## 2. Cloud Source Registration (Marge Service)
The SoundTouch application registers a new music source (e.g., Spotify) with the Bose cloud profile.
* **Route**: `POST /streaming/account/{account}/source`
* **Purpose**: To add the new source (username, credentials, display name) to the user's emulated cloud profile.
* **Implementation**: `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go`.
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/streaming` route group.
* **Payload Format**: XML `application/vnd.bose.streaming-v1.1+xml` containing `<source>` with `<username>`, `<sourceproviderid>`, and `<credential type="token_version_3">`.
## 3. Redirect Handling (Browser to App)
The `soundtouch://` deep link redirect URI is handled by the management interface which provides the OAuth callback.
* **Callback Route**: `GET /mgmt/spotify/callback`
* **Implementation**: `HandleMgmtSpotifyCallback` in `pkg/service/handlers/handlers_mgmt.go`.
* **Confirmation Route**: `POST /mgmt/spotify/confirm` (used by mobile apps for deep-link codes).
* **Implementation**: `HandleMgmtSpotifyConfirm` in `pkg/service/handlers/handlers_mgmt.go`.
## Implementation Details
1. **Marge Add Source**:
* `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go` parses the incoming XML and persists the new source to the `DataStore` for the corresponding account.
2. **OAuth Account Token Exchange**:
* `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go` supports the `/oauth/account/.../token/cs` path.
* It responds with a JSON payload including `access_token` and `token_type` "Bearer" after exchanging the code via `ExchangeCodeAndStore`.
3. **Router Registration**:
* These paths are registered in `cmd/soundtouch-service/main.go` within the `/streaming`, `/oauth`, and `/mgmt` route blocks.
+52
View File
@@ -0,0 +1,52 @@
### Stockholm App Analysis Report
#### 1. Overview
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
- **Key Libraries**:
- **jQuery**: Core DOM manipulation and event handling.
- **iScroll**: Used for smooth scrolling in lists and carousels.
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
#### 2. Directory Structure
- `js/`: Core application logic.
- `app/`: Main application entry point (`app.js`).
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
- `views/`: UI view templates and logic.
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
- `json/`: Configuration files and static data.
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
- `sourceFeatures.json`: Capability mapping for different sources.
- `setup/`: Onboarding and initial device setup logic.
- `lang/`: Localization files for multi-language support.
#### 3. Communication Architecture
The app uses several communication channels to interact with the SoundTouch ecosystem:
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
#### 4. Key Features & Functionality
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
- **Preset Management**: Browsing and setting presets directly from the UI.
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
- **Onboarding**: A dedicated setup flow for new devices.
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
#### 5. Integration Opportunities for Bose-SoundTouch Project
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
#### 6. Conclusion
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
+19 -19
View File
@@ -185,7 +185,7 @@ type NowPlaying struct {
type PlayStatus string
const (
PlayStatusPlaying PlayStatus = "PLAY_STATE"
PlayStatusPaused PlayStatus = "PAUSE_STATE"
PlayStatusPaused PlayStatus = "PAUSE_STATE"
PlayStatusStopped PlayStatus = "STOP_STATE"
)
@@ -277,19 +277,19 @@ type Config struct {
// Server configuration
WebPort int `env:"WEB_PORT" default:"8080"`
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
// Discovery configuration
// Discovery configuration
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
CacheTTL time.Duration `env:"CACHE_TTL" default:"5m"`
// CORS configuration (for web proxy)
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
LogFormat string `env:"LOG_FORMAT" default:"json"`
// Development
DevMode bool `env:"DEV_MODE" default:"false"`
}
@@ -537,7 +537,7 @@ build-all: build-linux build-darwin build-windows
dev-cli:
air -c .air-cli.toml
dev-webapp:
dev-webapp:
air -c .air-webapp.toml
dev-wasm:
@@ -556,7 +556,7 @@ check: fmt vet lint test
# Docker development environment
docker-dev:
docker compose up --build
docker-compose up --build
# Release packaging
release: build-all
@@ -596,7 +596,7 @@ import (
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -609,36 +609,36 @@ func main() {
if err != nil {
log.Fatal(err)
}
if len(devices) == 0 {
log.Fatal("No SoundTouch devices found")
}
// Create client for first device
client := client.NewClient(client.ClientConfig{
Host: devices[0].Host,
Port: 8090,
Timeout: 10 * time.Second,
})
// Get device info
info, err := client.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Connected to: %s\n", info.Name)
// Get current playback
nowPlaying, err := client.GetNowPlaying()
if err != nil {
log.Fatal(err)
}
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
fmt.Printf("Playing: %s - %s (%s)\n",
fmt.Printf("Playing: %s - %s (%s)\n",
nowPlaying.Artist, nowPlaying.Track, nowPlaying.Album)
}
// Control playback
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
client.SendKey(models.KeyPause)
@@ -737,11 +737,11 @@ docker run -p 8080:8080 soundtouch-webapp
```bash
# Local development with hot reload
make dev-webapp # Web app development
make dev-wasm # WASM development
make dev-wasm # WASM development
make dev-cli # CLI development
# Full development environment
docker compose up # Mock devices + web app
docker-compose up # Mock devices + web app
```
## Success Criteria
@@ -781,7 +781,7 @@ docker compose up # Mock devices + web app
- [Bose SoundTouch Web API Documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
- [Go WebAssembly](https://github.com/golang/go/wiki/WebAssembly)
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [Go Embed Directive](https://pkg.go.dev/embed)
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
+4 -16
View File
@@ -816,7 +816,7 @@ func TestAccountManager_CreateAccount(t *testing.T) {
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := manager.CreateAccount(tt.input)
@@ -848,18 +848,6 @@ go test ./... -v -cover
go test -bench=. ./...
```
## Performance Requirements
### Response Time Targets
- Local API requests: < 100ms (95th percentile)
- Mirror requests: < 200ms overhead (asynchronous)
- Discovery time: < 5s for network scan
### Resource Constraints
- Memory usage: < 64MB for small deployments
- CPU usage: < 5% on dual-core ARM systems (idle)
- Storage: < 100MB for interaction logs (rotatable)
### Security Considerations
#### Simple Security Model
@@ -961,12 +949,12 @@ func (s *Server) HandleHealthCheck(w http.ResponseWriter, r *http.Request) {
Version: version,
Uptime: time.Since(startTime).String(),
}
// Simple checks
if !s.canWriteToDataDir() {
health.Status = "error"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(health)
}
@@ -998,4 +986,4 @@ func (m *SimpleMetrics) Save(dataDir string) error {
}
```
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
+2 -12
View File
@@ -394,9 +394,6 @@ soundtouch-cli --host <device> source spotify
soundtouch-cli --host <device> source bluetooth
soundtouch-cli --host <device> source aux
# Custom radio selection (via soundtouch-service)
soundtouch-cli --host <device> source custom-radio --url <STREAM_URL> [--name <NAME>] [--artwork <ARTWORK>] [--service-url <SERVICE_URL>]
# Advanced content selection
soundtouch-cli --host <device> source internet-radio --location <URL> [--name <NAME>]
soundtouch-cli --host <device> source local-music --location <LOCATION> --account <ACCOUNT>
@@ -485,7 +482,6 @@ soundtouch-cli --host 192.168.1.10 source compare
| Command | Description | Requirements |
|---------|-------------|--------------|
| `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL |
| `custom-radio` | Select custom radio stream via soundtouch-service | Stream URL and service URL |
| `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server |
| `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server |
| `content` | Generic content selection (advanced) | Source and location |
@@ -499,12 +495,6 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
soundtouch-cli --host 192.168.1.10 source internet-radio \
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout"
# Using local soundtouch-service for custom streams
soundtouch-cli --host 192.168.1.10 source custom-radio \
--url "https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout" \
--service-url "http://localhost:8080"
```
#### Service Introspection
@@ -1054,7 +1044,7 @@ soundtouch-cli --host 192.168.1.10 speaker beep
**Supported Languages for TTS:**
- `EN` - English (default)
- `DE` - German
- `DE` - German
- `ES` - Spanish
- `FR` - French
- `IT` - Italian
@@ -1095,7 +1085,7 @@ soundtouch-cli --host <device> events subscribe [flags]
**Event Types:**
- `nowPlaying` - Track changes, playback status
- `volume` - Volume and mute changes
- `volume` - Volume and mute changes
- `connection` - Network connectivity status
- `preset` - Preset configuration changes
- `zone` - Multiroom zone changes
+51 -25
View File
@@ -26,11 +26,10 @@ The service consists of several key components:
### BMX Services (Bose Media eXchange)
- **TuneIn Integration**: Direct playback of radio stations and podcasts
- **Custom Streams**: Flexible playback of any internet radio URL via dynamic proxy
- **Service Registry**: Media service discovery and configuration
- **Playback Control**: Stream URL resolution and audio metadata
### Marge Services (Account & Device Management)
### Marge Services (Account & Device Management)
- **Account Management**: User account simulation and device association
- **Preset Synchronization**: Cross-device preset storage and sync
- **Recent Items**: Playback history tracking and management
@@ -58,7 +57,7 @@ go build -o soundtouch-service ./cmd/soundtouch-service
### Docker Support
You can run the SoundTouch service using Docker or Docker Compose.
You can run the SoundTouch service using Docker or Docker Compose.
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
@@ -116,7 +115,7 @@ volumes:
And run:
```bash
docker compose up -d
docker-compose up -d
```
## Quick Start
@@ -213,23 +212,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
```bash
# Get migration summary first
curl http://localhost:8000/setup/migration-summary/192.168.1.100
curl http://localhost:8000/setup/devices/192.168.1.100/summary
# Perform migration
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
# Verify migration status
curl http://localhost:8000/setup/devices
curl http://localhost:8000/devices
```
#### Advanced Migration Options
```bash
# Migration with proxy fallback for original services
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
# Migration with custom target URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
```
### Post-Migration Verification
@@ -238,13 +237,13 @@ After migration, verify the device is working correctly:
```bash
# Check device status
curl http://localhost:8000/setup/devices
curl http://localhost:8000/devices
# Test preset functionality
curl "http://192.168.1.100:8090/presets"
# Monitor device events (if needed)
curl "http://localhost:8000/events/192.168.1.100"
curl "http://localhost:8000/devices/08DF1F0BA325/events"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
@@ -348,7 +347,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor
### Discovery & Setup
#### `GET /setup/devices`
#### `GET /devices`
Lists all discovered SoundTouch devices with their current status.
**Response:**
@@ -369,10 +368,10 @@ Lists all discovered SoundTouch devices with their current status.
#### `POST /setup/discover`
Triggers immediate network device discovery.
#### `GET /setup/info/{deviceIP}`
#### `GET /devices/{deviceIP}/info`
Gets detailed device information and configuration.
#### `GET /setup/migration-summary/{deviceIP}`
#### `GET /setup/devices/{deviceIP}/summary`
Analyzes device configuration and provides migration preview.
**Response:**
@@ -389,17 +388,44 @@ Analyzes device configuration and provides migration preview.
}
```
#### `POST /setup/migrate/{deviceIP}`
#### `POST /setup/devices/{deviceIP}/migrate`
Migrates device to use local services.
**Query Parameters:**
- `target_url`: Custom service URL (optional)
- `proxy_url`: Proxy URL for fallback (optional)
- `proxy_url`: Proxy URL for fallback (optional)
- `marge`: Set to "original" to proxy Marge requests (optional)
- `stats`: Set to "original" to proxy stats requests (optional)
- `sw_update`: Set to "original" to proxy update requests (optional)
- `bmx`: Set to "original" to proxy BMX requests (optional)
#### `POST /setup/devices/{deviceIP}/revert`
Reverts device to Bose cloud defaults.
#### `POST /setup/devices/{deviceIP}/trust-ca`
Injects the AfterTouch root CA into the device's trust store.
#### `POST /setup/devices/{deviceIP}/sync`
Syncs presets and recents from the device to local storage.
#### `POST /setup/devices/{deviceIP}/backup`
Creates a backup of the current device configuration.
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
Enables persistent SSH/remote services on the device.
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
Removes persistent SSH/remote services from the device.
#### `POST /setup/devices/{deviceIP}/test-connection`
Tests HTTPS connection from device to service.
#### `POST /setup/devices/{deviceIP}/test-hosts`
Tests /etc/hosts redirection on the device.
#### `POST /setup/devices/{deviceIP}/test-dns`
Tests DNS redirection on the device.
### BMX Services (Bose Media eXchange)
#### `GET /bmx/registry/v1/services`
@@ -659,13 +685,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
### Setup API
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
- `POST /setup/devices`: Manually add a device by IP.
- `GET /devices`: List all known (auto-discovered and manual) devices.
- `POST /devices`: Manually add a device by IP.
- `POST /setup/discover`: Trigger a new network discovery scan.
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
@@ -765,7 +791,7 @@ ls -la data/events/
This service implementation is based on and inspired by several excellent community projects:
### SoundCork
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
- **Authors**: Deborah Gu and contributors
- **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies.
@@ -797,7 +823,7 @@ func customBMXHandler(w http.ResponseWriter, r *http.Request) {
func main() {
r := chi.NewRouter()
r.Get("/custom/endpoint", customBMXHandler)
r.Get("/bmx/custom/endpoint", customBMXHandler)
http.ListenAndServe(":8000", r)
}
```
@@ -810,9 +836,9 @@ soundtouch:
- host: 192.168.1.100
port: 8090
name: "Living Room Speaker"
rest:
- resource: "http://localhost:8000/setup/devices"
- resource: "http://localhost:8000/devices"
scan_interval: 60
sensor:
- name: "SoundTouch Devices"
-197
View File
@@ -1,197 +0,0 @@
# Spotify Account Addition Technical Reference
This document details the exact network requests performed by the Bose SoundTouch "Stockholm" application and the SoundTouch speaker when adding a new Spotify account. This information is based on analysis of the Stockholm firmware version `27.0.13-4277-8963611`.
## Flow Overview
1. **User Authorization Initiation**: The app opens the system browser to Spotify's authorization page.
2. **Redirect Handling**: After authorization, Spotify redirects back to the app via a custom URI scheme, delivering an authorization `code`.
3. **OAuth Token Exchange**: The app sends this `code` to the background worker, which exchanges it for a Bose-mediated token.
4. **Cloud Source Registration**: The app registers the Spotify account as a "source" in the user's Bose Cloud (Marge) profile.
5. **Local Device Sync**: The app notifies the local SoundTouch speaker about the new source, which then updates its internal configuration.
---
## 0. User Authorization Initiation
The process begins in the Stockholm UI when the user selects Spotify to add a new account.
### Request Details (App to Browser)
- **Action**: Open System Browser
- **Base URL**: `[SPOTIFY_AUTH_URL]` (e.g., `https://accounts.spotify.com/authorize`)
- **Query Parameters**:
- `client_id`: Bose Spotify Client ID
- `response_type`: `code`
- `redirect_uri`: `http://localhost` (often used as a placeholder or specifically handled by the app's internal webview/proxy)
- `scope`: `user-read-private user-read-email ...`
- `state`: A base64-encoded JSON object containing metadata, e.g., `{"service": "SPOTIFY"}`.
### Redirect (Browser to App)
Upon successful login and authorization, Spotify redirects the browser to a URL that the SoundTouch app intercepts.
- **URL Format**: `soundtouch://bose/musicservice/spotify/login?code=[AUTH_CODE]&state=[STATE]`
- **App Action**: The `UIMain` component (in `ui_main.js`) handles this "deep link". It extracts the `code` from the query parameters and prepares to send it to the background worker.
---
## 1. OAuth Token Exchange (Bose Cloud)
After the UI intercepts the redirect and extracts the `code`, it sends a `createOAuthAccountRequest` to the background `SpotifyWorker`. The worker then performs the exchange for a Bose-mediated token.
### What is a "Bose-mediated token"?
The "Bose-mediated token" is a token issued by the Bose OAuth proxy. When the app (or device) requests a token via `oauth.streaming.bose.com`, Bose's service performs the actual OAuth2 exchange with Spotify.
- **It is not directly a Spotify refresh token**: Instead, it is a Bose-issued token that *represents* the underlying Spotify session.
- **Token Version 3**: Modern firmware uses `token_version_3`, which signifies that the device doesn't store the raw Spotify tokens but instead uses a Bose-specific "secret" that the Bose Cloud uses to fetch fresh Spotify access tokens on the device's behalf.
- **Access vs Refresh**: The initial response from the `.../token/cs` endpoint typically contains an `access_token` (valid for ~1 hour) and a `token_type: "Bearer"`. The Bose cloud service manages the persistent refresh token internally.
### Internal Message (UI to Worker)
- **Message Type**: `createOAuthAccountRequest`
- **Payload**:
```json
{
"source": "SPOTIFY",
"code": "[AUTH_CODE_FROM_REDIRECT]",
"credentialType": "token_version_3"
}
```
### Outgoing Request (Worker to Bose OAuth Proxy)
- **Endpoint**: `https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs`
- **Method**: `POST`
- **Headers**:
- `Content-Type: application/json`
- `Accept: application/json`
- `Authorization: Bearer [SESSION_TOKEN]` (The user's Bose account session token)
### Payload (JSON)
```json
{
"grant_type": "authorization_code",
"code": "[AUTH_CODE_FROM_SPOTIFY]",
"redirect_uri": "http://localhost"
}
```
### curl Example
```bash
curl -X POST "https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer [SESSION_TOKEN]" \
-d '{
"grant_type": "authorization_code",
"code": "[AUTH_CODE_FROM_SPOTIFY]",
"redirect_uri": "http://localhost"
}'
```
---
## 2. Cloud Source Registration (Marge)
The app now registers the Spotify account with the Bose "Marge" service. This makes the source available across all devices linked to the same Bose account.
### Request Details
- **Endpoint**: `https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source`
- **Method**: `POST`
- **Headers**:
- `Content-Type: application/vnd.bose.streaming-v1.1+xml`
- `Authorization: [MARGE_TOKEN]`
- `GUID: [DEVICE_GUID]`
- `ClientType: Stockholm`
### Payload (XML)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<source>
<username>[SPOTIFY_USER_ID]</username>
<sourceproviderid>15</sourceproviderid>
<credential type="token_version_3">[SECRET_TOKEN_OBTAINED_IN_STEP_1]</credential>
<sourcename>[DISPLAY_NAME_E_G_EMAIL]</sourcename>
</source>
```
### curl Example
```bash
curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source" \
-H "Content-Type: application/vnd.bose.streaming-v1.1+xml" \
-H "Authorization: [MARGE_TOKEN]" \
-d '<?xml version="1.0" encoding="UTF-8"?><source><username>[USER]</username><sourceproviderid>15</sourceproviderid><credential type="token_version_3">[TOKEN]</credential><sourcename>[NAME]</sourcename></source>'
```
---
### Local Device Sync (LISA API)
The app notifies the physical SoundTouch speaker about the new source. This is usually done via the device's management API on port 8090.
#### Modern Flow (OAuth)
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount`
- **Method**: `POST`
- **Payload**:
```xml
<OAuthCredentials source="SPOTIFY" displayName="[DISPLAY_NAME]">
<user>[SPOTIFY_USER_ID]</user>
<code>[AUTH_CODE_OR_TOKEN]</code>
<version>token_version_3</version>
</OAuthCredentials>
```
#### Marge-Sync Notification (Fall-back)
If the speaker returns `1029 UNKNOWN_ACTION_ERROR`, it signifies the LISA API version is too old for the OAuth flow. Stockholm-based firmware often expects the account to be registered in Marge first, followed by a notification to sync.
- **Endpoint**: `http://[DEVICE_IP]:8090/notification`
- **Method**: `POST`
- **Payload**:
```xml
<updates deviceID="[DEVICE_UID]">
<sourcesUpdated></sourcesUpdated>
</updates>
```
#### Legacy Flow (Fall-back)
For older firmware that doesn't use Marge for Spotify:
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount`
- **Method**: `POST`
- **Payload**:
```xml
<credentials source="SPOTIFY" displayName="Spotify Premium">
<user>[USER]</user>
<pass>[TOKEN]</pass>
</credentials>
```
---
## Implementation in SoundTouch-Service
This project implements the "Bose-mediated token" flow as follows:
1. **Surrogate Secrets**: When a user links their Spotify account via `soundtouch-service`, the service generates a 32-character hex string (a "Bose Secret").
2. **Marge & LISA registration**: This secret is sent to the speaker and stored in the emulated Marge cloud as the `credential`. The raw Spotify refresh token never leaves the server.
3. **Token Refresh Proxy**: When the speaker needs a fresh Spotify `access_token`, it calls the `soundtouch-service` proxy (`/oauth/device/.../token/cs3`) providing this secret. The server maps the secret back to the actual Spotify account, performs the refresh with Spotify, and returns a fresh short-lived `access_token` to the speaker.
---
## Placeholders and Constants
| Placeholder | Description |
|:------------------|:----------------------------------------------------|
| `[ACCOUNT_ID]` | The internal Bose account ID (UUID). |
| `[SESSION_TOKEN]` | Temporary token from Bose login. |
| `[MARGE_TOKEN]` | Persistent authorization token for Marge services. |
| `[DEVICE_GUID]` | Unique identifier for the controller app instance. |
| `[DEVICE_IP]` | Local IP address of the SoundTouch speaker. |
| `15` | Constant `sourceproviderid` for Spotify. |
| `token_version_3` | Credential type for modern OAuth2 Spotify accounts. |
---
## Resulting Persistence
Once these requests succeed, the device updates its `/mnt/nv/BoseApp-Persistence/1/Sources.xml` file:
```xml
<source displayName="user@example.com" secret="[SECRET_BLOB]" secretType="token_version_3">
<sourceKey type="SPOTIFY" account="user" />
</source>
```
+2 -2
View File
@@ -1,8 +1,8 @@
module navigation-station-demo
go 1.26.2
go 1.26.1
require github.com/gesellix/bose-soundtouch v0.57.0
require github.com/gesellix/bose-soundtouch v0.43.0
require github.com/gorilla/websocket v1.5.3 // indirect
+2 -2
View File
@@ -1,8 +1,8 @@
module preset-management-example
go 1.26.2
go 1.26.1
require github.com/gesellix/bose-soundtouch v0.57.0
require github.com/gesellix/bose-soundtouch v0.43.0
require github.com/gorilla/websocket v1.5.3 // indirect
+7 -11
View File
@@ -1,6 +1,6 @@
module github.com/gesellix/bose-soundtouch
go 1.26.2
go 1.26.1
require (
github.com/go-chi/chi/v5 v5.2.5
@@ -8,20 +8,16 @@ require (
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.50.0
golang.org/x/crypto v0.48.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+14 -22
View File
@@ -13,10 +13,6 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
@@ -28,18 +24,16 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -50,8 +44,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -59,8 +53,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -73,8 +67,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -85,8 +79,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -97,8 +91,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -106,6 +98,6 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+3 -115
View File
@@ -146,9 +146,7 @@ import (
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
@@ -194,51 +192,12 @@ func NewClient(config *Config) *Client {
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
}
host := config.Host
if !strings.Contains(host, "://") {
host = "http://" + host
}
u, err := url.Parse(host)
if err != nil {
// Fallback for invalid URLs
port := config.Port
if port == 0 {
port = 8090
}
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
httpClient: &http.Client{
Timeout: config.Timeout,
},
timeout: config.Timeout,
userAgent: config.UserAgent,
}
}
// Use SplitHostPort to check for port in the host string
_, p, splitErr := net.SplitHostPort(u.Host)
if splitErr != nil {
// No port in the host string, use the one from config or default
port := config.Port
if port == 0 {
port = 8090
}
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
} else if p == "" {
// Empty port, use config or default
port := config.Port
if port == 0 {
port = 8090
}
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
if config.Port == 0 {
config.Port = 8090
}
return &Client{
baseURL: u.String(),
baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
httpClient: &http.Client{
Timeout: config.Timeout,
},
@@ -1157,19 +1116,6 @@ func (c *Client) post(endpoint string, payload interface{}) error {
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
// Try to parse as ErrorsResponse (speaker error format)
var errs models.ErrorsResponse
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
return &errs
}
// Try to parse as APIError (standard format)
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
}
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
@@ -1214,19 +1160,6 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
// Try to parse as ErrorsResponse (speaker error format)
var errs models.ErrorsResponse
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
return &errs
}
// Try to parse as APIError (standard format)
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
}
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
@@ -1239,11 +1172,6 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
// Parse the actual response first
if err := xml.Unmarshal(responseBody, result); err != nil {
// Check if it might be an API error response instead
var errs models.ErrorsResponse
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
return &errs
}
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
@@ -1962,46 +1890,6 @@ func (c *Client) SetMusicServiceAccount(credentials *models.MusicServiceCredenti
return nil
}
// SetMusicServiceOAuthAccount adds or updates a music service account using OAuth credentials
func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredentials) error {
if credentials == nil {
return fmt.Errorf("credentials cannot be nil")
}
var response models.MusicServiceAccountResponse
// Note: Modern firmware uses /setMusicServiceOAuthAccount, but we reuse the success logic
err := c.postWithResponse("/setMusicServiceOAuthAccount", credentials, &response)
if err != nil {
return fmt.Errorf("failed to set music service OAuth account for %s: %w", credentials.Source, err)
}
// The speaker returns /setMusicServiceOAuthAccount on success
if response.Status != "/setMusicServiceOAuthAccount" {
return fmt.Errorf("music service OAuth account operation failed: unexpected response %s", response.Status)
}
return nil
}
// NotifySourcesUpdated notifies the device that sources have been updated in Marge
func (c *Client) NotifySourcesUpdated(deviceID string) error {
notification := models.NewSourcesUpdatedNotification(deviceID)
var response models.MusicServiceAccountResponse
err := c.postWithResponse("/notification", notification, &response)
if err != nil {
return fmt.Errorf("failed to send sources updated notification: %w", err)
}
if response.Status != "/notification" {
return fmt.Errorf("sources updated notification failed: unexpected response %s", response.Status)
}
return nil
}
// RemoveMusicServiceAccount removes an existing music service account
func (c *Client) RemoveMusicServiceAccount(credentials *models.MusicServiceCredentials) error {
if credentials == nil {
+10 -5
View File
@@ -66,7 +66,7 @@ func TestNewClientFromHost(t *testing.T) {
func TestGetDeviceInfo_Success(t *testing.T) {
// Load test data
testData := loadTestData(t, "info_response_st10.xml")
testData := loadTestData(t, "info_response.xml")
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -117,8 +117,8 @@ func TestGetDeviceInfo_Success(t *testing.T) {
t.Errorf("Expected Name 'My SoundTouch Device', got '%s'", deviceInfo.Name)
}
if deviceInfo.MargeAccountUUID != "1234567" {
t.Errorf("Expected MargeAccountUUID '1234567', got '%s'", deviceInfo.MargeAccountUUID)
if deviceInfo.MargeAccountUUID != "3230304" {
t.Errorf("Expected MargeAccountUUID '3230304', got '%s'", deviceInfo.MargeAccountUUID)
}
if deviceInfo.ModuleType != "sm2" {
@@ -227,7 +227,7 @@ func TestGetDeviceInfo_APIError(t *testing.T) {
}
func TestPing_Success(t *testing.T) {
testData := loadTestData(t, "info_response_st10.xml")
testData := loadTestData(t, "info_response.xml")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
@@ -1059,7 +1059,12 @@ func loadTestData(t *testing.T, filename string) string {
}
func createTestClient(serverURL string) *Client {
return NewClientFromHost(serverURL)
config := DefaultConfig()
config.Host = "localhost" // Will be overridden by baseURL
client := NewClient(config)
client.baseURL = serverURL
return client
}
func contains(s, substr string) bool {
-135
View File
@@ -1,135 +0,0 @@
package client
import (
"encoding/xml"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestClient_Post_ErrorsResponse(t *testing.T) {
// Mock speaker error response
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(errorXML))
}))
defer server.Close()
c := createTestClient(server.URL)
// Test post method
err := c.post("/test", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
errs := &models.ErrorsResponse{}
ok := errors.As(err, &errs)
if !ok {
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
}
if errs.DeviceID != "08DF1F0BA325" {
t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID)
}
if len(errs.Errors) != 1 {
t.Fatalf("expected 1 error, got %d", len(errs.Errors))
}
if errs.Errors[0].Value != 1029 {
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
}
if errs.Errors[0].Name != "UNKNOWN_ACTION_ERROR" {
t.Errorf("expected error name UNKNOWN_ACTION_ERROR, got %s", errs.Errors[0].Name)
}
expectedMsg := "This version of SCM does not support spotify create account functionality."
if errs.Errors[0].Message != expectedMsg {
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
}
if err.Error() != expectedMsg {
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
}
}
func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) {
// Mock speaker error response
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(errorXML))
}))
defer server.Close()
c := createTestClient(server.URL)
// Test postWithResponse method
var result struct {
XMLName xml.Name `xml:"status"`
Data string `xml:",chardata"`
}
err := c.postWithResponse("/test", nil, &result)
if err == nil {
t.Fatal("expected error, got nil")
}
errs := &models.ErrorsResponse{}
ok := errors.As(err, &errs)
if !ok {
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
}
if errs.Errors[0].Value != 1029 {
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
}
}
func TestClient_Post_StandardAPIError(t *testing.T) {
// Mock standard API error response
errorXML := `<?xml version="1.0" encoding="UTF-8"?><error code="404">Not Found</error>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(errorXML))
}))
defer server.Close()
c := createTestClient(server.URL)
err := c.post("/test", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr := &models.APIError{}
ok := errors.As(err, &apiErr)
if !ok {
t.Fatalf("expected models.APIError, got %T: %v", err, err)
}
if apiErr.Code != 404 {
t.Errorf("expected code 404, got %d", apiErr.Code)
}
if apiErr.Message != "Not Found" {
t.Errorf("expected message 'Not Found', got '%s'", apiErr.Message)
}
}
@@ -2,7 +2,7 @@
<info deviceID="ABCD1234EFGH">
<name>My SoundTouch Device</name>
<type>SoundTouch 10</type>
<margeAccountUUID>1234567</margeAccountUUID>
<margeAccountUUID>3230304</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
+1 -1
View File
@@ -2,7 +2,7 @@
<info deviceID="ABCD1234EFGH">
<name>My SoundTouch Device</name>
<type>SoundTouch 20</type>
<margeAccountUUID>1234567</margeAccountUUID>
<margeAccountUUID>3230304</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
+7 -32
View File
@@ -2,7 +2,6 @@ package client
import (
"context"
"encoding/xml"
"fmt"
"log"
"net/url"
@@ -357,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
}
attempt++
// Check if device is reachable before attempting full WS connection to reduce log noise
if err := ws.client.Ping(); err != nil {
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
continue
}
ws.logger.Printf("Reconnection attempt %d", attempt)
if err := ws.connectWithConfig(config); err != nil {
@@ -520,37 +526,6 @@ func (ws *WebSocketClient) SendMessage(message []byte) error {
return conn.WriteMessage(websocket.TextMessage, message)
}
// PairWithAccount sends a request to pair the device with a specific account
func (ws *WebSocketClient) PairWithAccount(accountID, userAuthToken string) error {
request := models.PairDeviceWithAccount{
AccountID: accountID,
UserAuthToken: userAuthToken,
}
data, err := xml.Marshal(request)
if err != nil {
return fmt.Errorf("failed to marshal pairing request: %w", err)
}
ws.logger.Printf("Sending PairDeviceWithAccount for account %s", accountID)
return ws.SendMessage(data)
}
// UnPairFromAccount sends a request to unpair the device from its account
func (ws *WebSocketClient) UnPairFromAccount() error {
request := models.UnPairDeviceWithAccount{}
data, err := xml.Marshal(request)
if err != nil {
return fmt.Errorf("failed to marshal unpairing request: %w", err)
}
ws.logger.Printf("Sending UnPairDeviceWithAccount")
return ws.SendMessage(data)
}
// Wait blocks until the WebSocket connection is closed or context is cancelled
func (ws *WebSocketClient) Wait() {
<-ws.ctx.Done()
+3 -21
View File
@@ -384,24 +384,8 @@ func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
serviceIP := "192.168.1.100"
// Mock server that deliberately delays its response
mux := dns.NewServeMux()
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
time.Sleep(200 * time.Millisecond) // Longer than the timeout
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5358", Net: "udp", Handler: mux}
go func() { _ = ts.ListenAndServe() }()
defer func() { _ = ts.Shutdown() }()
// Give the server a moment to start
time.Sleep(50 * time.Millisecond)
upstreamDNS := []string{"127.0.0.1:5358"}
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
upstreamDNS := []string{"192.0.2.1:53"} // TEST-NET-1, usually non-routable
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.timeout = 100 * time.Millisecond
@@ -414,14 +398,12 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
d.forward(rw, m)
duration := time.Since(start)
// Since we're forwarding to a local server that sleeps for 200ms,
// and our timeout is 100ms, it should take at least 100ms.
if duration < 100*time.Millisecond {
t.Errorf("Expected forward to take at least 100ms (timeout), but took %v", duration)
}
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure after timeout, got msg: %v", rw.msg)
t.Errorf("Expected RcodeServerFailure after timeout")
}
}
+1 -32
View File
@@ -110,44 +110,13 @@ func (cred *MusicServiceCredentials) GetDescription() string {
}
}
// OAuthCredentials represents the credentials sent to /setMusicServiceOAuthAccount
type OAuthCredentials struct {
XMLName xml.Name `xml:"OAuthCredentials"`
Source string `xml:"source,attr"`
DisplayName string `xml:"displayName,attr,omitempty"`
User string `xml:"user"`
Code string `xml:"code"`
Version string `xml:"version"`
}
// NewSpotifyOAuthCredentials creates OAuth credentials for Spotify
func NewSpotifyOAuthCredentials(user, code, displayName string) *OAuthCredentials {
if displayName == "" {
displayName = user
}
return &OAuthCredentials{
Source: "SPOTIFY",
DisplayName: displayName,
User: user,
Code: code,
Version: "token_version_3",
}
}
// MusicServiceAccountResponse represents the response from account management operations
type MusicServiceAccountResponse struct {
XMLName xml.Name `xml:"status"`
Status string `xml:",chardata"`
}
// SourcesUpdatedResponse represents the response from /notification
type SourcesUpdatedResponse struct {
XMLName xml.Name `xml:"status"`
Status string `xml:",chardata"`
}
// IsSuccess returns true if the account operation was successful
func (resp *MusicServiceAccountResponse) IsSuccess() bool {
return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount" || resp.Status == "/notification"
return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount"
}
-39
View File
@@ -36,22 +36,6 @@ type NetworkInfo struct {
IPAddress string `xml:"ipAddress"`
}
// SourcesUpdatedNotification represents the notification XML sent to the device
type SourcesUpdatedNotification struct {
XMLName xml.Name `xml:"updates"`
DeviceID string `xml:"deviceID,attr"`
Sources struct {
XMLName xml.Name `xml:"sourcesUpdated"`
} `xml:"sourcesUpdated"`
}
// NewSourcesUpdatedNotification creates a new sources updated notification
func NewSourcesUpdatedNotification(deviceID string) *SourcesUpdatedNotification {
return &SourcesUpdatedNotification{
DeviceID: deviceID,
}
}
// XMLResponse is a generic wrapper for API responses
type XMLResponse struct {
XMLName xml.Name
@@ -69,29 +53,6 @@ func (e *APIError) Error() string {
return e.Message
}
// ErrorsResponse represents a multi-error response from the API (common in some firmware versions)
type ErrorsResponse struct {
XMLName xml.Name `xml:"errors"`
DeviceID string `xml:"deviceID,attr"`
Errors []DeviceError `xml:"error"`
}
// Error implements the error interface for ErrorsResponse
func (e *ErrorsResponse) Error() string {
if len(e.Errors) > 0 {
return e.Errors[0].Message
}
return "unknown API error"
}
// DeviceError represents a single error in an ErrorsResponse
type DeviceError struct {
Value int `xml:"value,attr"`
Name string `xml:"name,attr"`
Message string `xml:",chardata"`
}
// DiscoveredDevice represents a device found through network discovery
type DiscoveredDevice struct {
Name string `json:"name"`
+21 -578
View File
@@ -5,9 +5,6 @@ package models
import (
"encoding/xml"
"strconv"
"strings"
"time"
)
// Link represents a navigational link with URL and client usage preferences.
@@ -135,321 +132,42 @@ type SourceProvider struct {
// ServiceContentItem represents a media content item with source and location details.
type ServiceContentItem struct {
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name"`
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
Type string `json:"type,omitempty" xml:"type,attr,omitempty"`
ContentItemType string `json:"content_item_type,omitempty" xml:"contentItemType,omitempty"`
Location string `json:"location,omitempty" xml:"location,attr,omitempty"`
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
SourceID string `json:"source_id,omitempty" xml:"sourceid"`
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
Username string `json:"username,omitempty" xml:"username,omitempty"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"itemName"`
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
Type string `json:"type" xml:"type,attr"`
Location string `json:"location" xml:"location,attr"`
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
}
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
ID string `json:"id,omitempty" xml:"id,attr,omitempty"`
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
Username string `json:"-" xml:"username,omitempty"`
}
// MarshalXML implements the xml.Marshaler interface for ServicePreset to match upstream parity.
func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type Alias struct {
ButtonNumber string `xml:"buttonNumber,attr,omitempty"`
ContainerArt string `xml:"containerArt"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
Location string `xml:"location"`
Name string `xml:"name"`
SourceID string `xml:"sourceid,omitempty"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
createdOn := p.CreatedOn
if _, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
if t, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
createdOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
}
updatedOn := p.UpdatedOn
if _, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
if t, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
updatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
}
a := Alias{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: createdOn,
Location: p.Location,
Name: p.Name,
SourceID: p.SourceID,
UpdatedOn: updatedOn,
Username: p.Username,
}
if a.Username == "" && a.Name != "" {
a.Username = a.Name
}
start.Name.Local = "preset"
// Remove all attributes because they are handled in Alias
start.Attr = nil
return e.EncodeElement(a, start)
}
// ServiceRecent represents recently played media content as stored in Recents.xml.
// ServiceRecent represents recently played media content.
type ServiceRecent struct {
XMLName xml.Name `json:"-" xml:"recent"`
ServiceContentItem
DeviceID string `json:"device_id" xml:"deviceID,attr,omitempty"`
UtcTime string `json:"utc_time" xml:"utcTime,attr,omitempty"`
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat,omitempty"`
}
// RecentItemParity represents recently played media content for web API responses (flat format).
type RecentItemParity struct {
XMLName xml.Name `xml:"recent"`
ID string `xml:"id,attr"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
LastPlayedAt string `xml:"lastplayedat"`
Location string `xml:"location"`
Name string `xml:"name"`
Source *RecentItemParitySource `xml:"source,omitempty"`
SourceID string `xml:"sourceid"`
UpdatedOn string `xml:"updatedOn"`
}
// RecentItemParitySource represents the source in a RecentItemParity.
type RecentItemParitySource struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
CreatedOn string `xml:"createdOn"`
Credential *RecentItemParityCredential `xml:"credential"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
// RecentItemParityCredential represents the credential in a RecentItemParitySource.
type RecentItemParityCredential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
}
// UnmarshalXML implements the xml.Unmarshaler interface to handle both nested and flat formats for ServiceRecent.
func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type NestedContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}
type Alias struct {
XMLName xml.Name `xml:"recent"`
ServiceContentItem
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
ContainerArt string `xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
LastPlayedAt string `xml:"lastplayedat"`
ContentItem *NestedContentItem `xml:"contentItem,omitempty"`
// Flat format might use these tags
FlatLocation string `xml:"location"`
FlatTypeTag string `xml:"type"`
AttrType string `xml:"type,attr"`
FlatSourceAccount string `xml:"sourceAccount"`
FlatIsPresetable string `xml:"isPresetable"`
FlatContentItemType string `xml:"contentItemType"`
AttrContentItemType string `xml:"contentItemType,attr"`
FlatName string `xml:"name"`
FlatSourceID string `xml:"sourceid"`
FlatSourceIDAttr string `xml:"sourceid,attr"`
FlatSource string `xml:"source_key"`
AttrSource string `xml:"source,attr"`
}
var a Alias
if err := d.DecodeElement(&a, &start); err != nil {
return err
}
r.ServiceContentItem = a.ServiceContentItem
r.DeviceID = a.DeviceID
r.UtcTime = a.UtcTime
r.ID = a.ID
r.CreatedOn = a.CreatedOn
r.UpdatedOn = a.UpdatedOn
r.ContainerArt = a.ContainerArt
r.LastPlayedAt = a.LastPlayedAt
r.SourceID = a.FlatSourceID
// Prefer nested contentItem data if present
if a.ContentItem != nil {
r.Source = a.ContentItem.Source
r.Type = a.ContentItem.Type
r.ContentItemType = a.ContentItem.Type // Set ContentItemType from nested type
r.Location = a.ContentItem.Location
r.SourceAccount = a.ContentItem.SourceAccount
r.IsPresetable = a.ContentItem.IsPresetable
r.Name = a.ContentItem.ItemName
if a.ContentItem.ContainerArt != "" {
r.ContainerArt = a.ContentItem.ContainerArt
}
} else {
// Fallback to flat fields
if a.FlatLocation != "" {
r.Location = a.FlatLocation
}
switch {
case a.FlatContentItemType != "":
r.ContentItemType = a.FlatContentItemType
case a.FlatTypeTag != "":
r.ContentItemType = a.FlatTypeTag
case a.AttrType != "":
r.ContentItemType = a.AttrType
}
switch {
case a.FlatTypeTag != "":
r.Type = a.FlatTypeTag
case a.AttrType != "":
r.Type = a.AttrType
}
switch {
case a.FlatSourceID != "":
r.SourceID = a.FlatSourceID
case a.FlatSourceIDAttr != "":
r.SourceID = a.FlatSourceIDAttr
}
switch {
case a.FlatSource != "":
r.Source = a.FlatSource
case a.AttrSource != "":
r.Source = a.AttrSource
}
if a.FlatName != "" {
r.Name = a.FlatName
}
if a.FlatSourceAccount != "" {
r.SourceAccount = a.FlatSourceAccount
}
if a.FlatIsPresetable != "" {
r.IsPresetable = a.FlatIsPresetable
}
}
return nil
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ServiceRecent (nested format).
func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type NestedContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}
type Alias struct {
XMLName xml.Name `xml:"recent"`
ID string `xml:"id,attr"`
DeviceID string `xml:"deviceID,attr,omitempty"`
UtcTime string `xml:"utcTime,attr,omitempty"`
ContentItem *NestedContentItem `xml:"contentItem"`
CreatedOn string `xml:"createdOn"`
UpdatedOn string `xml:"updatedOn"`
LastPlayedAt string `xml:"lastplayedat"`
SourceID string `xml:"sourceid"`
Username string `xml:"username"`
}
a := Alias{
ID: r.ID,
DeviceID: r.DeviceID,
UtcTime: r.UtcTime,
CreatedOn: r.CreatedOn,
UpdatedOn: r.UpdatedOn,
LastPlayedAt: r.LastPlayedAt,
SourceID: r.SourceID,
Username: r.Name, // Using Name as Username for parity
ContentItem: &NestedContentItem{
Source: r.Source,
Type: r.Type,
Location: r.Location,
SourceAccount: r.SourceAccount,
IsPresetable: r.IsPresetable,
ItemName: r.Name,
ContainerArt: r.ContainerArt,
},
}
start.Name.Local = "recent"
return e.EncodeElement(a, start)
DeviceID string `json:"device_id" xml:"deviceid"`
UtcTime string `json:"utc_time" xml:"utc_time"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
}
// ConfiguredSource represents a configured media source with authentication details.
type ConfiguredSource struct {
XMLName xml.Name `json:"-" xml:"source"`
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
ID string `json:"id" xml:"id,attr,omitempty"`
Secret string `json:"secret" xml:"secret,attr,omitempty"`
SecretType string `json:"secret_type" xml:"secretType,attr,omitempty"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `json:"-" xml:"credential"`
SourceKey struct {
DisplayName string `json:"display_name" xml:"displayName,attr"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"secret,attr"`
SecretType string `json:"secret_type" xml:"secretType,attr"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `json:"source_key" xml:"sourceKey"`
Type string `xml:"type,attr,omitempty"`
// Parity fields
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid,omitempty"`
Username string `json:"username,omitempty" xml:"username,omitempty"`
SourceName string `json:"source_name,omitempty" xml:"sourcename,omitempty"`
Name string `json:"name,omitempty" xml:"name,omitempty"`
SourceSettings string `json:"-" xml:"sourceSettings,omitempty"`
Status string `json:"status,omitempty" xml:"-"`
// Legacy fields for backward compatibility in code if needed,
// though it's better to update the code to use SourceKey.
@@ -457,102 +175,6 @@ type ConfiguredSource struct {
SourceKeyAccount string `json:"source_key_account" xml:"-"`
}
type sourceCredential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
}
type sourceAlias struct {
XMLName xml.Name `xml:"source"`
DisplayName string `xml:"displayName,attr,omitempty"`
ID string `xml:"id,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
CreatedOn string `xml:"createdOn,omitempty"`
Credential *sourceCredential `xml:"credential,omitempty"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid,omitempty"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn,omitempty"`
Username string `xml:"username"`
}
func (s ConfiguredSource) getFirstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
a := sourceAlias{
XMLName: xml.Name{Local: start.Name.Local},
DisplayName: s.DisplayName,
ID: s.ID,
Type: s.Type,
CreatedOn: s.CreatedOn,
Name: s.Name,
SourceProviderID: s.SourceProviderID,
SourceName: s.SourceName,
SourceSettings: s.SourceSettings,
UpdatedOn: s.UpdatedOn,
Username: s.Username,
}
// Bose XML for sources usually does NOT include displayName attribute
// except for when it's explicitly stored in our datastore as such.
// For parity with official responses, we omit it if ID is present or for standard sources.
if s.ID != "" || s.SourceKeyType != "" || s.Type != "" {
a.DisplayName = ""
}
a.Name = s.Name
a.SourceName = s.SourceName
a.Username = s.Username
// Parity: for TuneIn and some others, sourcename, name and username should NOT automatically fall back to displayName
// if they are intended to be empty. However, if they are ALL empty, we need some value.
isTuneIn := strings.EqualFold(s.DisplayName, "TUNEIN") || strings.EqualFold(s.SourceKeyType, "TUNEIN") || strings.EqualFold(s.ID, "TUNEIN")
if a.Name == "" {
a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
}
if a.SourceName == "" && !isTuneIn {
a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
}
if a.Username == "" && !isTuneIn {
a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
}
if s.Secret != "" || s.SecretType != "" {
a.Credential = &sourceCredential{
Type: s.SecretType,
Value: s.Secret,
}
} else if s.Credential.Value != "" || s.Credential.Type != "" {
a.Credential = &sourceCredential{
Type: s.Credential.Type,
Value: s.Credential.Value,
}
}
if a.SourceSettings == "" {
a.SourceSettings = ""
}
// Important: Clear automatically generated attributes from the start element
// because we are using Alias to control attribute order and presence.
start.Attr = nil
return e.EncodeElement(a, start)
}
// ServiceDeviceInfo represents information about a SoundTouch device.
type ServiceDeviceInfo struct {
DeviceID string `json:"device_id" xml:"deviceID,attr"`
@@ -570,19 +192,10 @@ type ServiceDeviceInfo struct {
// ServiceComponent represents a hardware or software component of a device.
type ServiceComponent struct {
Type string `json:"type" xml:"type,attr"`
Category string `json:"category,omitempty" xml:"category,attr,omitempty"`
SoftwareVersion string `json:"firmware_version" xml:"firmware-version"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
Label string `json:"label,omitempty" xml:"componentlabel"`
}
// ServiceAccountInfo represents account-level metadata.
type ServiceAccountInfo struct {
AccountID string `json:"account_id"`
PreferredLanguage string `json:"preferred_language"`
ProviderSettings []ProviderSetting `json:"provider_settings"`
IsPlaceholder bool `json:"is_placeholder,omitempty"`
Type string `xml:"type,attr"`
Category string `xml:"category,attr"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
}
// CustomerSupportDevice represents device information for customer support purposes.
@@ -704,173 +317,3 @@ type EmailAddressResponse struct {
XMLName xml.Name `xml:"emailAddress"`
Email string `xml:",chardata"`
}
// FullResponseSource represents a configured media source specifically for the /full response.
// It follows the specific XML structure and field order of the upstream /full response.
type FullResponseSource struct {
ID string `json:"id" xml:"id,attr"`
Type string `json:"type" xml:"type,attr"`
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Credential struct {
Type string `json:"type" xml:"type,attr"`
Value string `json:"value" xml:",chardata"`
} `json:"credential" xml:"credential"`
Name string `json:"name" xml:"name"`
SourceProviderID string `json:"sourceproviderid" xml:"sourceproviderid"`
SourceName string `json:"source_name" xml:"sourcename"`
SourceSettings string `json:"source_settings" xml:"sourceSettings"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
SourceLabel string `json:"source_label" xml:"-"`
}
// FullResponsePreset represents a preset specifically for the /full response.
type FullResponsePreset struct {
ButtonNumber string `json:"button_number" xml:"buttonNumber,attr"`
ContainerArt string `json:"container_art" xml:"containerArt"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Location string `json:"location" xml:"location"`
Name string `json:"name" xml:"name"`
Source FullResponseSource `json:"source" xml:"source"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
}
// FullResponseRecent represents a recent item specifically for the /full response.
type FullResponseRecent struct {
ID string `json:"id" xml:"id,attr"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
CreatedOn string `json:"created_on" xml:"createdOn"`
LastPlayedAt string `json:"last_played_at" xml:"lastplayedat"`
Location string `json:"location" xml:"location"`
Name string `json:"name" xml:"name"`
Source FullResponseSource `json:"source" xml:"source"`
SourceID string `json:"source_id" xml:"sourceid"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
}
// AccountFullResponse represents the complete account XML structure.
type AccountFullResponse struct {
XMLName xml.Name `xml:"account"`
ID string `xml:"id,attr"`
AccountStatus string `xml:"accountStatus"`
Devices []AccountDevice `xml:"devices>device"`
Mode string `xml:"mode"`
PreferredLanguage string `xml:"preferredLanguage"`
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
Sources []FullResponseSource `xml:"sources>source"`
}
// AccountSourcesResponse represents the response from /streaming/account/{accountId}/sources.
type AccountSourcesResponse struct {
XMLName xml.Name `xml:"sources"`
Sources []FullResponseSource `xml:"source"`
}
// AccountDevicesResponse represents the response from /streaming/account/{accountId}/devices.
type AccountDevicesResponse struct {
XMLName xml.Name `xml:"devices"`
Devices []MargeAccountDevice `xml:"device"`
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
}
// MargeAccountDevice represents a device specifically for the /devices response.
// It matches the structure in 06_orig.xml, which is a subset of AccountDevice.
type MargeAccountDevice struct {
DeviceID string `json:"device_id" xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
CreatedOn string `json:"created_on" xml:"createdOn"`
IPAddress string `json:"ip_address" xml:"ipaddress"`
Name string `json:"name" xml:"name"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// AccountDevice represents a device in the account response.
type AccountDevice struct {
DeviceID string `json:"device_id" xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
CreatedOn string `json:"created_on" xml:"createdOn"`
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
IPAddress string `json:"ip_address" xml:"ipaddress"`
Name string `json:"name" xml:"name"`
Presets []FullResponsePreset `json:"presets" xml:"presets>preset,omitempty"`
ProductCode string `json:"product_code" xml:"-"`
Recents []FullResponseRecent `json:"recents" xml:"recents>recent,omitempty"`
SerialNumber string `json:"serial_number" xml:"serialNumber,omitempty"`
DeviceSerialNumber string `json:"device_serial_number,omitempty" xml:"-"`
MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty" xml:"-"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// AttachedProduct represents product information for a device.
type AttachedProduct struct {
ProductCode string `json:"product_code" xml:"product_code,attr"`
Components []ServiceComponent `json:"components" xml:"components>component,omitempty"`
ProductLabel string `json:"product_label" xml:"productlabel"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// ProviderSetting represents a single provider setting.
type ProviderSetting struct {
BoseID string `json:"bose_id" xml:"boseId"`
KeyName string `json:"key_name" xml:"keyName"`
Value string `json:"value" xml:"value"`
ProviderID string `json:"provider_id" xml:"providerId"`
ProviderName string `json:"provider_name,omitempty" xml:"-"`
}
// MargeLoginRequest represents a login request from Stockholm.
type MargeLoginRequest struct {
XMLName xml.Name `xml:"login"`
Username string `xml:"username"`
Password string `xml:"password"`
}
// MargeAccountCreateRequest represents an account creation request from Stockholm.
type MargeAccountCreateRequest struct {
XMLName xml.Name `xml:"account"`
ID string `xml:"id,attr,omitempty"` // Optional ID for testing/overrides
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
Email string `xml:"email"`
Password string `xml:"password"`
CountryCode string `xml:"countryCode"`
PreferredLanguage string `xml:"preferredLanguage"`
}
// MargeAddSourceResponse represents the response after adding a source to Marge.
type MargeAddSourceResponse struct {
XMLName xml.Name `xml:"source"`
SourceID string `xml:"sourceID"`
SourceProviderID string `xml:"sourceProviderID"`
CreatedOn string `xml:"createdOn"`
UpdatedOn string `xml:"updatedOn"`
}
// EligibilityResponse represents the XML response for music provider eligibility.
type EligibilityResponse struct {
XMLName xml.Name `xml:"eligibility"`
IsEligible bool `xml:"isEligible"`
}
// MargeAPIVersionsResponse represents the XML response for Marge API versions.
type MargeAPIVersionsResponse struct {
XMLName xml.Name `xml:"marge"`
Version string `xml:"version,attr"`
Project string `xml:"project,attr"`
Apis []MargeAPI `xml:"apis>api"`
Dependencies string `xml:"dependencies"`
}
// MargeAPI represents a single API entry in MargeAPIVersionsResponse.
type MargeAPI struct {
Type string `xml:"type,attr"`
XML string `xml:"xml"`
JSON string `xml:"json"`
}
-190
View File
@@ -1,190 +0,0 @@
package models
import (
"encoding/xml"
"testing"
)
func TestServiceRecent_Parity(t *testing.T) {
t.Run("Unmarshal local response (nested contentItem)", func(t *testing.T) {
localXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recent deviceID="" utcTime="1774176828" id="2568595253">
<contentItem source="Audio" type="" location="/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u" sourceAccount="user-name" isPresetable="true">
<itemName>Coco, Pt. 1</itemName>
</contentItem>
<createdOn>2026-03-14T22:39:17.000+00:00</createdOn>
<updatedOn>2026-03-14T22:39:17.000+00:00</updatedOn>
<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>
<sourceid>10863533</sourceid>
<source displayName="user-name" secret="TOKEN" secretType="token_version_3" id="10863533" type="Audio" createdOn="2016-01-06T08:52:04.000+00:00" updatedOn="2020-04-25T20:29:11.000+00:00" sourceproviderid="15">
<sourceKey type="Audio" account="user-name"></sourceKey>
</source>
</recent>`
var recent ServiceRecent
err := xml.Unmarshal([]byte(localXML), &recent)
if err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if recent.ID != "2568595253" {
t.Errorf("Expected ID 2568595253, got %s", recent.ID)
}
if recent.Name != "Coco, Pt. 1" {
t.Errorf("Expected Name 'Coco, Pt. 1', got %s", recent.Name)
}
if recent.SourceID != "10863533" {
t.Errorf("Expected SourceID 10863533, got %s", recent.SourceID)
}
})
t.Run("Unmarshal upstream response (flat contentItem)", func(t *testing.T) {
upstreamXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recent id="2569047180">
<contentItemType>tracklisturl</contentItemType>
<createdOn>2026-03-22T10:00:04.000+00:00</createdOn>
<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>
<location>/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP</location>
<name>Dopamine</name>
<source id="10863533" type="Audio">
<createdOn>2016-01-06T08:52:04.000+00:00</createdOn>
<credential type="token_version_3">TOKEN</credential>
<name>user-name</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>user-name@mail.internal</sourcename>
<sourceSettings/>
<updatedOn>2020-04-25T20:29:11.000+00:00</updatedOn>
<username>user-name</username>
</source>
<sourceid>10863533</sourceid>
<updatedOn>2026-03-22T10:53:50.719+00:00</updatedOn>
</recent>`
var recent ServiceRecent
err := xml.Unmarshal([]byte(upstreamXML), &recent)
if err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if recent.ID != "2569047180" {
t.Errorf("Expected ID 2569047180, got %s", recent.ID)
}
if recent.Name != "Dopamine" {
t.Errorf("Expected Name 'Dopamine', got %s", recent.Name)
}
if recent.ContentItemType != "tracklisturl" {
t.Errorf("Expected ContentItemType 'tracklisturl', got %s", recent.ContentItemType)
}
if recent.Location != "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP" {
t.Errorf("Expected Location '/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP', got %s", recent.Location)
}
if recent.SourceID != "10863533" {
t.Errorf("Expected SourceID 10863533, got %s", recent.SourceID)
}
})
t.Run("Marshal ServiceRecent should follow local style (nested)", func(t *testing.T) {
recent := ServiceRecent{
ServiceContentItem: ServiceContentItem{
ID: "2569047180",
Name: "Dopamine",
ContentItemType: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP",
SourceID: "10863533",
Source: "SPOTIFY",
Type: "tracklisturl",
SourceAccount: "user-name",
IsPresetable: "true",
},
CreatedOn: "2026-03-22T10:00:04.000+00:00",
UpdatedOn: "2026-03-22T10:53:50.719+00:00",
LastPlayedAt: "2026-03-22T10:53:48.000+00:00",
}
data, err := xml.MarshalIndent(recent, "", " ")
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
xmlStr := string(data)
if !contains_substr(xmlStr, "<contentItem ") || !contains_substr(xmlStr, "<itemName>Dopamine</itemName>") {
t.Errorf("Marshaled ServiceRecent missing nested <contentItem> element\nGot: %s", xmlStr)
}
})
t.Run("Marshal RecentItemParity should follow upstream style (flat)", func(t *testing.T) {
recent := RecentItemParity{
ID: "2569047180",
Name: "Dopamine",
ContentItemType: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP",
SourceID: "10863533",
CreatedOn: "2026-03-22T10:00:04.000+00:00",
UpdatedOn: "2026-03-22T10:53:50.719+00:00",
LastPlayedAt: "2026-03-22T10:53:48.000+00:00",
Source: &RecentItemParitySource{
ID: "10863533",
Type: "Audio",
Credential: &RecentItemParityCredential{
Type: "token",
Value: "",
},
},
}
data, err := xml.MarshalIndent(recent, "", " ")
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
xmlStr := string(data)
expectedElements := []string{
`<recent id="2569047180">`,
`<contentItemType>tracklisturl</contentItemType>`,
`<createdOn>2026-03-22T10:00:04.000+00:00</createdOn>`,
`<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>`,
`<location>/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP</location>`,
`<name>Dopamine</name>`,
`<sourceid>10863533</sourceid>`,
`<updatedOn>2026-03-22T10:53:50.719+00:00</updatedOn>`,
`<credential type="token"></credential>`,
}
for _, expected := range expectedElements {
if !contains_substr(xmlStr, expected) {
t.Errorf("Marshaled XML missing expected element: %s\nGot: %s", expected, xmlStr)
}
}
// It should NOT have nested contentItem
if contains_substr(xmlStr, "<contentItem ") || contains_substr(xmlStr, "<contentItem>") {
t.Errorf("Marshaled RecentItemParity should not have nested <contentItem> element\nGot: %s", xmlStr)
}
})
t.Run("Round-trip: Nested XML -> ServiceRecent -> Unmarshal -> Marshal -> Nested XML", func(t *testing.T) {
nestedXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recent deviceID="DEVICE_ID" utcTime="1774176828" id="2568595253">
<contentItem source="Audio" type="TRACK" location="/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u" sourceAccount="user-name" isPresetable="true">
<itemName>Coco, Pt. 1</itemName>
</contentItem>
</recent>`
var recent1 ServiceRecent
if err := xml.Unmarshal([]byte(nestedXML), &recent1); err != nil {
t.Fatalf("Unmarshal nested failed: %v", err)
}
// Marshal it (should produce nested XML again)
nestedData, err := xml.MarshalIndent(recent1, "", " ")
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
xmlStr := string(nestedData)
if !contains_substr(xmlStr, "<contentItem ") || !contains_substr(xmlStr, "<itemName>Coco, Pt. 1</itemName>") {
t.Errorf("Round-trip failed to maintain nested structure\nGot: %s", xmlStr)
}
})
}
func contains_substr(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || (len(substr) > 0 && (s[:len(substr)] == substr || contains_substr(s[1:], substr))))
}
-20
View File
@@ -35,10 +35,6 @@ const (
EventTypeRecentsUpdated WebSocketEventType = "recentsUpdated"
// EventTypeLanguageUpdated indicates a language setting change
EventTypeLanguageUpdated WebSocketEventType = "languageUpdated"
// EventTypePairDeviceWithAccount indicates a device pairing request
EventTypePairDeviceWithAccount WebSocketEventType = "PairDeviceWithAccount"
// EventTypeUnPairDeviceWithAccount indicates a device unpairing request
EventTypeUnPairDeviceWithAccount WebSocketEventType = "UnPairDeviceWithAccount"
// EventTypeUnknown indicates an unrecognized event type
EventTypeUnknown WebSocketEventType = "unknown"
)
@@ -70,10 +66,6 @@ func (e WebSocketEventType) String() string {
return "Recents Updated"
case EventTypeLanguageUpdated:
return "Language Updated"
case EventTypePairDeviceWithAccount:
return "Pair Device With Account"
case EventTypeUnPairDeviceWithAccount:
return "UnPair Device With Account"
default:
return "Unknown Event"
}
@@ -307,18 +299,6 @@ type Language struct {
Value string `xml:",chardata"`
}
// PairDeviceWithAccount represents a device pairing request message
type PairDeviceWithAccount struct {
XMLName xml.Name `xml:"PairDeviceWithAccount"`
AccountID string `xml:"accountId"`
UserAuthToken string `xml:"userAuthToken"`
}
// UnPairDeviceWithAccount represents a device unpairing request message
type UnPairDeviceWithAccount struct {
XMLName xml.Name `xml:"UnPairDeviceWithAccount"`
}
// SpecialMessageType represents message types that are not part of <updates>
type SpecialMessageType string
+23 -28
View File
@@ -280,33 +280,6 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error
return response, nil
}
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
streamList := []models.Stream{
{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: streamURL,
},
}
audio := models.Audio{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: streamURL,
Streams: streamList,
}
response := &models.BmxPlaybackResponse{
Audio: audio,
ImageUrl: imageURL,
Name: name,
StreamType: "liveRadio",
}
return response, nil
}
// PlayCustomStream builds a playback response from a base64-encoded JSON blob
// with fields streamUrl, imageUrl, and name.
func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
@@ -329,5 +302,27 @@ func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
return nil, err
}
return BuildCustomStreamResponse(jsonObj.StreamURL, jsonObj.ImageURL, jsonObj.Name)
streamList := []models.Stream{
{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: jsonObj.StreamURL,
},
}
audio := models.Audio{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: jsonObj.StreamURL,
Streams: streamList,
}
response := &models.BmxPlaybackResponse{
Audio: audio,
ImageUrl: jsonObj.ImageURL,
Name: jsonObj.Name,
StreamType: "liveRadio",
}
return response, nil
}
+1 -20
View File
@@ -9,7 +9,6 @@ import (
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"time"
@@ -81,10 +80,6 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
domainMap[d] = true
}
for _, ip := range cert.IPAddresses {
domainMap[ip.String()] = true
}
for _, d := range domains {
if !domainMap[d] {
generate = true
@@ -242,19 +237,6 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
return nil, nil, err
}
var (
dnsNames []string
ipAddresses []net.IP
)
for _, domain := range domains {
if ip := net.ParseIP(domain); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
dnsNames = append(dnsNames, domain)
}
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
@@ -265,8 +247,7 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: dnsNames,
IPAddresses: ipAddresses,
DNSNames: domains,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &priv.PublicKey, caKey)
@@ -126,47 +126,3 @@ func TestCertificateManager(t *testing.T) {
}
}
}
func TestCertificateManagerIPAddress(t *testing.T) {
tempDir, err := os.MkdirTemp("", "crypto-ip-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
// Test certificate generation with an IP address
domains := []string{"192.168.1.100", "localhost"}
certPEM, _, err := cm.GenerateCertificate(domains)
if err != nil {
t.Fatalf("Failed to generate certificate: %v", err)
}
// Verify generated certificate
block, _ := pem.Decode(certPEM)
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse certificate: %v", err)
}
// Check IP addresses
foundIP := false
for _, ip := range cert.IPAddresses {
if ip.String() == "192.168.1.100" {
foundIP = true
break
}
}
if !foundIP {
t.Errorf("Expected IP address 192.168.1.100 in IPAddresses, but it was not found")
}
// Check if it was mistakenly added to DNSNames
for _, dns := range cert.DNSNames {
if dns == "192.168.1.100" {
t.Errorf("IP address 192.168.1.100 should NOT be in DNSNames")
}
}
}
+41 -279
View File
@@ -1,277 +1,47 @@
// Package constants defines file names, directories, and common values used by the service layer.
package constants
import "strconv"
// SourceProvider represents a media source provider configuration.
type SourceProvider struct {
ID int
Name string
Label string
CreatedOn string
UpdatedOn string
}
const (
// ProviderPandora is the identifier for Pandora.
ProviderPandora = "PANDORA"
// ProviderInternetRadio is the identifier for Internet Radio.
ProviderInternetRadio = "INTERNET_RADIO"
// ProviderOff is the identifier for Off.
ProviderOff = "OFF"
// ProviderLocal is the identifier for Local.
ProviderLocal = "LOCAL"
// ProviderAirplay is the identifier for AirPlay.
ProviderAirplay = "AIRPLAY"
// ProviderCuratedRadio is the identifier for Curated Radio.
ProviderCuratedRadio = "CURRATED_RADIO"
// ProviderStoredMusic is the identifier for Stored Music.
ProviderStoredMusic = "STORED_MUSIC"
// ProviderSlaveSource is the identifier for Slave Source.
ProviderSlaveSource = "SLAVE_SOURCE"
// ProviderAux is the identifier for Aux.
ProviderAux = "AUX"
// ProviderRecommendedInternetRadio is the identifier for Recommended Internet Radio.
ProviderRecommendedInternetRadio = "RECOMMENDED_INTERNET_RADIO"
// ProviderLocalInternetRadio is the identifier for Local Internet Radio.
ProviderLocalInternetRadio = "LOCAL_INTERNET_RADIO"
// ProviderGlobalInternetRadio is the identifier for Global Internet Radio.
ProviderGlobalInternetRadio = "GLOBAL_INTERNET_RADIO"
// ProviderHello is the identifier for Hello.
ProviderHello = "HELLO"
// ProviderDeezer is the identifier for Deezer.
ProviderDeezer = "DEEZER"
// ProviderSpotify is the identifier for Spotify.
ProviderSpotify = "SPOTIFY"
// ProviderIHeart is the identifier for iHeartRadio.
ProviderIHeart = "IHEART"
// ProviderSiriusXM is the identifier for SiriusXM.
ProviderSiriusXM = "SIRIUSXM"
// ProviderGooglePlayMusic is the identifier for Google Play Music.
ProviderGooglePlayMusic = "GOOGLE_PLAY_MUSIC"
// ProviderQQMusic is the identifier for QQMusic.
ProviderQQMusic = "QQMUSIC"
// ProviderAmazon is the identifier for Amazon Music.
ProviderAmazon = "AMAZON"
// ProviderLocalMusic is the identifier for Local Music Library.
ProviderLocalMusic = "LOCAL_MUSIC"
// ProviderWbmx is the identifier for WBMX.
ProviderWbmx = "WBMX"
// ProviderSoundcloud is the identifier for SoundCloud.
ProviderSoundcloud = "SOUNDCLOUD"
// ProviderTidal is the identifier for Tidal.
ProviderTidal = "TIDAL"
// ProviderTunein is the identifier for TuneIn Radio.
ProviderTunein = "TUNEIN"
// ProviderQPlay is the identifier for QPlay.
ProviderQPlay = "QPLAY"
// ProviderJuke is the identifier for Juke.
ProviderJuke = "JUKE"
// ProviderBbc is the identifier for BBC.
ProviderBbc = "BBC"
// ProviderDarfm is the identifier for DAR.fm.
ProviderDarfm = "DARFM"
// Provider7Digital is the identifier for 7digital.
Provider7Digital = "7DIGITAL"
// ProviderSaavn is the identifier for Saavn.
ProviderSaavn = "SAAVN"
// ProviderRdio is the identifier for Rdio.
ProviderRdio = "RDIO"
// ProviderPhoneMusic is the identifier for Phone Music.
ProviderPhoneMusic = "PHONE_MUSIC"
// ProviderAlexa is the identifier for Amazon Alexa.
ProviderAlexa = "ALEXA"
// ProviderRadioplayer is the identifier for Radioplayer.
ProviderRadioplayer = "RADIOPLAYER"
// ProviderRadioDotCom is the identifier for Radio.com.
ProviderRadioDotCom = "RADIO.COM"
// ProviderRadioCom is the identifier for Radio.com (alternate).
ProviderRadioCom = "RADIO_COM"
// ProviderSiriusXmEverest is the identifier for SiriusXM Everest.
ProviderSiriusXmEverest = "SIRIUSXM_EVEREST"
// ProviderRadioBrowser is the identifier for Radio Browser.
ProviderRadioBrowser = "RADIO_BROWSER"
// ProviderBluetooth is the identifier for Bluetooth.
ProviderBluetooth = "BLUETOOTH"
// ProviderBmx is the identifier for BMX.
ProviderBmx = "BMX"
// ProviderNotification is the identifier for Notifications.
ProviderNotification = "NOTIFICATION"
// ProviderAuxIn is the identifier for AUX IN.
ProviderAuxIn = "AUX_IN"
)
const (
// PandoraProviderID is the provider identifier for Pandora.
PandoraProviderID = 1
// InternetRadioProviderID is the provider identifier for Internet Radio.
InternetRadioProviderID = 2
// OffProviderID is the provider identifier for Off.
OffProviderID = 3
// LocalProviderID is the provider identifier for Local.
LocalProviderID = 4
// AirplayProviderID is the provider identifier for AirPlay.
AirplayProviderID = 5
// CuratedRadioProviderID is the provider identifier for Curated Radio.
CuratedRadioProviderID = 6
// StoredMusicProviderID is the provider identifier for Stored Music.
StoredMusicProviderID = 7
// SlaveSourceProviderID is the provider identifier for Slave Source.
SlaveSourceProviderID = 8
// AuxProviderID is the provider identifier for Aux.
AuxProviderID = 9
// RecommendedInternetRadioProviderID is the provider identifier for Recommended Internet Radio.
RecommendedInternetRadioProviderID = 10
// LocalInternetRadioProviderID is the provider identifier for Local Internet Radio.
LocalInternetRadioProviderID = 11
// GlobalInternetRadioProviderID is the provider identifier for Global Internet Radio.
GlobalInternetRadioProviderID = 12
// HelloProviderID is the provider identifier for Hello.
HelloProviderID = 13
// DeezerProviderID is the provider identifier for Deezer.
DeezerProviderID = 14
// SpotifyProviderID is the provider identifier for Spotify.
SpotifyProviderID = 15
// IHeartProviderID is the provider identifier for iHeartRadio.
IHeartProviderID = 16
// SiriusXMProviderID is the provider identifier for SiriusXM.
SiriusXMProviderID = 17
// GooglePlayMusicProviderID is the provider identifier for Google Play Music.
GooglePlayMusicProviderID = 18
// QQMusicProviderID is the provider identifier for QQMusic.
QQMusicProviderID = 19
// AmazonProviderID is the provider identifier for Amazon Music.
AmazonProviderID = 20
// LocalMusicProviderID is the provider identifier for Local Music Library.
LocalMusicProviderID = 21
// WbmxProviderID is the provider identifier for WBMX.
WbmxProviderID = 22
// SoundcloudProviderID is the provider identifier for SoundCloud.
SoundcloudProviderID = 23
// TidalProviderID is the provider identifier for Tidal.
TidalProviderID = 24
// TuneinProviderID is the provider identifier for TuneIn Radio.
TuneinProviderID = 25
// QPlayProviderID is the provider identifier for QPlay.
QPlayProviderID = 26
// JukeProviderID is the provider identifier for Juke.
JukeProviderID = 27
// BbcProviderID is the provider identifier for BBC.
BbcProviderID = 28
// DarfmProviderID is the provider identifier for DAR.fm.
DarfmProviderID = 29
// SevenDigitalProviderID is the provider identifier for 7digital.
SevenDigitalProviderID = 30
// SaavnProviderID is the provider identifier for Saavn.
SaavnProviderID = 31
// RdioProviderID is the provider identifier for Rdio.
RdioProviderID = 32
// PhoneMusicProviderID is the provider identifier for Phone Music.
PhoneMusicProviderID = 33
// AlexaProviderID is the provider identifier for Amazon Alexa.
AlexaProviderID = 34
// RadioplayerProviderID is the provider identifier for Radioplayer.
RadioplayerProviderID = 35
// RadioDotComProviderID is the provider identifier for Radio.com.
RadioDotComProviderID = 36
// RadioComProviderID is the provider identifier for Radio.com (alternate).
RadioComProviderID = 37
// SiriusXmEverestProviderID is the provider identifier for SiriusXM Everest.
SiriusXmEverestProviderID = 38
// RadioBrowserProviderID is the provider identifier for Radio Browser.
RadioBrowserProviderID = 39
// BluetoothProviderID is the provider identifier for Bluetooth.
BluetoothProviderID = 40
// BmxProviderID is the provider identifier for BMX.
BmxProviderID = 41
// NotificationProviderID is the provider identifier for Notifications.
NotificationProviderID = 42
// AuxInProviderID is the provider identifier for AUX IN.
AuxInProviderID = 43
)
// StaticProviders lists known source provider identifiers with their metadata.
var StaticProviders = []SourceProvider{
{ID: PandoraProviderID, Name: ProviderPandora, Label: "Pandora", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: InternetRadioProviderID, Name: ProviderInternetRadio, Label: "Internet Radio", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: OffProviderID, Name: ProviderOff, Label: "Off", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
{ID: LocalProviderID, Name: ProviderLocal, Label: "Local", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: AirplayProviderID, Name: ProviderAirplay, Label: "AirPlay", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: CuratedRadioProviderID, Name: ProviderCuratedRadio, Label: "Curated Radio", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: StoredMusicProviderID, Name: ProviderStoredMusic, Label: "Stored Music", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: SlaveSourceProviderID, Name: ProviderSlaveSource, Label: "Slave Source", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: AuxProviderID, Name: ProviderAux, Label: "Aux", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: RecommendedInternetRadioProviderID, Name: ProviderRecommendedInternetRadio, Label: "Recommended Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: LocalInternetRadioProviderID, Name: ProviderLocalInternetRadio, Label: "Local Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: GlobalInternetRadioProviderID, Name: ProviderGlobalInternetRadio, Label: "Global Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: HelloProviderID, Name: ProviderHello, Label: "Hello", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
{ID: DeezerProviderID, Name: ProviderDeezer, Label: "Deezer", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: SpotifyProviderID, Name: ProviderSpotify, Label: "Spotify", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: IHeartProviderID, Name: ProviderIHeart, Label: "iHeartRadio", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: SiriusXMProviderID, Name: ProviderSiriusXM, Label: "SiriusXM", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: GooglePlayMusicProviderID, Name: ProviderGooglePlayMusic, Label: "Google Play Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: QQMusicProviderID, Name: ProviderQQMusic, Label: "QQMusic", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: AmazonProviderID, Name: ProviderAmazon, Label: "Amazon Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: LocalMusicProviderID, Name: ProviderLocalMusic, Label: "Local Music Library", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
{ID: WbmxProviderID, Name: ProviderWbmx, Label: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: SoundcloudProviderID, Name: ProviderSoundcloud, Label: "SoundCloud", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: TidalProviderID, Name: ProviderTidal, Label: "Tidal", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: TuneinProviderID, Name: ProviderTunein, Label: "TuneIn Radio", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: QPlayProviderID, Name: ProviderQPlay, Label: "QPlay", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
{ID: JukeProviderID, Name: ProviderJuke, Label: "Juke", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: BbcProviderID, Name: ProviderBbc, Label: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: DarfmProviderID, Name: ProviderDarfm, Label: "DAR.fm", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: SevenDigitalProviderID, Name: Provider7Digital, Label: "7digital", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: SaavnProviderID, Name: ProviderSaavn, Label: "Saavn", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: RdioProviderID, Name: ProviderRdio, Label: "Rdio", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: PhoneMusicProviderID, Name: ProviderPhoneMusic, Label: "Phone Music", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
{ID: AlexaProviderID, Name: ProviderAlexa, Label: "Amazon Alexa", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
{ID: RadioplayerProviderID, Name: ProviderRadioplayer, Label: "Radioplayer", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
{ID: RadioDotComProviderID, Name: ProviderRadioDotCom, Label: "Radio.com", CreatedOn: "2019-05-28T18:21:41.000+00:00", UpdatedOn: "2019-05-28T18:21:41.000+00:00"},
{ID: RadioComProviderID, Name: ProviderRadioCom, Label: "Radio.com", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
{ID: SiriusXmEverestProviderID, Name: ProviderSiriusXmEverest, Label: "SiriusXM Everest", CreatedOn: "2019-11-25T18:00:33.000+00:00", UpdatedOn: "2019-11-25T18:00:33.000+00:00"},
{ID: RadioBrowserProviderID, Name: ProviderRadioBrowser, Label: "Radio Browser", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
{ID: BluetoothProviderID, Name: ProviderBluetooth, Label: "Bluetooth", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: BmxProviderID, Name: ProviderBmx, Label: "BMX", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: NotificationProviderID, Name: ProviderNotification, Label: "Notifications", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: AuxInProviderID, Name: ProviderAuxIn, Label: "AUX IN", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
}
// GetSourceLabel returns a user-friendly label for a source type.
func GetSourceLabel(sourceType string) string {
for _, provider := range StaticProviders {
if provider.Name == sourceType {
return provider.Label
}
}
return sourceType
}
// GetProviderName returns the human-readable name for a provider ID.
func GetProviderName(providerID string) string {
id, err := strconv.Atoi(providerID)
if err != nil {
return providerID
}
for _, p := range StaticProviders {
if p.ID == id {
return p.Name
}
}
return providerID
}
// GetProviders returns a list of known source provider names.
func GetProviders() []string {
var providers []string
for _, p := range StaticProviders {
providers = append(providers, p.Name)
}
return providers
// Providers lists known source provider identifiers used by Bose SoundTouch.
var Providers = []string{
"PANDORA",
"INTERNET_RADIO",
"OFF",
"LOCAL",
"AIRPLAY",
"CURRATED_RADIO",
"STORED_MUSIC",
"SLAVE_SOURCE",
"AUX",
"RECOMMENDED_INTERNET_RADIO",
"LOCAL_INTERNET_RADIO",
"GLOBAL_INTERNET_RADIO",
"HELLO",
"DEEZER",
"SPOTIFY",
"IHEART",
"SIRIUSXM",
"GOOGLE_PLAY_MUSIC",
"QQMUSIC",
"AMAZON",
"LOCAL_MUSIC",
"WBMX",
"SOUNDCLOUD",
"TIDAL",
"TUNEIN",
"QPLAY",
"JUKE",
"BBC",
"DARFM",
"7DIGITAL",
"SAAVN",
"RDIO",
"PHONE_MUSIC",
"ALEXA",
"RADIOPLAYER",
"RADIO.COM",
"RADIO_COM",
"SIRIUSXM_EVEREST",
"RADIO_BROWSER",
}
// Common file and path constants used by the datastore and setup logic.
@@ -290,12 +60,4 @@ const (
// DateStr is the hardcoded date used in many Bose XML responses
DateStr = "2012-09-19T12:43:00.000+00:00"
// XMLHeader is the standard XML declaration for Bose SoundTouch responses
XMLHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
// CredentialTypeToken is the standard token credential type.
CredentialTypeToken = "token"
// CredentialTypeTokenV3 is the token version 3 credential type, used for Spotify.
CredentialTypeTokenV3 = "token_version_3"
)
+1 -1
View File
@@ -13,7 +13,7 @@ func TestConstants(t *testing.T) {
t.Errorf("Expected SpeakerHTTPPort 8090, got %d", SpeakerHTTPPort)
}
if len(GetProviders()) == 0 {
if len(Providers) == 0 {
t.Error("Providers should not be empty")
}
}
File diff suppressed because it is too large Load Diff
+5 -52
View File
@@ -1,7 +1,6 @@
package datastore
import (
"encoding/xml"
"os"
"path/filepath"
"testing"
@@ -93,37 +92,6 @@ func TestDataStore(t *testing.T) {
if ds.AccountDir(account) != expectedAccountDir {
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
}
// Test GetAccountInfo with placeholder
accInfo, err := ds.GetAccountInfo("non-existent")
if err != nil {
t.Errorf("GetAccountInfo failed: %v", err)
}
if !accInfo.IsPlaceholder {
t.Errorf("Expected IsPlaceholder to be true for non-existent account")
}
// Test SaveAccountInfo and GetAccountInfo
accountID := "acc-123"
info2 := &models.ServiceAccountInfo{
AccountID: accountID,
PreferredLanguage: "en",
}
err = ds.SaveAccountInfo(accountID, info2)
if err != nil {
t.Errorf("SaveAccountInfo failed: %v", err)
}
loadedAccInfo, err := ds.GetAccountInfo(accountID)
if err != nil {
t.Errorf("GetAccountInfo failed: %v", err)
}
if loadedAccInfo.PreferredLanguage != "en" {
t.Errorf("Expected language en, got %s", loadedAccInfo.PreferredLanguage)
}
if loadedAccInfo.IsPlaceholder {
t.Errorf("Expected IsPlaceholder to be false for existing account")
}
}
func TestListAllDevices_Empty(t *testing.T) {
@@ -370,27 +338,12 @@ func TestConfiguredSources(t *testing.T) {
t.Fatalf("Expected %d sources, got %d", len(sources), len(loadedSources))
}
for i := range sources {
for i, s := range sources {
ls := loadedSources[i]
expected := sources[i]
expected.Secret = ""
expected.SecretType = ""
expected.Type = ls.Type // Ignore Type mismatch in this test if it's auto-populated
// Clear secrets for comparison since they are not loaded by GetConfiguredSources
ls.Secret = ""
ls.SecretType = ""
if ls.DisplayName != expected.DisplayName || ls.ID != expected.ID || ls.Secret != expected.Secret ||
ls.SecretType != expected.SecretType || ls.SourceKeyType != expected.SourceKeyType ||
ls.SourceKeyAccount != expected.SourceKeyAccount || ls.Type != expected.Type {
// Clean XMLName for comparison
ls.XMLName = xml.Name{}
if ls.DisplayName != expected.DisplayName || ls.ID != expected.ID || ls.Secret != expected.Secret ||
ls.SecretType != expected.SecretType || ls.SourceKeyType != expected.SourceKeyType ||
ls.SourceKeyAccount != expected.SourceKeyAccount || ls.Type != expected.Type {
t.Errorf("Source %d mismatch. Expected %+v, got %+v", i, expected, ls)
}
if ls.DisplayName != s.DisplayName || ls.ID != s.ID || ls.Secret != s.Secret ||
ls.SecretType != s.SecretType || ls.SourceKeyType != s.SourceKeyType ||
ls.SourceKeyAccount != s.SourceKeyAccount {
t.Errorf("Source %d mismatch. Expected %+v, got %+v", i, s, ls)
}
}
-64
View File
@@ -1,64 +0,0 @@
package datastore
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveDeviceInfo_MergesName(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
// 1. Initial save with name
info1 := &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
Name: "Living Room",
ProductCode: "SoundTouch 20",
}
if err := ds.SaveDeviceInfo(account, device, info1); err != nil {
t.Fatalf("First SaveDeviceInfo failed: %v", err)
}
// 2. Verify name is saved
saved1, err := ds.GetDeviceInfo(account, device)
if err != nil {
t.Fatalf("First GetDeviceInfo failed: %v", err)
}
if saved1.Name != "Living Room" {
t.Errorf("Expected name 'Living Room', got '%s'", saved1.Name)
}
// 3. Save with empty name (simulating power_on)
info2 := &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
Name: "",
ProductCode: "SoundTouch 20",
IPAddress: "192.168.1.100",
}
if err := ds.SaveDeviceInfo(account, device, info2); err != nil {
t.Fatalf("Second SaveDeviceInfo failed: %v", err)
}
// 4. Verify name is preserved
saved2, err := ds.GetDeviceInfo(account, device)
if err != nil {
t.Fatalf("Second GetDeviceInfo failed: %v", err)
}
if saved2.Name != "Living Room" {
t.Errorf("Expected name 'Living Room' to be preserved, but got '%s'", saved2.Name)
}
if saved2.IPAddress != "192.168.1.100" {
t.Errorf("Expected IPAddress '192.168.1.100', got '%s'", saved2.IPAddress)
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ func TestDataStore_MacAddressMapping(t *testing.T) {
// Test mapping resolution in AccountDeviceDir
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedDir, _ := filepath.Abs(filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber))
expectedDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
if resolvedDir != expectedDir {
t.Errorf("expected dir %s, got %s", expectedDir, resolvedDir)
}
@@ -1,194 +0,0 @@
package datastore
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSavePresets_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-format-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "test-playlist",
Source: "SPOTIFY",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
SourceAccount: "test-user",
IsPresetable: "true",
},
ID: "1",
ButtonNumber: "1",
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
CreatedOn: "1719128436",
UpdatedOn: "1728740382",
},
}
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Fatalf("SavePresets failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Presets.xml: %v", err)
}
xmlContent := string(data)
// Check for correct id attribute
if !strings.Contains(xmlContent, `id="1"`) {
t.Errorf("Presets.xml missing correct id attribute, got: %s", xmlContent)
}
// Check that contentItemType is NOT present (as requested in previous issues)
if strings.Contains(xmlContent, "contentItemType") {
t.Errorf("Presets.xml should not contain contentItemType tag, got: %s", xmlContent)
}
// Verify unmarshaling still works
loadedPresets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets failed: %v", err)
}
if len(loadedPresets) != 1 {
t.Fatalf("Expected 1 preset, got %d", len(loadedPresets))
}
if loadedPresets[0].ID != "1" {
t.Errorf("Expected ID 1, got %s", loadedPresets[0].ID)
}
if loadedPresets[0].ContentItemType != "tracklisturl" {
t.Errorf("Expected ContentItemType to be tracklisturl, got %s", loadedPresets[0].ContentItemType)
}
}
func TestSavePresets_PreservesID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-id-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "test-acc"
device := "test-dev"
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 1",
},
ID: "1",
ButtonNumber: "1",
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 2",
},
ID: "2",
// ButtonNumber is empty, should fall back to ID
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 3",
},
ButtonNumber: "3",
// ID is empty, should use ButtonNumber
},
}
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Fatalf("SavePresets failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Presets.xml: %v", err)
}
xmlContent := string(data)
if !strings.Contains(xmlContent, `id="1"`) {
t.Errorf("Expected id=\"1\", got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `id="2"`) {
t.Errorf("Expected id=\"2\", got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `id="3"`) {
t.Errorf("Expected id=\"3\", got: %s", xmlContent)
}
// Now check if GetPresets loads them correctly
loaded, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets failed: %v", err)
}
if len(loaded) != 3 {
t.Fatalf("Expected 3 presets, got %d", len(loaded))
}
for i, p := range loaded {
expectedID := strconv.Itoa(i + 1)
if p.ID != expectedID {
t.Errorf("At index %d, expected ID %s, got %s", i, expectedID, p.ID)
}
if p.ButtonNumber != expectedID {
t.Errorf("At index %d, expected ButtonNumber %s, got %s", i, expectedID, p.ButtonNumber)
}
}
}
func TestPresetsXML_NoID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-noid-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "No ID Preset",
},
},
}
err = ds.SavePresets("acc", "dev", presets)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(ds.AccountDeviceDir("acc", "dev"), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `id=""`) {
t.Errorf("Expected empty id attribute, got: %s", string(data))
}
}
@@ -1,110 +0,0 @@
package datastore
import (
"encoding/xml"
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveRecents_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore_recents_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "test-account"
device := "test-device"
recents := []models.ServiceRecent{
{
ServiceContentItem: models.ServiceContentItem{
ID: "2567119953",
Name: "The National",
Source: "SPOTIFY",
ContentItemType: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C",
SourceAccount: "test-user",
IsPresetable: "true",
},
DeviceID: "001122334455",
UtcTime: "1771666755",
},
}
if err := ds.SaveRecents(account, device, recents); err != nil {
t.Fatalf("SaveRecents failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Recents.xml")
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Recents.xml: %v", err)
}
expectedXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent id="2567119953" deviceID="001122334455" utcTime="1771666755">
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C" sourceAccount="test-user" isPresetable="true">
<itemName>The National</itemName>
</contentItem>
</recent>
</recents>`
// Normalize whitespace for comparison by unmarshaling both
var expected, actual struct {
XMLName xml.Name `xml:"recents"`
Recents []struct {
ID string `xml:"id,attr"`
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
} `xml:"contentItem"`
} `xml:"recent"`
}
if err := xml.Unmarshal([]byte(expectedXML), &expected); err != nil {
t.Fatalf("Failed to unmarshal expected XML: %v", err)
}
if err := xml.Unmarshal(content, &actual); err != nil {
t.Fatalf("Failed to unmarshal actual XML: %v", err)
}
if len(actual.Recents) != 1 {
t.Fatalf("Expected 1 recent, got %d", len(actual.Recents))
}
r := actual.Recents[0]
if r.ID != "2567119953" || r.DeviceID != "001122334455" || r.UtcTime != "1771666755" {
t.Errorf("Attributes mismatch: %+v", r)
}
if r.ContentItem.ItemName != "The National" || r.ContentItem.Source != "SPOTIFY" {
t.Errorf("ContentItem mismatch: %+v", r)
}
// Now test Round-trip (GetRecents)
loadedRecents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("GetRecents failed: %v", err)
}
if len(loadedRecents) != 1 {
t.Fatalf("Expected 1 loaded recent, got %d", len(loadedRecents))
}
lr := loadedRecents[0]
if lr.ID != "2567119953" || lr.Name != "The National" || lr.Source != "SPOTIFY" || lr.SourceAccount != "test-user" {
t.Errorf("Loaded recent mismatch: %+v", lr)
}
}
@@ -1,87 +0,0 @@
package datastore
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestIsSafeIdentifier(t *testing.T) {
tests := []struct {
id string
expected bool
}{
{"abc", true},
{"ABC", true},
{"123", true},
{"abc_123", true},
{"abc-123", true},
{"abc.123", true},
{"00:11:22:33:44:55", true},
{"", false},
{"/", false},
{"\\", false},
{"..", false},
{"../etc/passwd", false},
{"/etc/passwd", false},
{"a/b", false},
{"a\\b", false},
{"a..b", false},
{"a b", false},
{"a!b", false},
{"a@b", false},
{"a#b", false},
{"a$b", false},
{"a%b", false},
{"a^b", false},
{"a&b", false},
{"a*b", false},
{"a(b", false},
{"a)b", false},
}
for _, test := range tests {
result := isSafeIdentifier(test.id)
if result != test.expected {
t.Errorf("isSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
}
}
}
func TestSaveDeviceInfo_Validation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "datastore-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ds := NewDataStore(tmpDir)
info := &models.ServiceDeviceInfo{DeviceID: "dev1"}
tests := []struct {
account string
device string
wantErr bool
errMsg string
}{
{"acc1", "dev1", false, ""},
{"", "dev1", true, "account ID cannot be empty"},
{"acc1", "", true, "device ID/name cannot be empty"},
{"acc/1", "dev1", true, "invalid account ID"},
{"acc1", "dev/1", true, "invalid device ID"},
{"acc..1", "dev1", true, "invalid account ID"},
{"acc1", "dev..1", true, "invalid device ID"},
}
for _, test := range tests {
err := ds.SaveDeviceInfo(test.account, test.device, info)
if (err != nil) != test.wantErr {
t.Errorf("SaveDeviceInfo(%q, %q) error = %v, wantErr %v", test.account, test.device, err, test.wantErr)
continue
}
if test.wantErr && err.Error() != test.errMsg {
t.Errorf("SaveDeviceInfo(%q, %q) error message = %q, want %q", test.account, test.device, err.Error(), test.errMsg)
}
}
}
@@ -1,168 +0,0 @@
package datastore
import (
"os"
"path/filepath"
"testing"
)
func TestGetConfiguredSources_DeduceIDs(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "test-account"
device := "test-device"
// Create recents with specific source IDs for provider IDs
// Let's create a manual Recents.xml and Presets.xml in the temp directory to simulate the state.
deviceDir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
recentsXML := `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent id="2184615630">
<contentItemType></contentItemType>
<createdOn>2017-02-07T11:22:00.000+00:00</createdOn>
<lastplayedat>2017-05-17T13:18:57.000+00:00</lastplayedat>
<location>52349</location>
<name>Lounge FM Digital</name>
<source id="9330201" type="Audio">
<createdOn>2015-03-11T19:12:38.000+00:00</createdOn>
<credential type="token"></credential>
<name>9330201</name>
<sourceproviderid>2</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
<updatedOn>2015-03-11T19:12:38.000+00:00</updatedOn>
<username></username>
</source>
<sourceid>9330201</sourceid>
<updatedOn>2017-05-17T17:18:58.000+00:00</updatedOn>
<username>Lounge FM Digital</username>
</recent>
</recents>`
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
t.Fatalf("Failed to write Recents.xml: %v", err)
}
// Now call GetConfiguredSources and expect it to have "9330201" for provider ID "2"
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
foundDeducted := false
for _, s := range sources {
if s.SourceProviderID == "2" {
if s.ID == "9330201" {
foundDeducted = true
} else {
t.Errorf("Expected source ID 9330201 for provider 2, got %s", s.ID)
}
}
}
if !foundDeducted {
t.Errorf("Did not find source with provider ID 2 and deducted ID 9330201")
}
}
func TestGetConfiguredSources_DeduceIDs_AllProviders(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-test-all-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "test-account"
device := "test-device"
deviceDir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
// 2: INTERNET_RADIO
// 9: AUX
// 11: LOCAL_INTERNET_RADIO
// 25: TUNEIN
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1">
<contentItem source="INTERNET_RADIO" sourceAccount="" isPresetable="true" type="station" itemName="Station 2">
<containerArt>http://example.com/art2.png</containerArt>
</contentItem>
<source id="ID2" type="Audio" sourceproviderid="2" />
<sourceid>ID2</sourceid>
</preset>
<preset id="2">
<contentItem source="AUX" sourceAccount="AUX" isPresetable="true" type="station" itemName="Station 9">
<containerArt>http://example.com/art9.png</containerArt>
</contentItem>
<source id="ID9" type="Audio" sourceproviderid="9" />
<sourceid>ID9</sourceid>
</preset>
<preset id="3">
<contentItem source="LOCAL_INTERNET_RADIO" sourceAccount="" isPresetable="true" type="station" itemName="Station 11">
<containerArt>http://example.com/art11.png</containerArt>
</contentItem>
<source id="ID11" type="Audio" sourceproviderid="11" />
<sourceid>ID11</sourceid>
</preset>
<preset id="4">
<contentItem source="TUNEIN" sourceAccount="" isPresetable="true" type="station" itemName="Station 25">
<containerArt>http://example.com/art25.png</containerArt>
</contentItem>
<source id="ID25" type="Audio" sourceproviderid="25" />
<sourceid>ID25</sourceid>
</preset>
</presets>`
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
t.Fatalf("Failed to write Presets.xml: %v", err)
}
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
expected := map[string]string{
"2": "ID2",
"9": "ID9",
"11": "ID11",
"25": "ID25",
}
found := make(map[string]bool)
for _, s := range sources {
if expID, ok := expected[s.SourceProviderID]; ok {
if s.ID != expID {
t.Errorf("Expected source ID %s for provider %s, got %s", expID, s.SourceProviderID, s.ID)
}
found[s.SourceProviderID] = true
} else if s.SourceKeyType == "AUX" && s.SourceProviderID == "" {
// Special case for AUX if it doesn't have provider ID 9 by default
if expID, ok := expected["9"]; ok {
if s.ID != expID {
t.Errorf("Expected source ID %s for AUX, got %s", expID, s.ID)
}
found["9"] = true
}
}
}
for pid := range expected {
if !found[pid] {
t.Errorf("Did not find source with provider ID %s", pid)
}
}
}
@@ -1,100 +0,0 @@
package datastore
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveSources_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-sources-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
sources := []models.ConfiguredSource{
{
DisplayName: "AUX IN",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "AUX", Account: "AUX"},
},
{
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "INTERNET_RADIO", Account: ""},
},
{
DisplayName: "user@example.com",
Secret: "dummy-token-spotify",
SecretType: "token_version_3",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "SPOTIFY", Account: "test-user"},
},
}
err = ds.SaveConfiguredSources(account, device, sources)
if err != nil {
t.Fatalf("SaveConfiguredSources failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Sources.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Sources.xml: %v", err)
}
xmlContent := string(data)
// Check for correct attributes in first source
if !strings.Contains(xmlContent, `<source displayName="AUX IN" secret="" secretType="">`) {
t.Errorf("First source missing expected attributes. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX" />`) &&
!strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX"></sourceKey>`) {
t.Errorf("First sourceKey incorrect. Got: %s", xmlContent)
}
// Check for credential element (new format)
if !strings.Contains(xmlContent, `<credential type="token_version_3">dummy-token-spotify</credential>`) {
t.Errorf("Spotify source missing <credential> element. Got: %s", xmlContent)
}
// Check for third source (Spotify)
if !strings.Contains(xmlContent, `displayName="user@example.com"`) {
t.Errorf("Spotify source missing displayName. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `secret="dummy-token-spotify" secretType="token_version_3">`) {
t.Errorf("Spotify source missing secret. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user" />`) &&
!strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user"></sourceKey>`) {
t.Errorf("Spotify sourceKey incorrect. Got: %s", xmlContent)
}
// Negative checks for extra tags
if strings.Contains(xmlContent, "<sourcename>") {
t.Errorf("Sources.xml should not contain <sourcename> tag")
}
if strings.Contains(xmlContent, "<username>") {
t.Errorf("Sources.xml should not contain <username> tag")
}
if strings.Contains(xmlContent, "<name>") {
t.Errorf("Sources.xml should not contain <name> tag")
}
if strings.Contains(xmlContent, "<sourceSettings>") {
t.Errorf("Sources.xml should not contain <sourceSettings> tag")
}
}
@@ -220,8 +220,8 @@ func TestUPnPDiscoveryToDatastoreMapping_FullFlow(t *testing.T) {
{
name: "InvalidMAC",
requestMAC: "INVALID123456",
shouldWork: true, // Changed: GetPresets now returns empty list instead of error if file missing
description: "Invalid MAC (should return empty list)",
shouldWork: false,
description: "Invalid MAC (should fail)",
},
}
@@ -234,11 +234,7 @@ func TestUPnPDiscoveryToDatastoreMapping_FullFlow(t *testing.T) {
if err != nil {
t.Errorf("%s failed: %v", tc.description, err)
} else if len(presets) == 0 {
if tc.name != "InvalidMAC" {
t.Errorf("%s: no presets returned", tc.description)
} else {
t.Logf("✓ %s: Successfully retrieved empty presets list", tc.description)
}
t.Errorf("%s: no presets returned", tc.description)
} else {
t.Logf("✓ %s: Successfully retrieved %d presets", tc.description, len(presets))
-107
View File
@@ -1,107 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestHandleBMXRegistry_DNSDependent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "bmx-registry-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
localURL := "https://soundtouch.local"
server := NewServer(ds, nil, localURL, false, false, false)
t.Run("DNSEnabled_UsesBoseURL", func(t *testing.T) {
server.SetDNSSettings(true, "8.8.8.8", ":5353")
req := httptest.NewRequest("GET", "/bmx/v1/services", nil)
w := httptest.NewRecorder()
server.HandleBMXRegistry(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
services := resp["bmx_services"].([]interface{})
foundTuneIn := false
for _, s := range services {
service := s.(map[string]interface{})
if service["id"].(map[string]interface{})["name"] == "TUNEIN" {
foundTuneIn = true
baseURL := service["baseUrl"].(string)
if baseURL != "https://content.api.bose.io/bmx/tunein" {
t.Errorf("Expected baseUrl https://content.api.bose.io/bmx/tunein, got %s", baseURL)
}
// Check assets (MEDIA_SERVER) - should still be local
assets := service["assets"].(map[string]interface{})
icons := assets["icons"].(map[string]interface{})
for k, v := range icons {
iconURL := v.(string)
if strings.HasPrefix(iconURL, "{") {
t.Errorf("Icon %s still has placeholder: %s", k, iconURL)
}
if !strings.HasPrefix(iconURL, localURL+"/media/bmx-icons/tunein") {
t.Errorf("Icon %s should point to local media server bmx-icons subdirectory, got %s", k, iconURL)
}
}
}
}
if !foundTuneIn {
t.Error("TuneIn service not found in registry")
}
})
t.Run("DNSDisabled_UsesLocalURL", func(t *testing.T) {
server.SetDNSSettings(false, "", "")
req := httptest.NewRequest("GET", "/bmx/v1/services", nil)
w := httptest.NewRecorder()
server.HandleBMXRegistry(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
services := resp["bmx_services"].([]interface{})
foundTuneIn := false
for _, s := range services {
service := s.(map[string]interface{})
if service["id"].(map[string]interface{})["name"] == "TUNEIN" {
foundTuneIn = true
baseURL := service["baseUrl"].(string)
if baseURL != localURL+"/bmx/tunein" {
t.Errorf("Expected baseUrl %s/bmx/tunein, got %s", localURL, baseURL)
}
}
}
if !foundTuneIn {
t.Error("TuneIn service not found in registry")
}
})
}
-176
View File
@@ -1,176 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
)
func TestSpotifyBridge(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// Mock Speaker (LISA API)
var speakerReceived atomic.Bool
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/setMusicServiceOAuthAccount" {
speakerReceived.Store(true)
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceOAuthAccount</status>`))
}
}))
defer speakerTS.Close()
// Register the speaker in the datastore so the bridge finds it
devInfo := &models.ServiceDeviceInfo{
DeviceID: "DEV123",
AccountID: "acc123",
Name: "Test Speaker",
IPAddress: strings.TrimPrefix(speakerTS.URL, "http://"),
}
_ = ds.SaveDeviceInfo("acc123", "DEV123", devInfo)
// Ensure the directory structure exists for marge.AddSource
_ = os.MkdirAll(ds.AccountDevicesDir("acc123"), 0755)
_ = os.MkdirAll(filepath.Join(ds.AccountDevicesDir("acc123"), "DEV123"), 0755)
// Mock Spotify response
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "access-123",
"refresh_token": "refresh-123",
"expires_in": 3600,
})
case "/me":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": "spotify-user",
"display_name": "Spotify User",
"email": "user@example.com",
})
}
}))
defer ts.Close()
// Initialize Spotify service
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
ss.SetEndpoints(ts.URL+"/token", ts.URL)
server.SetSpotifyService(ss)
r := chi.NewRouter()
r.Get("/mgmt/spotify/callback", server.HandleMgmtSpotifyCallback)
// Trigger the callback
req := httptest.NewRequest("GET", "/mgmt/spotify/callback?code=fake-code&account=acc123", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
// 1. Verify Marge registration
// We need to check if the source was added to the datastore
foundInMarge := false
sources, err := ds.GetConfiguredSources("acc123", "DEV123")
if err == nil {
for _, src := range sources {
t.Logf(" Found source: %s (User: %s)", src.SourceKey.Type, src.Username)
if (src.Username == "spotify-user" || src.SourceKey.Account == "spotify-user") &&
(src.SourceProviderID == "15" || src.SourceKey.Type == "SPOTIFY") {
foundInMarge = true
break
}
}
}
if !foundInMarge {
// Log what we found to debug
allDevices, _ := ds.ListAllDevices()
t.Logf("Total devices in datastore: %d", len(allDevices))
for _, d := range allDevices {
t.Logf("Device: %s (Account: %s)", d.DeviceID, d.AccountID)
s, _ := ds.GetConfiguredSources(d.AccountID, d.DeviceID)
t.Logf(" Sources: %d", len(s))
}
t.Errorf("Spotify user not found in Marge configured sources")
}
// 2. Verify Speaker notification (LISA API)
// Using time.Sleep for simplicity in this test
// Wait up to 1 second
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) && !speakerReceived.Load() {
time.Sleep(50 * time.Millisecond)
}
if !speakerReceived.Load() {
t.Errorf("Speaker did not receive /setMusicServiceOAuthAccount notification")
}
// 3. Verify Token Refresh via Surrogate
// Now simulate the speaker asking for a fresh token using the surrogate secret it received.
// We need to find the surrogate first.
sources, _ = ds.GetConfiguredSources("acc123", "DEV123")
var surrogate string
for _, src := range sources {
if src.SourceKey.Type == "SPOTIFY" {
surrogate = src.Secret
break
}
}
if surrogate == "" {
t.Fatal("Could not find surrogate token in Marge sources")
}
if !strings.HasPrefix(surrogate, "bs-") || len(surrogate) != 35 {
t.Errorf("Expected surrogate to have 'bs-' prefix and be 35 chars, got %s", surrogate)
}
// Request refresh
refreshReqBody := map[string]string{
"grant_type": "refresh_token",
"refresh_token": surrogate,
}
body, err := json.Marshal(refreshReqBody)
if err != nil {
t.Fatalf("Failed to marshal refresh request: %v", err)
}
refreshReq := httptest.NewRequest("POST", "/oauth/device/DEV123/music/musicprovider/15/token/cs3", strings.NewReader(string(body)))
refreshW := httptest.NewRecorder()
// Need to register the route for testing
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.ServeHTTP(refreshW, refreshReq)
if refreshW.Code != http.StatusOK {
t.Fatalf("Token refresh failed: %d: %s", refreshW.Code, refreshW.Body.String())
}
var refreshResp map[string]interface{}
if err := json.Unmarshal(refreshW.Body.Bytes(), &refreshResp); err != nil {
t.Fatalf("Failed to parse refresh response: %v", err)
}
if refreshResp["access_token"] != "access-123" {
t.Errorf("Expected access_token 'access-123', got '%v'", refreshResp["access_token"])
}
}
@@ -0,0 +1,425 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
func TestComprehensiveMigration_MultipleExistingDevices(t *testing.T) {
// This test simulates the real-world scenario where a device has been discovered
// and saved under multiple identifiers over time, and now needs to be consolidated
// into a single MAC-based identifier.
tempDir, err := os.MkdirTemp("", "comprehensive-migration-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
accountID := "3230304"
// Scenario: Same device has been saved under different identifiers:
// 1. Initially discovered by IP address
// 2. Later discovered with UPnP serial
// 3. Later discovered with device component serial
// Create device entry #1: Saved by IP address (early discovery)
ipDeviceID := "192.168.1.100"
ipInfo := &models.ServiceDeviceInfo{
DeviceID: ipDeviceID,
AccountID: accountID,
Name: "Unknown Device", // Generic name from early discovery
IPAddress: ipDeviceID,
ProductCode: "Unknown",
FirmwareVersion: "0.0.0",
DiscoveryMethod: "UPnP",
}
if err := ds.SaveDeviceInfo(accountID, ipDeviceID, ipInfo); err != nil {
t.Fatalf("Failed to save IP-based device: %v", err)
}
// Save some presets for the IP-based device
testPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Source: "SPOTIFY",
Location: "spotify://playlist/test1",
Name: "Test Playlist 1",
},
CreatedOn: "2024-01-01T00:00:00Z",
UpdatedOn: "2024-01-01T00:00:00Z",
},
}
if err := ds.SavePresets(accountID, ipDeviceID, testPresets); err != nil {
t.Fatalf("Failed to save presets for IP device: %v", err)
}
// Create device entry #2: Saved by component serial (later discovery with better info)
serialDeviceID := "I6332527703739342000020"
serialInfo := &models.ServiceDeviceInfo{
DeviceID: serialDeviceID,
AccountID: accountID,
Name: "Sound Machinechen", // Real name from /info
IPAddress: "192.168.1.100", // Same IP as before
DeviceSerialNumber: serialDeviceID,
ProductCode: "SoundTouch 10",
FirmwareVersion: "27.0.6.46330.5043500",
ProductSerialNumber: "069231P63364828AE",
DiscoveryMethod: "UPnP",
}
if err := ds.SaveDeviceInfo(accountID, serialDeviceID, serialInfo); err != nil {
t.Fatalf("Failed to save serial-based device: %v", err)
}
// Save different presets for the serial-based device (user might have configured both thinking they're different)
serialPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "2",
Source: "SPOTIFY",
Location: "spotify://playlist/test2",
Name: "Test Playlist 2",
},
CreatedOn: "2024-01-02T00:00:00Z",
UpdatedOn: "2024-01-02T00:00:00Z",
},
}
if err := ds.SavePresets(accountID, serialDeviceID, serialPresets); err != nil {
t.Fatalf("Failed to save presets for serial device: %v", err)
}
// Create device entry #3: Saved by UPnP serial (yet another discovery)
upnpDeviceID := "UPnP789XYZ"
upnpInfo := &models.ServiceDeviceInfo{
DeviceID: upnpDeviceID,
AccountID: accountID,
Name: "SoundTouch Device", // Generic UPnP name
IPAddress: "192.168.1.100", // Same IP again
ProductCode: "SoundTouch 10 sm2",
FirmwareVersion: "27.0.6.46330.5043500", // Same firmware as serial device
DiscoveryMethod: "UPnP",
}
if err := ds.SaveDeviceInfo(accountID, upnpDeviceID, upnpInfo); err != nil {
t.Fatalf("Failed to save UPnP-based device: %v", err)
}
t.Logf("Test setup complete:")
t.Logf(" Device #1: %s (IP-based, early discovery)", ipDeviceID)
t.Logf(" Device #2: %s (serial-based, better info)", serialDeviceID)
t.Logf(" Device #3: %s (UPnP-based, latest discovery)", upnpDeviceID)
// Now simulate the device being rediscovered with /info endpoint working
deviceInfoXML := `<info deviceID="A81B6A536A98">
<name>Sound Machinechen</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
<serialNumber>I6332527703739342000020</serialNumber>
</component>
<component>
<componentCategory>PackagedProduct</componentCategory>
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
<serialNumber>069231P63364828AE</serialNumber>
</component>
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
</info>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, deviceInfoXML)
} else {
http.NotFound(w, r)
}
}))
defer server.Close()
deviceIP := server.URL[len("http://"):]
sm := setup.NewManager(server.URL, ds, nil)
srv := NewServer(ds, sm, server.URL, false, false, false, false, false)
// Simulate device rediscovery
discoveredDevice := models.DiscoveredDevice{
Host: deviceIP,
Name: "Generic Discovery Name",
ModelID: "SoundTouch",
SerialNo: "UPnP789XYZ", // This should match one of the existing devices
DiscoveryMethod: "UPnP",
}
t.Logf("\nSimulating comprehensive device rediscovery...")
t.Logf(" Discovery IP: %s", deviceIP)
t.Logf(" Discovery Serial: %s", discoveredDevice.SerialNo)
// Handle discovered device - should find and migrate all existing variants
srv.handleDiscoveredDevice(discoveredDevice)
// Verify the device now exists under the MAC address
expectedDeviceID := "A81B6A536A98"
migratedInfo, err := ds.GetDeviceInfo(accountID, expectedDeviceID)
if err != nil {
t.Fatalf("Failed to get migrated device info: %v", err)
}
// Verify the migrated device has the correct information
if migratedInfo.DeviceID != expectedDeviceID {
t.Errorf("Expected deviceID '%s', got '%s'", expectedDeviceID, migratedInfo.DeviceID)
}
if migratedInfo.Name != "Sound Machinechen" {
t.Errorf("Expected name 'Sound Machinechen' (from /info), got '%s'", migratedInfo.Name)
}
if migratedInfo.MacAddress != "A81B6A536A98" {
t.Errorf("Expected MAC 'A81B6A536A98', got '%s'", migratedInfo.MacAddress)
}
if migratedInfo.DeviceSerialNumber != "I6332527703739342000020" {
t.Errorf("Expected device serial 'I6332527703739342000020', got '%s'", migratedInfo.DeviceSerialNumber)
}
t.Logf("\nMigration completed successfully:")
t.Logf(" New device ID: %s (MAC address)", migratedInfo.DeviceID)
t.Logf(" Device name: %s", migratedInfo.Name)
t.Logf(" Device serial: %s", migratedInfo.DeviceSerialNumber)
t.Logf(" Product serial: %s", migratedInfo.ProductSerialNumber)
t.Logf(" MAC address: %s", migratedInfo.MacAddress)
// Verify MAC address resolution works
if err := ds.Initialize(); err != nil {
t.Fatalf("Failed to initialize datastore: %v", err)
}
resolvedDir := ds.AccountDeviceDir(accountID, "A81B6A536A98")
expectedDir := ds.AccountDeviceDir(accountID, expectedDeviceID)
if resolvedDir != expectedDir {
t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedDir, resolvedDir)
}
t.Logf("\nMAC address resolution verified:")
t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory")
// Note: In a complete implementation, we'd also verify that presets from all
// the old devices were consolidated, but that requires more sophisticated
// preset merging logic which is beyond the current migration scope.
t.Logf("\n✅ Comprehensive migration test completed successfully!")
}
func TestFindAllExistingDeviceVariants_MatchingCriteria(t *testing.T) {
tempDir, err := os.MkdirTemp("", "variants-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
accountID := "testaccount"
// Create devices that should match various criteria
devices := []models.ServiceDeviceInfo{
{
DeviceID: "192.168.1.100",
AccountID: accountID,
Name: "IP Device",
IPAddress: "192.168.1.100",
DeviceSerialNumber: "SERIAL123",
MacAddress: "AA:BB:CC:DD:EE:FF",
},
{
DeviceID: "SERIAL123",
AccountID: accountID,
Name: "Sound Speaker",
IPAddress: "192.168.1.101", // Different IP
DeviceSerialNumber: "SERIAL123",
MacAddress: "AA:BB:CC:DD:EE:FF",
},
{
DeviceID: "UPnPSerial456",
AccountID: accountID,
Name: "Sound Speaker",
IPAddress: "192.168.1.102", // Different IP again
ProductCode: "SoundTouch 10 sm2",
},
{
DeviceID: "UnrelatedDevice",
AccountID: accountID,
Name: "Other Device",
IPAddress: "192.168.1.200",
DeviceSerialNumber: "OTHERSSERIAL",
},
}
for _, device := range devices {
if err := ds.SaveDeviceInfo(accountID, device.DeviceID, &device); err != nil {
t.Fatalf("Failed to save device %s: %v", device.DeviceID, err)
}
}
// Create mock discovery and live info
discovery := models.DiscoveredDevice{
Host: "192.168.1.100", // Matches first device by IP
SerialNo: "UPnPSerial456", // Matches third device by UPnP serial
}
liveInfo := &setup.DeviceInfoXML{
DeviceID: "AABBCCDDEEFF", // New MAC-based ID
Name: "Sound Speaker", // Matches second and third devices by name
Type: "SoundTouch 10",
ModuleType: "sm2",
SerialNumber: "SERIAL123", // Matches first and second devices by serial
NetworkInfo: []struct {
Type string `xml:"type,attr"`
MacAddress string `xml:"macAddress"`
IPAddress string `xml:"ipAddress"`
}{
{Type: "SCM", MacAddress: "AA:BB:CC:DD:EE:FF", IPAddress: "192.168.1.100"},
},
}
// Test the matching logic
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
t.Logf("Found %d matching device variants:", len(matches))
for i, match := range matches {
t.Logf(" %d. %s (IP: %s, Serial: %s, MAC: %s, Name: %s)",
i+1, match.DeviceID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.Name)
}
// Verify expected matches
expectedMatches := map[string]string{
"192.168.1.100": "IP match",
"SERIAL123": "Serial match",
"UPnPSerial456": "UPnP serial match",
}
if len(matches) != len(expectedMatches) {
t.Errorf("Expected %d matches, got %d", len(expectedMatches), len(matches))
}
foundMatches := make(map[string]bool)
for _, match := range matches {
foundMatches[match.DeviceID] = true
}
for expectedID, reason := range expectedMatches {
if !foundMatches[expectedID] {
t.Errorf("Expected to find device %s (%s), but it was not matched", expectedID, reason)
}
}
// Verify UnrelatedDevice is NOT matched
if foundMatches["UnrelatedDevice"] {
t.Error("UnrelatedDevice should not have been matched, but it was")
}
t.Logf("✅ Device variant matching test completed successfully!")
}
func TestMigration_EdgeCases(t *testing.T) {
tempDir, err := os.MkdirTemp("", "migration-edge-cases-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
accountID := "testaccount"
t.Run("NoExistingDevices", func(t *testing.T) {
discovery := models.DiscoveredDevice{Host: "192.168.1.200"}
liveInfo := &setup.DeviceInfoXML{DeviceID: "NEWMAC123"}
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
if len(matches) != 0 {
t.Errorf("Expected 0 matches for new device, got %d", len(matches))
}
})
t.Run("SelfMatch", func(t *testing.T) {
// Device already exists with MAC as deviceID
macDeviceID := "AABBCCDDEEFF"
existing := &models.ServiceDeviceInfo{
DeviceID: macDeviceID,
AccountID: accountID,
Name: "Existing MAC Device",
IPAddress: "192.168.1.150",
}
if err := ds.SaveDeviceInfo(accountID, macDeviceID, existing); err != nil {
t.Fatalf("Failed to save MAC device: %v", err)
}
discovery := models.DiscoveredDevice{Host: "192.168.1.150"}
liveInfo := &setup.DeviceInfoXML{DeviceID: macDeviceID} // Same MAC
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
// Should find itself, but migration logic should skip it since deviceID matches
found := false
for _, match := range matches {
if match.DeviceID == macDeviceID {
found = true
break
}
}
if !found {
t.Error("Device should find itself in variants")
}
})
t.Run("PartialMatches", func(t *testing.T) {
// Device with some matching criteria but not others
partialDevice := &models.ServiceDeviceInfo{
DeviceID: "PARTIAL123",
AccountID: accountID,
Name: "Partial Device",
IPAddress: "192.168.1.160", // Different IP
// No serial number, no MAC
}
if err := ds.SaveDeviceInfo(accountID, "PARTIAL123", partialDevice); err != nil {
t.Fatalf("Failed to save partial device: %v", err)
}
discovery := models.DiscoveredDevice{Host: "192.168.1.170"} // Different IP
liveInfo := &setup.DeviceInfoXML{
DeviceID: "NEWMAC456",
Name: "Partial Device", // Same name
Type: "SoundTouch 20",
}
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
// Should match by name and product type
found := false
for _, match := range matches {
if match.DeviceID == "PARTIAL123" {
found = true
break
}
}
if !found {
t.Error("Should match device by name and product type")
}
})
}
+254
View File
@@ -0,0 +1,254 @@
package handlers
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestDeviceMigration_DirectoryRename(t *testing.T) {
tempDir, err := os.MkdirTemp("", "migration-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
accountID := "test-account"
macAddress := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
// Create serial-based device entry with full data (simulates legacy directory)
serialDeviceInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Living Room Speaker",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 30",
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
t.Fatalf("Failed to save serial-based device: %v", err)
}
// Create some preset data in the serial-based directory
serialPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "My Preset",
Source: "SPOTIFY",
},
},
}
if err := ds.SavePresets(accountID, serialNumber, serialPresets); err != nil {
t.Fatalf("Failed to save presets: %v", err)
}
// Verify initial state - serial directory exists
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
if _, err := os.Stat(serialDir); os.IsNotExist(err) {
t.Fatalf("Serial directory should exist before migration: %s", serialDir)
}
// Perform migration using migration manager
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
// Verify migration results
t.Run("VerifyMigration", func(t *testing.T) {
// 1. MAC directory should exist with files
macDir := ds.AccountDeviceDir(accountID, macAddress)
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
if _, err := os.Stat(macDir); os.IsNotExist(err) {
t.Errorf("MAC directory should exist after migration: %s", macDir)
}
// 2. Serial directory should be gone
if _, err := os.Stat(serialDir); !os.IsNotExist(err) {
t.Errorf("Serial directory should not exist after migration: %s", serialDir)
}
// 3. Simulate SaveDeviceInfo with fresh data (like real discovery flow)
// This overwrites DeviceInfo.xml with correct MAC-based deviceID
freshDeviceInfo := &models.ServiceDeviceInfo{
DeviceID: macAddress,
AccountID: accountID,
Name: "Sound Speaker Fresh",
IPAddress: "192.168.1.100",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 10 sm2",
FirmwareVersion: "3.4.6.2356",
ProductSerialNumber: "069231P63364828AE",
DiscoveryMethod: "Migration Test",
}
if err := ds.SaveDeviceInfo(accountID, macAddress, freshDeviceInfo); err != nil {
t.Errorf("Failed to save fresh device info: %v", err)
}
// 4. Device info should now have correct MAC-based deviceID
macInfo, err := ds.GetDeviceInfo(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get device info with MAC ID: %v", err)
} else if macInfo.DeviceID != macAddress {
t.Errorf("DeviceID should be updated to MAC address, got %s", macInfo.DeviceID)
}
// 5. All data should be accessible through MAC address
presets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get presets through MAC address: %v", err)
} else if len(presets) != 1 || presets[0].Name != "My Preset" {
t.Errorf("Presets should be preserved during migration")
}
t.Logf("✓ Device directory migration working correctly")
})
}
func TestDeviceMigration_NoExistingTarget(t *testing.T) {
tempDir, err := os.MkdirTemp("", "no-target-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
accountID := "test-account"
macAddress := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
// Create only serial-based device entry (no existing MAC directory)
serialDeviceInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Test Speaker",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 30",
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
t.Fatalf("Failed to save serial device: %v", err)
}
// Add some data files
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Preset",
Source: "SPOTIFY",
},
},
}
if err := ds.SavePresets(accountID, serialNumber, presets); err != nil {
t.Fatalf("Failed to save presets: %v", err)
}
// Verify MAC directory doesn't exist initially
macDir := ds.AccountDeviceDir(accountID, macAddress)
if _, err := os.Stat(macDir); !os.IsNotExist(err) {
t.Fatalf("MAC directory should not exist initially: %s", macDir)
}
// Migrate directory using migration manager
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
// Verify migration
if _, err := os.Stat(macDir); os.IsNotExist(err) {
t.Errorf("MAC directory should exist after migration: %s", macDir)
}
// Verify data is accessible
migratedPresets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to access presets after migration: %v", err)
} else if len(migratedPresets) != 1 || migratedPresets[0].Name != "Test Preset" {
t.Errorf("Presets should be preserved in migration")
}
t.Log("✓ Simple directory migration working correctly")
}
func TestDeviceMigration_ExistingTargetRemoved(t *testing.T) {
tempDir, err := os.MkdirTemp("", "existing-target-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
accountID := "test-account"
macAddress := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
// Create both directories (serial has rich data, MAC has minimal data)
serialInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Rich Data Device",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
}
macInfo := &models.ServiceDeviceInfo{
DeviceID: macAddress,
AccountID: accountID,
Name: "Minimal Data Device",
MacAddress: macAddress,
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialInfo); err != nil {
t.Fatalf("Failed to save serial device: %v", err)
}
if err := ds.SaveDeviceInfo(accountID, macAddress, macInfo); err != nil {
t.Fatalf("Failed to save MAC device: %v", err)
}
// Add rich data to serial directory
richPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Rich Preset",
Source: "SPOTIFY",
},
},
}
if err := ds.SavePresets(accountID, serialNumber, richPresets); err != nil {
t.Fatalf("Failed to save rich presets: %v", err)
}
// Migrate - should replace MAC directory with serial directory content
existingDevices := []models.ServiceDeviceInfo{*serialInfo}
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
// Verify the rich data is now accessible via MAC address
finalInfo, err := ds.GetDeviceInfo(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get device info after migration: %v", err)
} else if finalInfo.Name != "Rich Data Device" {
t.Errorf("Should have rich device data, got name: %s", finalInfo.Name)
}
finalPresets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get rich presets after migration: %v", err)
} else if len(finalPresets) != 1 || finalPresets[0].Name != "Rich Preset" {
t.Errorf("Should have rich presets after migration")
}
t.Log("✓ Migration correctly replaces existing target with richer source")
}
@@ -1,437 +0,0 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/go-chi/chi/v5"
)
// validatePathID ensures that an identifier is safe to use as a single path component.
func validatePathID(id string) bool {
if id == "" {
return false
}
if strings.Contains(id, "/") || strings.Contains(id, "\\") {
return false
}
if strings.Contains(id, "..") {
return false
}
return true
}
// HandleMgmtAccountDetails returns full details for an account for the Web UI.
func (s *Server) HandleMgmtAccountDetails(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
if !validatePathID(accountID) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
// 1. Get account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
log.Printf("[Mgmt] Failed to get account info for %s: %v", accountID, err)
accountInfo = &models.ServiceAccountInfo{AccountID: accountID}
}
// Enrich provider settings with names
for i := range accountInfo.ProviderSettings {
s := &accountInfo.ProviderSettings[i]
if s.ProviderName == "" {
s.ProviderName = constants.GetProviderName(s.ProviderID)
}
}
// 2. List all devices for this account
allDevices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Mgmt] Failed to list devices: %v", err)
}
accountDevices := make([]deviceDetail, 0)
for i := range allDevices {
d := &allDevices[i]
if d.AccountID != accountID {
continue
}
detail := s.getDeviceDetail(accountID, d)
accountDevices = append(accountDevices, detail)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"account": accountInfo,
"devices": accountDevices,
}); err != nil {
log.Printf("[Mgmt] Failed to encode account details: %v", err)
}
}
// HandleMgmtUpdateAccountLanguage updates the preferred language for an account.
func (s *Server) HandleMgmtUpdateAccountLanguage(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
if !validatePathID(accountID) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
var req struct {
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Language != "en" && req.Language != "de" {
http.Error(w, "Language must be 'en' or 'de'", http.StatusBadRequest)
return
}
// 1. Load current account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 2. Update language
accountInfo.AccountID = accountID // Ensure ID is correct
accountInfo.PreferredLanguage = req.Language
accountInfo.IsPlaceholder = false
// 3. Save account info
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleMgmtUpdateAccountProviderSetting updates a specific provider setting for an account.
func (s *Server) HandleMgmtUpdateAccountProviderSetting(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
if !validatePathID(accountID) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
var req struct {
ProviderID string `json:"provider_id"`
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.ProviderID == "" || req.Key == "" {
http.Error(w, "provider_id and key are required", http.StatusBadRequest)
return
}
// 1. Load current account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 2. Update the specific setting
found := false
for i, setting := range accountInfo.ProviderSettings {
if setting.ProviderID == req.ProviderID && setting.KeyName == req.Key {
accountInfo.ProviderSettings[i].Value = req.Value
found = true
break
}
}
if !found {
// If not found, we can choose to add it or return error.
// For now, let's return an error as we expect to edit existing ones.
http.Error(w, "Provider setting not found", http.StatusNotFound)
return
}
accountInfo.AccountID = accountID // Ensure ID is correct
accountInfo.IsPlaceholder = false
// 3. Save account info
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
type deviceDetail struct {
models.AccountDevice
Presets []models.FullResponsePreset `json:"presets,omitempty"`
Recents []models.FullResponseRecent `json:"recents,omitempty"`
Sources []models.FullResponseSource `json:"sources,omitempty"`
Components []models.ServiceComponent `json:"components,omitempty"`
}
func (s *Server) getDeviceDetail(accountID string, d *models.ServiceDeviceInfo) deviceDetail {
detail := deviceDetail{
AccountDevice: models.AccountDevice{
DeviceID: d.DeviceID,
FirmwareVersion: d.FirmwareVersion,
IPAddress: d.IPAddress,
Name: d.Name,
ProductCode: d.ProductCode,
SerialNumber: d.DeviceSerialNumber,
DeviceSerialNumber: d.DeviceSerialNumber,
MacAddress: d.MacAddress,
DiscoveryMethod: d.DiscoveryMethod,
},
}
// We also have AttachedProduct which has Components
detail.AttachedProduct = &models.AttachedProduct{
SerialNumber: d.DeviceSerialNumber,
ProductCode: d.ProductCode,
ProductLabel: d.Name,
Components: d.Components,
}
detail.Components = d.Components
// Fetch sources
var configuredSources []models.ConfiguredSource
sources, err := s.ds.GetConfiguredSources(accountID, d.DeviceID)
if err == nil {
configuredSources = sources
for j := range sources {
fs := mapToFullResponseSource(&sources[j])
if fs.Type == "" && fs.Name == "" && fs.DisplayName == "" {
log.Printf("[Mgmt] Skipping empty source for device %s", d.DeviceID)
continue
}
detail.Sources = append(detail.Sources, fs)
}
}
// Fetch presets
if presets, err := s.ds.GetPresets(accountID, d.DeviceID); err == nil {
for j := range presets {
detail.Presets = append(detail.Presets, mapToFullResponsePreset(&presets[j], configuredSources))
}
}
detail.AccountDevice.Presets = detail.Presets
// Fetch recents
if recents, err := s.ds.GetRecents(accountID, d.DeviceID); err == nil {
for j := range recents {
detail.Recents = append(detail.Recents, mapToFullResponseRecent(&recents[j], configuredSources))
}
}
detail.AccountDevice.Recents = detail.Recents
return detail
}
func mapToFullResponseSource(src *models.ConfiguredSource) models.FullResponseSource {
fs := models.FullResponseSource{
ID: src.ID,
Type: src.Type,
DisplayName: src.DisplayName,
Name: src.DisplayName,
Username: src.Username,
SourceName: src.SourceName,
SourceProviderID: src.SourceProviderID,
CreatedOn: src.CreatedOn,
UpdatedOn: src.UpdatedOn,
Account: src.SourceKey.Account,
SourceLabel: constants.GetSourceLabel(src.Type),
SourceSettings: src.SourceSettings,
}
fs.Credential.Value = src.Secret
fs.Credential.Type = src.SecretType
// If DisplayName is generic (e.g. "Audio") and we have a more specific Account name, use it.
if fs.DisplayName == fs.Type && fs.Account != "" {
fs.DisplayName = fs.Account
fs.Name = fs.Account
}
// Provide fallback for Name and SourceName if missing
switch {
case fs.Name != "":
// Name already set to DisplayName
case fs.Account != "":
fs.Name = fs.Account
case fs.SourceLabel != "":
fs.Name = fs.SourceLabel
default:
fs.Name = fs.Type
}
if fs.SourceName == "" {
fs.SourceName = fs.Name
}
if fs.DisplayName == "" {
fs.DisplayName = fs.Name
}
return fs
}
func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models.ConfiguredSource) models.FullResponsePreset {
fp := models.FullResponsePreset{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: p.CreatedOn,
Location: p.Location,
Name: p.Name,
UpdatedOn: p.UpdatedOn,
Username: p.Username,
}
if fp.Username == "" {
fp.Username = p.Name
}
if fp.Name == "" {
fp.Name = p.Name
}
if fp.CreatedOn == "" && p.CreatedOn != "" {
fp.CreatedOn = p.CreatedOn
}
// Attempt to find matching source in configuredSources
found := false
for k := range configuredSources {
src := &configuredSources[k]
if src.SourceKey.Type == p.Source && (src.SourceKey.Account == p.SourceAccount || p.SourceAccount == "") {
fp.Source = mapToFullResponseSource(src)
found = true
break
}
}
if !found && p.Source != "" {
// Create a dummy source for UI purposes if not found in configured sources
dummy := &models.ConfiguredSource{
Type: p.Source,
}
dummy.SourceKey.Type = p.Source
dummy.SourceKey.Account = p.SourceAccount
fp.Source = mapToFullResponseSource(dummy)
}
return fp
}
func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models.ConfiguredSource) models.FullResponseRecent {
fr := models.FullResponseRecent{
ID: r.ID,
ContentItemType: r.ContentItemType,
CreatedOn: r.CreatedOn,
LastPlayedAt: r.LastPlayedAt,
Location: r.Location,
Name: r.Name,
SourceID: r.SourceID,
UpdatedOn: r.UpdatedOn,
Username: r.Username,
}
if fr.LastPlayedAt == "" && r.UtcTime != "" {
if ut, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil {
fr.LastPlayedAt = time.Unix(ut, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
}
if fr.Username == "" {
fr.Username = r.Name
}
if fr.Name == "" {
fr.Name = r.Name
}
if fr.CreatedOn == "" && r.CreatedOn != "" {
fr.CreatedOn = r.CreatedOn
} else if fr.CreatedOn == "" && r.UtcTime != "" {
fr.CreatedOn = r.UtcTime
}
// Attempt to find matching source in configuredSources
found := false
for k := range configuredSources {
src := &configuredSources[k]
if src.SourceKey.Type == r.Source && (src.SourceKey.Account == r.SourceAccount || r.SourceAccount == "") {
fr.Source = mapToFullResponseSource(src)
if fr.SourceID == "" {
fr.SourceID = fr.Source.ID
}
found = true
break
}
}
if !found && r.Source != "" {
// Create a dummy source for UI purposes if not found in configured sources
dummy := &models.ConfiguredSource{
Type: r.Source,
}
dummy.SourceKey.Type = r.Source
dummy.SourceKey.Account = r.SourceAccount
fr.Source = mapToFullResponseSource(dummy)
if fr.SourceID == "" {
fr.SourceID = fr.Source.ID
}
}
return fr
}
// HandleMgmtListAccounts returns a list of all account IDs in the datastore.
func (s *Server) HandleMgmtListAccounts(w http.ResponseWriter, _ *http.Request) {
accounts, err := s.ds.ListAccounts()
if err != nil {
log.Printf("[Mgmt] Failed to list accounts: %v", err)
accounts = []string{"default"}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"accounts": accounts,
}); err != nil {
log.Printf("[Mgmt] Failed to encode accounts: %v", err)
}
}
@@ -1,426 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
tempBaseDir := "mgmt_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
deviceID := "001122334455"
// Setup a device with a recent item that has utcTime and name in ContentItem
deviceDir := ds.AccountDeviceDir(accountID, deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatal(err)
}
recentsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recents>
<recent id="2538285498" utcTime="1690000000">
<contentItem source="INTERNET_RADIO" type="stationurl">
<itemName>For Your Darkest Days</itemName>
</contentItem>
</recent>
</recents>`
err = os.WriteFile(deviceDir+"/Recents.xml", []byte(recentsXML), 0644)
if err != nil {
t.Fatal(err)
}
// Also need a device info file to be listed
deviceInfo := models.ServiceDeviceInfo{
AccountID: accountID,
DeviceID: deviceID,
Name: "Test Device",
}
err = ds.SaveDeviceInfo(accountID, deviceID, &deviceInfo)
if err != nil {
t.Fatal(err)
}
server := &Server{ds: ds}
r := chi.NewRouter()
r.Get("/mgmt/accounts/{accountId}", server.HandleMgmtAccountDetails)
req := httptest.NewRequest("GET", "/mgmt/accounts/1234567", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response struct {
Devices []struct {
Recents []models.FullResponseRecent `json:"recents"`
} `json:"devices"`
}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(response.Devices) == 0 {
t.Fatal("Expected at least one device")
}
recents := response.Devices[0].Recents
if len(recents) == 0 {
t.Fatal("Expected one recent item")
}
r0 := recents[0]
if r0.Name != "For Your Darkest Days" {
t.Errorf("Expected recent name 'For Your Darkest Days', got '%s'", r0.Name)
}
if r0.CreatedOn != "1690000000" {
t.Errorf("Expected recent created_on '1690000000' (from utcTime), got '%s'", r0.CreatedOn)
}
// Test Preset mapping as well
presetsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<presets>
<preset id="1" createdOn="1690000001">
<contentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
<itemName>test-playlist</itemName>
</contentItem>
</preset>
</presets>`
err = os.WriteFile(deviceDir+"/Presets.xml", []byte(presetsXML), 0644)
if err != nil {
t.Fatal(err)
}
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req)
var response2 struct {
Devices []struct {
Presets []models.FullResponsePreset `json:"presets"`
} `json:"devices"`
}
err = json.Unmarshal(w2.Body.Bytes(), &response2)
if err != nil {
t.Fatal(err)
}
if len(response2.Devices[0].Presets) == 0 {
t.Fatal("Expected one preset")
}
p0 := response2.Devices[0].Presets[0]
if p0.Name != "test-playlist" {
t.Errorf("Expected preset name 'test-playlist', got '%s'", p0.Name)
}
if p0.CreatedOn != "1690000001" {
t.Errorf("Expected preset created_on '1690000001', got '%s'", p0.CreatedOn)
}
// Verify ButtonNumber/ID handling
if p0.ButtonNumber != "1" {
t.Errorf("Expected button_number '1', got '%s'", p0.ButtonNumber)
}
}
func TestHandleMgmtUpdateAccountLanguage(t *testing.T) {
tempBaseDir := "mgmt_test_data_lang"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
server := &Server{ds: ds}
r := chi.NewRouter()
r.Post("/mgmt/accounts/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
t.Run("Valid Language de", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"language": "de"})
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
accInfo, _ := ds.GetAccountInfo(accountID)
if accInfo.PreferredLanguage != "de" {
t.Errorf("Expected language 'de', got '%s'", accInfo.PreferredLanguage)
}
})
t.Run("Invalid Language fr", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"language": "fr"})
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
})
}
func TestHandleMgmtUpdateAccountProviderSetting(t *testing.T) {
tempBaseDir := "mgmt_test_data_provider"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
server := &Server{ds: ds}
// Setup initial account info
initialInfo := &models.ServiceAccountInfo{
AccountID: accountID,
ProviderSettings: []models.ProviderSetting{
{
ProviderID: "15",
KeyName: "STREAMING_QUALITY",
Value: "2",
},
},
}
ds.SaveAccountInfo(accountID, initialInfo)
r := chi.NewRouter()
r.Post("/mgmt/accounts/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
t.Run("Valid Update", func(t *testing.T) {
payload := map[string]string{
"provider_id": "15",
"key": "STREAMING_QUALITY",
"value": "3",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
accInfo, _ := ds.GetAccountInfo(accountID)
found := false
for _, s := range accInfo.ProviderSettings {
if s.ProviderID == "15" && s.KeyName == "STREAMING_QUALITY" {
if s.Value != "3" {
t.Errorf("Expected value '3', got '%s'", s.Value)
}
found = true
}
}
if !found {
t.Error("Provider setting not found after update")
}
})
t.Run("Setting Not Found", func(t *testing.T) {
payload := map[string]string{
"provider_id": "99",
"key": "NON_EXISTENT",
"value": "val",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
})
}
func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
tempBaseDir := "sources_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
deviceID := "001122334455"
deviceDir := ds.AccountDeviceDir(accountID, deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatal(err)
}
// Mock Sources.xml as it might be read by Sync/Save logic if we were using it,
// but here we will save them directly via DataStore.
sources := []models.ConfiguredSource{
{
ID: "9330201",
Type: "Audio",
DisplayName: "Audio",
SourceName: "Audio",
Name: "Audio",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{
Type: "Audio",
},
},
{
ID: "10863533",
Type: "Audio",
DisplayName: "Audio",
SourceName: "Audio",
Name: "Audio",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{
Type: "Audio",
Account: "gesellix",
},
},
}
err = ds.SaveConfiguredSources(accountID, deviceID, sources)
if err != nil {
t.Fatal(err)
}
// Also need a device info file to be listed
deviceInfo := models.ServiceDeviceInfo{
AccountID: accountID,
DeviceID: deviceID,
Name: "Test Device",
}
err = ds.SaveDeviceInfo(accountID, deviceID, &deviceInfo)
if err != nil {
t.Fatal(err)
}
server := &Server{ds: ds}
r := chi.NewRouter()
r.Get("/mgmt/accounts/{accountId}", server.HandleMgmtAccountDetails)
req := httptest.NewRequest("GET", "/mgmt/accounts/1234567", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response struct {
Devices []struct {
Sources []models.FullResponseSource `json:"sources"`
} `json:"devices"`
}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(response.Devices) == 0 {
t.Fatal("Expected one device")
}
if len(response.Devices[0].Sources) < 2 {
t.Fatalf("Expected at least 2 sources, got %d", len(response.Devices[0].Sources))
}
// Find the gesellix source
var gesellixSource *models.FullResponseSource
for i := range response.Devices[0].Sources {
if response.Devices[0].Sources[i].ID == "10863533" {
gesellixSource = &response.Devices[0].Sources[i]
break
}
}
if gesellixSource == nil {
t.Fatal("gesellix source not found")
} else {
// It should have fallen back to Account name "gesellix" because DisplayName was generic "Audio"
if gesellixSource.DisplayName != "gesellix" {
t.Errorf("Expected display_name 'gesellix', got '%s'", gesellixSource.DisplayName)
}
if gesellixSource.Name != "gesellix" {
t.Errorf("Expected name 'gesellix', got '%s'", gesellixSource.Name)
}
if gesellixSource.Type != "Audio" {
t.Errorf("Expected type 'Audio', got '%s'", gesellixSource.Type)
}
}
// Find the generic audio source
var audioSource *models.FullResponseSource
for i := range response.Devices[0].Sources {
if response.Devices[0].Sources[i].ID == "9330201" {
audioSource = &response.Devices[0].Sources[i]
break
}
}
if audioSource == nil {
t.Fatal("audio source not found")
} else {
// It should still be "Audio" as there is no account fallback
if audioSource.DisplayName != "Audio" {
t.Errorf("Expected display_name 'Audio', got '%s'", audioSource.DisplayName)
}
}
}
+1 -175
View File
@@ -2,10 +2,8 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
@@ -16,47 +14,16 @@ import (
func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
baseURL := s.serverURL
s.mu.RLock()
dnsEnabled := s.dnsEnabled
s.mu.RUnlock()
bmxServer := baseURL
if dnsEnabled {
bmxServer = "https://content.api.bose.io"
}
content := string(bmxServicesJSON)
content = strings.ReplaceAll(content, "{BMX_SERVER}", bmxServer)
content = strings.ReplaceAll(content, "{BMX_SERVER}", baseURL)
content = strings.ReplaceAll(content, "{MEDIA_SERVER}", baseURL+"/media")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(content))
}
// HandleBMXServicesAvailability returns the BMX services availability.
func (s *Server) HandleBMXServicesAvailability(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(bmxServicesAvailabilityJSON)
}
func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`<!doctype html>
<html lang=en>
<title>401 Unauthorized</title>
<h1>Unauthorized</h1>
<p>Authorization not set. No access token found.</p>
`))
}
// HandleTuneInPlayback returns TuneIn playback information.
func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
stationID := chi.URLParam(r, "stationID")
resp, err := bmx.TuneInPlayback(stationID)
@@ -75,11 +42,6 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
// HandleTuneInPodcastInfo returns TuneIn podcast information.
func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
podcastID := chi.URLParam(r, "podcastID")
encodedName := r.URL.Query().Get("encoded_name")
@@ -99,11 +61,6 @@ func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request)
// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information.
func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
podcastID := chi.URLParam(r, "podcastID")
resp, err := bmx.TuneInPlaybackPodcast(podcastID)
@@ -120,40 +77,8 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
}
}
// HandleTuneInToken returns a TuneIn access token.
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
var req struct {
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// For now, we return the provided refresh_token as access_token and refresh_token,
// mirroring the behavior seen in the recordings.
resp := map[string]string{
"access_token": req.RefreshToken,
"refresh_token": req.RefreshToken,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleOrionPlayback returns Orion playback information.
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
data := chi.URLParam(r, "data")
resp, err := bmx.PlayCustomStream(data)
@@ -169,102 +94,3 @@ func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
return
}
}
// HandleCustomPlayback returns custom playback information for a given stream URL.
func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
encodedURL := chi.URLParam(r, "encodedURL")
imageUrl := r.URL.Query().Get("imageUrl")
name := r.URL.Query().Get("name")
// Decode URL if it's base64 encoded
var streamUrl string
decoded, err := base64.URLEncoding.DecodeString(encodedURL)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(encodedURL)
}
if err == nil {
streamUrl = string(decoded)
} else {
// Try unescaping if it's not base64
streamUrl, err = url.PathUnescape(encodedURL)
if err != nil {
streamUrl = encodedURL
}
}
resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleTuneInReport handles TuneIn playback reporting.
func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
var req struct {
EventType string `json:"eventType"`
}
// We don't strictly need the body to determine the response,
// but we decode it to see the eventType.
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
if req.EventType == "START" {
// Mirroring the response from 0196-20260329-233306.072-POST.http
resp := map[string]interface{}{
"_links": map[string]interface{}{
"self": map[string]interface{}{
"href": "/v1/report?" + r.URL.RawQuery,
},
},
"nextReportIn": 1800,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
// For STOP and other events, return an empty object
_, _ = w.Write([]byte("{}"))
}
// HandleTuneInNavigate returns TuneIn navigation information.
func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(tuneInNavigateJSON)
}
// HandleTuneInSearch returns TuneIn search results.
func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(tuneInSearchJSON)
}
@@ -1,73 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandleBMXServicesAvailability(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
req := httptest.NewRequest("GET", "/bmx/registry/v1/servicesAvailability", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", contentType)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
services, ok := resp["services"].([]interface{})
if !ok {
t.Fatal("Response missing 'services' field")
}
if len(services) != 2 {
t.Errorf("Expected 2 services, got %d", len(services))
}
foundTuneIn := false
foundSiriusXM := false
for _, s := range services {
service := s.(map[string]interface{})
name := service["service"].(string)
switch name {
case "TUNEIN":
foundTuneIn = true
if service["canAdd"] != true {
t.Errorf("TUNEIN: expected canAdd true, got %v", service["canAdd"])
}
if service["canRemove"] != false {
t.Errorf("TUNEIN: expected canRemove false, got %v", service["canRemove"])
}
case "SIRIUSXM_EVEREST":
foundSiriusXM = true
if service["canAdd"] != false {
t.Errorf("SIRIUSXM_EVEREST: expected canAdd false, got %v", service["canAdd"])
}
if service["canRemove"] != true {
t.Errorf("SIRIUSXM_EVEREST: expected canRemove true, got %v", service["canRemove"])
}
}
}
if !foundTuneIn {
t.Error("TUNEIN not found in servicesAvailability")
}
if !foundSiriusXM {
t.Error("SIRIUSXM_EVEREST not found in servicesAvailability")
}
}
@@ -1,87 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandleTuneInReport(t *testing.T) {
r, s := setupRouter("http://localhost:8001", nil)
s.SetMirrorSettings(false, nil, nil, "")
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("START event", func(t *testing.T) {
payload := `{"timeStamp":"2026-03-29T21:33:04+0000","eventType":"START","reason":"USER_SELECT_PLAYABLE","timeIntoTrack":0,"playbackDelay":7419}`
req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio", strings.NewReader(payload))
req.Header.Set("Authorization", "Bearer mock-token")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["nextReportIn"] != float64(1800) {
t.Errorf("Expected nextReportIn 1800, got %v", resp["nextReportIn"])
}
links := resp["_links"].(map[string]interface{})
self := links["self"].(map[string]interface{})
if !strings.Contains(self["href"].(string), "/v1/report") {
t.Errorf("Expected href to contain /v1/report, got %v", self["href"])
}
})
t.Run("STOP event", func(t *testing.T) {
payload := `{"timeStamp":"2026-03-29T21:33:44+0000","eventType":"STOP","reason":"USER_STOP","timeIntoTrack":39,"playbackDelay":0}`
req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio", strings.NewReader(payload))
req.Header.Set("Authorization", "Bearer mock-token")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if len(resp) != 0 {
t.Errorf("Expected empty response object, got %v", resp)
}
})
t.Run("Unauthorized", func(t *testing.T) {
req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report", nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %v", res.Status)
}
})
}
+1 -145
View File
@@ -5,7 +5,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -87,9 +86,7 @@ func TestOrionPlayback(t *testing.T) {
// Base64 encoded: {"streamUrl": "http://example.com/stream", "imageUrl": "http://example.com/img.jpg", "name": "Test Orion"}
data := "eyJzdHJlYW1VcmwiOiAiaHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbSIsICJpbWFnZVVybCI6ICJodHRwOi8vZXhhbXBsZS5jb20vaW1nLmpwZyIsICJuYW1lIjogIlRlc3QgT3Jpb24ifQ=="
req, _ := http.NewRequest("POST", ts.URL+"/bmx/orion/v1/playback/station/"+data, nil)
req.Header.Set("Authorization", "Bearer mock-token")
res, err := http.DefaultClient.Do(req)
res, err := http.Post(ts.URL+"/bmx/orion/v1/playback/station/"+data, "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -110,144 +107,3 @@ func TestOrionPlayback(t *testing.T) {
t.Errorf("Expected name Test Orion, got %v", resp["name"])
}
}
func TestCustomPlayback(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
// Base64 encoded: http://example.com/stream
encodedURL := "aHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbQ=="
imageUrl := "http://example.com/img.jpg"
name := "Test Custom"
res, err := http.Get(ts.URL + "/custom/v1/playback/" + encodedURL + "?imageUrl=" + url.QueryEscape(imageUrl) + "&name=" + url.QueryEscape(name))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
var resp map[string]interface{}
_ = json.Unmarshal(body, &resp)
if resp["name"] != "Test Custom" {
t.Errorf("Expected name Test Custom, got %v", resp["name"])
}
audio := resp["audio"].(map[string]interface{})
if audio["streamUrl"] != "http://example.com/stream" {
t.Errorf("Expected streamUrl http://example.com/stream, got %v", audio["streamUrl"])
}
if resp["imageUrl"] != imageUrl {
t.Errorf("Expected imageUrl %s, got %v", imageUrl, resp["imageUrl"])
}
}
func TestBMXUnauthorized(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
paths := []struct {
method string
path string
}{
{"GET", "/bmx/tunein/v1/playback/station/s123"},
{"GET", "/bmx/tunein/v1/playback/episodes/p123"},
{"GET", "/bmx/tunein/v1/playback/episode/p123"},
{"POST", "/bmx/orion/v1/playback/station/data"},
}
for _, tc := range paths {
req, _ := http.NewRequest(tc.method, ts.URL+tc.path, nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Errorf("%s %s: %v", tc.method, tc.path, err)
continue
}
defer res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s: Expected status 401, got %v", tc.method, tc.path, res.Status)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "401 Unauthorized") || !strings.Contains(bodyStr, "No access token found.") {
t.Errorf("%s %s: Unexpected response body: %s", tc.method, tc.path, bodyStr)
}
}
}
func TestHandleTuneInToken(t *testing.T) {
r, s := setupRouter("http://localhost:8001", nil)
s.SetMirrorSettings(false, nil, nil, "")
ts := httptest.NewServer(r)
defer ts.Close()
payload := `{"grant_type":"refresh_token","refresh_token":"test-refresh-token"}`
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]string
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["access_token"] != "test-refresh-token" {
t.Errorf("Expected access_token 'test-refresh-token', got %v", resp["access_token"])
}
if resp["refresh_token"] != "test-refresh-token" {
t.Errorf("Expected refresh_token 'test-refresh-token', got %v", resp["refresh_token"])
}
}
func TestHandleTuneInPlayback_Authorized(t *testing.T) {
r, s := setupRouter("http://localhost:8001", nil)
s.SetMirrorSettings(false, nil, nil, "")
ts := httptest.NewServer(r)
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/bmx/tunein/v1/playback/station/s166521", nil)
req.Header.Set("Authorization", "Bearer mock-token")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["name"] == "" {
t.Errorf("Expected station name, got empty")
}
if audio, ok := resp["audio"].(map[string]interface{}); !ok || audio["streamUrl"] == "" {
t.Errorf("Expected audio streamUrl, got %v", resp["audio"])
}
}
@@ -1,92 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandleTuneInNavigate(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
t.Run("Root navigate", func(t *testing.T) {
req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate", nil)
req.Header.Set("Authorization", "Bearer mock-token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if _, ok := resp["bmx_sections"]; !ok {
t.Error("Response missing 'bmx_sections'")
}
})
t.Run("Sub navigate", func(t *testing.T) {
req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate/some-path", nil)
req.Header.Set("Authorization", "Bearer mock-token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
})
t.Run("Unauthorized", func(t *testing.T) {
req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
})
}
func TestHandleTuneInSearch(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
t.Run("Search music", func(t *testing.T) {
req := httptest.NewRequest("GET", "/bmx/tunein/v1/search?q=music", nil)
req.Header.Set("Authorization", "Bearer mock-token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if _, ok := resp["bmx_sections"]; !ok {
t.Error("Response missing 'bmx_sections'")
}
})
t.Run("Unauthorized", func(t *testing.T) {
req := httptest.NewRequest("GET", "/bmx/tunein/v1/search?q=music", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
})
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
<head>
<meta charset="UTF-8">
<title>%s - Bose SoundTouch Toolkit Docs</title>
<link rel="icon" href="/web/img/favicon-braille.svg" type="image/svg+xml">
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
<style>
body { margin: 0; padding: 0; display: flex; font-family: sans-serif; height: 100vh; overflow: hidden; }
+3 -3
View File
@@ -14,11 +14,11 @@ import (
func TestEventLog(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
s := NewServer(ds, nil, "http://localhost", false, false, false)
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
r := chi.NewRouter()
r.Post("/streaming/stats/usage", s.HandleUsageStats)
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
t.Run("Record and Retrieve Events", func(t *testing.T) {
// 1. Post a usage stat
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
}
// 2. Retrieve events
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
+1 -1
View File
@@ -20,7 +20,7 @@ type healthResp struct {
func TestHealthEndpoint(t *testing.T) {
r := chi.NewRouter()
srv := NewServer(nil, nil, "http://localhost", false, false, false)
srv := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
r.Get("/health", srv.HandleHealth)
ts := httptest.NewServer(r)
+4 -370
View File
@@ -1,131 +1,19 @@
package handlers
import (
"crypto/rand"
"encoding/xml"
"io"
"log"
"math/big"
"net"
"net/http"
"strconv"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/go-chi/chi/v5"
)
// HandleMargeCreateAccount creates a new account from Stockholm (XML).
func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var req models.MargeAccountCreateRequest
err = xml.Unmarshal(body, &req)
if err != nil {
http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest)
return
}
// Use provided ID or generate new 7-digit ID
var id string
if req.ID != "" {
id = req.ID
} else {
for {
n, _ := rand.Int(rand.Reader, big.NewInt(9000000))
id = strconv.FormatInt(n.Int64()+1000000, 10)
existing, _ := s.ds.GetAccountInfo(id)
if existing == nil || existing.IsPlaceholder {
break
}
}
}
info := &models.ServiceAccountInfo{
AccountID: id,
PreferredLanguage: req.PreferredLanguage,
}
if info.PreferredLanguage == "" {
info.PreferredLanguage = "en"
}
err = s.ds.SaveAccountInfo(id, info)
if err != nil {
http.Error(w, "Failed to save account", http.StatusInternalServerError)
return
}
// Stockholm expects the account XML in response
data, err := marge.AccountFullToXML(s.ds, id)
if err != nil {
http.Error(w, "Failed to generate account XML", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
}
// HandleMargeLogin handles account login from Stockholm.
func (s *Server) HandleMargeLogin(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var req models.MargeLoginRequest
if err = xml.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest)
return
}
// Simple mock: find account by email or just return a default one if none exists
// For now, let's just return a fixed one for testing if nothing else matches
accounts, err := s.ds.ListAccounts()
accountID := ""
if err == nil {
for _, id := range accounts {
if id == "default" {
continue
}
// In a real system we'd check email/password
// Here we just pick the first one or use fallback
accountID = id
break
}
}
if accountID == "" {
http.Error(w, "No accounts found", http.StatusUnauthorized)
return
}
data, err := marge.AccountFullToXML(s.ds, accountID)
if err != nil {
http.Error(w, "Failed to generate account XML", http.StatusInternalServerError)
return
}
// Bose returns a token in the Credentials header
w.Header().Set("Credentials", "mock-token-"+accountID)
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write(data)
}
// HandleMargeSourceProviders returns the Marge source providers.
func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) {
etag := strconv.FormatInt(time.Now().UnixMilli(), 10)
@@ -168,52 +56,6 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(data)
}
// HandleMargeAccountSources returns the Marge account sources.
func (s *Server) HandleMargeAccountSources(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := r.URL.Query().Get("device")
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
data, err := marge.AccountSourcesToXML(s.ds, account)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.1+xml")
w.Header()["ETag"] = []string{etag}
_, _ = w.Write(data)
}
// HandleMargeAccountDevices returns the Marge account devices.
func (s *Server) HandleMargeAccountDevices(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := r.URL.Query().Get("device")
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
data, err := marge.AccountDevicesToXML(s.ds, account)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.1+xml")
w.Header()["ETag"] = []string{etag}
_, _ = w.Write(data)
}
// HandleMargePowerOn handles the Marge power on request.
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
@@ -243,37 +85,6 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
// Persist device details provided in the power_on request
if deviceID != "" && s.ds != nil {
// Use "default" account if not found or if the device is not yet mapped to an account.
// In a real scenario, this might be resolved differently if we already have the account info.
accountID := "default"
if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil && existing.AccountID != "" {
accountID = existing.AccountID
}
macAddress := ""
if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 0 {
macAddress = req.DiagnosticData.DeviceLandscape.MacAddresses[0]
}
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: accountID,
ProductCode: req.Device.Product.ProductCode,
DeviceSerialNumber: req.Device.SerialNumber,
ProductSerialNumber: req.Device.Product.SerialNumber,
FirmwareVersion: req.Device.FirmwareVersion,
IPAddress: deviceIP,
MacAddress: macAddress,
DiscoveryMethod: "power_on",
}
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("[Marge] Failed to save device info for %s: %v", deviceID, err)
}
}
if deviceIP != "" {
go s.PrimeDeviceWithSpotify(deviceIP)
} else {
@@ -398,30 +209,10 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
}
}
// HandleMargeAccountPresets handles the GET /streaming/account/{account}/presets/all request.
func (s *Server) HandleMargeAccountPresets(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
data, err := marge.AccountPresetsToXML(s.ds, account)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.1+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// HandleMargePresets returns the Marge presets for a device.
func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
if !validatePathID(account) || !validatePathID(device) {
http.Error(w, "Invalid account or device ID", http.StatusBadRequest)
return
}
etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10)
if r.Header.Get("If-None-Match") == etag {
@@ -443,12 +234,7 @@ func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) {
// HandleMargeUpdatePreset updates a Marge preset.
func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
if !validatePathID(account) || !validatePathID(device) {
http.Error(w, "Invalid account or device ID", http.StatusBadRequest)
return
}
etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10)
w.Header()["ETag"] = []string{etag}
@@ -457,25 +243,19 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
presetNumber, err := strconv.Atoi(presetNumberStr)
if err != nil {
log.Printf("[Marge] Invalid preset number: %s", presetNumberStr)
http.Error(w, "Invalid preset number", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[Marge] Failed to read body: %v", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
data, err := marge.UpdatePreset(s.ds, account, device, presetNumber, body)
if err != nil {
log.Printf("[Marge] UpdatePreset failed for account=%s, device=%s, preset=%d: %v", account, device, presetNumber, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -486,12 +266,7 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
// HandleMargeRecents returns the Marge recents for a device.
func (s *Server) HandleMargeRecents(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
if !validatePathID(account) || !validatePathID(device) {
http.Error(w, "Invalid account or device ID", http.StatusBadRequest)
return
}
etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10)
if r.Header.Get("If-None-Match") == etag {
@@ -513,12 +288,7 @@ func (s *Server) HandleMargeRecents(w http.ResponseWriter, r *http.Request) {
// HandleMargeAddRecent adds a recent item to Marge.
func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
if !validatePathID(account) || !validatePathID(device) {
http.Error(w, "Invalid account or device ID", http.StatusBadRequest)
return
}
etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10)
w.Header()["ETag"] = []string{etag}
@@ -543,10 +313,6 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) {
// HandleMargeAddDevice adds a device to a Marge account.
func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
if !validatePathID(account) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -554,62 +320,21 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
return
}
deviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body)
data, err := marge.AddDeviceToAccount(s.ds, account, body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header().Set("Location", s.serverURL+"/account/"+account+"/device/"+deviceID)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
}
// HandleMargeRemovePreset removes a preset for the specified account and device.
func (s *Server) HandleMargeRemovePreset(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
if !validatePathID(account) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
device := chi.URLParam(r, "device")
if !validatePathID(device) {
http.Error(w, "Invalid device ID", http.StatusBadRequest)
return
}
presetStr := chi.URLParam(r, "presetNumber")
presetNumber, err := strconv.Atoi(presetStr)
if err != nil || presetNumber < 1 || presetNumber > 6 {
http.Error(w, "Invalid preset number", http.StatusBadRequest)
return
}
if err := marge.RemovePreset(s.ds, account, device, presetNumber); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleMargeRemoveDevice removes a device from a Marge account.
func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
if !validatePathID(account) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
device := chi.URLParam(r, "device")
if !validatePathID(device) {
http.Error(w, "Invalid device ID", http.StatusBadRequest)
return
}
if err := marge.RemoveDeviceFromAccount(s.ds, account, device); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -619,36 +344,6 @@ func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request)
_, _ = w.Write([]byte(`{"ok": true}`))
}
// HandleMargeAddSource handles adding a new music source to the account.
// POST /streaming/account/{account}/source
func (s *Server) HandleMargeAddSource(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
if !validatePathID(account) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[Marge] Failed to read body: %v", err)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
resp, err := marge.AddSourceToAccount(s.ds, account, body)
if err != nil {
log.Printf("[Marge] Failed to add source: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(resp)
}
// HandleMargeProviderSettings returns Marge provider settings.
func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
@@ -675,7 +370,7 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header().Set("Authorization", bearerToken.GetAuthHeader())
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader))
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
@@ -684,7 +379,7 @@ func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request)
// Native firmware expects vnd.bose.streaming content type
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><group/>`))
}
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
@@ -699,38 +394,6 @@ func (s *Server) HandleMargeDeviceGroupMember(w http.ResponseWriter, r *http.Req
http.NotFound(w, r)
}
// HandleMusicProviderIsEligible returns the music provider eligibility.
func (s *Server) HandleMusicProviderIsEligible(w http.ResponseWriter, _ *http.Request) {
// For now, we return false as seen in the interaction sample.
resp := models.EligibilityResponse{
IsEligible: false,
}
data, err := xml.Marshal(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.1+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader))
_, _ = w.Write(data)
}
// HandleMargeAPIVersions returns the XML response for Marge API versions.
func (s *Server) HandleMargeAPIVersions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
output, err := marge.APIVersionsToXML()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(output)
}
// HandleMargeCustomerSupport handles Marge customer support uploads.
func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
@@ -740,7 +403,7 @@ func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Reque
}
var req models.CustomerSupportRequest
if err = xml.Unmarshal(body, &req); err != nil {
if err := xml.Unmarshal(body, &req); err != nil {
// Log error but might still return 200 as Bose expects
log.Printf("Failed to unmarshal CustomerSupportRequest: %v", err)
}
@@ -758,34 +421,5 @@ func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Reque
},
}
s.ds.AddDeviceEvent(req.Device.ID, event)
// Update DeviceInfo if possible
devices, err := s.ds.ListAllDevices()
if err == nil {
var account string
for i := range devices {
dev := &devices[i]
if dev.DeviceID == req.Device.ID {
account = dev.AccountID
break
}
}
if account != "" {
info, err := s.ds.GetDeviceInfo(account, req.Device.ID)
if err == nil && info != nil {
info.IPAddress = req.DiagnosticData.DeviceLandscape.IPAddress
info.FirmwareVersion = req.Device.FirmwareVersion
if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 0 {
info.MacAddress = req.DiagnosticData.DeviceLandscape.MacAddresses[0]
}
_ = s.ds.SaveDeviceInfo(account, req.Device.ID, info)
}
}
}
w.WriteHeader(http.StatusOK)
}
+17 -681
View File
@@ -2,8 +2,6 @@ package handlers
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -12,158 +10,9 @@ import (
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMargeCreateAccount(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
reqBody := `<account>
<preferredLanguage>de</preferredLanguage>
</account>`
res, err := http.Post(ts.URL+"/marge/streaming/account", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusCreated {
t.Errorf("Expected status Created, got %v", res.Status)
}
contentType := res.Header.Get("Content-Type")
if contentType != "application/vnd.bose.streaming-v1.2+xml" {
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", contentType)
}
body, _ := io.ReadAll(res.Body)
var resp models.AccountFullResponse
if err := xml.Unmarshal(body, &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.AccountStatus != "OK" {
t.Errorf("Expected AccountStatus OK, got %v", resp.AccountStatus)
}
if resp.PreferredLanguage != "de" {
t.Errorf("Expected PreferredLanguage de, got %v", resp.PreferredLanguage)
}
if len(resp.ID) != 7 {
t.Errorf("Expected 7-digit ID, got %v", resp.ID)
}
// Verify it has default sources
if len(resp.Sources) != 4 {
t.Errorf("Expected 4 default sources, got %d", len(resp.Sources))
} else {
if resp.Sources[0].ID != "10001" {
t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID)
}
}
// Verify it was saved in datastore
info, err := ds.GetAccountInfo(resp.ID)
if err != nil {
t.Errorf("Failed to get account from datastore: %v", err)
}
if info == nil {
t.Error("Account not found in datastore")
} else if info.PreferredLanguage != "de" {
t.Errorf("Expected saved PreferredLanguage de, got %v", info.PreferredLanguage)
}
}
func TestMargeLogin(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
accountID := "9876543"
_ = ds.SaveAccountInfo(accountID, &models.ServiceAccountInfo{
AccountID: accountID,
PreferredLanguage: "fr",
})
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
reqBody := `<login>
<username>test@example.com</username>
<password>secret</password>
</login>`
res, err := http.Post(ts.URL+"/marge/streaming/account/login", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
credentials := res.Header.Get("Credentials")
if credentials != "mock-token-"+accountID {
t.Errorf("Expected Credentials mock-token-%s, got %v", accountID, credentials)
}
body, _ := io.ReadAll(res.Body)
var resp models.AccountFullResponse
if err := xml.Unmarshal(body, &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.ID != accountID {
t.Errorf("Expected ID %s, got %v", accountID, resp.ID)
}
}
func TestMargeLogin_NoAccount(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
reqBody := `<login>
<username>none@example.com</username>
<password>secret</password>
</login>`
res, err := http.Post(ts.URL+"/marge/streaming/account/login", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusUnauthorized {
t.Errorf("Expected status Unauthorized, got %v", res.Status)
}
}
func TestMargeSourceProviders(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
@@ -278,258 +127,6 @@ func TestMargeAccountFull(t *testing.T) {
}
}
func TestMargeAccountSources(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "DEV1"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
_ = os.MkdirAll(deviceDir, 0755)
// Mock Sources.xml
sourcesXML := `
<sources>
<source id="SRC1" type="Audio" createdOn="2024-01-01T00:00:00Z" updatedOn="2024-01-01T00:00:00Z" displayName="Source1" secret="TOKEN1" secretType="token" sourceProviderId="2" sourceName="SourceName1">
<sourceKey type="NOT_TUNEIN" account="User1"/>
</source>
</sources>`
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources")
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
contentType := res.Header.Get("Content-Type")
if contentType != "application/vnd.bose.streaming-v1.1+xml" {
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", contentType)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "SRC1") {
t.Errorf("Response missing expected source ID: %s", bodyStr)
}
// Verify current XML structure produced by marge.go
expectedSnippets := []string{
"<sources>",
"<source id=\"SRC1\" type=\"Audio\"",
"<createdOn>2024-01-01T00:00:00Z</createdOn>",
"<updatedOn>2024-01-01T00:00:00Z</updatedOn>",
"<credential type=\"token\">TOKEN1</credential>",
"<name>User1</name>",
"<sourcename></sourcename>",
"<sourceSettings/>",
"<username>User1</username>",
}
for _, snippet := range expectedSnippets {
if !strings.Contains(bodyStr, snippet) {
t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
}
}
}
func TestMargeAccountPresets(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "12345"
device1 := "DEVICE1"
device2 := "DEVICE2"
// Setup presets for device1
presets1 := []models.ServicePreset{
{
ID: "1",
ServiceContentItem: models.ServiceContentItem{
Name: "Station 1",
},
},
}
if err := ds.SavePresets(account, device1, presets1); err != nil {
t.Fatal(err)
}
// Setup presets for device2
presets2 := []models.ServicePreset{
{
ID: "2",
ServiceContentItem: models.ServiceContentItem{
Name: "Station 2",
},
},
}
if err := ds.SavePresets(account, device2, presets2); err != nil {
t.Fatal(err)
}
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// Test /streaming/account/{account}/presets/all
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/presets/all")
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
contentType := res.Header.Get("Content-Type")
if contentType != "application/vnd.bose.streaming-v1.1+xml" {
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", contentType)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "<presets>") {
t.Error("Response body missing <presets>")
}
if !strings.Contains(bodyStr, "buttonNumber=\"1\"") {
t.Error("Response body missing preset 1")
}
if !strings.Contains(bodyStr, "buttonNumber=\"2\"") {
t.Error("Response body missing preset 2")
}
}
func TestMargeAccountDevices(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "DEV1"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
_ = os.MkdirAll(deviceDir, 0755)
// Mock DeviceInfo.json
deviceInfo := models.ServiceDeviceInfo{
DeviceID: deviceID,
Name: "Test Device",
IPAddress: "192.168.1.100",
DeviceSerialNumber: "ABCDE12345",
ProductCode: "SoundTouch 20",
ProductSerialNumber: "066802942560222AE",
}
_ = ds.SaveDeviceInfo(account, deviceID, &deviceInfo)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/devices")
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
contentType := res.Header.Get("Content-Type")
if contentType != "application/vnd.bose.streaming-v1.1+xml" {
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", contentType)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
// Verify current XML structure produced by marge.go
expectedSnippets := []string{
"<devices>",
"<device deviceid=\"DEV1\">",
"<name>Test Device</name>",
"<ipaddress>192.168.1.100</ipaddress>",
"<providerSettings>",
"ELIGIBLE_FOR_TRIAL",
}
for _, snippet := range expectedSnippets {
if !strings.Contains(bodyStr, snippet) {
t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
}
}
}
func TestMargeAccountSourcesNoDevices(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "12345"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources")
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
// Verify that we get the default sources with correct IDs and empty display names
expectedSnippets := []string{
"<sources>",
"<source id=\"10004\" type=\"Audio\"",
"<source id=\"10003\" type=\"Audio\"",
"<source id=\"10002\" type=\"Audio\"",
"<source id=\"10001\" type=\"Audio\"",
}
for _, snippet := range expectedSnippets {
if !strings.Contains(bodyStr, snippet) {
t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
}
}
// Verify that no sources have empty display names
if strings.Count(bodyStr, "displayName=\"\"") != 0 {
t.Errorf("Expected no sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
}
}
func TestMargePresets(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
@@ -570,10 +167,10 @@ func TestMargePresets(t *testing.T) {
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`
<presets>
<preset id="1">
<contentItem source="TUNEIN" type="station" location="/station/s123" sourceAccount="" isPresetable="true">
<ContentItem source="TUNEIN" type="station" location="/station/s123" sourceAccount="" isPresetable="true">
<itemName>Test Station</itemName>
<containerArt>http://example.com/art.jpg</containerArt>
</contentItem>
</ContentItem>
</preset>
</presets>
`), 0644); err != nil {
@@ -668,41 +265,6 @@ func TestMargeUpdatePreset(t *testing.T) {
if !strings.Contains(string(presetData), "New Preset") {
t.Error("Preset was not saved to datastore")
}
// Verify response body has correct XML structure (upstream parity)
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "<preset buttonNumber=\"1\">") {
t.Errorf("Response missing <preset buttonNumber=\"1\">: %s", bodyStr)
}
if strings.Contains(bodyStr, "source=\"TUNEIN\"") {
t.Errorf("Response should NOT have source attribute on root element: %s", bodyStr)
}
if strings.Contains(bodyStr, "<sourceid>") {
t.Errorf("Response should NOT have <sourceid> element: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<source") || !strings.Contains(bodyStr, "id=\"SRC1\"") {
t.Errorf("Response missing nested <source id=\"SRC1\">: %s", bodyStr)
}
// Verify two distinct <username> elements
usernameCount := strings.Count(bodyStr, "<username>")
if usernameCount != 2 {
t.Errorf("Expected 2 <username> elements, got %d: %s", usernameCount, bodyStr)
}
if !strings.Contains(bodyStr, "<username>New Preset</username>") {
t.Errorf("Response missing <username>New Preset</username>: %s", bodyStr)
}
// Verify empty tags are present (parity requirement)
//if !strings.Contains(bodyStr, "<sourcename></sourcename>") && !strings.Contains(bodyStr, "<sourcename/>") {
// t.Errorf("Response missing empty <sourcename>: %s", bodyStr)
//}
//if !strings.Contains(bodyStr, "<name></name>") && !strings.Contains(bodyStr, "<name/>") {
// t.Errorf("Response missing empty <name>: %s", bodyStr)
//}
if !strings.Contains(bodyStr, "<sourceSettings></sourceSettings>") && !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("Response missing empty <sourceSettings>: %s", bodyStr)
}
}
func TestMargeAddRecentRoute(t *testing.T) {
@@ -968,58 +530,6 @@ func TestMargeNativeStreamingRoutes(t *testing.T) {
}
})
t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} - valid Sources.xml", func(t *testing.T) {
payload := `
<preset>
<name>PUT Native Preset Singular</name>
<sourceid>SRC1</sourceid>
<location>/station/s888</location>
<contentItemType>station</contentItemType>
</preset>`
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/preset/6", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/xml")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
}
})
t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber}", func(t *testing.T) {
payload := `
<preset>
<name>PUT Native Preset Singular</name>
<sourceid>SRC1</sourceid>
<location>/station/s888</location>
<contentItemType>station</contentItemType>
</preset>`
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/preset/6", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/xml")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
}
// Verify file was saved
presetData, _ := os.ReadFile(filepath.Join(deviceDir, "Presets.xml"))
if !strings.Contains(string(presetData), "PUT Native Preset Singular") {
t.Error("Preset from singular native PUT route was not saved to datastore")
}
})
t.Run("POST /streaming/account/{account}/device/{device}/presets/{presetNumber}", func(t *testing.T) {
payload := `
<preset>
@@ -1165,13 +675,8 @@ func TestMargeAddRemoveDevice(t *testing.T) {
_ = res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Errorf("AddDevice: Expected status Created, got %v", res.Status)
}
location := res.Header.Get("Location")
if !strings.Contains(location, "/account/"+account+"/device/NEWDEV") {
t.Errorf("AddDevice: Expected Location header containing /account/%s/device/NEWDEV, got %s", account, location)
if res.StatusCode != http.StatusOK {
t.Errorf("AddDevice: Expected status OK, got %v", res.Status)
}
deviceFile := filepath.Join(accountDir, "devices", "NEWDEV", "DeviceInfo.xml")
@@ -1216,7 +721,7 @@ func TestMargePowerOn(t *testing.T) {
})
t.Run("FullBody", func(t *testing.T) {
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="001122334455"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>001122334455</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="A81B6A536A98"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>A81B6A536A98</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
@@ -1226,90 +731,6 @@ func TestMargePowerOn(t *testing.T) {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("Persistence", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts2 := httptest.NewServer(r)
defer ts2.Close()
deviceID := "001122334455"
serialNumber := "I6332527703739342000020"
firmware := "27.0.6.46330"
productCode := "SoundTouch 10 sm2"
productSerial := "069231P63364828AE"
ipAddress := "192.168.1.100"
macAddress := "001122334455"
payload := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?>
<device-data>
<device id="%s">
<serialnumber>%s</serialnumber>
<firmware-version>%s</firmware-version>
<product product_code="%s" type="5">
<serialnumber>%s</serialnumber>
</product>
</device>
<diagnostic-data>
<device-landscape>
<rssi>Excellent</rssi>
<gateway-ip-address>192.168.1.1</gateway-ip-address>
<macaddresses>
<macaddress>%s</macaddress>
</macaddresses>
<ip-address>%s</ip-address>
<network-connection-type>Wireless</network-connection-type>
</device-landscape>
</diagnostic-data>
</device-data>`, deviceID, serialNumber, firmware, productCode, productSerial, macAddress, ipAddress)
res, err := http.Post(ts2.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
// Verify data in datastore
info, err := ds.GetDeviceInfo("default", deviceID)
if err != nil {
t.Fatalf("Failed to get device info: %v", err)
}
if info.DeviceID != deviceID {
t.Errorf("Expected DeviceID %s, got %s", deviceID, info.DeviceID)
}
if info.DeviceSerialNumber != serialNumber {
t.Errorf("Expected SerialNumber %s, got %s", serialNumber, info.DeviceSerialNumber)
}
if info.FirmwareVersion != firmware {
t.Errorf("Expected Firmware %s, got %s", firmware, info.FirmwareVersion)
}
if info.ProductCode != productCode {
t.Errorf("Expected ProductCode %s, got %s", productCode, info.ProductCode)
}
if info.ProductSerialNumber != productSerial {
t.Errorf("Expected ProductSerialNumber %s, got %s", productSerial, info.ProductSerialNumber)
}
if info.IPAddress != ipAddress {
t.Errorf("Expected IPAddress %s, got %s", ipAddress, info.IPAddress)
}
if info.MacAddress != macAddress {
t.Errorf("Expected MacAddress %s, got %s", macAddress, info.MacAddress)
}
if info.DiscoveryMethod != "power_on" {
t.Errorf("Expected DiscoveryMethod power_on, got %s", info.DiscoveryMethod)
}
})
}
func TestMargeAdvancedFeatures(t *testing.T) {
@@ -1383,23 +804,11 @@ func TestMargeAdvancedFeatures(t *testing.T) {
})
t.Run("CustomerSupport", func(t *testing.T) {
account := "A123"
deviceId := "587A628A4042"
macAddress := "AABBCCDDEEFF"
ipAddress := "192.168.1.100"
firmware := "27.0.6"
// Pre-register device
_ = ds.SaveDeviceInfo(account, deviceId, &models.ServiceDeviceInfo{
DeviceID: deviceId,
Name: "TestDevice",
})
payload := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?>
payload := `<?xml version="1.0" encoding="UTF-8" ?>
<device-data>
<device id="%s">
<device id="587A628A4042">
<serialnumber>P123</serialnumber>
<firmware-version>%s</firmware-version>
<firmware-version>27.0.6</firmware-version>
<product product_code="SoundTouch 10" type="5">
<serialnumber>SN123</serialnumber>
</product>
@@ -1407,13 +816,10 @@ func TestMargeAdvancedFeatures(t *testing.T) {
<diagnostic-data>
<device-landscape>
<rssi>Good</rssi>
<macaddresses>
<macaddress>%s</macaddress>
</macaddresses>
<ip-address>%s</ip-address>
<ip-address>192.168.1.100</ip-address>
</device-landscape>
</diagnostic-data>
</device-data>`, deviceId, firmware, macAddress, ipAddress)
</device-data>`
res, err := http.Post(ts.URL+"/marge/streaming/support/customersupport", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
@@ -1431,15 +837,17 @@ func TestMargeAdvancedFeatures(t *testing.T) {
}
// Verify event was recorded
events := ds.GetDeviceEvents(deviceId)
events := ds.GetDeviceEvents("587A628A4042")
found := false
for _, e := range events {
if e.Type == "customer-support-upload" {
found = true
if e.Data["firmware"] != firmware {
t.Errorf("Expected firmware %s, got %v", firmware, e.Data["firmware"])
if e.Data["firmware"] != "27.0.6" {
t.Errorf("Expected firmware 27.0.6, got %v", e.Data["firmware"])
}
break
}
}
@@ -1447,26 +855,11 @@ func TestMargeAdvancedFeatures(t *testing.T) {
if !found {
t.Error("Customer support event not found in event log")
}
// Verify DeviceInfo was updated
info, err := ds.GetDeviceInfo(account, deviceId)
if err != nil {
t.Fatalf("Failed to get device info: %v", err)
}
if info.IPAddress != ipAddress {
t.Errorf("Expected updated IP %s, got %s", ipAddress, info.IPAddress)
}
if info.MacAddress != macAddress {
t.Errorf("Expected updated MAC %s, got %s", macAddress, info.MacAddress)
}
if info.FirmwareVersion != firmware {
t.Errorf("Expected updated firmware %s, got %s", firmware, info.FirmwareVersion)
}
})
t.Run("AddRecent_Reproduction", func(t *testing.T) {
account := "1234567"
device := "001122334455"
account := "3230304"
device := "A81B6A536A98"
// Setup sources for this device
deviceDir := ds.AccountDeviceDir(account, device)
@@ -1499,61 +892,4 @@ func TestMargeAdvancedFeatures(t *testing.T) {
t.Errorf("Expected name 'My top tracks playlist', got '%s'", recents[0].Name)
}
})
t.Run("MusicProviderIsEligible", func(t *testing.T) {
path := "/marge/streaming/music/musicprovider/26/is_eligible"
payload := `<?xml version = "1.0" encoding = "utf-8"?><account><accountId>12345</accountId></account>`
res, err := http.Post(ts.URL+path, "application/vnd.bose.streaming-v1.1+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.1+xml" {
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", ct)
}
body, _ := io.ReadAll(res.Body)
expected := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><eligibility><isEligible>false</isEligible></eligibility>`
if string(body) != expected {
t.Errorf("Expected body %s, got %s", expected, string(body))
}
})
t.Run("APIVersions", func(t *testing.T) {
path := "/marge/streaming/resources/api_versions.xml"
res, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
if ct := res.Header.Get("Content-Type"); ct != "text/xml" {
t.Errorf("Expected Content-Type text/xml, got %v", ct)
}
body, _ := io.ReadAll(res.Body)
if !strings.HasPrefix(string(body), "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<marge ") {
t.Errorf("Response body has incorrect header or root element: %s", body)
}
if !strings.Contains(string(body), `<api type="streaming">`) {
t.Error("Response body missing streaming API")
}
if !strings.Contains(string(body), `<api type="customer">`) {
t.Error("Response body missing customer API")
}
if !strings.Contains(string(body), `<api type="support">`) {
t.Error("Response body missing support API")
}
})
}
+3 -14
View File
@@ -11,7 +11,7 @@ import (
//go:embed web/index.html
var indexHTML []byte
//go:embed web/css/* web/js/* web/img/favicon-braille* web/img/favicon*
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
var webFS embed.FS
//go:embed static/media/*
@@ -20,18 +20,6 @@ var mediaFS embed.FS
//go:embed static/bmx_services.json
var bmxServicesJSON []byte
//go:embed static/bmx_services_availability.json
var bmxServicesAvailabilityJSON []byte
//go:embed static/tunein_navigate.json
var tuneInNavigateJSON []byte
//go:embed static/tunein_search.json
var tuneInSearchJSON []byte
// Upstream source available at https://worldwide.bose.com/updates/soundtouch?serialnumber=_serial_
// which results in a redirect to https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/index.xml
//
//go:embed static/swupdate.xml
var swUpdateXML []byte
@@ -62,6 +50,7 @@ func (s *Server) HandleMedia() http.HandlerFunc {
subFS, _ := fs.Sub(mediaFS, "static/media")
return func(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/media", http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS)))
fs.ServeHTTP(w, r)
}
}
+81 -46
View File
@@ -79,8 +79,8 @@ func TestStaticMedia(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// Use a known file from static/media in a subdirectory
res, err := http.Get(ts.URL + "/media/bmx-icons/siriusxm-everest/SiriusXM_Logo_Color.svg")
// Use a known file from static/media
res, err := http.Get(ts.URL + "/media/SiriusXM_Logo_Color.svg")
if err != nil {
t.Fatal(err)
}
@@ -103,33 +103,104 @@ func TestStaticWeb(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// 1. Test CSS
res, err := http.Get(ts.URL + "/web/css/style.css")
// 1. Test Migration UI CSS
res, err := http.Get(ts.URL + "/web/migration/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("CSS: Expected status OK, got %v", res.Status)
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 2. Test JS
res, err = http.Get(ts.URL + "/web/js/script.js")
// 2. Test Migration UI JS
res, err = http.Get(ts.URL + "/web/migration/script.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("JS: Expected status OK, got %v", res.Status)
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test Migration UI Index
res, err = http.Get(ts.URL + "/web/migration/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 4. Test Stockholm Mini
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 5. Test Stockholm Mini CSS
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 6. Test Shared CSS
res, err = http.Get(ts.URL + "/web/shared/common.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 7. Test Shared JS
res, err = http.Get(ts.URL + "/web/shared/common.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test diff.min.js
@@ -142,40 +213,4 @@ func TestStaticWeb(t *testing.T) {
if res.StatusCode != http.StatusOK {
t.Errorf("Diff JS: Expected status OK, got %v", res.Status)
}
// 4. Test Favicon
res, err = http.Get(ts.URL + "/web/img/favicon-braille.svg")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Favicon: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "image/svg+xml") {
t.Errorf("Favicon: Expected image/svg+xml content type, got %s", res.Header.Get("Content-Type"))
}
// 5. Test old Favicon path (should be 404)
res, err = http.Get(ts.URL + "/media/favicon-braille.svg")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("Old Favicon: Expected status NotFound, got %v", res.Status)
}
// 6. Test old Favicon path in web/ (should be 404)
res, err = http.Get(ts.URL + "/web/favicon-braille.svg")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("Web Root Favicon: Expected status NotFound, got %v", res.Status)
}
}
+2 -133
View File
@@ -2,18 +2,11 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
@@ -101,7 +94,7 @@ func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request)
}
// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL.
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, r *http.Request) {
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
@@ -111,8 +104,7 @@ func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, r *http.Request) {
return
}
state := r.URL.Query().Get("account")
redirectURL := svc.BuildAuthorizeURL(state)
redirectURL := svc.BuildAuthorizeURL()
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
@@ -167,14 +159,6 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques
return
}
// Register account in Marge and notify speakers
accountID := r.URL.Query().Get("account")
if accountID == "" {
accountID = r.URL.Query().Get("state")
}
s.bridgeSpotifyToMarge(accountID)
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><body><h1>Spotify Connected</h1><p>You can close this window.</p></body></html>`))
}
@@ -205,126 +189,11 @@ func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request
return
}
// Register account in Marge and notify speakers
accountID := r.URL.Query().Get("account")
if accountID == "" {
accountID = r.URL.Query().Get("state")
}
s.bridgeSpotifyToMarge(accountID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
func (s *Server) bridgeSpotifyToMarge(accountID string) {
if accountID == "" {
accountID = "default"
}
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
return
}
// For now, we use the first account found or match by ID if possible.
// In this bridge, we'll ensure all linked Spotify accounts are registered in Marge.
for _, acc := range accounts {
log.Printf("[Spotify Bridge] Registering Spotify user %s in Marge for account %s", acc.UserID, accountID)
// 1. Register in Marge (updates configuredsources.xml for all devices in the account)
// We use the BoseSecret as the credential instead of the AccessToken
credential := acc.BoseSecret
if credential == "" {
// Fallback to AccessToken if BoseSecret is not available (for old accounts)
credential = acc.AccessToken
}
_, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName)
if err != nil {
log.Printf("[Spotify Bridge] Failed to register source in Marge: %v", err)
continue
}
// 2. Notify discovered speakers via LISA API (/setMusicServiceOAuthAccount)
allDevices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Spotify Bridge] Failed to list devices: %v", err)
continue
}
for i := range allDevices {
dev := &allDevices[i]
if dev.AccountID != accountID && accountID != "default" {
continue
}
if dev.IPAddress == "" {
continue
}
go func(d models.ServiceDeviceInfo) {
log.Printf("[Spotify Bridge] Notifying speaker %s (%s) about new Spotify account", d.Name, d.IPAddress)
c := client.NewClientFromHost(d.IPAddress)
creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName)
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
log.Printf("[Spotify Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err)
// Fallback if OAuth is not supported (Error 1029)
errs := &models.ErrorsResponse{}
if errors.As(err, &errs) {
isUnsupported := false
for _, e := range errs.Errors {
if e.Value == 1029 {
isUnsupported = true
break
}
}
if isUnsupported {
log.Printf("[Spotify Bridge] Speaker %s doesn't support OAuth, falling back to Marge sync notification", d.Name)
// Some speakers (especially Stockholm-based) don't support /setMusicServiceOAuthAccount
// via LISA but will pick up the new source from Marge if notified.
if err := c.NotifySourcesUpdated(d.DeviceID); err != nil {
log.Printf("[Spotify Bridge] Sync notification failed for speaker %s: %v", d.Name, err)
// Final fallback to legacy account creation
log.Printf("[Spotify Bridge] Falling back to legacy account creation for speaker %s", d.Name)
legacyCreds := models.NewSpotifyCredentials(acc.UserID, credential)
if err := c.SetMusicServiceAccount(legacyCreds); err != nil {
log.Printf("[Spotify Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err)
} else {
log.Printf("[Spotify Bridge] Legacy fallback successful for speaker %s", d.Name)
}
} else {
log.Printf("[Spotify Bridge] Sync notification successful for speaker %s", d.Name)
}
return
}
}
} else {
log.Printf("[Spotify Bridge] Successfully notified speaker %s", d.Name)
}
}(*dev)
}
}
}
// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped).
func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
+27 -28
View File
@@ -13,40 +13,39 @@ import (
)
func TestHandleMgmtSpotifyInit(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
// No spotify service configured
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyInit(w, req)
t.Run("POST - No spotify service configured", func(t *testing.T) {
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyInit(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
})
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
// With spotify service
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
t.Run("POST - Success", func(t *testing.T) {
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
w := httptest.NewRecorder()
s.HandleMgmtSpotifyInit(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
}
})
w = httptest.NewRecorder()
s.HandleMgmtSpotifyInit(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
}
}
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
@@ -92,7 +91,7 @@ func TestHandleMgmtListSpeakers(t *testing.T) {
}
func TestHandleMgmtSpotifyCallback(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
@@ -151,7 +150,7 @@ func TestHandleMgmtSpotifyCallback(t *testing.T) {
}
func TestHandleMgmtSpotifyConfirm(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
@@ -192,7 +191,7 @@ func TestHandleMgmtDeviceEvents(t *testing.T) {
}
func TestBasicAuthMgmt(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
s.SetMgmtConfig("admin", "secret123")
handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-193
View File
@@ -1,193 +0,0 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
)
// HandleBoseToken handles the Bose-specific token refresh request from the speaker.
// POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3
func (s *Server) HandleBoseToken(w http.ResponseWriter, r *http.Request) {
sourceID := chi.URLParam(r, "sourceID")
for _, provider := range constants.StaticProviders {
if strconv.Itoa(provider.ID) == sourceID && provider.Name == constants.ProviderSpotify {
s.HandleBoseSpotifyToken(w, r)
return
}
}
s.HandleBoseProxy(w, r)
}
// HandleBoseLegacyToken handles the Bose-specific token refresh request (legacy or variant).
// POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token
func (s *Server) HandleBoseLegacyToken(w http.ResponseWriter, r *http.Request) {
s.HandleBoseToken(w, r)
}
// HandleBoseAccountToken handles the Bose-specific token refresh/exchange request from the app.
// POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs
func (s *Server) HandleBoseAccountToken(w http.ResponseWriter, r *http.Request) {
sourceID := chi.URLParam(r, "sourceID")
// If it's Spotify, handle it.
if sourceID == strconv.Itoa(constants.SpotifyProviderID) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[OAuth Proxy] Failed to read body: %v", err)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
_ = r.Body.Close()
var tokenReq struct {
GrantType string `json:"grant_type"`
Code string `json:"code"`
RedirectURI string `json:"redirect_uri"`
}
if err := json.Unmarshal(body, &tokenReq); err == nil && tokenReq.GrantType == "authorization_code" {
log.Printf("[Spotify Proxy] Handling authorization_code grant for account addition")
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
log.Printf("[Spotify Proxy] Spotify service not configured")
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
if err := svc.ExchangeCodeAndStore(tokenReq.Code); err != nil {
log.Printf("[Spotify Proxy] Failed to exchange code: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// After successful exchange, we can return the token for the newly added account.
// HandleBoseSpotifyToken will pick the first account, which is fine if this is the only one.
s.HandleBoseSpotifyToken(w, r)
return
}
}
s.HandleBoseSpotifyToken(w, r)
}
// HandleBoseSpotifyToken handles the Bose-specific Spotify token refresh request.
// POST /oauth/device/{deviceID}/music/musicprovider/15/token/cs3
func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceID")
log.Printf("[Spotify Proxy] Intercepted token request for device %s", deviceID)
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
log.Printf("[Spotify Proxy] Spotify service not configured, falling back to upstream")
s.HandleBoseProxy(w, r)
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
log.Printf("[Spotify Proxy] No Spotify accounts linked, falling back to upstream")
s.HandleBoseProxy(w, r)
return
}
// We use the first linked account.
// However, if the request provides a "secret" (which we use as our Bose surrogate token),
// we should use that to find the specific account.
var (
account *spotify.Account
accessToken string
userID string
)
// Spotify registration/refresh often passes the secret in the body as "refresh_token"
// or in the registration flow as "code".
body, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
var tokenReq struct {
RefreshToken string `json:"refresh_token"`
GrantType string `json:"grant_type"`
Code string `json:"code"`
}
_ = json.Unmarshal(body, &tokenReq)
secret := tokenReq.RefreshToken
if secret == "" {
secret = tokenReq.Code
}
if secret != "" {
if acc, ok := svc.GetAccountBySecret(secret); ok {
account = acc
log.Printf("[Spotify Proxy] Found account for secret %s: %s", secret, acc.UserID)
}
}
if account != nil {
if err := svc.RefreshAccessToken(account); err != nil {
log.Printf("[Spotify Proxy] Failed to refresh token for %s: %v. Falling back to upstream", account.UserID, err)
s.HandleBoseProxy(w, r)
return
}
accessToken = account.AccessToken
} else {
// Fallback to first account for backward compatibility or when secret is missing
var err error
accessToken, userID, err = svc.GetFreshToken()
if err != nil {
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
s.HandleBoseProxy(w, r)
return
}
log.Printf("[Spotify Proxy] Using default account %s", userID)
}
// Format response as expected by Bose firmware.
// Based on observed interactions, it's a JSON object with access_token.
// The "scope" and other fields might be needed by some firmware versions.
response := map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
// These scopes are typical for what Bose requests.
"scope": "playlist-read-private playlist-read-collaborative streaming user-library-read user-library-modify playlist-modify-private playlist-modify-public user-read-email user-read-private user-top-read",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Proxy-Origin", "self")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("[Spotify Proxy] Failed to encode response: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
-137
View File
@@ -1,137 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
)
func TestHandleBoseSpotifyToken_LocalResponse(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// Wait, I can't easily inject an account into spotify.Service from here because fields are private.
// Let's check if there's any other way.
// I could mock the spotify.Service if it was an interface, but it's a struct.
// ss.load() expects accounts in tmpDir/spotify/accounts.json
spotifyDir := filepath.Join(tmpDir, "spotify")
_ = os.MkdirAll(spotifyDir, 0755)
account := map[string]interface{}{
"user1": map[string]interface{}{
"user_id": "user1",
"display_name": "Test User",
"access_token": "valid-token",
"refresh_token": "refresh-token",
"expires_at": time.Now().Add(1 * time.Hour).Unix(),
},
}
data, err := json.Marshal(account)
if err != nil {
t.Fatalf("Failed to marshal account: %v", err)
}
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), data, 0644)
// Initialize ss so it loads the data
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
if err := ss.Load(); err != nil {
t.Fatalf("Failed to load account: %v", err)
}
server.SetSpotifyService(ss)
// chi.URLParam works when using chi router
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Header().Get("X-Proxy-Origin") != "self" {
t.Errorf("Expected X-Proxy-Origin: self, got %s", w.Header().Get("X-Proxy-Origin"))
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp["access_token"] != "valid-token" {
t.Errorf("Expected access_token 'valid-token', got %v", resp["access_token"])
}
}
func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// Mirroring must be enabled for HandleBoseProxy to work (based on previous changes)
// Actually I reverted that, so it should work regardless of MirrorEnabled now.
server.SetMirrorSettings(true, nil, nil, "")
// chi.URLParam works when using chi router
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
// Since there's no Spotify service, it should fall back to HandleBoseProxy.
// HandleBoseProxy will try to contact streaming.bose.com.
// We can check if it returns a 502 or 404 (since we are not actually proxying to real Bose).
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
req.Host = "localhost" // use localhost to avoid real network call
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// If it fell back to proxy, it should NOT have X-Proxy-Origin: self
if w.Header().Get("X-Proxy-Origin") == "self" {
t.Errorf("Expected fallback to proxy, but got X-Proxy-Origin: self")
}
// HandleBoseProxy sets X-Proxy-Origin: upstream
if w.Header().Get("X-Proxy-Origin") != "upstream" {
// It might fail before setting the header if the target host is invalid,
// but our HandleBoseProxy sets it in ModifyResponse.
// If it fails to connect, it might return 502 without the header.
if w.Code != http.StatusBadGateway && w.Code != http.StatusNotFound {
t.Errorf("Expected fallback to proxy (upstream), got status %d and origin %s", w.Code, w.Header().Get("X-Proxy-Origin"))
}
}
}
func TestHandleBoseLegacyToken(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
// Since we are not configuring Spotify, it should fall back to proxy
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token", nil)
req.Host = "localhost"
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Header().Get("X-Proxy-Origin") == "self" {
t.Errorf("Expected fallback to proxy")
}
}
-16
View File
@@ -15,13 +15,6 @@ import (
// HandleProxyRequest handles requests to the logging proxy.
func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Bose-Proxy-Hop") != "" {
log.Printf("[PROXY_LOOP] Loop detected for %s %s, breaking loop", r.Method, r.URL.Path)
http.Error(w, "Loop detected", http.StatusNotFound)
return
}
targetURLStr := strings.TrimPrefix(r.URL.Path, "/proxy/")
if targetURLStr == "" {
http.Error(w, "Target URL is required", http.StatusBadRequest)
@@ -72,8 +65,6 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
pr.Out.URL.Path = target.Path
}
pr.Out.Header.Set("X-Bose-Proxy-Hop", "1")
lp.LogRequest(pr.Out)
},
Transport: &http.Transport{
@@ -110,13 +101,6 @@ func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) {
// HandleBoseProxy proxies the request to the Bose upstream.
func (s *Server) HandleBoseProxy(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Bose-Proxy-Hop") != "" {
log.Printf("[PROXY_LOOP] Loop detected for %s %s, breaking loop", r.Method, r.URL.Path)
http.Error(w, "Loop detected", http.StatusNotFound)
return
}
host := r.Host
if host == "" {
host = "streaming.bose.com"
+1 -1
View File
@@ -33,7 +33,7 @@ func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
defer backend.Close()
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, false)
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
server.recordEnabled = true
server.proxyLogBody = true
recorder := proxy.NewRecorder(tmpDir)
+49 -75
View File
@@ -86,6 +86,7 @@ func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, _ *http.Request)
go s.DiscoverDevices(context.Background())
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"status": "Discovery started"}`))
}
// HandleGetDiscoveryStatus returns the current discovery status.
@@ -155,7 +156,6 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsBindAddr := s.dnsBindAddr
mirrorEnabled := s.mirrorEnabled
mirrorEndpoints := s.mirrorEndpoints
skipMirrorEndpoints := s.skipMirrorEndpoints
preferredSource := s.preferredSource
internalPaths := s.internalPaths
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
@@ -166,25 +166,24 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsRunning, actualBind := s.GetDNSRunning()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
"mirror_enabled": mirrorEnabled,
"mirror_endpoints": mirrorEndpoints,
"skip_mirror_endpoints": skipMirrorEndpoints,
"preferred_source": preferredSource,
"internal_paths": internalPaths,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
"spotify_configured": spotifyConfigured,
"server_url": serverURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
"mirror_enabled": mirrorEnabled,
"mirror_endpoints": mirrorEndpoints,
"preferred_source": preferredSource,
"internal_paths": internalPaths,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
"spotify_configured": spotifyConfigured,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -194,18 +193,17 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
// HandleUpdateSettings updates the service settings.
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
ServerURL string `json:"server_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints"`
PreferredSource string `json:"preferred_source"`
InternalPaths []string `json:"internal_paths"`
Shortcuts map[string]int `json:"shortcuts"`
ServerURL string `json:"server_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints"`
PreferredSource string `json:"preferred_source"`
InternalPaths []string `json:"internal_paths"`
Shortcuts map[string]int `json:"shortcuts"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -251,7 +249,6 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.mirrorEnabled = settings.MirrorEnabled
s.mirrorEndpoints = settings.MirrorEndpoints
s.skipMirrorEndpoints = settings.SkipMirrorEndpoints
s.preferredSource = settings.PreferredSource
s.internalPaths = settings.InternalPaths
@@ -272,22 +269,21 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
err = s.ds.SaveSettings(datastore.Settings{
ServerURL: s.serverURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
MirrorEnabled: s.mirrorEnabled,
MirrorEndpoints: s.mirrorEndpoints,
SkipMirrorEndpoints: s.skipMirrorEndpoints,
PreferredSource: s.preferredSource,
InternalPaths: s.internalPaths,
Shortcuts: s.shortcuts,
ServerURL: s.serverURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
MirrorEnabled: s.mirrorEnabled,
MirrorEndpoints: s.mirrorEndpoints,
PreferredSource: s.preferredSource,
InternalPaths: s.internalPaths,
Shortcuts: s.shortcuts,
})
dnsEnabled := s.dnsEnabled
@@ -1084,36 +1080,14 @@ func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
// HandleGetVersionInfo returns version information for the service.
func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
version := s.Version
commit := s.Commit
date := s.Date
repoURL := s.RepoURL
s.mu.RUnlock()
defer s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
var (
releaseURL string
commitURL string
)
if commit != "" && commit != "unknown" {
commitURL = fmt.Sprintf("%s/commit/%s", repoURL, commit)
}
// Release version: should point to the release, e.g. https://github.com/gesellix/Bose-SoundTouch/releases/tag/v0.58.0
// "dirty" versions don't get a release link (only the commit).
if version != "" && version != "dev" && version != "(devel)" && !strings.Contains(version, "dirty") {
releaseURL = fmt.Sprintf("%s/releases/tag/%s", repoURL, version)
}
if err := json.NewEncoder(w).Encode(map[string]string{
"version": version,
"commit": commit,
"date": date,
"repo_url": repoURL,
"release_url": releaseURL,
"commit_url": commitURL,
"version": s.Version,
"commit": s.Commit,
"date": s.Date,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
+23 -20
View File
@@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
}
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Migrate: Expected output field in response")
}
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("TrustCA: Expected output field in response")
}
// 4. Test POST /setup/reboot/{deviceIP}
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
// 4. Test POST /devices/{deviceIP}/reboot
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Reboot: Expected output field in response")
}
// 5. Test POST /setup/remove-remote-services/{deviceIP}
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -329,17 +329,20 @@ func TestRemoveDevice(t *testing.T) {
_ = ds.Initialize()
// Setup a dummy device in the datastore
account := "test-account"
account := "acc1"
deviceID := "TEST-DEVICE-ID"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
t.Fatalf("Failed to create device info file: %v", err)
// Register device in datastore so HandleRemoveDevice works
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: account,
IPAddress: "192.168.1.100",
})
// Verify directory exists where datastore expects it
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if _, err := os.Stat(deviceDir); err != nil {
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
}
r, _ := setupRouter("http://localhost:8001", ds)
@@ -347,7 +350,7 @@ func TestRemoveDevice(t *testing.T) {
defer ts.Close()
// 1. Verify device exists
res, err := http.Get(ts.URL + "/setup/devices")
res, err := http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
@@ -370,7 +373,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 2. Remove device
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
if err != nil {
t.Fatal(err)
}
@@ -385,7 +388,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 3. Verify device is gone
res, err = http.Get(ts.URL + "/setup/devices")
res, err = http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStatsHandlers(t *testing.T) {
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
s := NewServer(ds, nil, "http://localhost", false, false, false)
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
t.Run("HandleUsageStats XML", func(t *testing.T) {
xmlData := `
+124
View File
@@ -0,0 +1,124 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
// HandleGetStockholmDeviceInfo returns live information for a device.
func (s *Server) HandleGetStockholmDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Include IP address in both snake_case and camelCase for frontend compatibility
type deviceInfoResponse struct {
*setup.DeviceInfoXML `json:",inline"`
IPAddress string `json:"ip_address"`
IPAddressCamel string `json:"ipAddress,omitempty"`
}
resp := deviceInfoResponse{
DeviceInfoXML: info,
IPAddress: deviceIP,
IPAddressCamel: deviceIP,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleDeviceKey sends a key command to a device.
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
key := chi.URLParam(r, "key")
if deviceID == "" || key == "" {
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SendKey(key)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// HandleDeviceVolume sets the volume level for a device.
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
levelStr := chi.URLParam(r, "level")
if deviceID == "" || levelStr == "" {
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
level, err := strconv.Atoi(levelStr)
if err != nil {
http.Error(w, "Invalid volume level", http.StatusBadRequest)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SetVolume(level)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+203
View File
@@ -0,0 +1,203 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
const (
pongWait = 40 * time.Second
pingPeriod = 20 * time.Second // must be less than pongWait
)
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Upgrade the HTTP connection to a WebSocket for the browser
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Create a SoundTouch WebSocket client for the target device
c := client.NewClientFromHost(deviceIP)
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
// Channel-based write pump per Gorilla best practices
sendCh := make(chan []byte, 64) // buffer to smooth bursts
closeCh := make(chan struct{})
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
enqueue := func(v interface{}) {
b, err := json.Marshal(v)
if err != nil {
return
}
select {
case sendCh <- b:
default:
// drop to protect connection under burst
}
}
// Reader: we don't expect messages from the browser; just keep the
// connection alive by processing control frames and detect close.
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
go func() {
defer func() {
close(closeCh)
_ = wsClient.Disconnect()
_ = conn.Close()
}()
for {
mt, _, err := conn.ReadMessage()
if err != nil {
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
return
}
if mt == websocket.CloseMessage {
return
}
}
}()
// Writer: single writer goroutine handles JSON writes and ping keepalive
go func() {
pingTicker := time.NewTicker(pingPeriod)
defer func() {
pingTicker.Stop()
_ = conn.Close()
}()
for {
select {
case msg, ok := <-sendCh:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-pingTicker.C:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-closeCh:
return
}
}
}()
// Forward typed events with a simple envelope into the send queue
wsClient.SetHandlers(&models.WebSocketEventHandlers{
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
},
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
},
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
},
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
},
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
},
OnBassUpdated: func(e *models.BassUpdatedEvent) {
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
},
OnUnknownEvent: func(event *models.WebSocketEvent) {
bytes, _ := json.Marshal(event)
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
},
OnSpecialMessage: func(msg *models.SpecialMessage) {
enqueue(map[string]interface{}{"type": "special", "payload": msg})
},
})
// Add a separate goroutine to monitor the device connection status
go func() {
wsClient.Wait()
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
_ = conn.Close()
}()
// Connect to the device WebSocket
if err := wsClient.Connect(); err != nil {
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
return
}
// Optional: send an initial snapshot for convenience
go func() {
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
return
}
// Supplement with volume and now playing
c := client.NewClientFromHost(deviceIP)
payload := map[string]interface{}{
"deviceID": info.DeviceID,
"name": info.Name,
"type": info.Type,
//"maccAddress": info.MaccAddress,
"serialNumber": info.SerialNumber,
"softwareVersion": info.SoftwareVer,
// Provide IP in both styles for frontend robustness
"ip_address": deviceIP,
"ipAddress": deviceIP,
}
if vol, err := c.GetVolume(); err == nil {
payload["volume"] = vol
// Also add at top level for flatter frontend parsing
payload["actualVolume"] = vol.ActualVolume
}
if np, err := c.GetNowPlaying(); err == nil {
payload["nowPlaying"] = np
}
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
}()
}
+2 -2
View File
@@ -23,7 +23,7 @@ func TestInteractionHandlers(t *testing.T) {
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, false)
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
@@ -151,7 +151,7 @@ func TestRecordMiddleware(t *testing.T) {
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, true)
server := NewServer(ds, nil, "http://localhost", false, false, true, false, false)
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)

Some files were not shown because too many files have changed in this diff Show More