Compare commits

...
92 Commits
Author SHA1 Message Date
Tobias Gesellchen 1288a619f7 Add Stockholm Mini
This is also a refactoring of our api paths
2026-02-21 00:49:18 +01:00
Tobias Gesellchen ec8bbb2f86 Lint: cleanup 2026-02-21 00:42:07 +01:00
Tobias Gesellchen e75e2bea0c Update Raspberry Pi installation script to include Spotify 2026-02-21 00:42:07 +01:00
Tobias Gesellchen dd5aa2ad53 Disable HTML escaping in JSON response 2026-02-21 00:42:07 +01:00
Tobias Gesellchen aced0f3f81 Use the Chi BasicAuth middleware 2026-02-21 00:42:07 +01:00
Tobias Gesellchen a886518cad Add example redirect URIs for both browser and ueberboese-app 2026-02-21 00:42:07 +01:00
Tim Van Wassenhove dc81b0aa81 feat: separate browser callback and mobile app confirm endpoints
- Add GET /mgmt/spotify/callback (no auth) for browser OAuth redirect
- Restore POST /mgmt/spotify/confirm (Basic Auth) for ueberboese mobile app
- Callback returns HTML success/error pages; confirm returns JSON
- Both call the same ExchangeCodeAndStore() logic
2026-02-21 00:21:52 +01:00
Tim Van Wassenhove c648027735 fix: OAuth callback as GET outside auth group, remove dead zeroconf flag, update .env.example
- Change /mgmt/spotify/confirm from POST to GET (Spotify redirects via GET)
- Move confirm endpoint outside Basic Auth group (code is single-use, needs client_secret)
- Remove --zeroconf-primer-enabled flag (no ZeroConf primer code on this branch)
- Add Spotify/mgmt env var documentation to .env.example
2026-02-21 00:21:52 +01:00
Tim Van Wassenhove fced88a8a6 feat: add management API endpoints matching ueberboese-app 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove 0ee673c097 feat: wire Spotify service into server 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove 395b2fec8e feat: add Spotify OAuth service with token management 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove be7e44e14b feat: add Basic Auth middleware for management API 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove a87783d8c6 feat: add Spotify, management, and ZeroConf CLI flags 2026-02-21 00:21:52 +01:00
Tobias Gesellchen be017440b7 Add userInactivity event 2026-02-20 09:18:53 +01:00
Tobias Gesellchen 10de011c18 Simplify the PlayTTS method cmd 2026-02-19 08:46:55 +01:00
Tobias Gesellchen f7b74db3ea Make the linter happy 2026-02-19 08:44:26 +01:00
Tobias Gesellchen 72d75133c4 Capture server references before releasing mutex to avoid race condition 2026-02-19 08:44:26 +01:00
Tobias Gesellchen e4c12471b4 Add more upstream domains to the intercept list 2026-02-19 08:44:26 +01:00
Tobias Gesellchen 3329149282 Add support for RADIO_BROWSER source
This implementation follows the reference from soundcork pull request #158. It adds RADIO_BROWSER to the known providers and includes the service configuration in bmx_services.json. Documentation has also been added to explain how to use the RadioBrowser feature. Credits to @gmuth (https://github.com/gmuth) for the original idea and implementation in soundcork. Reference: https://github.com/deborahgu/soundcork/pull/158
2026-02-16 22:18:33 +01:00
Tobias Gesellchen 523ff0eb17 Fix deadlock in settings update and add efficient DNS settings validation 2026-02-16 21:02:42 +01:00
Tobias Gesellchen 025e15d65c Implement log throttling, loop prevention, and empty upstream handling in DNS discovery server 2026-02-16 21:02:42 +01:00
Tobias Gesellchen 7d140b3e2a Fix TestMigrationAndCA by enhancing mock SSH client
This commit updates the mock SSH client in the handler tests to support the recently added verification steps. It now correctly handles stateful responses for /etc/hosts and properly responds to file existence and CA trust checks.
2026-02-16 20:17:25 +01:00
Tobias Gesellchen 69210638e5 Add verification steps to speaker migration process
This update adds explicit verification checks after applying changes via XML, Hosts, and ResolvConf migration methods. The service now verifies that configuration files are correctly updated on the device before considering the migration successful, preventing unreliable states.
2026-02-16 20:17:25 +01:00
Tobias Gesellchen 6aef2b807d Enhance ResolvConf migration to support multiple DHCP script variants
This update allows the service to correctly patch both /etc/udhcpc.d/50default and /opt/Bose/udhcpc.script (used in SoundTouch 10 firmware) for DNS redirection. It also improves robustness by adding file existence checks in rc.local and ensures clean state by reverting to .original backups during migration.
2026-02-16 18:52:25 +01:00
Tobias Gesellchen 95f5e9c831 fix(setup): prevent and clean up corrupted rc.local with cat error message 2026-02-16 18:20:16 +01:00
Tobias Gesellchen 7337296ae9 refactor(setup): reduce cyclomatic complexity of RevertMigration 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 92a5d3592c feat(setup): replace obsolete resolv method with persistent DHCP-aware DNS hook 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 2f04af872b feat(setup): implement Aftertouch Hook (DHCP-aware DNS redirection); update UI and tests; docs now use aftertouch.resolv.conf 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 9479d6d11d Fix missing request body in recorded proxy interactions 2026-02-16 16:30:41 +01:00
Tobias Gesellchen f687ba0d82 go mod tidy 2026-02-16 12:45:04 +01:00
Tobias Gesellchen ab2bf0731a Add DNS-based discovery and migration via /etc/resolv.conf 2026-02-16 12:18:06 +01:00
Tobias Gesellchen cafaba1be0 Update SOUNDTOUCH-SERVICE.md with recent features (Soundcork proxy, session archiving, enhanced redaction) 2026-02-15 23:54:14 +01:00
Tobias Gesellchen 93082d2cdc Update root endpoint JSON response with AfterTouch and docs link 2026-02-15 23:47:10 +01:00
Tobias Gesellchen 087006c483 Add regression test for settings persistence 2026-02-15 23:28:45 +01:00
Tobias Gesellchen b7013a5ec8 Apply 'Redact Sensitive Headers' to recordings 2026-02-15 23:12:19 +01:00
Tobias Gesellchen 7d76b3fab2 Implement dynamic Bose proxy with detailed origin logging and Soundcork fallback 2026-02-15 22:50:13 +01:00
Tobias Gesellchen 6ca206053f Add session download feature to web UI 2026-02-15 22:20:16 +01:00
Tobias Gesellchen 090eb162fb Fix TypeError in Web UI by renaming proxy-domain to soundcork-url
This commit fixes a JS error in showSummary and migrate functions where they were still trying to access the UI element by its old ID 'proxy-domain' instead of the new 'soundcork-url'.
2026-02-15 22:01:58 +01:00
dependabot[bot] 972824e07f ci(deps): bump the actions-core group with 3 updates
Bumps the actions-core group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/configure-pages](https://github.com/actions/configure-pages) and [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact).


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `actions/configure-pages` from 4 to 5
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

Updates `actions/upload-pages-artifact` from 3 to 4
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-15 21:57:12 +01:00
dependabot[bot] 1e2148d53b deps(deps): bump the golang group with 4 updates
Bumps the golang group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/crypto` from 0.47.0 to 0.48.0
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.48.0)

Updates `golang.org/x/mod` from 0.32.0 to 0.33.0
- [Commits](https://github.com/golang/mod/compare/v0.32.0...v0.33.0)

Updates `golang.org/x/net` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

Updates `golang.org/x/tools` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.33.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.42.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-15 21:56:17 +01:00
Tobias Gesellchen 9a070da1ef Fix data race in RecordMiddleware and improve recorder robustness
This commit addresses the data race detected in TestRecordMiddleware: - Updated Recorder.Record to clone Request and Response objects (including bodies) before background processing. - Ensures background workers can safely access data after the main request handler has finished. - Enabled synchronous recording in handler tests to ensure deterministic results and avoid race conditions.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen d4b518da23 Fix proxy and recorder tests by ensuring synchronous recording during testing
This commit addresses the test failures in pkg/service/proxy: - Ensures synchronous recording in tests by setting RECORDER_ASYNC=false. - Adds a Close() method to the Recorder for proper cleanup. - Fixes a panic in TestRecorder_Record_Redaction caused by race conditions.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen 89bafd97b6 Optimize recording performance and add Soundcork proxy toggle
This commit introduces several key improvements: Performance Optimization (asynchronous recording), Legacy Proxy Control (Soundcork proxy toggle), X-Forwarded-For Sanitization, consistent Soundcork naming across the stack, and various code quality improvements.
2026-02-15 21:51:55 +01:00
Tobias Gesellchen d616bc09fd fix unbound variable (tmp) 2026-02-15 20:48:26 +01:00
Tobias Gesellchen 8af60c7e4b fix linter issues 2026-02-15 20:20:47 +01:00
Tobias Gesellchen 8a21db3517 Capture additional redirect methods and improve recorder functionality 2026-02-15 20:20:47 +01:00
Tobias Gesellchen 742484568e feat: implement Stockholm-related cloud API emulation - Added handlers for Stockholm app events (/v1/stapp, /v1/scmudc) - Implemented account profile, password management, and device settings endpoints - Added Go models for new API responses and requests - Created docs/reference/CLOUD-API.md and updated SUMMARY.md - Added comprehensive unit tests for all new handlers - Updated ueberboese-api.yaml with new endpoints and schemas 2026-02-15 20:20:47 +01:00
Tobias Gesellchen ed2d8680e4 fix: align streaming_token with Bose protocol to avoid 502 errors 2026-02-15 18:58:35 +01:00
Tobias Gesellchen 6dc8c23f04 feat: detect migrated devices and prompt for reboot after migration 2026-02-15 18:58:35 +01:00
Tobias Gesellchen fa57ee9574 Rebrand to AfterTouch and cleanup SoundCork references 2026-02-15 18:09:49 +01:00
Tobias Gesellchen e438db05d9 Fix release workflow to avoid +dirty version suffix by building in isolated directory 2026-02-15 17:27:41 +01:00
Tobias Gesellchen 8c02a009dc Update documentation for interaction session management 2026-02-15 16:52:44 +01:00
Tobias Gesellchen f20cfcb319 Enhance interaction session management and cleanup UI 2026-02-15 16:52:44 +01:00
Tobias Gesellchen a453059d6d Enhance interaction recording and analysis features 2026-02-15 16:52:44 +01:00
Tobias Gesellchen 505e6dd760 Refactor data storage to use account-based hierarchy and update Web UI 2026-02-15 15:36:01 +01:00
Tobias Gesellchen 735187cae8 docs: link README.md in SUMMARY.md to fix TestDocsConsistency 2026-02-15 00:50:29 +01:00
Tobias Gesellchen e8622cc382 docs: add Jekyll build step to workflow 2026-02-15 00:38:04 +01:00
Tobias Gesellchen c59052bdb4 docs: improve Jekyll configuration with minimal theme and relative links plugin 2026-02-15 00:35:32 +01:00
Tobias Gesellchen 15a6c4b0a0 docs: add Jekyll configuration with Cayman theme 2026-02-15 00:35:19 +01:00
Tobias Gesellchen ae3a3765db docs: add landing page for GitHub Pages 2026-02-15 00:32:32 +01:00
Tobias Gesellchen 59019cf55c docs: deploy documentation to GitHub Pages and update links in Web UI and README 2026-02-15 00:30:28 +01:00
Tobias Gesellchen 5e612e57ec Fix golangci-lint issues in main.go 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 02026a9f3a Add configurable shortcuts and log them on startup 2026-02-15 00:27:25 +01:00
Tobias Gesellchen aaf067088a Auto-create missing configuration files with default values 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 358ea18138 Implement device merging logic and web-based device removal 2026-02-15 00:15:09 +01:00
Tobias Gesellchen 0c5c1803a5 docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen cdf80a793e docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b511e052e2 docs: fix broken documentation links and update CI workflow paths 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b7197a8679 Add version visibility and discovery controls to Web UI and API 2026-02-14 23:03:53 +01:00
Tobias Gesellchen 5bfc24b7fb Use v0.18.1 version as default 2026-02-14 22:21:43 +01:00
Tobias Gesellchen 1e61adbb46 Integrate self-update logic into Raspberry Pi installer and simplify update workflow 2026-02-14 22:21:43 +01:00
Tobias Gesellchen b8ab4b5723 Enhance Raspberry Pi installer and modernize systemd deployment documentation 2026-02-14 21:53:24 +01:00
Tobias Gesellchen 5da7e001b2 Add a Systemd install script 2026-02-14 21:53:24 +01:00
Tobias Gesellchen 5269c05e56 Enhance settings management with persistence and explicit saving, including SAN updates and unit tests 2026-02-14 21:50:36 +01:00
Tobias Gesellchen d7a15c4dbe Minor cleanup and formatting fixes in docs handler and setup manager 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 701889076d Refactor documentation structure, add SUMMARY.md sidebar, and automated consistency checks 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 3acc983183 Cleanup the web ui/flow 2026-02-14 18:26:52 +01:00
Tobias Gesellchen 7d40c61cad Fix variable shadowing in setup methods
Renamed shadowed 'err' and 'out' variables in MigrateSpeaker and TrustCACert to comply with linting rules.
2026-02-14 14:24:46 +01:00
Tobias Gesellchen 93cfd9dbbc Implement safe migration revert with backup validation
- Added strict guardrails for RevertMigration: now fails if .original backup is missing.
- Revert now uses copy (cp) instead of move (mv) to preserve original backups on the device.
- Decoupled reboot from migration/revert processes, making it a manual operation.
- Added standalone Reboot API and manual 'Reboot Speaker' button in the Web UI.
- Separated 'Remove Remote Services' from the revert process to allow independent management.
- Implemented command output capture and display in the Web UI for all setup actions (Migrate, Revert, Trust CA, Backup, Reboot, Remove Remote Services).
- Updated doc.go with a modern overview of the library and SoundTouch service features.
- Fixed several tests to align with new method signatures and behavior changes.
2026-02-14 14:24:46 +01:00
Tobias Gesellchen e084f8db1f Enhance Docker configuration for Swarm and consolidate environment templates 2026-02-14 13:09:52 +01:00
Tobias Gesellchen a19d34b55e Align soundtouch-service CLI with soundtouch-cli 2026-02-14 12:55:14 +01:00
Tobias Gesellchen dcf2e29c16 Fix linting issues and refactor for improved code quality 2026-02-14 12:39:39 +01:00
Tobias Gesellchen 9be1c7d588 Allow toggling HTTP interaction recording via CLI, environment, and Web UI 2026-02-14 12:39:39 +01:00
Tobias Gesellchen 133c07fefa Improve visibility of multiple devices in recordings by adding original value comments to .http files 2026-02-14 12:39:39 +01:00
Tobias Gesellchen ef90b4e848 Improve structure and re-usability of HTTP interaction recordings 2026-02-14 12:39:39 +01:00
Tobias Gesellchen c8ef1a9de4 Update README and documentation to reflect Toolkit expansion and Docker support 2026-02-14 00:14:29 +01:00
Tobias Gesellchen e47fa4c92c Complete local Bose SoundTouch emulation service with guided migration UI 2026-02-14 00:14:29 +01:00
Tobias Gesellchen 1a39c14b35 Rename crypto package to certmanager to resolve golangci-lint naming conflict
- Renamed pkg/service/crypto to pkg/service/certmanager
- Updated package declaration from 'crypto' to 'certmanager'
- Fixed all import statements across the codebase
- Updated type references from *crypto.CertificateManager to *certmanager.CertificateManager
- Renamed files for consistency: crypto.go -> certmanager.go, crypto_test.go -> certmanager_test.go
- Resolves golangci-lint var-naming issue about conflicting with Go standard library package names
- All tests pass and linter reports 0 issues
2026-02-13 22:36:36 +01:00
Tobias Gesellchen c9f648096e Implement label-based CA certificate management and add timeout flags to curl commands 2026-02-13 22:36:36 +01:00
Tobias Gesellchen 408753c33e Add note about automatic TLS SAN inclusion for custom server URLs 2026-02-13 00:11:27 +01:00
Tobias Gesellchen dff060565e Add Docker usage example with custom server URLs 2026-02-13 00:11:27 +01:00
Tobias Gesellchen b5df6ab91f Include SERVER_URL and HTTPS_SERVER_URL hostnames in TLS certificate SANs 2026-02-13 00:11:27 +01:00
157 changed files with 15453 additions and 2244 deletions
+20
View File
@@ -1,6 +1,9 @@
# Bose SoundTouch Configuration
# Copy this file to .env and customize for your setup
# Docker/Service Settings
SOUNDTOUCH_HOSTNAME=soundtouch.local
# Discovery Settings
DISCOVERY_TIMEOUT=5s
UPNP_ENABLED=true
@@ -38,3 +41,20 @@ PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.
# Alternative format examples:
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
# Spotify Integration
# Create an app at https://developer.spotify.com/dashboard
# SPOTIFY_CLIENT_ID=your_client_id
# SPOTIFY_CLIENT_SECRET=your_client_secret
# Auth confirmation url using GET, works in browsers
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/callback
# Auth confirmation url using POST, works with the ueberboese-app (https://github.com/julius-d/ueberboese-app)
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/confirm
# Management API Authentication
# Protects /mgmt/* endpoints (Spotify token access, account management)
MGMT_USERNAME=admin
MGMT_PASSWORD=change_me!
# External base URL (required when behind a reverse proxy for OAuth callbacks)
# BASE_URL=https://your-server.example.com
+3 -3
View File
@@ -149,8 +149,8 @@ jobs:
# Check that all documented endpoints exist in code
echo "Validating API documentation consistency..."
# Extract endpoint patterns from cookbook
if [ -f "docs/API-COOKBOOK.md" ]; then
# Check API cookbook
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
echo "✓ API Cookbook exists"
else
echo "✗ API Cookbook missing"
@@ -158,7 +158,7 @@ jobs:
fi
# Check getting started guide
if [ -f "docs/GETTING-STARTED.md" ]; then
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
echo "✓ Getting Started guide exists"
else
echo "✗ Getting Started guide missing"
+37
View File
@@ -0,0 +1,37 @@
name: Deploy Documentation
on:
push:
branches:
- main
paths:
- 'docs/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
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@v4
with:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+7 -4
View File
@@ -133,10 +133,13 @@ jobs:
local CMD_PATH=$2
local OUTPUT_NAME
# Ensure build directory exists
mkdir -p build
if [[ "${{ matrix.goos }}" == "windows" ]]; then
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
else
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
fi
echo "Building $BINARY_NAME: $OUTPUT_NAME"
@@ -193,8 +196,8 @@ jobs:
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: |
soundtouch-cli-v*
soundtouch-service-v*
build/soundtouch-cli-v*
build/soundtouch-service-v*
retention-days: 1
checksums:
+3
View File
@@ -19,14 +19,17 @@ dist/
/example-unified
/mdns-scanner
/websocket-demo
/main
# Environment configuration
.env
.env.local
.env.*.local
docker-compose.override.yml
# Test coverage reports
coverage.out
coverage*.out
coverage.html
*.prof
+4 -4
View File
@@ -76,7 +76,7 @@ When filing a bug report, include:
Feature requests are welcome! Please:
1. **Check if the feature already exists** in documentation
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/API-Endpoints-Overview.md))
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
3. **Explain the use case** and how it benefits users
### 🔧 Contributing Code
@@ -469,10 +469,10 @@ Contributors will be:
- [Go Documentation](https://golang.org/doc/)
- [Effective Go](https://golang.org/doc/effective_go.html)
- [Bose SoundTouch API Documentation](docs/API-Endpoints-Overview.md)
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
- [Project Architecture](docs/PROJECT-PATTERNS.md)
- [Development Status](docs/STATUS.md)
- [Development Status](docs/archive/STATUS.md)
---
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
+54 -145
View File
@@ -1,6 +1,6 @@
# Bose SoundTouch API Client
# Bose SoundTouch Toolkit
A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices via their Web API.
A comprehensive solution for controlling and preserving Bose SoundTouch devices, including a Go library, CLI tool, and a local service for cloud emulation.
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
@@ -17,11 +17,16 @@ A comprehensive Go library and CLI tool for controlling 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
- 📻 **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
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
- 📊 **Traffic Analysis**: Proxy and log device communications for debugging
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
- 📊 **Traffic Analysis**: Proxy and log device communications
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
@@ -42,148 +47,52 @@ go get github.com/gesellix/bose-soundtouch
### CLI Usage
#### Discover Devices
Find SoundTouch devices on your network:
```bash
# Find SoundTouch devices on your network
soundtouch-cli discover devices
```
# Control a Device
Control a device (replace `192.168.1.100` with your speaker's IP):
```bash
# Basic device information
soundtouch-cli --host 192.168.1.100 info get
# Basic information
soundtouch-cli --host 192.168.1.100 info
# Media controls
soundtouch-cli --host 192.168.1.100 play start
soundtouch-cli --host 192.168.1.100 volume set --level 50
soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
# Preset management
soundtouch-cli --host 192.168.1.100 preset list
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
soundtouch-cli --host 192.168.1.100 preset select --slot 1
# Browse and discover content
soundtouch-cli --host 192.168.1.100 browse tunein
soundtouch-cli --host 192.168.1.100 station search-tunein --query "jazz"
soundtouch-cli --host 192.168.1.100 station add --source TUNEIN --token <token> --name "Jazz Radio"
# Speaker notifications (ST-10 only)
soundtouch-cli --host 192.168.1.100 speaker tts --text "Welcome home" --app-key YOUR_KEY
soundtouch-cli --host 192.168.1.100 speaker url --url "https://example.com/doorbell.mp3" --app-key YOUR_KEY
soundtouch-cli --host 192.168.1.100 speaker beep
# Real-time monitoring
soundtouch-cli --host 192.168.1.100 events subscribe
```
### SoundTouch Service
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
The `soundtouch-service` is a local server that emulates Bose's cloud services, enabling offline operation and custom integrations. This is particularly valuable as Bose has announced the discontinuation of cloud support in May 2026.
### SoundTouch Service (Cloud Shutdown Protection)
#### Key Features
The `soundtouch-service` is a local server that emulates Bose's cloud services. This is critical for keeping your speakers functional after the **Bose Cloud Shutdown in May 2026**.
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
#### Quick Start
#### Key Features:
- **🏠 Local Emulation**: BMX and Marge service implementation
- **🔌 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
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
#### Quick Start:
```bash
# Install the service
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
# Start with default settings (http://localhost:8000, proxying to http://localhost:8001)
# Start the service
soundtouch-service
# Or configure with environment variables
PORT=9000 PYTHON_BACKEND_URL=http://your-python-backend:8001 DATA_DIR=/my/data soundtouch-service
```
Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface.
#### Running with Docker
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html).
You can also run the SoundTouch service using Docker or Docker Compose.
Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html).
> **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.
##### Using Docker
**Linux (with host networking for discovery):**
```bash
docker run -d \
--name soundtouch-service \
--network host \
-v $(pwd)/data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
**macOS / Windows (with port mapping):**
```bash
docker run -d \
--name soundtouch-service \
-p 8000:8000 \
-v $(pwd)/data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
##### Using Docker Compose
Create a `docker-compose.yml` file:
```yaml
services:
soundtouch-service:
image: ghcr.io/gesellix/bose-soundtouch:latest
container_name: soundtouch-service
# Linux users: use host networking for device discovery
# network_mode: host
# macOS/Windows users: use port mapping (discovery will be manual)
ports:
- "8000:8000"
environment:
- PORT=8000
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
restart: unless-stopped
```
And run:
```bash
docker-compose up -d
```
> **Note**: `--network host` is required for device discovery via UPnP and mDNS to work correctly within the container.
#### Device Migration Example
```bash
# 1. Start the service
soundtouch-service
# 2. Open web UI at http://localhost:8000
# 3. Discover your devices
# 4. Click "Migrate" to configure devices to use local services
# Or use the API directly:
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
```
#### Service Endpoints
- **Web UI**: `http://localhost:8000/` - Device management interface
- **Discovery**: `GET /setup/devices` - List discovered devices
- **Migration**: `POST /setup/migrate/{deviceIP}` - Switch device to local services
- **BMX Services**: `/bmx/*` - Music service emulation (TuneIn, etc.)
- **Marge Services**: `/marge/*` - Account and device management
- **Proxy**: `/proxy/*` - Traffic inspection and debugging
See [docs/SOUNDTOUCH-SERVICE.md](docs/SOUNDTOUCH-SERVICE.md) for detailed configuration and API reference.
For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
### Library Usage
@@ -412,8 +321,8 @@ func main() {
Port: 8090,
})
// Play Text-to-Speech message
err := c.PlayTTS("Welcome home!", "your-app-key", 70)
// 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)
}
@@ -476,19 +385,19 @@ This library supports all Bose SoundTouch-compatible devices, including:
## Documentation
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
- 📚 [API Reference](docs/API-Endpoints-Overview.md) - Complete endpoint documentation
- 🔧 [CLI Reference](docs/CLI-REFERENCE.md) - Command-line tool guide
- 🌐 [SoundTouch Service Guide](docs/SOUNDTOUCH-SERVICE.md) - Local service setup and migration
- 🎯 [Getting Started](docs/GETTING-STARTED.md) - Detailed setup and usage
- 📻 [Preset Quick Start](docs/PRESET-QUICKSTART.md) - Favorite content management
- 🧭 [Navigation Guide](docs/NAVIGATION-GUIDE.md) - Content browsing and station management
- 📋 [Navigation API Reference](docs/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
- ⚙️ [Advanced Features](docs/SYSTEM-ENDPOINTS.md) - Advanced functionality
- 🏠 [Multiroom Setup](docs/zone-management.md) - Zone configuration guide
- ⚡ [WebSocket Events](docs/websocket-events.md) - Real-time event handling
- 🔔 [Speaker Notifications](docs/SPEAKER_ENDPOINT.md) - TTS and audio notifications guide
- 🔍 [Device Discovery](docs/DISCOVERY.md) - Discovery configuration
- 🛠️ [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation
- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide
- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration
- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage
- 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management
- 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management
- 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality
- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide
- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling
- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide
- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration
- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions
## Development
@@ -576,7 +485,7 @@ This project builds upon the excellent work of several community projects:
### SoundCork 🍾
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
- **Authors**: Deborah Kaplan and contributors
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by and based on SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
- **License**: MIT License
@@ -623,13 +532,13 @@ If you discover new endpoints, features, or improvements through this library, p
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new)
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
-**Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
- 📖 **Documentation**: Browse the [docs/](docs/) directory
- 🔍 **New Discoveries**: See [Undocumented Community Features](docs/UNDOCUMENTED-COMMUNITY-FEATURES.md) for advanced API research
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](docs/UPSTREAM-URLS-ANALYSIS.md) for cloud dependency research
- 🔧 **Redirection Guide**: [Device Redirect Methods](docs/DEVICE-REDIRECT-METHODS.md) for custom service setup
- 🐣 **Initial Setup**: [Device Initial Setup Variants](docs/DEVICE-INITIAL-SETUP.md) for out-of-the-box configuration
- 📜 **Logging & Debugging**: [Device Logging Guide](docs/DEVICE-LOGGING.md) for accessing system and traffic logs
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](docs/HTTPS-SETUP.md) for secure `/etc/hosts` redirection
- 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/)
- 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md)
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html)
- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html)
- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
- 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md)
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
---
+10
View File
@@ -389,6 +389,10 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
if !filters["userActivity"] {
return
}
case models.MessageTypeUserInactivity:
if !filters["userInactivity"] {
return
}
}
}
@@ -402,6 +406,12 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
case models.MessageTypeUserActivity:
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
case models.MessageTypeUserInactivity:
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
+8 -19
View File
@@ -2,7 +2,6 @@ package main
import (
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -35,23 +34,12 @@ func playTTS(c *cli.Context) error {
return err
}
// URL encode the text for Google TTS
encodedText := url.QueryEscape(text)
// Build TTS URL with language support
ttsURL := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, encodedText)
// Create PlayInfo for TTS
playInfo := &models.PlayInfo{
URL: ttsURL,
AppKey: appKey,
Service: "TTS Notification",
Message: "Google TTS",
Reason: text,
}
var playInfo *models.PlayInfo
if volume > 0 {
playInfo.SetVolume(volume)
playInfo = models.NewTTSPlayInfo(text, appKey, language, volume)
} else {
playInfo = models.NewTTSPlayInfo(text, appKey, language)
}
err = client.PlayCustom(playInfo)
@@ -121,10 +109,11 @@ func playURL(c *cli.Context) error {
}
// Create PlayInfo for URL content
playInfo := models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
var playInfo *models.PlayInfo
if volume > 0 {
playInfo.SetVolume(volume)
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason, volume)
} else {
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
}
err = client.PlayCustom(playInfo)
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
"os"
"regexp"
"runtime"
"strconv"
@@ -331,7 +332,7 @@ func PrintWarning(message string) {
// showVersionInfo displays detailed version information including build details
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("soundtouch-cli version %s\n", version)
fmt.Printf("%s version %s\n", os.Args[0], version)
fmt.Printf("Build commit: %s\n", commit)
fmt.Printf("Build date: %s\n", date)
fmt.Printf("Go version: %s\n", runtime.Version())
+1 -1
View File
@@ -104,7 +104,7 @@ func main() {
Version: version,
Authors: []*cli.Author{
{
Name: "Tobias Gesellchen, and the SoundTouch CLI Contributors",
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
},
},
Flags: CommonFlags,
+605 -151
View File
@@ -5,117 +5,372 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/crypto"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
)
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
func updateBuildInfo() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
commit = setting.Value
case "vcs.time":
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
date = t.Format("2006-01-02_15:04:05")
}
}
}
}
}
func main() {
config := loadConfig()
ds := initDataStore(config.dataDir)
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody)
updateBuildInfo()
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
app := &cli.App{
Name: "soundtouch-service",
Usage: "Local service for Bose SoundTouch cloud emulation and management",
Description: `⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ A local server that emulates Bose cloud services (BMX, Marge).
It enables offline operation, device migration, and HTTP interaction recording.`,
Version: version,
Authors: []*cli.Author{
{
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
},
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "HTTP port to bind the service to",
Value: "8000",
EnvVars: []string{"PORT"},
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "soundcork-url",
Usage: "URL for Soundcork-based service components (legacy)",
Value: "http://localhost:8001",
EnvVars: []string{"SOUNDCORK_BACKEND_URL", "TARGET_URL"},
},
&cli.BoolFlag{
Name: "enable-soundcork-proxy",
Usage: "Enable proxying unknown requests to the Soundcork backend",
EnvVars: []string{"ENABLE_SOUNDCORK_PROXY"},
},
&cli.StringFlag{
Name: "data-dir",
Usage: "Directory for persistent data",
Value: "data",
EnvVars: []string{"DATA_DIR"},
},
&cli.StringFlag{
Name: "server-url",
Aliases: []string{"s"},
Usage: "External URL of this service",
EnvVars: []string{"SERVER_URL"},
},
&cli.StringFlag{
Name: "https-port",
Usage: "HTTPS port to bind the service to",
Value: "8443",
EnvVars: []string{"HTTPS_PORT"},
},
&cli.StringFlag{
Name: "https-server-url",
Aliases: []string{"S"},
Usage: "External HTTPS URL",
EnvVars: []string{"HTTPS_SERVER_URL"},
},
&cli.BoolFlag{
Name: "redact-logs",
Usage: "Redact sensitive data in proxy logs",
Value: true,
EnvVars: []string{"REDACT_PROXY_LOGS"},
},
&cli.BoolFlag{
Name: "log-bodies",
Usage: "Log full request/response bodies",
EnvVars: []string{"LOG_PROXY_BODY"},
},
&cli.BoolFlag{
Name: "record-interactions",
Usage: "Record HTTP interactions to disk",
Value: true,
EnvVars: []string{"RECORD_INTERACTIONS"},
},
&cli.StringFlag{
Name: "discovery-interval",
Usage: "Device discovery interval",
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server for non-Bose queries",
Value: "8.8.8.8",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
&cli.StringFlag{
Name: "spotify-client-id",
Usage: "Spotify OAuth client ID",
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
},
&cli.StringFlag{
Name: "spotify-client-secret",
Usage: "Spotify OAuth client secret",
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "spotify-redirect-uri",
Usage: "Spotify OAuth redirect URI",
Value: "ueberboese-login://spotify",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
Value: "change_me!",
EnvVars: []string{"MGMT_PASSWORD"},
},
&cli.StringFlag{
Name: "base-url",
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
ds := initDataStore(config.dataDir)
persisted := applyPersistedSettings(ds, &config)
if persisted.ServerURL == "" {
log.Printf("Creating default settings.json in %s", config.dataDir)
persisted = createDefaultSettings(ds, config)
}
// Recalculate domains if settings changed
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
sm.GetDNSRunning = server.GetDNSRunning
server.SetSoundcorkURL(config.soundcorkURL)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
server.SetBaseURL(config.baseURL)
if config.spotifyClientID != "" {
spotifyService := spotify.NewSpotifyService(
config.spotifyClientID,
config.spotifyClientSecret,
config.spotifyRedirectURI,
config.dataDir,
)
server.SetSpotifyService(spotifyService)
clientIDPrefix := config.spotifyClientID
if len(clientIDPrefix) > 8 {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
}
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
if err == nil && len(dnsDiscoveries) > 0 {
initial := make(map[string]*discovery.DiscoveredHost)
for _, entry := range dnsDiscoveries {
initial[entry.Hostname] = &discovery.DiscoveredHost{
Hostname: entry.Hostname,
FirstSeen: entry.FirstSeen,
LastSeen: entry.LastSeen,
QueryCount: entry.QueryCount,
IsBoseService: entry.IsBoseService,
IsIntercepted: entry.IsIntercepted,
RemoteAddr: entry.RemoteAddr,
}
}
server.SetDNSDiscoveries(initial)
}
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
}
recorder := proxy.NewRecorder(config.dataDir)
recorder.Redact = config.redact
patternsPath := filepath.Join(config.dataDir, "patterns.json")
patterns, err := proxy.LoadPatterns(patternsPath)
if err != nil {
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
}
if len(patterns) == 0 {
log.Printf("Creating default patterns at %s", patternsPath)
patterns = proxy.DefaultPatterns()
patternsData, jsonErr := json.MarshalIndent(patterns, "", " ")
if jsonErr != nil {
log.Printf("Warning: Failed to marshal default patterns: %v", jsonErr)
} else {
_ = os.WriteFile(patternsPath, patternsData, 0644)
}
}
if len(patterns) > 0 {
recorder.Patterns = patterns
}
server.SetRecorder(recorder)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
startDeviceDiscovery(server)
r := setupRouter(server)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
if tlsConfig != nil {
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
}
return http.ListenAndServe(config.addr, r)
},
Commands: []*cli.Command{
{
Name: "version",
Aliases: []string{"v"},
Usage: "Show detailed version information",
Action: showVersionInfo,
},
},
}
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody)
startDeviceDiscovery(server)
r := setupRouter(server, pyProxy)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
if tlsConfig != nil {
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
log.Fatal(http.ListenAndServe(config.addr, r))
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("%s version %s\n", os.Args[0], version)
fmt.Printf("Build commit: %s\n", commit)
fmt.Printf("Build date: %s\n", date)
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
return nil
}
type serviceConfig struct {
port string
bindAddr string
addr string
targetURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
domains []string
port string
bindAddr string
addr string
soundcorkURL string
dataDir string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
record bool
enableSoundcorkProxy bool
dnsEnabled bool
dnsUpstream string
dnsBind string
discoveryInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
mgmtUsername string
mgmtPassword string
baseURL string
}
func loadConfig() serviceConfig {
port := os.Getenv("PORT")
if port == "" {
port = "8000"
}
bindAddr := os.Getenv("BIND_ADDR")
func loadConfig(c *cli.Context) serviceConfig {
port := c.String("port")
bindAddr := c.String("bind")
addr := bindAddr + ":" + port
if bindAddr == "" {
addr = ":" + port
}
targetURL := os.Getenv("PYTHON_BACKEND_URL")
if targetURL == "" {
targetURL = "http://localhost:8001"
}
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "data"
}
serverURL := os.Getenv("SERVER_URL")
if serverURL == "" {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
serverURL = "http://" + strings.ToLower(hostname) + ":" + port
}
httpsPort := os.Getenv("HTTPS_PORT")
if httpsPort == "" {
httpsPort = "8443"
}
httpsAddr := bindAddr + ":" + httpsPort
if bindAddr == "" {
httpsAddr = ":" + httpsPort
}
httpsServerURL := os.Getenv("HTTPS_SERVER_URL")
if httpsServerURL == "" {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
httpsServerURL = "https://" + strings.ToLower(hostname) + ":" + httpsPort
}
soundcorkURL := c.String("soundcork-url")
dataDir := c.String("data-dir")
hostname, _ := os.Hostname()
if hostname == "" {
@@ -124,31 +379,169 @@ func loadConfig() serviceConfig {
hostname = strings.ToLower(hostname)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
setup.TestDomain,
hostname,
"localhost",
"127.0.0.1",
serverURL := c.String("server-url")
if serverURL == "" {
serverURL = "http://" + hostname + ":" + port
}
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
targetURL: targetURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: os.Getenv("REDACT_PROXY_LOGS") != "false",
logBody: os.Getenv("LOG_PROXY_BODY") == "true",
domains: domains,
httpsPort := c.String("https-port")
httpsAddr := bindAddr + ":" + httpsPort
if bindAddr == "" {
httpsAddr = ":" + httpsPort
}
httpsServerURL := c.String("https-server-url")
if httpsServerURL == "" {
httpsServerURL = "https://" + hostname + ":" + httpsPort
}
domains := getDomains(serverURL, httpsServerURL, hostname)
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
record := c.Bool("record-interactions")
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
dnsEnabled := c.Bool("dns-discovery")
dnsUpstream := c.String("dns-upstream")
dnsBind := c.String("dns-bind")
discoveryIntervalStr := c.String("discovery-interval")
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
if err != nil {
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", discoveryIntervalStr, err)
discoveryInterval = 5 * time.Minute
}
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
baseURL := c.String("base-url")
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
soundcorkURL: soundcorkURL,
dataDir: dataDir,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
enableSoundcorkProxy: enableSoundcorkProxy,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
discoveryInterval: discoveryInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
baseURL: baseURL,
}
}
func getDomains(serverURL, httpsServerURL, hostname string) []string {
domainsMap := map[string]bool{
"streaming.bose.com": true,
"updates.bose.com": true,
"stats.bose.com": true,
"bmx.bose.com": true,
"content.api.bose.io": true,
setup.TestDomain: true,
hostname: true,
"localhost": true,
"127.0.0.1": true,
}
if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" {
domainsMap[strings.ToLower(u.Hostname())] = true
}
if u, err := url.Parse(httpsServerURL); err == nil && u.Hostname() != "" {
domainsMap[strings.ToLower(u.Hostname())] = true
}
domains := make([]string, 0, len(domainsMap))
for d := range domainsMap {
domains = append(domains, d)
}
return domains
}
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
persisted, err := ds.GetSettings()
if err != nil {
return datastore.Settings{}
}
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
if persisted.SoundcorkURL != "" {
config.soundcorkURL = persisted.SoundcorkURL
}
if persisted.HTTPServerURL != "" {
config.httpsServerURL = persisted.HTTPServerURL
}
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
config.redact = persisted.RedactLogs
config.logBody = persisted.LogBodies
config.record = persisted.RecordInteractions
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
config.dnsEnabled = persisted.DNSEnabled
if persisted.DNSUpstream != "" {
config.dnsUpstream = persisted.DNSUpstream
}
if persisted.DNSBindAddr != "" {
config.dnsBind = persisted.DNSBindAddr
}
return persisted
}
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
SoundcorkURL: config.soundcorkURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
EnableSoundcorkProxy: config.enableSoundcorkProxy,
DNSEnabled: config.dnsEnabled,
DNSUpstream: config.dnsUpstream,
DNSBindAddr: config.dnsBind,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
},
}
_ = ds.SaveSettings(settings)
return settings
}
func initDataStore(dataDir string) *datastore.DataStore {
@@ -160,8 +553,8 @@ func initDataStore(dataDir string) *datastore.DataStore {
return ds
}
func initCertificateManager(dataDir string) *crypto.CertificateManager {
cm := crypto.NewCertificateManager(filepath.Join(dataDir, "certs"))
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
if err := cm.EnsureCA(); err != nil {
log.Printf("Warning: Failed to ensure CA: %v", err)
}
@@ -169,51 +562,25 @@ func initCertificateManager(dataDir string) *crypto.CertificateManager {
return cm
}
func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseProxy {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("Failed to parse target URL: %v", err)
}
pyProxy := httputil.NewSingleHostReverseProxy(target)
pyProxy.ModifyResponse = func(res *http.Response) error {
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
}
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.LogResponse(res)
return nil
}
originalPyDirector := pyProxy.Director
pyProxy.Director = func(req *http.Request) {
originalPyDirector(req)
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.LogRequest(req)
}
return pyProxy
}
func startDeviceDiscovery(server *handlers.Server) {
go func() {
for {
server.DiscoverDevices(context.Background())
time.Sleep(5 * time.Minute)
currentInterval, enabled := server.GetDiscoverySettings()
if enabled {
server.DiscoverDevices(context.Background())
}
time.Sleep(currentInterval)
}
}()
}
func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.Mux {
func setupRouter(server *handlers.Server) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.ShortcutMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
@@ -224,6 +591,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/media/*", server.HandleMedia())
r.Get("/web/*", server.HandleWeb())
r.Get("/docs/*", server.HandleDocs)
r.Route("/bmx", func(r chi.Router) {
r.Get("/registry/v1/services", server.HandleBMXRegistry)
@@ -233,6 +601,13 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
})
// 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("/marge", func(r chi.Router) {
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
@@ -246,6 +621,37 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
})
// Legacy or direct domain calls without /marge prefix
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Route("/customer", func(r chi.Router) {
r.Get("/account/{account}", server.HandleMargeAccountProfile)
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
})
r.Route("/streaming/stats", func(r chi.Router) {
@@ -253,30 +659,78 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Post("/error", server.HandleErrorStats)
})
r.Route("/mgmt", func(r chi.Router) {
// Browser OAuth callback — no auth required (Spotify redirects the
// user's browser here directly). The authorization code is single-use,
// short-lived, and useless without the client_secret.
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
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.Get("/proxy/*", server.HandleProxyRequest)
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("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
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(func(w http.ResponseWriter, r *http.Request) {
pyProxy.ServeHTTP(w, r)
})
r.NotFound(server.HandleNotFound)
return r
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestApplyPersistedSettings(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "main-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(tmpDir)
t.Run("overrides true with false", func(t *testing.T) {
config := &serviceConfig{
redact: true,
logBody: true,
record: true,
enableSoundcorkProxy: true,
}
// Simulate the bug by using the old bitwise OR logic in the test,
// which should fail if we expect false.
// config.redact = config.redact || false -> stays true
settings := datastore.Settings{
RedactLogs: false,
LogBodies: false,
RecordInteractions: false,
EnableSoundcorkProxy: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
if config.logBody != false {
t.Errorf("Expected logBody to be false, got true")
}
if config.record != false {
t.Errorf("Expected record to be false, got true")
}
if config.enableSoundcorkProxy != false {
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
}
})
t.Run("retains false when settings are false", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
})
t.Run("overrides false with true", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: true,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != true {
t.Errorf("Expected redact to be true, got false")
}
})
}
+5
View File
@@ -1,2 +1,7 @@
accounts/
certs/
default/
dns/
interactions/
patterns.json
settings.json
+24 -76
View File
@@ -1,8 +1,11 @@
// Package soundtouch provides a comprehensive Go library and CLI tool for controlling Bose SoundTouch devices.
// Package soundtouch provides a comprehensive Go library, CLI tool, and local service for controlling and emulating Bose SoundTouch devices.
//
// This library implements the complete Bose SoundTouch Web API, enabling programmatic control
// This project implements the complete Bose SoundTouch Web API, enabling programmatic control
// of SoundTouch speakers including playback control, volume management, source selection,
// multiroom zone management, and real-time event monitoring via WebSocket connections.
// multiroom zone management, and real-time event monitoring.
//
// It also provides a local service (`soundtouch-service`) that can emulate the Bose Cloud,
// allowing for offline control and enhanced debugging through HTTP interaction recording.
//
// # Quick Start
//
@@ -41,63 +44,20 @@
// if err != nil {
// log.Fatal(err)
// }
//
// // Set volume
// err = client.SetVolume(50)
// if err != nil {
// log.Fatal(err)
// }
// }
//
// # Device Discovery
// # SoundTouch Service
//
// Automatically discover SoundTouch devices on your network:
// The `soundtouch-service` provides several advanced features:
//
// import "github.com/gesellix/bose-soundtouch/pkg/discovery"
// - Bose Cloud Emulation: Allows speakers to work without an internet connection.
// - HTTP Interaction Recording: Captures all traffic as IntelliJ-compatible .http files.
// - Speaker Migration: Automated tools to redirect speakers to the local service.
// - Web Interface: A management dashboard for proxy settings and speaker setup.
//
// // Discover devices using UPnP/SSDP
// service := discovery.NewService(5*time.Second)
// devices, err := service.DiscoverDevices(ctx)
// if err != nil {
// log.Fatal(err)
// }
// Install the service:
//
// for _, device := range devices {
// fmt.Printf("Found device: %s at %s\n", device.Name, device.Host)
// }
//
// # Real-time Events
//
// Monitor device state changes in real-time using WebSocket connections:
//
// // Subscribe to device events
// events, err := client.SubscribeToEvents(ctx)
// if err != nil {
// log.Fatal(err)
// }
//
// for event := range events {
// switch e := event.(type) {
// case *models.NowPlayingUpdated:
// fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
// case *models.VolumeUpdated:
// fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
// }
// }
//
// # Multiroom Zone Management
//
// Create and manage multiroom zones:
//
// // Create a zone with multiple speakers
// zone := &models.Zone{
// Master: "192.168.1.100",
// Members: []models.ZoneMember{
// {IPAddress: "192.168.1.101"},
// {IPAddress: "192.168.1.102"},
// },
// }
// err = client.SetZone(zone)
// go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
//
// # CLI Tool
//
@@ -111,45 +71,33 @@
//
// # Control a device
// soundtouch-cli --host 192.168.1.100 play start
// soundtouch-cli --host 192.168.1.100 volume set --level 50
// soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
//
// # Supported Features
//
// - ✅ Device Information & Capabilities
// - ✅ Playback Control (Play/Pause/Stop/Next/Previous)
// - ✅ Volume, Bass, and Balance Control
// - ✅ Source Selection (Spotify, Bluetooth, AUX, etc.)
// - ✅ Preset Management
// - ✅ Clock/Time Management
// - ✅ Network Information
// - ✅ Playback, Volume, Bass, and Balance Control
// - ✅ Source Selection & Preset Management
// - ✅ Real-time WebSocket Events
// - ✅ Multiroom Zone Management
// - ✅ Device Discovery (UPnP/SSDP and mDNS)
// - ✅ Cross-platform Support (Windows, macOS, Linux)
// - ✅ Local Cloud Emulation (soundtouch-service)
// - ✅ HTTP Traffic Recording & Sanitization
// - ✅ Automated Speaker Migration & Revert
//
// # Package Structure
//
// - client: HTTP client for SoundTouch Web API
// - discovery: Device discovery using UPnP/SSDP and mDNS
// - models: Data structures for API requests/responses
// - config: Configuration management
// - service: Core logic for the soundtouch-service (proxy, recording, setup)
// - cmd/soundtouch-cli: Command-line interface tool
//
// # Hardware Compatibility
//
// This library has been tested with real Bose SoundTouch hardware and supports
// all SoundTouch-compatible devices including:
// - SoundTouch 10, 20, 30 series
// - SoundTouch Portable
// - Wave SoundTouch music system
// - And other SoundTouch-enabled Bose speakers
// - cmd/soundtouch-service: Local cloud emulation service
//
// # Implementation Notes
//
// This implementation is based on the official Bose SoundTouch Web API documentation
// and provides 90% coverage of all available endpoints. It is an independent project
// and is not affiliated with or endorsed by Bose Corporation.
// This project is an independent effort to preserve the functionality of Bose SoundTouch
// devices and provide enhanced debugging and control capabilities. It is not
// affiliated with or endorsed by Bose Corporation.
//
// For detailed API documentation, examples, and advanced usage patterns, visit:
// https://pkg.go.dev/github.com/gesellix/bose-soundtouch
+29 -3
View File
@@ -1,15 +1,41 @@
services:
soundtouch-service:
build: .
image: ghcr.io/gesellix/bose-soundtouch:latest
# build: .
container_name: soundtouch-service
# network_mode: host # Linux only, required for discovery
# Linux only, required for discovery. Swarm requires host network at the task level.
# network_mode: host
ports:
- "8000:8000"
- "8443:8443"
environment:
- PORT=8000
- HTTPS_PORT=8443
- DATA_DIR=/app/data
- LOG_PROXY_BODY=false
- REDACT_PROXY_LOGS=true
- RECORD_INTERACTIONS=true
- DISCOVERY_INTERVAL=5m
- SERVER_URL=http://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8000
- HTTPS_SERVER_URL=https://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8443
volumes:
- ./data:/app/data
- soundtouch-data:/app/data
# Use host volume for local development if preferred:
# - ./data:/app/data
restart: unless-stopped
deploy:
replicas: 1
restart_policy:
condition: on-failure
resources:
limits:
cpus: '0.50'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
volumes:
soundtouch-data:
# Named volumes are preferred in Swarm. For multi-node persistence,
# consider using a volume driver like NFS or GlusterFS.
+2 -3
View File
@@ -4,9 +4,9 @@
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
- **[PLAN.md](PLAN.md)** - Project planning and roadmap
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
- **[API-Endpoints-Overview.md](API-Endpoints-Overview.md)** - API endpoints overview
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
## Development Guidelines
@@ -95,4 +95,3 @@ When creating test data for API endpoints, prefer real device responses over hyp
- **Documentation**: Completely in English for international accessibility
- Conduct regular code reviews
- Consider performance from the beginning
+4 -3
View File
@@ -195,8 +195,9 @@ soundtouch-cli --host 192.168.1.100 source internet-radio \
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
- [Content Selection Example](/examples/content-selection/)
- [CLI Reference](/docs/CLI-REFERENCE.md)
- [Content Selection Example](../examples/content-selection/README.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Content Selection Example (Direct)](../examples/content-selection/)
## ✅ Verification
@@ -208,4 +209,4 @@ This implementation has been verified to:
5. ✅ Include complete documentation and examples
6. ✅ Maintain backward compatibility
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
+1 -1
View File
@@ -74,7 +74,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
### "IsItBose" Validation Failures
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
### Disappearing Sources (TuneIn/Local Radio)
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
+1 -1
View File
@@ -895,4 +895,4 @@ For additional help:
---
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](PRESET-MANAGEMENT.md).*
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
+5 -5
View File
@@ -332,14 +332,14 @@ soundtouch-cli --host 192.168.1.100 info
## Next Steps
- 📖 [Complete CLI Reference](CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](preset-store.md)
- 📡 [WebSocket Events Documentation](websocket-events.md)
- 📖 [Complete CLI Reference](guides/CLI-REFERENCE.md)
- 🔧 [Full Implementation Guide](reference/PRESET-MANAGEMENT.md)
- 📡 [WebSocket Events Documentation](reference/WEBSOCKET-EVENTS.md)
- 💻 [Preset Management Example](../examples/preset-management/)
- 📚 [API Endpoints Overview](API-Endpoints-Overview.md)
- 📚 [API Endpoints Overview](reference/API-ENDPOINTS.md)
## Need Help?
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues)
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
-**Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
-**Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
+32
View File
@@ -0,0 +1,32 @@
# Bose SoundTouch Toolkit Documentation
Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026.
## 📖 Quick Links
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Getting Started](guides/GETTING-STARTED.md)
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)
## 🗂 Documentation Structure
### User Guides
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
- [HTTPS Setup](guides/HTTPS-SETUP.md)
- [Deployment Guide](guides/DEPLOYMENT.md)
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
- [Troubleshooting](guides/TROUBLESHOOTING.md)
### Technical Reference
- [API Endpoints](reference/API-ENDPOINTS.md)
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
- [Zone Management](reference/ZONE-MANAGEMENT.md)
- [Preset Management](reference/PRESET-MANAGEMENT.md)
### Analysis & Research
- [Upstream URLs](analysis/UPSTREAM-URLS.md)
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
For a complete list of all documents, see the [Summary](SUMMARY.md).
+2 -2
View File
@@ -23,7 +23,7 @@ This document summarizes the implementation of the `/serviceAvailability` endpoi
### Modified Files
1. **`pkg/client/client.go`** - Added `GetServiceAvailability()` method
2. **`docs/API-Endpoints-Overview.md`** - Updated implementation status
2. **`docs/reference/API-ENDPOINTS.md`** - Updated implementation status
3. **`docs/UNIMPLEMENTED-ENDPOINTS.md`** - Marked as implemented
## API Interface
@@ -263,4 +263,4 @@ BenchmarkGetServiceAvailability-8 1000 1.2ms/op
**Performance benchmarks established**
**Error handling verified**
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
+9 -9
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
@@ -144,10 +144,10 @@ LOG_PROXY_BODY=true soundtouch-service
## 📚 Documentation
- **[Complete Service Guide](SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
- **[Complete Service Guide](guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
- **[API Reference](guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
- **[Migration Guide](guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
- **[Troubleshooting](guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
## 🤝 Contributing
@@ -172,9 +172,9 @@ The collaborative spirit of reverse engineering and documentation in the SoundTo
## 🔗 Links
- **[Main Repository](https://github.com/gesellix/bose-soundtouch)**
- **[Service Documentation](SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](CLI-REFERENCE.md)**
- **[Getting Started Guide](GETTING-STARTED.md)**
- **[Service Documentation](guides/SOUNDTOUCH-SERVICE.md)**
- **[CLI Documentation](guides/CLI-REFERENCE.md)**
- **[Getting Started Guide](guides/GETTING-STARTED.md)**
- **[SoundCork Project](https://github.com/deborahgu/soundcork)**
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)**
@@ -187,4 +187,4 @@ go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
soundtouch-service
```
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
-534
View File
@@ -1,534 +0,0 @@
# SoundTouch Service
The `soundtouch-service` is a comprehensive local server that emulates Bose's cloud services, enabling offline SoundTouch device operation and advanced debugging capabilities. This service is particularly valuable given Bose's announcement that cloud support will end in May 2026.
## Overview
The service provides:
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
## Architecture
The service consists of several key components:
### BMX Services (Bose Media eXchange)
- **TuneIn Integration**: Direct playback of radio stations and podcasts
- **Service Registry**: Media service discovery and configuration
- **Playback Control**: Stream URL resolution and audio metadata
### 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
- **Configuration Management**: Device settings and preferences
### Discovery & Migration
- **Network Scanning**: UPnP/SSDP and mDNS device discovery
- **Device Analysis**: Configuration assessment and compatibility checking
- **Service Migration**: Automated configuration updates for local service usage
- **Health Monitoring**: Device connectivity and service status tracking
## Installation
### Install from Source
```bash
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
```
### Build from Repository
```bash
git clone https://github.com/gesellix/bose-soundtouch.git
cd Bose-SoundTouch
go build -o soundtouch-service ./cmd/soundtouch-service
```
### Docker (coming soon)
```bash
# Docker support planned for future release
docker run -p 8000:8000 gesellix/soundtouch-service
```
## Quick Start
### 1. Start the Service
```bash
# Start with default settings (port 8000)
soundtouch-service
```
### 2. Access the Web Interface
Open your browser to `http://localhost:8000` to access the management interface.
### 3. Discover Devices
The service will automatically start discovering SoundTouch devices on your network. You can also trigger manual discovery from the web UI or API.
### 4. Migrate Devices
Use the web interface or API to migrate devices from Bose cloud services to your local instance.
## Configuration
The service can be configured via environment variables or command-line flags:
| Variable | Flag | Description | Default |
|----------|------|-------------|---------|
| `PORT` | `--port` | Port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url` | External URL of this service | `http://<hostname>:8000` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
### Configuration Examples
```bash
# Custom port and data directory
PORT=9000 DATA_DIR=/home/user/soundtouch soundtouch-service
# External server with custom URL
SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
# Development mode with full logging
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
```
## Device Migration
### Understanding Migration
Device migration switches your SoundTouch devices from Bose's cloud services to your local service instance. This process:
1. **Backs up** existing device configuration
2. **Updates** device service URLs to point to your local server
3. **Maintains** all existing presets and settings
4. **Enables** offline operation and advanced debugging
### Migration Methods
#### Web Interface (Recommended)
1. Start the service: `soundtouch-service`
2. Open `http://localhost:8000`
3. Wait for device discovery to complete
4. Click "Migrate" next to each device
5. Monitor migration status in real-time
#### API Migration
```bash
# Get migration summary first
curl http://localhost:8000/setup/migration-summary/192.168.1.100
# Perform migration
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
# Verify migration status
curl http://localhost:8000/setup/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"
# Migration with custom target URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
```
### Post-Migration Verification
After migration, verify the device is working correctly:
```bash
# Check device status
curl http://localhost:8000/setup/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"
```
## API Reference
### Discovery & Setup
#### `GET /setup/devices`
Lists all discovered SoundTouch devices with their current status.
**Response:**
```json
[
{
"device_id": "08DF1F0BA325",
"name": "Living Room Speaker",
"ip_address": "192.168.1.100",
"product_code": "SoundTouch 20",
"firmware_version": "19.0.5",
"migrated": true,
"last_seen": "2024-01-15T10:30:00Z"
}
]
```
#### `POST /setup/discover`
Triggers immediate network device discovery.
#### `GET /setup/info/{deviceIP}`
Gets detailed device information and configuration.
#### `GET /setup/migration-summary/{deviceIP}`
Analyzes device configuration and provides migration preview.
**Response:**
```json
{
"device_name": "Living Room Speaker",
"device_model": "SoundTouch 20",
"firmware_version": "19.0.5",
"ssh_success": true,
"current_config": "<?xml version=\"1.0\"?>...",
"planned_config": "<?xml version=\"1.0\"?>...",
"remote_services_enabled": false,
"migration_required": true
}
```
#### `POST /setup/migrate/{deviceIP}`
Migrates device to use local services.
**Query Parameters:**
- `target_url`: Custom service URL (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)
### BMX Services (Bose Media eXchange)
#### `GET /bmx/registry/v1/services`
Returns available media services for device registration.
#### `GET /bmx/tunein/v1/playbook/station/{stationID}`
Provides TuneIn station playback information.
#### `GET /bmx/tunein/v1/podcast/{podcastID}`
Returns podcast episode information and playback URLs.
### Marge Services (Account & Device Management)
#### `GET /marge/streaming/sourceproviders`
Lists available music service providers.
#### `GET /marge/accounts/{account}/devices/any/presets`
Returns user presets for synchronization.
#### `GET /marge/accounts/{account}/devices/any/recents`
Returns recent playback items.
#### `PUT /marge/accounts/{account}/devices/{device}/presets/{slot}`
Updates a specific preset slot.
#### `POST /marge/streaming/support/addrecent`
Adds item to recent playback history.
#### `GET /marge/updates/soundtouch`
Returns software update configuration (disabled by default).
### Proxy Services
#### `GET /proxy/{encodedURL}`
Proxies requests to external services with logging.
**Example:**
```bash
# Proxy request to Bose services
curl "http://localhost:8000/proxy/aHR0cHM6Ly9hcGkuc291bmR0b3VjaC5ib3NlLmNvbS8="
```
### Health & Monitoring
#### `GET /health`
Returns service health status.
#### `GET /events/{deviceID}`
WebSocket endpoint for real-time device events.
#### `GET /stats/usage`
Returns usage statistics.
#### `GET /stats/errors`
Returns error statistics.
## Web Interface
### Overview
The web management interface provides a comprehensive dashboard for managing your SoundTouch devices:
**URL:** `http://localhost:8000/`
### Features
#### Device Dashboard
- **Device Discovery**: Real-time view of discovered devices
- **Migration Status**: Visual indicators of migration state
- **Device Health**: Connectivity and service status monitoring
- **Quick Actions**: One-click migration and configuration
#### Device Management
- **Configuration Viewer**: Inspect current and planned device configs
- **Migration Wizard**: Step-by-step device migration process
- **Backup Management**: View and restore configuration backups
- **Service Testing**: Test connectivity to local services
#### Monitoring & Debugging
- **Traffic Logs**: Real-time proxy request/response logging
- **Event Streaming**: Live device event monitoring
- **Statistics Dashboard**: Usage and error analytics
- **Debug Tools**: Device communication testing utilities
### Usage Tips
1. **First Time Setup**: The interface will guide you through initial device discovery
2. **Migration Monitoring**: Watch migration progress in real-time with detailed status updates
3. **Troubleshooting**: Use the debug tools to diagnose device connectivity issues
4. **Log Analysis**: Enable detailed logging for development and troubleshooting
## Persistent Data
### Data Directory Structure
By default, the service creates a `data/` directory in the current working directory:
```
data/
├── accounts/
│ └── default/
│ ├── devices/
│ │ ├── {DEVICE_ID}/
│ │ │ ├── DeviceInfo.xml
│ │ │ └── config_backup_*.xml
│ │ └── ...
│ ├── Sources.xml
│ ├── Presets.xml
│ └── Recents.xml
├── stats/
│ ├── usage/
│ │ └── *.json
│ └── error/
│ └── *.json
└── events/
└── device_events_*.log
```
### Data Components
#### Device Data (`accounts/default/devices/{DEVICE_ID}/`)
- **DeviceInfo.xml**: Device metadata and capabilities
- **config_backup_*.xml**: Configuration backups before migration
- **presets.xml**: Device-specific preset configurations
#### Account Data (`accounts/default/`)
- **Sources.xml**: Configured music service providers
- **Presets.xml**: Cross-device preset synchronization
- **Recents.xml**: Recent playback history
#### Statistics (`stats/`)
- **usage/**: Device usage analytics and patterns
- **error/**: Error logs and diagnostic information
#### Events (`events/`)
- **device_events_*.log**: Device event history and debugging logs
### Data Management
#### Backup Strategy
```bash
# Manual backup
cp -r data/ backup-$(date +%Y%m%d)/
# Automated backup (cron example)
0 2 * * * cp -r /path/to/data/ /backup/soundtouch-$(date +\%Y\%m\%d)/
```
#### Data Migration
```bash
# Moving to new server
tar czf soundtouch-data.tar.gz data/
# Transfer to new server
tar xzf soundtouch-data.tar.gz
```
#### Cleanup
```bash
# Clean old event logs (older than 30 days)
find data/events/ -name "*.log" -mtime +30 -delete
# Clean old statistics (older than 90 days)
find data/stats/ -name "*.json" -mtime +90 -delete
```
## Troubleshooting
### Common Issues
#### Device Not Discovered
```bash
# Check network connectivity
ping 192.168.1.100
# Trigger manual discovery
curl -X POST http://localhost:8000/setup/discover
# Check device accessibility
curl http://192.168.1.100:8090/info
```
#### Migration Failures
```bash
# Check SSH connectivity
ssh-keyscan 192.168.1.100
# Get migration summary
curl http://localhost:8000/setup/migration-summary/192.168.1.100
# Verify device configuration
curl http://192.168.1.100:8090/info
```
#### Service Connectivity Issues
```bash
# Test local service endpoints
curl http://localhost:8000/health
curl http://localhost:8000/bmx/registry/v1/services
curl http://localhost:8000/marge/streaming/sourceproviders
```
### Debug Mode
Enable debug logging for detailed troubleshooting:
```bash
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
```
### Log Analysis
```bash
# Monitor service logs
tail -f /var/log/soundtouch-service.log
# Analyze proxy traffic
grep "PROXY" /var/log/soundtouch-service.log
# Check device events
ls -la data/events/
```
## Credits & Inspiration
This service implementation is based on and inspired by several excellent community projects:
### 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.
### ÜberBöse API
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
- **Author**: Julius D.
- **Contribution**: Advanced API endpoint discovery and implementation details that helped make this service more complete and robust.
We are grateful to these projects for paving the way and providing the research foundation that made this comprehensive service implementation possible.
## Advanced Usage
### Custom Service Integration
```go
// Example: Custom BMX service handler
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func customBMXHandler(w http.ResponseWriter, r *http.Request) {
// Custom BMX service logic
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"custom": "service"}`))
}
func main() {
r := chi.NewRouter()
r.Get("/bmx/custom/endpoint", customBMXHandler)
http.ListenAndServe(":8000", r)
}
```
### Integration with Home Assistant
```yaml
# configuration.yaml
soundtouch:
- host: 192.168.1.100
port: 8090
name: "Living Room Speaker"
rest:
- resource: "http://localhost:8000/setup/devices"
scan_interval: 60
sensor:
- name: "SoundTouch Devices"
value_template: "{{ value_json | length }}"
```
### Monitoring & Alerting
```bash
# Health check script
#!/bin/bash
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health)
if [ $response != "200" ]; then
echo "SoundTouch service is down!" | mail -s "Alert" admin@example.com
fi
```
## Security Considerations
- **Network Security**: The service binds to all interfaces by default. Consider using `BIND_ADDR=127.0.0.1` for localhost-only access.
- **SSH Access**: Migration requires SSH access to devices. Ensure your network security policies allow this.
- **Proxy Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
- **Data Protection**: The data directory contains device configurations and usage patterns. Secure appropriately.
## Performance Tuning
### Resource Usage
- **Memory**: ~50MB baseline + ~5MB per discovered device
- **CPU**: Minimal during steady state, ~10% during discovery/migration
- **Disk**: ~1MB per device configuration + logs
### Scaling Considerations
```bash
# For many devices, increase discovery interval
DISCOVERY_INTERVAL=10m soundtouch-service
# For high-traffic environments, consider reverse proxy
nginx -> soundtouch-service instances
```
+67
View File
@@ -0,0 +1,67 @@
# Table of Contents
* [Introduction](README.md)
## User Guides
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
* [CLI Reference](guides/CLI-REFERENCE.md)
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
* [Troubleshooting](guides/TROUBLESHOOTING.md)
* [Useful Links](#useful-links)
### Useful Links
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
* [CLI Reference](guides/CLI-REFERENCE.md)
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [Cloud API Emulation](reference/CLOUD-API.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
* [Discovery](reference/DISCOVERY.md)
* [Zone Management](reference/ZONE-MANAGEMENT.md)
* [Preset Management](reference/PRESET-MANAGEMENT.md)
* [Source Selection](reference/SOURCE-SELECTION.md)
* [Volume Controls](reference/VOLUME-CONTROLS.md)
* [RadioBrowser](reference/radio-browser.md)
* [Bass Controls](reference/BASS-CONTROLS.md)
* [Key Controls](reference/KEY-CONTROLS.md)
* [Feature Mapping](reference/FEATURE-MAPPING.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
* [Supported URLs](analysis/SUPPORTED-URLS.md)
* [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)
## Appendix (Other Documents)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
* [Device Logging](DEVICE-LOGGING.md)
* [Feature History](FEATURE_HISTORY.md)
* [Host/Port Parsing](HOST-PORT-PARSING.md)
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
* [Navigation Guide](NAVIGATION-GUIDE.md)
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
* [Preset Quickstart](PRESET-QUICKSTART.md)
* [Project Patterns](PROJECT-PATTERNS.md)
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
* [Preset Store](preset-store.md)
+2 -2
View File
@@ -370,7 +370,7 @@ soundtouch-cli speaker beep
**Go Client Usage:**
```go
// Text-to-Speech
client.PlayTTS("Hello World", "your-app-key", 70)
client.PlayTTS("Hello World", "your-app-key", "EN", 70)
// URL content
client.PlayURL("https://example.com/audio.mp3", "your-app-key", "Service", "Message", "Reason", 60)
@@ -1044,4 +1044,4 @@ The SoundTouch Plus Wiki provides comprehensive documentation for **64 additiona
This documentation provides the complete foundation for implementing all endpoints from the SoundTouch Plus Wiki, enabling this Go library to become the definitive SoundTouch integration solution for everything from basic home automation to professional audio installations.
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
+11
View File
@@ -0,0 +1,11 @@
title: Bose SoundTouch Toolkit
description: Documentation for controlling and preserving Bose SoundTouch devices
remote_theme: pages-themes/minimal@v0.2.0
plugins:
- jekyll-remote-theme
- jekyll-relative-links
relative_links:
enabled: true
collections: true
include:
- SUMMARY.md
@@ -9,6 +9,9 @@ SoundTouch devices primarily communicate with the following domains:
- `updates.bose.com`: Software updates
- `stats.bose.com`: Telemetry and analytics
- `bmx.bose.com`: Bose Media eXchange registry
- `events.api.bosecm.com`: Stockholm app analytics
- `bose-prod.apigee.net`: Apigee gateway (used by some services)
- `worldwide.bose.com`: Software update metadata and secondary services
---
@@ -153,7 +156,7 @@ For developers creating a completely isolated "dark" environment (no internet at
1. **XML**: Point all URLs to local services.
2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs.
3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud.
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time.
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. This is particularly useful for handling unknown hostnames or deep-hooking into service discovery logic that might bypass standard DNS lookups.
---
+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.
+1 -1
View File
@@ -784,4 +784,4 @@ docker-compose up # Mock devices + web app
- [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
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
+6 -6
View File
@@ -206,14 +206,14 @@ This project implements a comprehensive Go client library and CLI tool for Bose
### ✅ Complete Documentation
- `README.md` - Project overview and usage examples ✅
- `docs/API-Endpoints-Overview.md` - API reference with status ✅
- `docs/KEY-CONTROLS.md` - Media control implementation ✅
- `docs/VOLUME-CONTROLS.md` - Volume management guide ✅
- `docs/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
- `docs/reference/API-ENDPOINTS.md` - API reference with status ✅
- `docs/reference/KEY-CONTROLS.md` - Media control implementation ✅
- `docs/guides/VOLUME-CONTROLS.md` - Volume management guide ✅
- `docs/reference/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
- `docs/HOST-PORT-PARSING.md` - Enhanced CLI feature ✅
- `docs/PLAN.md` - Development roadmap (updated) ✅
- `docs/archive/PLAN.md` - Development roadmap (updated) ✅
- `docs/PROJECT-PATTERNS.md` - Development guidelines ✅
- `SPEAKER_ENDPOINT.md` - Complete speaker notification documentation ✅
- `docs/reference/SPEAKER-ENDPOINT.md` - Complete speaker notification documentation ✅
### 📝 Documentation Notes
- All docs are synchronized with current implementation
@@ -1248,6 +1248,6 @@ SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
## See Also
- [Getting Started Guide](GETTING-STARTED.md) - Basic setup and usage
- [WebSocket Events](websocket-events.md) - Real-time monitoring
- [Zone Management](zone-management.md) - Multi-room setup
- [API Endpoints](API-Endpoints-Overview.md) - Complete API reference
- [WebSocket Events](../reference/WEBSOCKET-EVENTS.md) - Real-time monitoring
- [Zone Management](../reference/ZONE-MANAGEMENT.md) - Multi-room setup
- [API Endpoints](../reference/API-ENDPOINTS.md) - Complete API reference
@@ -13,6 +13,10 @@ This guide covers everything you need to know to deploy robust, scalable SoundTo
- [Performance Optimization](#performance-optimization)
- [Error Handling Recovery](#error-handling-recovery)
- [Deployment Strategies](#deployment-strategies)
- [Docker Deployment](#docker-deployment)
- [Kubernetes Deployment](#kubernetes-deployment)
- [Systemd Service](#systemd-service)
- [Raspberry Pi Installer](#raspberry-pi-installer)
- [Maintenance Operations](#maintenance-operations)
---
@@ -926,39 +930,49 @@ data:
device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102"
```
### Systemd Service
#### Systemd Service
A standard systemd unit for manual installation. This example assumes the binary is at `/usr/local/bin/soundtouch-service` and data is stored in `/var/lib/soundtouch-service`.
```ini
# /etc/systemd/system/soundtouch.service
# /etc/systemd/system/soundtouch-service.service
[Unit]
Description=SoundTouch Control Service
After=network.target
Wants=network.target
Description=Bose SoundTouch Service
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=soundtouch
Group=soundtouch
WorkingDirectory=/opt/soundtouch
ExecStart=/opt/soundtouch/bin/soundtouch-app
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
Environment=DEVICE_HOSTS=192.168.1.100,192.168.1.101
Environment=LOG_LEVEL=info
Environment=CONFIG_FILE=/opt/soundtouch/config/production.yaml
WorkingDirectory=/var/lib/soundtouch-service
ExecStart=/usr/local/bin/soundtouch-service
Environment=PORT=80
Environment=SERVER_URL=http://soundtouch.local
# Security settings
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/soundtouch/logs
# Allow binding to privileged ports (80/443) without running as root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Restart=on-failure
RestartSec=5
# Security hardening
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/soundtouch-service
[Install]
WantedBy=multi-user.target
```
#### Raspberry Pi Installer
For users deploying on a Raspberry Pi, we provide a specialized automated installer that handles everything from architecture detection to security hardening.
See the [Raspberry Pi Installation Guide](RASPBERRY-PI.md) for step-by-step instructions.
---
## Maintenance Operations
@@ -1071,4 +1085,4 @@ func init() {
// Set GC target percentage
if os.Getenv("GOGC") == "" {
debug.SetGCPerc
debug.SetGCPerc
@@ -1,6 +1,6 @@
# HTTPS Setup & Custom CA Certificate
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the Root CA certificate used by the local `soundtouch-service`.
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
## 1. Automated Migration (Hosts Method)
@@ -13,12 +13,12 @@ curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
This command will:
1. Connect to the device via SSH.
2. Update `/etc/hosts` to point Bose domains to the service IP.
3. Inject the auto-generated Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
4. Reboot the device.
## 2. Managing the Root CA
The `soundtouch-service` automatically generates a Root CA when it first starts.
The AfterTouch service automatically generates a Root CA when it first starts.
- **CA Certificate**: `data/certs/ca.crt`
- **CA Private Key**: `data/certs/ca.key`
@@ -34,7 +34,7 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
- **Automatic Setup**: On first start, it generates a server certificate signed by your local Root CA.
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
#### TLS Security
+44
View File
@@ -0,0 +1,44 @@
### Professional Migration & Safety Guide
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
#### 🛠 Technical Safety Enhancements
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
#### 📋 Professional Migration Checklist
Before you proceed with the actual migration, follow these steps:
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
- Create an empty file named `remote_services` on a USB stick.
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
- Reboot the speaker (unplug and replug).
- The speaker will now allow SSH connections as `root` with no password.
- **Verify**: Run `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>` to confirm access. (Note: older devices may require enabling `ssh-rsa` support).
2. **Network Isolation (Optional but Recommended)**: Ensure the device is on a stable wired connection if possible, or a dedicated 2.4GHz SSID to avoid drops during SSH operations.
3. **Initial Discovery & Sync**:
- Run `soundtouch-cli discover devices` to ensure connectivity.
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Migration Methods**:
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
If something goes wrong or you want to return to the original Bose cloud services:
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
By using the built-in off-device backups and pre-flight checks, the risk of "bricking" or losing configuration during the transition is significantly reduced.
+69
View File
@@ -0,0 +1,69 @@
# Raspberry Pi Installation Guide
This guide explains how to install the `soundtouch-service` as a persistent systemd service on a Raspberry Pi (tested on Raspberry Pi Zero 2W, 3, and 4).
## Automated Installer
We provide a specialized installer script located in the `scripts/raspberry-pi/` directory of the repository.
### Features
* **Automatic start on boot**: Installs a systemd unit.
* **Non-root operation**: Uses `AmbientCapabilities` to bind to ports 80/443 without root privileges.
* **Arch Detection**: Automatically selects the correct binary for `armv7`, `arm64`, or `amd64`.
* **Easy Updates**: Re-running the script updates the binary to the latest version.
### Installation Steps
1. **Download the installer**:
```bash
curl -fsSL -o install.sh https://raw.githubusercontent.com/gesellix/bose-soundtouch/main/scripts/raspberry-pi/install.sh
```
2. **Run with sudo**:
```bash
sudo bash install.sh
```
### Overriding Defaults
You can customize the installation using environment variables:
```bash
sudo \
VERSION=v0.17.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
bash install.sh
```
### Updating the Service
To update the service to a specific version, run the installer with the version as an argument:
```bash
sudo bash install.sh v0.18.1
```
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
## Management
Once installed, use standard `systemctl` commands to manage the service:
```bash
# Check status
systemctl status soundtouch-service
# Follow logs
journalctl -u soundtouch-service -f
# Restart
sudo systemctl restart soundtouch-service
```
## Configuration
Configuration is stored in `/etc/soundtouch-service/soundtouch-service.env`. Note that settings saved via the Web UI (in `settings.json`) will take precedence over these environment variables once the service is running.
For more details, see the [scripts/raspberry-pi/README.md](../../scripts/raspberry-pi/README.md) in the repository.
+836
View File
@@ -0,0 +1,836 @@
# SoundTouch Service
The `soundtouch-service` is a comprehensive local server that emulates Bose's cloud services, enabling offline SoundTouch device operation and advanced debugging capabilities. This service is particularly valuable given Bose's announcement that cloud support will end in May 2026.
## Overview
The service provides:
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
- **🔗 Bose Proxy & Soundcork Fallback**: Dynamic proxying with automatic fallback to local [SoundCork](https://github.com/deborahgu/soundcork) emulation if enabled
## Architecture
The service consists of several key components:
### BMX Services (Bose Media eXchange)
- **TuneIn Integration**: Direct playback of radio stations and podcasts
- **Service Registry**: Media service discovery and configuration
- **Playback Control**: Stream URL resolution and audio metadata
### 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
- **Configuration Management**: Device settings and preferences
### Discovery & Migration
- **Network Scanning**: UPnP/SSDP and mDNS device discovery
- **Device Analysis**: Configuration assessment and compatibility checking
- **Service Migration**: Automated configuration updates for local service usage
- **Health Monitoring**: Device connectivity and service status tracking
## Installation
### Install from Source
```bash
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
```
### Build from Repository
```bash
git clone https://github.com/gesellix/bose-soundtouch.git
cd Bose-SoundTouch
go build -o soundtouch-service ./cmd/soundtouch-service
```
### Docker Support
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.
#### Using Docker
**Linux (with host networking for discovery):**
```bash
docker run -d \
--name soundtouch-service \
--network host \
-v $(pwd)/data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
**macOS / Windows (with port mapping):**
```bash
docker run --rm -it \
-p 8000:8000 -p 8443:8443 \
-v $(pwd)/data:/app/data \
--env SERVER_URL=http://soundtouch.local:8000 \
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
ghcr.io/gesellix/bose-soundtouch:latest
```
> **Note**: The hostnames configured via `SERVER_URL` and `HTTPS_SERVER_URL` are automatically added as Subject Alternative Names (SAN) to the generated TLS certificate, ensuring valid SSL connections.
#### Using Docker Compose
Create a `docker-compose.yml` file:
```yaml
services:
soundtouch-service:
image: ghcr.io/gesellix/bose-soundtouch:latest
container_name: soundtouch-service
# Linux users: use host networking for device discovery
# network_mode: host
# macOS/Windows users: use port mapping (discovery will be manual)
ports:
- "8000:8000"
- "8443:8443"
environment:
- PORT=8000
- SERVER_URL=http://soundtouch.local:8000
- HTTPS_SERVER_URL=https://soundtouch.local:8443
- DATA_DIR=/app/data
volumes:
- soundtouch-data:/app/data
restart: unless-stopped
volumes:
soundtouch-data:
```
And run:
```bash
docker-compose up -d
```
## Quick Start
### 1. Start the Service
```bash
# Start with default settings (port 8000)
soundtouch-service
```
### 2. Access the Web Interface
Open your browser to `http://localhost:8000` to access the management interface.
### 3. Discover Devices
The service will automatically start discovering SoundTouch devices on your network. You can also trigger manual discovery from the web UI or API.
### 4. Migrate Devices
Use the web interface or API to migrate devices from Bose cloud services to your local instance.
## Configuration
### Configuration Precedence
The service supports multiple ways to configure its behavior. When multiple sources provide the same setting, the following precedence rules apply (highest to lowest):
1. **`settings.json`**: Settings saved via the Web UI (stored in the data directory) take the highest precedence. This ensures that changes made in the browser persist across service restarts even if environment variables or flags change.
2. **Environment Variables / CLI Flags**: If a setting is not present in `settings.json`, environment variables and flags are used.
3. **Default Values**: If no configuration is provided, the service uses its built-in defaults.
> **Tip**: If you find that changes to environment variables are not taking effect, check the **Settings** tab in the Web UI or inspect the `settings.json` file in your data directory, as it might be overriding your manual configuration.
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
### Configuration Examples
```bash
# Custom port and data directory
PORT=9000 DATA_DIR=/home/user/soundtouch soundtouch-service
# External server with custom URL
SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
# Development mode with full logging
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
```
## Device Migration
### Understanding Migration
Device migration switches your SoundTouch devices from Bose's cloud services to your local service instance. This process:
1. **Backs up** existing device configuration
2. **Updates** device service URLs to point to your local server
3. **Maintains** all existing presets and settings
4. **Enables** offline operation and advanced debugging
### Migration Methods
#### Web Interface (Recommended)
1. Start the service: `soundtouch-service`
2. Open `http://localhost:8000`
3. Wait for device discovery to complete
4. Click "Migrate" next to each device
5. Monitor migration status in real-time
#### API Migration
```bash
# Get migration summary first
curl http://localhost:8000/setup/devices/192.168.1.100/summary
# Perform migration
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
# Verify migration status
curl http://localhost:8000/devices
```
#### Advanced Migration Options
```bash
# Migration with proxy fallback for original services
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/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
```
### Post-Migration Verification
After migration, verify the device is working correctly:
```bash
# Check device status
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/devices/08DF1F0BA325/events"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration.
**Advantages:**
- **Discovery**: Automatically discover all Bose endpoints queried by the device.
- **Dynamic Interception**: Intercept new or unknown services without further device modifications.
- **Fail-Safe**: Falls back to the standard network DNS (provided by your router) if the Aftertouch service is unavailable.
- **DHCP Compatible**: Preserves your router's assigned search domain and secondary DNS servers.
- **Wildcard Support**: Seamlessly handles `*.bose.com` redirection via your local DNS server.
- **Persistent**: Survives reboots and DHCP renewals.
**How it works:**
1. **Configuration**: A custom file named `/mnt/nv/aftertouch.resolv.conf` is created on the device's persistent partition.
2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP scripts (`/etc/udhcpc.d/50default` or `/opt/Bose/udhcpc.script`) have been patched.
3. **Surgical Patch**: If not patched, it injects a one-line check into the relevant DHCP scripts.
4. **Resolution**: Whenever the device acquires a DHCP lease, the scripts now read your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings.
**Setup:**
1. Enable SSH via the `remote_services` USB trick.
2. Create `/mnt/nv/aftertouch.resolv.conf` with your server details:
```text
# Created by Aftertouch/SoundTouch-Service
# Priority nameserver for Bose service redirection
nameserver 192.168.1.XXX
```
3. Update `/mnt/nv/rc.local` with the idempotent patch:
```sh
#!/bin/sh
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
HOOK_MARKER="/mnt/nv/aftertouch.resolv.conf"
if [ -f "$HOOK_MARKER" ]; then
# Patch 50default if it exists
TARGET_FILE="/etc/udhcpc.d/50default"
if [ -f "$TARGET_FILE" ] && ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then
sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' && dns=""' "$TARGET_FILE"
fi
# Patch udhcpc.script if it exists (e.g. SoundTouch 10)
TARGET_SCRIPT="/opt/Bose/udhcpc.script"
if [ -f "$TARGET_SCRIPT" ] && ! grep -q "$HOOK_MARKER" "$TARGET_SCRIPT"; then
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' >> '"\$RESOLV_CONF"' && dns=""' "$TARGET_SCRIPT"
fi
fi
```
4. Make the script executable: `chmod +x /mnt/nv/rc.local`.
5. Reboot the speaker.
### DNS Discovery Server
The SoundTouch service includes a built-in DNS server specifically designed for Bose devices.
#### How it Works
When enabled, the DNS server:
1. Receives DNS queries from migrated SoundTouch devices.
2. **Intercepts** known Bose domains (e.g., `api.bose.com`, `streaming.bose.com`, `bmx.bose.com`) and resolves them to the AfterTouch service IP.
3. **Logs** all other queries for discovery purposes, allowing you to identify new Bose cloud endpoints.
4. **Forwards** unknown or non-Bose queries to the configured upstream DNS server (default: `8.8.8.8`).
#### Configuration
You can enable and configure the DNS server via the Web UI or environment variables:
- `ENABLE_DNS_DISCOVERY=true`: Turns on the DNS server.
- `DNS_BIND_ADDR=:53`: The port to listen on (requires root privileges for port 53).
- `DNS_UPSTREAM=1.1.1.1`: Your preferred upstream DNS provider. **Note:** Ensure this is not set to the same address as the DNS server itself (loopback or local IP) to avoid forwarding loops. The server includes built-in loop prevention, but misconfiguration will cause forwarding to fail. DNS Discovery cannot be enabled if this setting is empty.
#### Manual Discovery via DNS
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
## API Reference
### Discovery & Setup
#### `GET /devices`
Lists all discovered SoundTouch devices with their current status.
**Response:**
```json
[
{
"device_id": "08DF1F0BA325",
"name": "Living Room Speaker",
"ip_address": "192.168.1.100",
"product_code": "SoundTouch 20",
"firmware_version": "19.0.5",
"migrated": true,
"last_seen": "2024-01-15T10:30:00Z"
}
]
```
#### `POST /setup/discover`
Triggers immediate network device discovery.
#### `GET /devices/{deviceIP}/info`
Gets detailed device information and configuration.
#### `GET /setup/devices/{deviceIP}/summary`
Analyzes device configuration and provides migration preview.
**Response:**
```json
{
"device_name": "Living Room Speaker",
"device_model": "SoundTouch 20",
"firmware_version": "19.0.5",
"ssh_success": true,
"current_config": "<?xml version=\"1.0\"?>...",
"planned_config": "<?xml version=\"1.0\"?>...",
"remote_services_enabled": false,
"migration_required": true
}
```
#### `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)
- `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`
Returns available media services for device registration.
#### `GET /bmx/tunein/v1/playbook/station/{stationID}`
Provides TuneIn station playback information.
#### `GET /bmx/tunein/v1/podcast/{podcastID}`
Returns podcast episode information and playback URLs.
### Marge Services (Account & Device Management)
#### `GET /marge/streaming/sourceproviders`
Lists available music service providers.
#### `GET /marge/accounts/{account}/devices/any/presets`
Returns user presets for synchronization.
#### `GET /marge/accounts/{account}/devices/any/recents`
Returns recent playback items.
#### `PUT /marge/accounts/{account}/devices/{device}/presets/{slot}`
Updates a specific preset slot.
#### `POST /marge/streaming/support/addrecent`
Adds item to recent playback history.
#### `GET /marge/updates/soundtouch`
Returns software update configuration (disabled by default).
### Proxy Services
#### `GET /proxy/{encodedURL}`
Proxies requests to external services with logging.
**Example:**
```bash
# Proxy request to Bose services
curl "http://localhost:8000/proxy/aHR0cHM6Ly9hcGkuc291bmR0b3VjaC5ib3NlLmNvbS8="
```
### Health & Monitoring
#### `GET /health`
Returns service health status.
#### `GET /events/{deviceID}`
WebSocket endpoint for real-time device events.
#### `GET /stats/usage`
Returns usage statistics.
#### `GET /stats/errors`
Returns error statistics.
## Web Interface
### Overview
The web management interface provides a comprehensive dashboard for managing your SoundTouch devices:
**URL:** `http://localhost:8000/`
### Features
#### Device Dashboard
- **Device Discovery**: Real-time view of discovered devices
- **Migration Status**: Visual indicators of migration state
- **Device Health**: Connectivity and service status monitoring
- **Quick Actions**: One-click migration and configuration
#### Device Management
- **Configuration Viewer**: Inspect current and planned device configs
- **Migration Wizard**: Step-by-step device migration process
- **Backup Management**: View and restore configuration backups
- **Service Testing**: Test connectivity to local services
#### Monitoring & Debugging
- **Traffic Logs**: Real-time proxy request/response logging
- **Event Streaming**: Live device event monitoring
- **Statistics Dashboard**: Usage and error analytics
- **Debug Tools**: Device communication testing utilities
#### Interactions & Traffic Analysis
- **Traffic Overview**: View aggregate request counts for self-handled and proxied traffic.
- **Session Browsing**: Browse recorded interactions grouped by session.
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
- **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports.
- **DNS Discoveries**: Real-time table of all hostnames discovered via the AfterTouch DNS server, categorized by interception status (Self/Upstream).
### Usage Tips
1. **First Time Setup**: The interface will guide you through initial device discovery
2. **Migration Monitoring**: Watch migration progress in real-time with detailed status updates
3. **Troubleshooting**: Use the debug tools to diagnose device connectivity issues
4. **Log Analysis**: Enable detailed logging for development and troubleshooting
## HTTP Interaction Recording
The service automatically records all HTTP interactions (both those handled locally and those proxied upstream) as `.http` files. These files are compatible with the [IntelliJ IDEA HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html).
### Key Features
- **Session Grouping**: All interactions from a single server session are stored in a dedicated directory named `{timestamp}-{pid}`.
- **Chronological Order**: Files are prefixed with a sequential number (e.g., `0001-`, `0002-`) to preserve the exact order of requests across the entire session.
- **Path-Based Structure**: Recordings are organized into subdirectories based on their URL path for better discoverability.
- **Automatic Sanitization**: Variable path segments like IP addresses, Device IDs, and Account IDs are automatically identified and replaced with placeholders (e.g., `{{ip}}`, `{{deviceId}}`). The original values are preserved as comments at the top of the recorded `.http` files for easy identification.
- **Re-playability**: An `http-client.env.json` file is generated for each session, allowing you to re-play the recorded requests immediately in IntelliJ IDEA.
- **Management UI**: The **5. Interactions** tab provides a built-in viewer and management tools for all recorded data.
### Configuration
#### Redaction
By default, the service redacts sensitive information from the recorded `.http` files, including:
- `Authorization` headers
- `Cookie` headers
- `X-Bose-Token` headers
- `X-Bose-Key` headers
- `Proxy-Authorization` headers
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
#### Custom Patterns
The service uses regex patterns to identify variable segments in URL paths. These patterns are loaded from `data/patterns.json`. You can add custom patterns to this file to support additional variable segments:
```json
[
{
"name": "MyVariable",
"regexp": "^[0-9]{5}$",
"replacement": "{myVar}"
}
]
```
Variables found via these patterns will be:
1. Used as directory names in the `interactions/` folder.
2. Parameterized as `{{myVar}}` within the `.http` files.
3. Added to the `http-client.env.json` file with their actual values.
## Persistent Data
### Data Directory Structure
By default, the service creates a `data/` directory in the current working directory:
```
data/
├── accounts/
│ └── default/
│ ├── devices/
│ │ ├── {DEVICE_ID}/
│ │ │ ├── DeviceInfo.xml
│ │ │ └── config_backup_*.xml
│ │ └── ...
│ ├── Sources.xml
│ ├── Presets.xml
│ └── Recents.xml
├── interactions/
│ └── {SESSION_ID}/
│ ├── self/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ ├── upstream/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ └── http-client.env.json
├── dns/
│ └── discoveries.json
├── stats/
│ ├── usage/
│ │ └── *.json
│ └── error/
│ └── *.json
└── events/
└── device_events_*.log
```
### Data Components
#### Device Data (`accounts/default/devices/{DEVICE_ID}/`)
- **DeviceInfo.xml**: Device metadata and capabilities
- **config_backup_*.xml**: Configuration backups before migration
- **presets.xml**: Device-specific preset configurations
#### Account Data (`accounts/default/`)
- **Sources.xml**: Configured music service providers
- **Presets.xml**: Cross-device preset synchronization
- **Recents.xml**: Recent playback history
#### DNS Data (`dns/`)
- **discoveries.json**: Persisted DNS discovery logs with hostname deduplication
#### Statistics (`stats/`)
- **usage/**: Device usage analytics and patterns
- **error/**: Error logs and diagnostic information
#### Events (`events/`)
- **device_events_*.log**: Device event history and debugging logs
#### HTTP Interactions (`interactions/`)
- **{SESSION_ID}/**: A unique directory per server run (format: `YYYYMMDD-HHMMSS-PID`).
- **self/**: Requests handled directly by the service.
- **upstream/**: Requests proxied to external Bose services.
- **{PATH}/**: Nested subdirectories reflecting the URL path (sanitized).
- **http-client.env.json**: IntelliJ IDEA HTTP Client environment file with session variables.
- **{SEQ}-{TIME}-{METHOD}.http**: Individual interaction recordings in standard HTTP Client format.
### Data Management
#### Backup Strategy
```bash
# Manual backup
cp -r data/ backup-$(date +%Y%m%d)/
# Automated backup (cron example)
0 2 * * * cp -r /path/to/data/ /backup/soundtouch-$(date +\%Y\%m\%d)/
```
#### Data Migration
```bash
# Moving to new server
tar czf soundtouch-data.tar.gz data/
# Transfer to new server
tar xzf soundtouch-data.tar.gz
```
#### Cleanup
```bash
# Clean old event logs (older than 30 days)
find data/events/ -name "*.log" -mtime +30 -delete
# Clean old statistics (older than 90 days)
find data/stats/ -name "*.json" -mtime +90 -delete
```
## API Endpoints
### Management UI
- **URL**: `http://localhost:8000/` or `http://localhost:8000/web/`
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
### Setup API
- `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 /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`
Lists recorded interactions with optional filtering.
**Query Parameters:**
- `session`: Filter by session ID (optional)
- `category`: Filter by category (`self` or `upstream`) (optional)
- `since`: Filter by timestamp (e.g., `2026-02-15 15:00:00`) (optional)
#### `GET /setup/interaction-stats`
Returns aggregate statistics about recorded interactions across all sessions.
#### `GET /setup/interaction-content?file={path}`
Returns the raw content of a specific recorded `.http` file.
#### `DELETE /setup/interactions/sessions/{sessionID}`
Deletes all recordings associated with a specific session.
#### `DELETE /setup/interactions/sessions?keep={N}`
Bulk cleanup: deletes all but the most recent `N` sessions.
### DNS Discovery API
#### `GET /setup/dns-discoveries`
Returns merged in-memory and persisted DNS discoveries, sorted by last seen timestamp.
#### `DELETE /setup/dns-discoveries`
Clears all recorded DNS discovery data from memory and disk.
### Emulated Services
- `/bmx/registry/v1/services`: BMX service registry.
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
- `/marge/accounts/*`: Account and device management.
- `/marge/updates/soundtouch`: Software update emulation.
- `/proxy/*`: Logging proxy for original Bose services.
## Troubleshooting
### Common Issues
#### Device Not Discovered
```bash
# Check network connectivity
ping 192.168.1.100
# Trigger manual discovery
curl -X POST http://localhost:8000/setup/discover
# Check device accessibility
curl http://192.168.1.100:8090/info
```
#### Migration Failures
```bash
# Check SSH connectivity
ssh-keyscan 192.168.1.100
# Get migration summary
curl http://localhost:8000/setup/migration-summary/192.168.1.100
# Verify device configuration
curl http://192.168.1.100:8090/info
```
#### Service Connectivity Issues
```bash
# Test local service endpoints
curl http://localhost:8000/health
curl http://localhost:8000/bmx/registry/v1/services
curl http://localhost:8000/marge/streaming/sourceproviders
```
### Debug Mode
Enable debug logging for detailed troubleshooting:
```bash
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
```
### Log Analysis
```bash
# Monitor service logs
tail -f /var/log/soundtouch-service.log
# Analyze proxy traffic
grep "PROXY" /var/log/soundtouch-service.log
# Check device events
ls -la data/events/
```
## Credits & Inspiration
This service implementation is based on and inspired by several excellent community projects:
### 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.
### ÜberBöse API
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
- **Author**: Julius D.
- **Contribution**: Advanced API endpoint discovery and implementation details that helped make this service more complete and robust.
We are grateful to these projects for paving the way and providing the research foundation that made this comprehensive service implementation possible.
## Advanced Usage
### Custom Service Integration
```go
// Example: Custom BMX service handler
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func customBMXHandler(w http.ResponseWriter, r *http.Request) {
// Custom BMX service logic
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"custom": "service"}`))
}
func main() {
r := chi.NewRouter()
r.Get("/bmx/custom/endpoint", customBMXHandler)
http.ListenAndServe(":8000", r)
}
```
### Integration with Home Assistant
```yaml
# configuration.yaml
soundtouch:
- host: 192.168.1.100
port: 8090
name: "Living Room Speaker"
rest:
- resource: "http://localhost:8000/devices"
scan_interval: 60
sensor:
- name: "SoundTouch Devices"
value_template: "{{ value_json | length }}"
```
### Monitoring & Alerting
```bash
# Health check script
#!/bin/bash
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health)
if [ $response != "200" ]; then
echo "SoundTouch service is down!" | mail -s "Alert" admin@example.com
fi
```
## Security Considerations
- **Network Security**: The service binds to all interfaces by default. Consider using `BIND_ADDR=127.0.0.1` for localhost-only access.
- **SSH Access**: Migration requires SSH access to devices. Ensure your network security policies allow this.
- **Proxy Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
- **Data Protection**: The data directory contains device configurations and usage patterns. Secure appropriately.
## Performance Tuning
### Resource Usage
- **Memory**: ~50MB baseline + ~5MB per discovered device
- **CPU**: Minimal during steady state, ~10% during discovery/migration
- **Disk**: ~1MB per device configuration + logs
### Scaling Considerations
```bash
# For many devices, increase discovery interval
DISCOVERY_INTERVAL=10m soundtouch-service
# For high-traffic environments, consider reverse proxy
nginx -> soundtouch-service instances
```
+85
View File
@@ -0,0 +1,85 @@
### Bose Cloud Shutdown: Survival Guide for SoundTouch
With Bose's announcement of discontinuing cloud support for SoundTouch devices in May 2026, this project provides the necessary tools to keep your speakers fully functional using a local emulation service.
This guide explains how to set up the `soundtouch-service` to run your devices independently of Bose's servers.
---
### Supported Use Cases
1. **Local Service Emulation**: The service emulates Bose's BMX (Bose Media eXchange) and Marge services, which handle content registries, presets, recents, and software update checks.
2. **Traffic Redirection**: Tools are provided to redirect your speakers to this local service instead of `*.bose.com`.
3. **Offline Operation**: Once redirected, the speakers function without needing to reach Bose's servers.
4. **Preset & Recent Management**: Captures and stores presets and "recently played" items locally.
---
### Setup Steps
To set up your SoundTouch system for local-only operation, follow these steps:
#### 1. Install and Start the Service
Run the `soundtouch-service` on a machine that is always on (like a Raspberry Pi or a NAS) within your local network.
```bash
# Install the service
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
# Start the service (defaults to http://localhost:8000)
soundtouch-service
```
#### 2. Access the Management UI
Open your web browser and navigate to the service's web interface:
`http://<your-server-ip>:8000/`, e.g. `http://localhost:8000/`
*Note: The service also supports a `/web/` path for management.*
#### 3. Enable SSH on Your Speakers
To migrate your speakers, the service needs SSH access. You can enable it by:
1. Creating an empty file named `remote_services` on a USB stick.
2. Inserting the USB stick into the SoundTouch speaker's service port.
3. Rebooting the speaker (unplug/replug).
**Verify SSH Access:**
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
Once enabled, you can log in as `root` (no password).
#### 4. Setup Through the Web UI
The web interface handles the entire process in a guided flow. Before proceeding, we strongly recommend reviewing the [Migration & Safety Guide](MIGRATION-SAFETY.md).
* **Step 1: Settings**: Configure your server's IP or domain. This ensures the speakers know where to find the local services.
* **Step 2: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
* **Step 3: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
* **Step 4: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration". After the migration, reboot your speaker to apply the changes.
#### 5. Verify Your Local Data
Once migrated, your speaker will use the data captured during the Sync step.
* The service stores data in the `data/` directory, organized by device serial number (e.g., `data/default/devices/<SERIAL>/`).
* **Automatic Capture**: As you use the device (changing presets, playing new music), the service continues to "learn" and update your local files.
---
### Comparison with other implementations (soundcork)
Our implementation (`soundtouch-service`) is largely compatible with the Python-based `soundcork` project but offers several advantages:
- **Web UI**: Integrated management interface for discovery and migration.
- **Surgical Migration**: Uses XML-based redirection by default, which is less invasive than `/etc/hosts`.
- **Automated SSL**: Handles Root CA injection automatically for secure communication.
- **Proxy Support**: Can proxy requests to original Bose servers while "learning" your configuration.
---
### Alternative: DNS Redirection (No SSH)
If you prefer not to modify your speakers via SSH, you can use a local DNS server (like Pi-hole, AdGuard Home, or Unbound) to point the following domains to your local server's IP:
* `bmx.bose.com`
* `streaming.bose.com`
* `updates.bose.com`
* `stats.bose.com`
* `content.api.bose.io`
*Note: DNS redirection for HTTPS services requires the speakers to trust your local service's SSL certificate. The SSH-based migration handles this automatically by injecting the CA.*
---
@@ -418,10 +418,10 @@ soundtouch-cli -host <discovered-ip> -bass # Verify final state
## Related Documentation
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
- **[Volume Controls](VOLUME-CONTROLS.md)** - Related audio control documentation
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[Models](../pkg/models/bass.go)** - Bass model implementation
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[Models](../../pkg/models/bass.go)** - Bass model implementation
## API Compliance
@@ -443,4 +443,4 @@ The implementation follows the official SoundTouch API:
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Real Device Validation**: SoundTouch 10, SoundTouch 20
**API Compliance**: Full compliance with SoundTouch Web API specification
**API Compliance**: Full compliance with SoundTouch Web API specification
+73
View File
@@ -0,0 +1,73 @@
# Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
## Marge API (Account & Configuration)
Base path: `/marge`
### GET /streaming/sourceproviders
Retrieves a list of available streaming source providers.
### GET /accounts/{accountId}/full
Retrieves the full account configuration including sources, presets, and devices.
### GET /streaming/account/{accountId}/emailaddress
Retrieves the email address associated with the account.
### GET /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Retrieves settings for a specific device (e.g., clock format).
### POST /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Updates settings for a specific device.
### POST /accounts/{accountId}/devices/{deviceId}/presets/{presetNumber}
Updates a preset for a device.
### POST /accounts/{accountId}/devices/{deviceId}/recents
Adds an item to the device's recently played history.
### POST /accounts/{accountId}/devices
Adds a device to the account.
### DELETE /accounts/{accountId}/devices/{deviceId}
Removes a device from the account.
## Customer API (Profile & Password)
Base path: `/customer`
### GET /account/{accountId}
Retrieves the customer account profile.
### POST /account/{accountId}
Updates the customer account profile.
### POST /account/{accountId}/password
Changes the account password.
## Analytics & Stats API
Base path: `/v1` (App Events) or `/streaming/stats` (Device Stats)
### POST /v1/stapp/{deviceId}
Endpoint called by Bose SoundTouch mobile and web applications (Stockholm) to submit event data.
### POST /v1/scmudc/{deviceId}
Endpoint equivalent to `/v1/stapp/{deviceId}` sometimes used by apps or devices.
### POST /streaming/stats/usage
Endpoint used by physical devices to report usage statistics.
### POST /streaming/stats/error
Endpoint used by physical devices to report error statistics.
## BMX API (Streaming & Registry)
Base path: `/bmx`
### GET /registry/v1/services
Retrieves the registry of available streaming services.
### GET /tunein/v1/playback/station/{stationID}
Retrieves playback information for a TuneIn station.
@@ -366,7 +366,7 @@ This implementation now provides the full preset management lifecycle:
## Related Documentation
- [API Endpoints Overview](API-Endpoints-Overview.md) - Complete API reference
- [API Endpoints Overview](API-ENDPOINTS.md) - Complete API reference
- [Volume Controls](VOLUME-CONTROLS.md) - Volume management
- [Key Controls](KEY-CONTROLS.md) - Media control commands
- [Source Selection](SOURCE-SELECTION.md) - Audio source management
@@ -375,4 +375,4 @@ This implementation now provides the full preset management lifecycle:
Preset management in the Bose SoundTouch API is **intentionally read-only** by design. The API provides excellent capabilities for analyzing and understanding preset configurations, but preset creation must be done through official channels (app or device). This is a deliberate design decision that respects user control over their personal preset configurations.
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
@@ -38,6 +38,7 @@ The Bose SoundTouch Go client provides comprehensive source selection functional
- `IHEARTRADIO` - iHeartRadio streaming
- `STORED_MUSIC` - Local/network stored music
- `AIRPLAY` - Apple AirPlay (device dependent)
- `RADIO_BROWSER` - [RadioBrowser](radio-browser.md) internet radio directory
## Client Library Usage
@@ -345,13 +346,13 @@ The implementation follows the official SoundTouch API:
## Related Documentation
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
- **[Sources](../pkg/models/sources.go)** - Source model implementation
- **[Now Playing](../pkg/models/nowplaying.go)** - ContentItem model
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
- **[Sources](../../pkg/models/sources.go)** - Source model implementation
- **[Now Playing](../../pkg/models/nowplaying.go)** - ContentItem model
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
---
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Real Device Validation**: SoundTouch 10, SoundTouch 20
**Real Device Validation**: SoundTouch 10, SoundTouch 20
@@ -68,14 +68,14 @@ func main() {
client := client.NewClient(config)
// Play TTS at current volume
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY")
// Play TTS at current volume (language code "EN", "DE", etc.)
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
if err != nil {
log.Fatal(err)
}
// Play TTS at specific volume (70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", 70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
if err != nil {
log.Fatal(err)
}
@@ -277,7 +277,7 @@ You'll need to provide your own application key. The format and generation metho
```go
// Doorbell notification
client.PlayTTS("Someone is at the front door", "home-automation-key", 80)
client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
// Security alert
client.PlayURL(
@@ -311,4 +311,4 @@ soundtouch-cli speaker url --url "https://www.soundjay.com/misc/sounds/bell-ring
4. **URL content fails**: Ensure URL is accessible and contains valid audio
5. **Volume not restored**: May occur if device is powered off during playback
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
+34
View File
@@ -0,0 +1,34 @@
## radio-browser.info
- https://www.radio-browser.info is a community driven radio station database.
- It provides an API to access the data and allows users to submit new stations or update existing ones.
### Search for stations
- Go to https://www.radio-browser.info and find a station you like.
- Click on the station and copy the UUID from the URL.
- e.g. `https://www.radio-browser.info/history/d28420a4-eccf-47a2-ace1-088c7e7cb7e0`
### RADIO_BROWSER
- This project supports source type RADIO_BROWSER to play radio stations.
- Set the `location` attribute to `/stations/byuuid/{UUID}`.
```xml
<ContentItem
source="RADIO_BROWSER"
type="stationurl"
isPresetable="true"
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
<itemName>RADIO_BROWSER</itemName>
<containerArt></containerArt>
</ContentItem>
```
### Playing the station
To start the radio stream replace `<uuid>` and `<soundtouch>` and run curl like this:
```bash
curl -d '<ContentItem source="RADIO_BROWSER" type="stationurl" location="/stations/byuuid/<uuid>"/>' <soundtouch>:8090/select
```
+1 -1
View File
@@ -149,4 +149,4 @@ After configuring accounts:
3. Use `browse` commands to explore content
4. Use `play` commands to start playback
See the [CLI Reference](../../docs/CLI-REFERENCE.md) for complete documentation.
See the [CLI Reference](../../docs/guides/CLI-REFERENCE.md) for complete documentation.
+2 -2
View File
@@ -176,5 +176,5 @@ The example gracefully handles missing services:
## Related Documentation
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [CLI Reference](../../docs/CLI-REFERENCE.md)
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md)
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md)
+4 -4
View File
@@ -177,11 +177,11 @@ if err != nil {
This introspect data is useful before:
- [Preset Management](../preset-management/) - Verify service state before storing presets
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Check capabilities before switching sources
- [Zone Management](../../docs/zone-management.md) - Ensure all devices support the service
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Check capabilities before switching sources
- [Zone Management](../../docs/reference/ZONE-MANAGEMENT.md) - Ensure all devices support the service
## API Documentation
For complete API documentation, see:
- [API Reference](../../docs/API-Endpoints-Overview.md)
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
+4 -4
View File
@@ -275,10 +275,10 @@ go run ./cmd/soundtouch-cli --host 192.168.1.100 info
## Related Documentation
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Browse and station commands
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md) - Comprehensive navigation documentation
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Browse and station commands
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md) - Comprehensive navigation documentation
- [Navigation API Reference](../../docs/API-NAVIGATION-REFERENCE.md) - Technical API details
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
## Use Cases
@@ -288,4 +288,4 @@ This example demonstrates patterns for:
- **Direct Playback**: Play content without storing as presets first
- **Content Exploration**: Browse large music libraries efficiently
- **Smart Home Integration**: Programmatically start specific content
- **Personalized Experiences**: Access account-specific content from streaming services
- **Personalized Experiences**: Access account-specific content from streaming services
+4 -4
View File
@@ -256,10 +256,10 @@ Error: All preset slots are occupied
## Related Documentation
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Command-line usage
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Command-line usage
- [Preset Implementation Guide](../../docs/preset-store.md) - Technical details
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
- [API Reference](../../docs/API-Endpoints-Overview.md) - Complete API documentation
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
- [API Reference](../../docs/reference/API-ENDPOINTS.md) - Complete API documentation
## Use Cases
@@ -269,4 +269,4 @@ This example demonstrates patterns for:
- **Music Management**: Organize favorite content into quick-access presets
- **Family Scenarios**: Each person gets their own preset slots
- **Party Mode**: Pre-configure playlists for different moods
- **Radio Favorites**: Save frequently listened radio stations
- **Radio Favorites**: Save frequently listened radio stations
+5 -5
View File
@@ -252,8 +252,8 @@ go run main.go -host 192.168.1.100 -type unknown
This recents data is useful for:
- [Preset Management](../preset-management/) - Finding presetable content to save
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Understanding usage patterns
- [Navigation](../../docs/NAVIGATION-GUIDE.md) - Quickly accessing recently played content
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Understanding usage patterns
- [Navigation](../../docs/guides/SURVIVAL-GUIDE.md) - Quickly accessing recently played content
## Related CLI Commands
@@ -274,6 +274,6 @@ soundtouch-cli --host 192.168.1.100 recents latest
## API Documentation
For complete API documentation, see:
- [API Reference](../../docs/API-Endpoints-Overview.md)
- [CLI Reference](../../docs/CLI-REFERENCE.md)
- [Recents Models](../../pkg/models/recents.go)
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
- [Recents Models](../../pkg/models/recents.go)
+6 -6
View File
@@ -6,18 +6,18 @@ require (
github.com/go-chi/chi/v5 v5.2.5
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.48.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+10 -10
View File
@@ -24,16 +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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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=
@@ -44,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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=
@@ -79,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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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=
@@ -98,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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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=
+2 -2
View File
@@ -1769,8 +1769,8 @@ func (c *Client) hasCapability(capabilities *models.Capabilities, capability str
}
// PlayTTS plays a Text-To-Speech message using Google TTS on the speaker
func (c *Client) PlayTTS(text, appKey string, volume ...int) error {
playInfo := models.NewTTSPlayInfo(text, appKey, volume...)
func (c *Client) PlayTTS(text, appKey, language string, volume ...int) error {
playInfo := models.NewTTSPlayInfo(text, appKey, language, volume...)
if err := playInfo.Validate(); err != nil {
return fmt.Errorf("invalid TTS request: %w", err)
+7
View File
@@ -356,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 {
+398
View File
@@ -0,0 +1,398 @@
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
package discovery
import (
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// DNSDiscovery handles DNS queries and records discovered hosts.
type DNSDiscovery struct {
// Configuration
upstreamDNS string
serviceIP string
// State
discovered map[string]*DiscoveredHost
mu sync.RWMutex
// Callbacks
onNewDiscovery func(hostname string)
// Servers for Shutdown
udpServer *dns.Server
tcpServer *dns.Server
// Address for loop prevention
bindAddr string
// Log throttling
lastLog map[string]time.Time
lastLogMu sync.Mutex
}
// DiscoveredHost represents a host discovered via DNS queries.
type DiscoveredHost struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// NewDNSDiscovery creates a new DNSDiscovery instance.
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
discovered: make(map[string]*DiscoveredHost),
lastLog: make(map[string]time.Time),
}
}
// ServeDNS implements the dns.Handler interface.
func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
remoteAddr := ""
if w.RemoteAddr() != nil {
remoteAddr = w.RemoteAddr().String()
}
// Decide how to respond
isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test"
// Record discovery
d.recordQuery(hostname, isIntercepted, remoteAddr)
if isIntercepted {
// Return your service IP
d.respondWithIP(w, r, d.serviceIP)
d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP))
} else {
// Forward to real DNS
if d.upstreamDNS == "" {
d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward")
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
_ = w.WriteMsg(m)
return
}
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS))
d.forward(w, r)
}
}
func (d *DNSDiscovery) throttledLog(msg string) {
d.lastLogMu.Lock()
defer d.lastLogMu.Unlock()
now := time.Now()
if last, ok := d.lastLog[msg]; ok && now.Sub(last) < 10*time.Second {
return
}
d.lastLog[msg] = now
log.Print(msg)
}
// recordQuery logs a DNS query and updates the internal state.
func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) {
d.mu.Lock()
defer d.mu.Unlock()
host, exists := d.discovered[hostname]
if !exists {
// New discovery!
host = &DiscoveredHost{
Hostname: hostname,
FirstSeen: time.Now(),
LastSeen: time.Now(),
QueryCount: 1,
IsBoseService: d.isBoseRelated(hostname),
IsIntercepted: isIntercepted,
RemoteAddr: remoteAddr,
}
d.discovered[hostname] = host
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
hostname, host.IsBoseService, host.IsIntercepted)
if d.onNewDiscovery != nil {
go d.onNewDiscovery(hostname)
}
} else {
host.LastSeen = time.Now()
host.QueryCount++
host.IsIntercepted = isIntercepted
if remoteAddr != "" {
host.RemoteAddr = remoteAddr
}
}
}
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
// Intercept known Bose cloud services
interceptList := []string{
"api.bose.com",
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"bose-test.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"bosecm.com",
"bose.io",
}
for _, service := range interceptList {
if strings.Contains(hostname, service) {
return true
}
}
return false
}
func (d *DNSDiscovery) isBoseRelated(hostname string) bool {
return strings.Contains(hostname, "bose") ||
strings.Contains(hostname, "soundtouch")
}
func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) {
m := new(dns.Msg)
m.SetReply(r)
m.Compress = false // Embedded clients sometimes don't like compression
m.Authoritative = true
m.RecursionAvailable = true
q := r.Question[0]
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
switch q.Qtype {
case dns.TypeA, dns.TypeANY:
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
} else {
log.Printf("[DNS] Error creating A record: %v", err)
}
case dns.TypeAAAA:
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
default:
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
}
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write response: %v", err)
}
}
func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
// Don't forward PTR queries for our own service IP to avoid loops or slow timeouts
if q.Qtype == dns.TypePTR {
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeNameError
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err)
}
return
}
// Add port 53 if not present
upstream := d.upstreamDNS
if !strings.Contains(upstream, ":") {
upstream += ":53"
}
// Loop prevention: don't forward to ourselves
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
_ = w.WriteMsg(m)
return
}
c := new(dns.Client)
c.Timeout = 2 * time.Second
in, _, err := c.Exchange(r, upstream)
if err != nil {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d): %v", q.Name, q.Qtype, err))
// Return a failure response instead of just dropping
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
}
return
}
if err := w.WriteMsg(in); err != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
}
}
// GetDiscovered returns a map of all discovered hosts.
func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
// Return copy
result := make(map[string]*DiscoveredHost)
for k, v := range d.discovered {
result[k] = v
}
return result
}
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
var result []*DiscoveredHost
for _, host := range d.discovered {
if host.IsBoseService {
result = append(result, host)
}
}
return result
}
// SetDiscovered sets the map of discovered hosts.
func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) {
d.mu.Lock()
defer d.mu.Unlock()
d.discovered = discovered
}
// Start DNS server starts both UDP and TCP listeners
func (d *DNSDiscovery) Start(addr string) error {
mux := dns.NewServeMux()
mux.HandleFunc(".", d.ServeDNS)
d.mu.Lock()
d.bindAddr = addr
d.udpServer = &dns.Server{
Addr: addr,
Net: "udp",
Handler: mux,
}
d.tcpServer = &dns.Server{
Addr: addr,
Net: "tcp",
Handler: mux,
}
// Capture server references before releasing mutex to avoid race condition
udpServer := d.udpServer
tcpServer := d.tcpServer
d.mu.Unlock()
errChan := make(chan error, 2)
go func() {
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
if err := udpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("UDP server failed: %w", err)
}
}()
go func() {
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
if err := tcpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("TCP server failed: %w", err)
}
}()
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
// Wait for first error
return <-errChan
}
// IsRunning returns true if the DNS server is active and bound to the specified address.
func (d *DNSDiscovery) IsRunning(addr string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
if d.udpServer == nil || d.tcpServer == nil {
return false
}
// We check if the address matches what we expect
return d.udpServer.Addr == addr && d.tcpServer.Addr == addr
}
// Shutdown stops the DNS server listeners
func (d *DNSDiscovery) Shutdown() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.udpServer != nil {
if err := d.udpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down UDP server: %v", err)
}
d.udpServer = nil
}
if d.tcpServer != nil {
if err := d.tcpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down TCP server: %v", err)
}
d.tcpServer = nil
}
return nil
}
+302
View File
@@ -0,0 +1,302 @@
package discovery
import (
"log"
"net"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
func TestDNSDiscovery_Interception(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
// Test intercepting Bose service
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if len(rw.msg.Answer) == 0 {
t.Fatal("Expected an answer in the response")
}
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
}
// Test aftertouch.test
m2 := new(dns.Msg)
m2.SetQuestion("aftertouch.test.", dns.TypeA)
rw2 := &mockResponseWriter{}
d.ServeDNS(rw2, m2)
if rw2.msg == nil || len(rw2.msg.Answer) == 0 {
t.Fatal("Expected response for aftertouch.test")
}
if a, ok := rw2.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0])
}
}
func TestDNSDiscovery_Forwarding(t *testing.T) {
// This test is harder because it needs a real upstream or a mock.
// For now, let's just test that it calls forward and record.
serviceIP := "192.168.1.100"
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
// Start a mock upstream DNS server
mux := dns.NewServeMux()
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() {
_ = ts.ListenAndServe()
}()
defer func() { _ = ts.Shutdown() }()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// We expect forward to succeed
d.ServeDNS(rw, m)
d.mu.RLock()
host, exists := d.discovered["google.com"]
d.mu.RUnlock()
if !exists {
t.Error("Expected google.com to be recorded in discovery")
}
if host.IsBoseService {
t.Error("google.com should not be identified as a Bose service")
}
}
func TestDNSDiscovery_StartTCP(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5354"
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
// Test TCP resolution
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
c := new(dns.Client)
c.Net = "tcp"
in, _, err := c.Exchange(m, addr)
if err != nil {
t.Fatalf("Failed to exchange via TCP: %v", err)
}
if len(in.Answer) == 0 {
t.Fatal("Expected answer in TCP response")
}
if a, ok := in.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record via TCP, got %T", in.Answer[0])
}
// Test Shutdown
err = d.Shutdown()
if err != nil {
t.Errorf("Shutdown failed: %v", err)
}
// Verify it's really shut down by trying to connect
_, _, err = c.Exchange(m, addr)
if err == nil {
t.Error("Expected error after shutdown, but could still exchange")
}
}
func TestDNSDiscovery_IsRunning(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5355"
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false before Start")
}
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
if !d.IsRunning(addr) {
t.Error("Expected IsRunning to be true after Start")
}
if d.IsRunning("127.0.0.1:9999") {
t.Error("Expected IsRunning to be false for wrong address")
}
_ = d.Shutdown()
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false after Shutdown")
}
}
type mockResponseWriter struct {
msg *dns.Msg
}
func (m *mockResponseWriter) LocalAddr() net.Addr { return nil }
func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil }
func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil }
func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (m *mockResponseWriter) Close() error { return nil }
func (m *mockResponseWriter) TsigStatus() error { return nil }
func (m *mockResponseWriter) TsigTimersOnly(bool) {}
func (m *mockResponseWriter) Hijack() {}
func TestDNSDiscovery_LogThrottling(t *testing.T) {
d := NewDNSDiscovery("8.8.8.8", "192.168.1.100")
// Capture log output
var logBuf strings.Builder
oldOutput := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(oldOutput)
msg := "Test log message"
d.throttledLog(msg)
d.throttledLog(msg)
d.throttledLog(msg)
count := strings.Count(logBuf.String(), msg)
if count != 1 {
t.Errorf("Expected log message to appear once due to throttling, but appeared %d times", count)
}
// Advance time by 11 seconds to bypass throttling
d.lastLogMu.Lock()
d.lastLog[msg] = time.Now().Add(-11 * time.Second)
d.lastLogMu.Unlock()
d.throttledLog(msg)
count = strings.Count(logBuf.String(), msg)
if count != 2 {
t.Errorf("Expected log message to appear twice after advancing time, but appeared %d times", count)
}
}
func TestDNSDiscovery_LoopPrevention(t *testing.T) {
serviceIP := "192.168.1.100"
bindAddr := "127.0.0.1:53"
upstreamDNS := "127.0.0.1:53"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = bindAddr
// Capture log output to avoid panic if it's being throttled/logged
var logBuf strings.Builder
oldOutput := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(oldOutput)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.forward(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message")
}
if rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure (2), got %d", rw.msg.Rcode)
}
}
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "" // Empty upstream
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = ":53"
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure (2) for empty upstream, got %d", rw.msg.Rcode)
}
// Verify log message (optional, but good to check it's the simplified one)
}
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
serviceIP := "192.168.1.100"
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
upstreamDNS := "192.0.2.1:53" // TEST-NET-1, usually non-routable
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
start := time.Now()
d.forward(rw, m)
duration := time.Since(start)
if duration < 2*time.Second {
t.Errorf("Expected forward to take at least 2 seconds (timeout), but took %v", duration)
}
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure after timeout")
}
}
+82 -6
View File
@@ -160,12 +160,19 @@ type ServiceRecent struct {
// ConfiguredSource represents a configured media source with authentication details.
type ConfiguredSource struct {
DisplayName string `json:"display_name" xml:"sourcename"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"credential"`
SecretType string `json:"secret_type" xml:"credential_type,attr"`
SourceKeyType string `json:"source_key_type" xml:"sourceproviderid"`
SourceKeyAccount string `json:"source_key_account" xml:"username"`
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"`
// Legacy fields for backward compatibility in code if needed,
// though it's better to update the code to use SourceKey.
SourceKeyType string `json:"source_key_type" xml:"-"`
SourceKeyAccount string `json:"source_key_account" xml:"-"`
}
// ServiceDeviceInfo represents information about a SoundTouch device.
@@ -177,6 +184,8 @@ type ServiceDeviceInfo struct {
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
IPAddress string `json:"ip_address" xml:"ipAddress"`
Name string `json:"name" xml:"name"`
DiscoveryMethod string `json:"discovery_method,omitempty"`
AccountID string `json:"account_id,omitempty"`
}
// CustomerSupportDevice represents device information for customer support purposes.
@@ -231,3 +240,70 @@ type DeviceEvent struct {
MonoTime int64 `json:"monoTime"`
Data map[string]interface{} `json:"data"`
}
// DeviceEventsRequest represents a request containing multiple device events (stapp/scmudc).
type DeviceEventsRequest struct {
Envelope struct {
MonoTime int64 `json:"monoTime"`
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
PayloadType string `json:"payloadType"`
ProtocolVersion string `json:"protocolVersion"`
Time string `json:"time"`
UniqueID string `json:"uniqueId"`
} `json:"envelope"`
Payload struct {
DeviceInfo struct {
BoseID string `json:"boseID"`
DeviceID string `json:"deviceID"`
DeviceType string `json:"deviceType"`
SoftwareVersion string `json:"softwareVersion"`
} `json:"deviceInfo"`
Events []struct {
Data map[string]interface{} `json:"data"`
Time string `json:"time"`
Type string `json:"type"`
} `json:"events"`
} `json:"payload"`
}
// DeviceSettingsResponse represents device settings.
type DeviceSettingsResponse struct {
XMLName xml.Name `xml:"deviceSettings"`
Settings []DeviceSetting `xml:"deviceSetting"`
}
// DeviceSetting represents a single device setting.
type DeviceSetting struct {
Name string `xml:"name"`
Value string `xml:"value"`
}
// AccountProfileResponse represents a customer account profile.
type AccountProfileResponse struct {
XMLName xml.Name `xml:"customer"`
AccountID string `xml:"accountID"`
Email string `xml:"email"`
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
CountryCode string `xml:"countryCode"`
LanguageCode string `xml:"languageCode"`
Street string `xml:"street"`
City string `xml:"city"`
PostalCode string `xml:"postalCode"`
State string `xml:"state"`
Phone string `xml:"phone"`
MarketingOptIn bool `xml:"marketingOptIn"`
}
// ChangePasswordRequest represents a request to change the account password.
type ChangePasswordRequest struct {
XMLName xml.Name `xml:"passwordChange"`
OldPassword string `xml:"oldPassword"`
NewPassword string `xml:"newPassword"`
}
// EmailAddressResponse represents the account email address.
type EmailAddressResponse struct {
XMLName xml.Name `xml:"emailAddress"`
Email string `xml:",chardata"`
}
+4 -2
View File
@@ -3,6 +3,8 @@ package models
import (
"encoding/xml"
"errors"
"fmt"
"net/url"
)
// Error constants for speaker validation
@@ -57,9 +59,9 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
}
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
func NewTTSPlayInfo(text, appKey string, volume ...int) *PlayInfo {
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
// URL encode the text for Google TTS
url := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=" + text
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
playInfo := &PlayInfo{
XMLName: xml.Name{Local: "play_info"},
+3 -3
View File
@@ -35,9 +35,9 @@ func TestNewPlayInfo(t *testing.T) {
func TestNewTTSPlayInfo(t *testing.T) {
// Test without volume
playInfo := NewTTSPlayInfo("Hello World", "test-key")
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello World"
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
if playInfo.URL != expectedURL {
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
}
@@ -63,7 +63,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
}
// Test with volume
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", 50)
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", "EN", 50)
if playInfoWithVolume.Volume == nil || *playInfoWithVolume.Volume != 50 {
t.Errorf("Expected Volume to be 50, got %v", playInfoWithVolume.Volume)
}
+38 -2
View File
@@ -304,8 +304,9 @@ type SpecialMessageType string
// Constants for special message types
const (
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
MessageTypeUserActivity SpecialMessageType = "userActivity"
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
MessageTypeUserActivity SpecialMessageType = "userActivity"
MessageTypeUserInactivity SpecialMessageType = "userInactivity"
)
// SoundTouchSdkInfo represents the SDK info message sent on connection
@@ -321,6 +322,12 @@ type UserActivityUpdate struct {
DeviceID string `xml:"deviceID,attr"`
}
// UserInactivityUpdate represents user inactivity notifications
type UserInactivityUpdate struct {
XMLName xml.Name `xml:"userInactivityUpdate"`
DeviceID string `xml:"deviceID,attr"`
}
// SpecialMessage represents non-updates WebSocket messages
type SpecialMessage struct {
Type SpecialMessageType
@@ -604,6 +611,22 @@ func ParseSpecialMessage(data []byte) (*SpecialMessage, error) {
}, nil
}
// Check for userInactivityUpdate
if strings.Contains(dataStr, "<userInactivityUpdate") {
var userInactivity UserInactivityUpdate
if err := xml.Unmarshal(data, &userInactivity); err != nil {
return nil, fmt.Errorf("failed to parse userInactivityUpdate: %w", err)
}
return &SpecialMessage{
Type: MessageTypeUserInactivity,
DeviceID: userInactivity.DeviceID,
Data: &userInactivity,
RawData: data,
Timestamp: time.Now(),
}, nil
}
return nil, fmt.Errorf("unknown special message type: %s", dataStr)
}
@@ -629,6 +652,17 @@ func (sm *SpecialMessage) GetUserActivity() *UserActivityUpdate {
return nil
}
// GetUserInactivity returns the parsed UserInactivity data if the message is of that type
func (sm *SpecialMessage) GetUserInactivity() *UserInactivityUpdate {
if sm.Type == MessageTypeUserInactivity {
if userInactivity, ok := sm.Data.(*UserInactivityUpdate); ok {
return userInactivity
}
}
return nil
}
// String returns a string representation of the special message
func (sm *SpecialMessage) String() string {
switch sm.Type {
@@ -638,6 +672,8 @@ func (sm *SpecialMessage) String() string {
}
case MessageTypeUserActivity:
return fmt.Sprintf("User Activity [Device: %s]", sm.DeviceID)
case MessageTypeUserInactivity:
return fmt.Sprintf("User Inactivity [Device: %s]", sm.DeviceID)
}
return fmt.Sprintf("Unknown Special Message - Type: %s", sm.Type)
@@ -1,5 +1,5 @@
// Package crypto provides tools for managing Root CAs and generating SSL certificates.
package crypto
// Package certmanager provides tools for managing Root CAs and generating SSL certificates.
package certmanager
import (
"crypto/rand"
@@ -148,8 +148,8 @@ func (cm *CertificateManager) GenerateCA() error {
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
CommonName: "SoundTouch Local Root CA",
Organization: []string{"AfterTouch"},
CommonName: "AfterTouch Local Root CA",
},
NotBefore: notBefore,
NotAfter: notAfter,
@@ -240,7 +240,7 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SoundTouch Local Service"},
Organization: []string{"AfterTouch"},
CommonName: domains[0],
},
NotBefore: notBefore,
@@ -1,4 +1,4 @@
package crypto
package certmanager
import (
"crypto/x509"
+1
View File
@@ -41,6 +41,7 @@ var Providers = []string{
"RADIO.COM",
"RADIO_COM",
"SIRIUSXM_EVEREST",
"RADIO_BROWSER",
}
// Common file and path constants used by the datastore and setup logic.
+218 -114
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
@@ -42,12 +43,12 @@ func NewDataStore(dataDir string) *DataStore {
// AccountDir returns the directory path for a specific account.
func (ds *DataStore) AccountDir(account string) string {
return filepath.Join(ds.DataDir, account)
return filepath.Join(ds.DataDir, "accounts", account)
}
// AccountDevicesDir returns the devices directory path for a specific account.
func (ds *DataStore) AccountDevicesDir(account string) string {
return filepath.Join(ds.DataDir, account, constants.DevicesDir)
return filepath.Join(ds.AccountDir(account), constants.DevicesDir)
}
// AccountDeviceDir returns the directory path for a specific device within an account.
@@ -132,7 +133,10 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
}
accDevices := ds.listDevicesInAccount(dir, acc.Name())
for _, info := range accDevices {
for i := range accDevices {
info := accDevices[i]
info.AccountID = acc.Name()
key := info.DeviceID
if key == "" {
key = info.IPAddress
@@ -151,13 +155,13 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
func (ds *DataStore) getPossibleDataDirs() []string {
dirs := []string{}
if exists(ds.DataDir) {
dirs = append(dirs, ds.DataDir)
if exists(filepath.Join(ds.DataDir, "accounts")) {
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
}
// Also check soundcork-go/data if it's different and exists
altDir := "soundcork-go/data"
if ds.DataDir != altDir && exists(altDir) {
// Also check st-go/data/accounts if it's different and exists
altDir := "st-go/data/accounts"
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
dirs = append(dirs, altDir)
}
@@ -219,6 +223,7 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
} `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod"`
}
if err := xml.Unmarshal(data, &info); err != nil {
@@ -226,9 +231,10 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
}
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: info.DeviceID,
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
Name: info.Name,
DeviceID: info.DeviceID,
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
Name: info.Name,
DiscoveryMethod: info.DiscoveryMethod,
}
for _, comp := range info.Components {
@@ -250,9 +256,9 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
return deviceInfo, nil
}
// GetPresets retrieves all presets for the specified account.
func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) {
path := filepath.Join(ds.AccountDir(account), constants.PresetsFile)
// GetPresets retrieves all presets for the specified account and device.
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
data, err := os.ReadFile(path)
if err != nil {
@@ -303,9 +309,9 @@ func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error)
return presets, nil
}
// SavePresets saves the preset list for the specified account.
func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) error {
path := filepath.Join(ds.AccountDir(account), constants.PresetsFile)
// SavePresets saves the preset list for the specified account and device.
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
type PresetXML struct {
ID string `xml:"id,attr"`
@@ -357,9 +363,9 @@ func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset)
return os.WriteFile(path, append(header, data...), 0644)
}
// GetRecents retrieves all recent items for the specified account.
func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) {
path := filepath.Join(ds.AccountDir(account), constants.RecentsFile)
// GetRecents retrieves all recent items for the specified account and device.
func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
data, err := os.ReadFile(path)
if err != nil {
@@ -410,9 +416,9 @@ func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error)
return recents, nil
}
// SaveRecents saves the recent items list for the specified account.
func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) error {
path := filepath.Join(ds.AccountDir(account), constants.RecentsFile)
// SaveRecents saves the recent items list for the specified account and device.
func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
type RecentXML struct {
ID string `xml:"id,attr"`
@@ -494,13 +500,14 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
}
type InfoXML struct {
XMLName xml.Name `xml:"info"`
DeviceID string `xml:"deviceID,attr"`
Name string `xml:"name"`
Type string `xml:"type"`
ModuleType string `xml:"moduleType"`
Components []ComponentXML `xml:"components>component"`
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
XMLName xml.Name `xml:"info"`
DeviceID string `xml:"deviceID,attr"`
Name string `xml:"name"`
Type string `xml:"type"`
ModuleType string `xml:"moduleType"`
Components []ComponentXML `xml:"components>component"`
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod,omitempty"`
}
// Parsing product code back to type and moduleType (best effort)
@@ -539,6 +546,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
IPAddress: info.IPAddress,
},
},
DiscoveryMethod: info.DiscoveryMethod,
}
data, err := xml.MarshalIndent(ix, "", " ")
@@ -557,9 +565,9 @@ func (ds *DataStore) RemoveDevice(account, device string) error {
return os.RemoveAll(dir)
}
// GetConfiguredSources retrieves all configured sources for the specified account.
func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDir(account), constants.SourcesFile)
// GetConfiguredSources retrieves all configured sources for the specified account and device.
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := os.ReadFile(path)
if err != nil {
@@ -567,81 +575,52 @@ func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSo
}
var sourcesWrap struct {
Sources []struct {
DisplayName string `xml:"displayName,attr"`
ID string `xml:"id,attr"`
Secret string `xml:"secret,attr"`
SecretType string `xml:"secretType,attr"`
SourceKey struct {
Account string `xml:"account,attr"`
Type string `xml:"type,attr"`
} `xml:"sourceKey"`
} `xml:"source"`
Sources []models.ConfiguredSource `xml:"source"`
}
if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
}
var sources []models.ConfiguredSource
lastID := 100001
for _, s := range sourcesWrap.Sources {
id := s.ID
if id == "" {
id = strconv.Itoa(lastID)
lastID++
for i := range sourcesWrap.Sources {
s := &sourcesWrap.Sources[i]
if s.ID == "" {
s.ID = strconv.Itoa(100001 + i)
}
sources = append(sources, models.ConfiguredSource{
DisplayName: s.DisplayName,
ID: id,
Secret: s.Secret,
SecretType: s.SecretType,
SourceKeyType: s.SourceKey.Type,
SourceKeyAccount: s.SourceKey.Account,
})
// Sync legacy fields
s.SourceKeyType = s.SourceKey.Type
s.SourceKeyAccount = s.SourceKey.Account
}
return sources, nil
return sourcesWrap.Sources, nil
}
// SaveConfiguredSources saves the configured sources list for the specified account.
func (ds *DataStore) SaveConfiguredSources(account string, sources []models.ConfiguredSource) error {
path := filepath.Join(ds.AccountDir(account), constants.SourcesFile)
// SaveConfiguredSources saves the configured sources list for the specified account and device.
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
type sourceXML struct {
DisplayName string `xml:"displayName,attr"`
ID string `xml:"id,attr"`
Secret string `xml:"secret,attr"`
SecretType string `xml:"secretType,attr"`
SourceKey struct {
Account string `xml:"account,attr"`
Type string `xml:"type,attr"`
} `xml:"sourceKey"`
}
type sourcesWrap struct {
XMLName xml.Name `xml:"sources"`
Sources []sourceXML `xml:"source"`
XMLName xml.Name `xml:"sources"`
Sources []models.ConfiguredSource `xml:"source"`
}
wrap := sourcesWrap{}
for _, s := range sources {
sx := sourceXML{
DisplayName: s.DisplayName,
ID: s.ID,
Secret: s.Secret,
SecretType: s.SecretType,
// Ensure SourceKey is populated from legacy fields if necessary before saving
for i := range sources {
s := &sources[i]
if s.SourceKey.Type == "" && s.SourceKeyType != "" {
s.SourceKey.Type = s.SourceKeyType
}
sx.SourceKey.Account = s.SourceKeyAccount
sx.SourceKey.Type = s.SourceKeyType
wrap.Sources = append(wrap.Sources, sx)
if s.SourceKey.Account == "" && s.SourceKeyAccount != "" {
s.SourceKey.Account = s.SourceKeyAccount
}
}
wrap := sourcesWrap{
Sources: sources,
}
data, err := xml.MarshalIndent(wrap, "", " ")
@@ -661,23 +640,12 @@ func (ds *DataStore) Initialize() error {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Ensure default account exists
defaultDir := ds.AccountDir("default")
if err := os.MkdirAll(defaultDir, 0755); err != nil {
return fmt.Errorf("failed to create default account directory: %w", err)
}
// Ensure devices subdirectory for default account
if err := os.MkdirAll(ds.AccountDevicesDir("default"), 0755); err != nil {
return fmt.Errorf("failed to create default devices directory: %w", err)
}
return nil
}
// GetETagForPresets returns the ETag (modification time) for the presets file.
func (ds *DataStore) GetETagForPresets(account string) int64 {
path := filepath.Join(ds.AccountDir(account), constants.PresetsFile)
// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device.
func (ds *DataStore) GetETagForPresets(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
info, err := os.Stat(path)
if err != nil {
@@ -687,9 +655,9 @@ func (ds *DataStore) GetETagForPresets(account string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// GetETagForSources returns the ETag (modification time) for the sources file.
func (ds *DataStore) GetETagForSources(account string) int64 {
path := filepath.Join(ds.AccountDir(account), constants.SourcesFile)
// GetETagForSources returns the ETag (modification time) for the sources file for a specific device.
func (ds *DataStore) GetETagForSources(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
info, err := os.Stat(path)
if err != nil {
@@ -699,9 +667,9 @@ func (ds *DataStore) GetETagForSources(account string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// GetETagForRecents returns the ETag (modification time) for the recents file.
func (ds *DataStore) GetETagForRecents(account string) int64 {
path := filepath.Join(ds.AccountDir(account), constants.RecentsFile)
// GetETagForRecents returns the ETag (modification time) for the recents file for a specific device.
func (ds *DataStore) GetETagForRecents(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
info, err := os.Stat(path)
if err != nil {
@@ -711,11 +679,11 @@ func (ds *DataStore) GetETagForRecents(account string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account.
func (ds *DataStore) GetETagForAccount(account string) int64 {
e1 := ds.GetETagForPresets(account)
e2 := ds.GetETagForSources(account)
e3 := ds.GetETagForRecents(account)
// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account and device.
func (ds *DataStore) GetETagForAccount(account, device string) int64 {
e1 := ds.GetETagForPresets(account, device)
e2 := ds.GetETagForSources(account, device)
e3 := ds.GetETagForRecents(account, device)
maxETag := e1
if e2 > maxETag {
@@ -729,6 +697,67 @@ func (ds *DataStore) GetETagForAccount(account string) int64 {
return maxETag
}
// Settings represents the global service settings.
type Settings struct {
ServerURL string `json:"server_url"`
SoundcorkURL string `json:"soundcork_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
// GetSettings retrieves the global service settings.
func (ds *DataStore) GetSettings() (Settings, error) {
if ds == nil || ds.DataDir == "" {
return Settings{}, nil
}
path := filepath.Join(ds.DataDir, "settings.json")
if !exists(path) {
return Settings{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return Settings{}, err
}
var settings Settings
if err := json.Unmarshal(data, &settings); err != nil {
return Settings{}, err
}
return settings, nil
}
// SaveSettings saves the global service settings.
func (ds *DataStore) SaveSettings(settings Settings) error {
if ds == nil || ds.DataDir == "" {
return nil
}
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(ds.DataDir, "settings.json")
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
dir := filepath.Join(ds.DataDir, "stats", "usage")
@@ -797,3 +826,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
return copiedEvents
}
// DNSDiscoveryEntry represents a persisted DNS discovery.
type DNSDiscoveryEntry struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
if ds == nil || ds.DataDir == "" {
return nil
}
dir := filepath.Join(ds.DataDir, "dns")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create dns directory: %w", err)
}
path := filepath.Join(dir, "discoveries.json")
// Sort by last seen descending
sort.Slice(discoveries, func(i, j int) bool {
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
})
data, err := json.MarshalIndent(discoveries, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
if ds == nil || ds.DataDir == "" {
return []DNSDiscoveryEntry{}, nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return []DNSDiscoveryEntry{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var discoveries []DNSDiscoveryEntry
if err := json.Unmarshal(data, &discoveries); err != nil {
return nil, err
}
return discoveries, nil
}
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
func (ds *DataStore) ClearDNSDiscoveries() error {
if ds == nil || ds.DataDir == "" {
return nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return nil
}
return os.Remove(path)
}
+81 -27
View File
@@ -9,7 +9,7 @@ import (
)
func TestDataStore(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatal(err)
}
@@ -22,8 +22,9 @@ func TestDataStore(t *testing.T) {
// Test Save/Get DeviceInfo
info := &models.ServiceDeviceInfo{
DeviceID: device,
Name: "Test Speaker",
DeviceID: device,
Name: "Test Speaker",
AccountID: account,
}
err = ds.SaveDeviceInfo(account, device, info)
@@ -49,12 +50,12 @@ func TestDataStore(t *testing.T) {
},
}
err = ds.SavePresets(account, presets)
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Errorf("SavePresets failed: %v", err)
}
loadedPresets, err := ds.GetPresets(account)
loadedPresets, err := ds.GetPresets(account, device)
if err != nil {
t.Errorf("GetPresets failed: %v", err)
}
@@ -72,12 +73,12 @@ func TestDataStore(t *testing.T) {
},
}
err = ds.SaveRecents(account, recents)
err = ds.SaveRecents(account, device, recents)
if err != nil {
t.Errorf("SaveRecents failed: %v", err)
}
loadedRecents, err := ds.GetRecents(account)
loadedRecents, err := ds.GetRecents(account, device)
if err != nil {
t.Errorf("GetRecents failed: %v", err)
}
@@ -87,14 +88,14 @@ func TestDataStore(t *testing.T) {
}
// Test path helpers
expectedAccountDir := filepath.Join(tempDir, account)
expectedAccountDir := filepath.Join(tempDir, "accounts", account)
if ds.AccountDir(account) != expectedAccountDir {
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
}
}
func TestListAllDevices_Empty(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-empty-test-*")
tempDir, err := os.MkdirTemp("", "st-empty-test-*")
if err != nil {
t.Fatal(err)
}
@@ -133,7 +134,7 @@ func TestListAllDevices_Empty(t *testing.T) {
}
func TestListAllDevices(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-list-test-*")
tempDir, err := os.MkdirTemp("", "st-list-test-*")
if err != nil {
t.Fatal(err)
}
@@ -151,6 +152,7 @@ func TestListAllDevices(t *testing.T) {
DeviceSerialNumber: deviceID,
ProductCode: "SoundTouch 10",
FirmwareVersion: "1.2.3",
AccountID: account,
}
err = ds.SaveDeviceInfo(account, deviceID, info)
@@ -173,7 +175,7 @@ func TestListAllDevices(t *testing.T) {
}
func TestListAllDevices_EmptyDeviceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-empty-id-test-*")
tempDir, err := os.MkdirTemp("", "st-empty-id-test-*")
if err != nil {
t.Fatal(err)
}
@@ -185,8 +187,9 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
deviceID := ""
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
Name: "Empty ID Speaker",
DeviceID: deviceID,
Name: "Empty ID Speaker",
AccountID: account,
}
// Use IP as fallback for device ID if it is empty
@@ -215,7 +218,7 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
}
func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-multi-empty-test-*")
tempDir, err := os.MkdirTemp("", "st-multi-empty-test-*")
if err != nil {
t.Fatal(err)
}
@@ -230,11 +233,13 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
DeviceID: "",
Name: "Speaker 1",
IPAddress: "192.168.1.1",
AccountID: account,
}
info2 := &models.ServiceDeviceInfo{
DeviceID: "",
Name: "Speaker 2",
IPAddress: "192.168.1.2",
AccountID: account,
}
// We use the same logic as in main.go: use IP as fallback for directory name
@@ -259,7 +264,7 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
}
func TestListAllDevices_MalformedXML(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-malformed-test-*")
tempDir, err := os.MkdirTemp("", "st-malformed-test-*")
if err != nil {
t.Fatal(err)
}
@@ -294,29 +299,37 @@ func TestConfiguredSources(t *testing.T) {
sources := []models.ConfiguredSource{
{
DisplayName: "Source 1",
ID: "101",
Secret: "secret1",
SecretType: "type1",
DisplayName: "Source 1",
ID: "101",
Secret: "secret1",
SecretType: "type1",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "TUNEIN", Account: "user1"},
SourceKeyType: "TUNEIN",
SourceKeyAccount: "user1",
},
{
DisplayName: "Source 2",
ID: "102",
Secret: "secret2",
SecretType: "type2",
DisplayName: "Source 2",
ID: "102",
Secret: "secret2",
SecretType: "type2",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "PANDORA", Account: "user2"},
SourceKeyType: "PANDORA",
SourceKeyAccount: "user2",
},
}
err := ds.SaveConfiguredSources(account, sources)
err := ds.SaveConfiguredSources(account, "any", sources)
if err != nil {
t.Fatalf("SaveConfiguredSources failed: %v", err)
}
loadedSources, err := ds.GetConfiguredSources(account)
loadedSources, err := ds.GetConfiguredSources(account, "any")
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
@@ -343,12 +356,12 @@ func TestConfiguredSources(t *testing.T) {
},
}
err = ds.SaveConfiguredSources(account, sources2)
err = ds.SaveConfiguredSources(account, "any", sources2)
if err != nil {
t.Fatal(err)
}
loadedSources2, err := ds.GetConfiguredSources(account)
loadedSources2, err := ds.GetConfiguredSources(account, "any")
if err != nil {
t.Fatal(err)
}
@@ -357,3 +370,44 @@ func TestConfiguredSources(t *testing.T) {
t.Error("Expected auto-assigned ID for source with empty ID")
}
}
func TestSettingsPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "settings-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
settings := Settings{
ServerURL: "http://myserver:8000",
SoundcorkURL: "http://myproxy:8001",
LogBodies: true,
DiscoveryInterval: "10m",
DiscoveryEnabled: true,
}
err = ds.SaveSettings(settings)
if err != nil {
t.Fatalf("SaveSettings failed: %v", err)
}
loaded, err := ds.GetSettings()
if err != nil {
t.Fatalf("GetSettings failed: %v", err)
}
if loaded.ServerURL != settings.ServerURL {
t.Errorf("Expected ServerURL %s, got %s", settings.ServerURL, loaded.ServerURL)
}
if loaded.LogBodies != settings.LogBodies {
t.Errorf("Expected LogBodies %v, got %v", settings.LogBodies, loaded.LogBodies)
}
if loaded.DiscoveryInterval != settings.DiscoveryInterval {
t.Errorf("Expected DiscoveryInterval %s, got %s", settings.DiscoveryInterval, loaded.DiscoveryInterval)
}
if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
}
}
@@ -0,0 +1,75 @@
package datastore
import (
"os"
"testing"
"time"
)
func TestDNSDiscoveryPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-dns-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
now := time.Now().Round(time.Second)
discoveries := []DNSDiscoveryEntry{
{
Hostname: "api.bose.com",
FirstSeen: now.Add(-1 * time.Hour),
LastSeen: now,
QueryCount: 10,
IsBoseService: true,
IsIntercepted: true,
RemoteAddr: "192.168.1.100",
},
{
Hostname: "google.com",
FirstSeen: now.Add(-2 * time.Hour),
LastSeen: now.Add(-1 * time.Hour),
QueryCount: 5,
IsBoseService: false,
IsIntercepted: false,
RemoteAddr: "192.168.1.101",
},
}
// Test Save
err = ds.SaveDNSDiscoveries(discoveries)
if err != nil {
t.Fatalf("SaveDNSDiscoveries failed: %v", err)
}
// Test Load
loaded, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries failed: %v", err)
}
if len(loaded) != 2 {
t.Errorf("Expected 2 discoveries, got %d", len(loaded))
}
// Check if sorted by LastSeen (SaveDNSDiscoveries sorts them)
if loaded[0].Hostname != "api.bose.com" {
t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname)
}
// Test Clear
err = ds.ClearDNSDiscoveries()
if err != nil {
t.Fatalf("ClearDNSDiscoveries failed: %v", err)
}
loadedAfterClear, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err)
}
if len(loadedAfterClear) != 0 {
t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear))
}
}
+80
View File
@@ -0,0 +1,80 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestDNSSettingsValidation(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dns-validation-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, server := setupRouter("http://localhost:8001", ds)
// Test Case 1: Enable DNS with empty upstream
update := map[string]interface{}{
"dns_enabled": true,
"dns_upstream": "",
"dns_bind_addr": ":5353",
}
body, err := json.Marshal(update)
if err != nil {
t.Fatalf("Failed to marshal update: %v", err)
}
req := httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 when enabling DNS without upstream, got %d", w.Code)
}
// Verify DNS server is NOT running
running, _ := server.GetDNSRunning()
if running {
t.Error("DNS server should not be running after invalid config attempt")
}
// Test Case 2: Enable DNS with valid upstream
// Using a random port to avoid conflicts and ensure it's fast
updateValid := map[string]interface{}{
"dns_enabled": true,
"dns_upstream": "8.8.8.8",
"dns_bind_addr": "127.0.0.1:0", // Random port
}
bodyValid, err := json.Marshal(updateValid)
if err != nil {
t.Fatalf("Failed to marshal updateValid: %v", err)
}
reqValid := httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(bodyValid))
wValid := httptest.NewRecorder()
r.ServeHTTP(wValid, reqValid)
if wValid.Code != http.StatusOK {
t.Errorf("Expected status 200 when enabling DNS with valid upstream, got %d. Body: %s", wValid.Code, wValid.Body.String())
}
// Verify DNS state in server
if !server.dnsEnabled {
t.Error("DNS should be enabled in server state")
}
// Shutdown server to clean up
if server.dnsDiscovery != nil {
_ = server.dnsDiscovery.Shutdown()
}
}
@@ -0,0 +1,71 @@
package handlers
import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDocsConsistency(t *testing.T) {
// Root of the project relative to this test file
// The test runs in the directory of the package
projectRoot := "../../.."
docsDir := filepath.Join(projectRoot, "docs")
summaryPath := filepath.Join(docsDir, "SUMMARY.md")
summaryContent, err := os.ReadFile(summaryPath)
if err != nil {
t.Fatalf("Failed to read SUMMARY.md: %v", err)
}
summaryText := string(summaryContent)
// List of directories to check
dirsToCheck := []string{".", "guides", "reference", "analysis"}
for _, dir := range dirsToCheck {
dirPath := filepath.Join(docsDir, dir)
err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Don't recurse into subdirectories if we are checking the root,
// as they are handled separately or ignored (like archive)
if dir == "." && path != dirPath {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
// Skip SUMMARY.md itself
if d.Name() == "SUMMARY.md" {
return nil
}
// Get relative path from docs/
relPath, err := filepath.Rel(docsDir, path)
if err != nil {
return err
}
// Check if this file is linked in SUMMARY.md
// We look for [Label](relPath)
linkPattern := "(" + relPath + ")"
if !strings.Contains(summaryText, linkPattern) {
t.Errorf("Documentation file %s is not linked in docs/SUMMARY.md", relPath)
}
return nil
})
if err != nil {
t.Errorf("Error walking directory %s: %v", dir, err)
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package handlers
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/russross/blackfriday/v2"
)
// HandleDocs returns a handler for serving documentation files as HTML.
func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/docs")
path = strings.TrimPrefix(path, "/")
if path == "" {
path = "guides/SURVIVAL-GUIDE.md"
}
// Ensure we only serve files from the docs directory
filePath := filepath.Join("docs", path)
if !strings.HasPrefix(filepath.Clean(filePath), "docs") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
content, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
// Load sidebar (SUMMARY.md)
summaryContent, _ := os.ReadFile(filepath.Join("docs", "SUMMARY.md"))
sidebar := ""
if len(summaryContent) > 0 {
// Render summary to HTML
sidebar = string(blackfriday.Run(summaryContent))
// Adjust links in sidebar to be relative to /docs/
sidebar = strings.ReplaceAll(sidebar, "href=\"guides/", "href=\"/docs/guides/")
sidebar = strings.ReplaceAll(sidebar, "href=\"reference/", "href=\"/docs/reference/")
sidebar = strings.ReplaceAll(sidebar, "href=\"analysis/", "href=\"/docs/analysis/")
// Fix relative links that don't have a directory prefix (root docs)
// We look for href="filename.md" and replace with href="/docs/filename.md"
// This avoids manual listing of every file.
sidebar = s.fixSidebarLinks(sidebar)
}
// Render markdown to HTML
output := blackfriday.Run(content)
// Wrap in a documentation template with sidebar
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>%s - Bose SoundTouch Toolkit Docs</title>
<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; }
.sidebar { width: 300px; background: #f8f9fa; border-right: 1px solid #dee2e6; padding: 20px; overflow-y: auto; flex-shrink: 0; }
.content-area { flex-grow: 1; overflow-y: auto; padding: 40px; }
.markdown-body { max-width: 800px; margin: 0 auto; line-height: 1.6; color: #333; }
h1, h2, h3 { color: #2196F3; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
code { font-family: monospace; background: #eee; padding: 2px 4px; border-radius: 3px; }
pre code { background: none; padding: 0; }
a { color: #2196F3; text-decoration: none; }
a:hover { text-decoration: underline; }
.back-link { margin-bottom: 20px; display: block; font-weight: bold; }
.sidebar h2 { font-size: 1.1em; margin-top: 20px; color: #666; text-transform: uppercase; letter-spacing: 1px; }
.sidebar ul { list-style: none; padding: 0; }
.sidebar li { margin-bottom: 8px; }
.sidebar a { color: #444; font-size: 0.95em; }
.sidebar a:hover { color: #2196F3; }
</style>
</head>
<body>
<div class="sidebar">
<a href="/" class="back-link">&larr; Back to Toolkit</a>
%s
</div>
<div class="content-area">
<div class="markdown-body">
%s
</div>
</div>
</body>
</html>`, path, sidebar, output)
}
// fixSidebarLinks ensures that relative links in the SUMMARY.md (sidebar)
// are correctly prefixed with /docs/ for the web UI.
func (s *Server) fixSidebarLinks(sidebar string) string {
// Root links like [Label](file.md) become href="file.md"
// We want href="/docs/file.md", but only if it doesn't already start with /docs/
// and isn't an external link.
// Since blackfriday renders [Label](file.md) as <a href="file.md">
// A simple but effective way is to use a regex or just check for common patterns.
// We already handled subdirectories. Now we handle files in the root of docs/
// We'll look for href="filename.md" where filename doesn't contain a slash
// and isn't already prefixed.
// Since we know our doc files always end in .md, we can look for that.
lines := strings.Split(sidebar, "\n")
for i, line := range lines {
if strings.Contains(line, "href=\"") && !strings.Contains(line, "href=\"/docs/") && !strings.Contains(line, "://") {
// Extract filename
start := strings.Index(line, "href=\"") + 6
end := strings.Index(line[start:], "\"") + start
filename := line[start:end]
if strings.HasSuffix(filename, ".md") && !strings.Contains(filename, "/") {
lines[i] = strings.ReplaceAll(line, "href=\""+filename+"\"", "href=\"/docs/"+filename+"\"")
}
}
}
return strings.Join(lines, "\n")
}
+8 -6
View File
@@ -16,24 +16,26 @@ const normalizedEtag = "Etag"
const caseSensitiveETag = "ETag"
func TestMargeETags(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "soundcork-etag-test-*")
tempDir, _ := os.MkdirTemp("", "st-etag-test-*")
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "12345"
accountDir := filepath.Join(tempDir, account)
_ = os.MkdirAll(accountDir, 0755)
deviceID := "DEV1"
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
_ = os.MkdirAll(deviceDir, 0755)
// Create some initial data
presetsFile := filepath.Join(accountDir, "Presets.xml")
presetsFile := filepath.Join(deviceDir, "Presets.xml")
_ = os.WriteFile(presetsFile, []byte("<presets/>"), 0644)
sourcesFile := filepath.Join(accountDir, "Sources.xml")
sourcesFile := filepath.Join(deviceDir, "Sources.xml")
_ = os.WriteFile(sourcesFile, []byte("<sources/>"), 0644)
recentsFile := filepath.Join(accountDir, "Recents.xml")
recentsFile := filepath.Join(deviceDir, "Recents.xml")
_ = os.WriteFile(recentsFile, []byte("<recents/>"), 0644)
// Ensure devices directory exists for AccountFull
+2 -2
View File
@@ -18,7 +18,7 @@ func TestEventLog(t *testing.T) {
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)
+101 -8
View File
@@ -36,7 +36,9 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque
func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
etag := strconv.FormatInt(s.ds.GetETagForAccount(account), 10)
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
@@ -58,6 +60,85 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
// HandleMargeAccountProfile returns the account profile.
func (s *Server) HandleMargeAccountProfile(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "account")
// Mock profile data
profile := models.AccountProfileResponse{
AccountID: accountID,
Email: "user@example.com",
FirstName: "SoundTouch",
LastName: "User",
CountryCode: "US",
LanguageCode: "en",
}
data, err := xml.MarshalIndent(profile, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeUpdateAccountProfile updates the account profile.
func (s *Server) HandleMargeUpdateAccountProfile(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeChangePassword changes the account password.
func (s *Server) HandleMargeChangePassword(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeGetEmailAddress returns the account email address.
func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Request) {
resp := models.EmailAddressResponse{
Email: "user@example.com",
}
data, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeGetDeviceSettings returns device settings.
func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Request) {
resp := models.DeviceSettingsResponse{
Settings: []models.DeviceSetting{
{Name: "CLOCK_FORMAT", Value: "24HR"},
},
}
data, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeUpdateDeviceSettings updates device settings.
func (s *Server) HandleMargeUpdateDeviceSettings(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeSoftwareUpdate returns the Marge software update information.
func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Request) {
etag := "default-embedded"
@@ -79,14 +160,15 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
// 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")
etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10)
etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
data, err := marge.PresetsToXML(s.ds, account)
data, err := marge.PresetsToXML(s.ds, account, device)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -102,7 +184,7 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10)
etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10)
w.Header()["ETag"] = []string{etag}
presetNumberStr := chi.URLParam(r, "presetNumber")
@@ -134,7 +216,7 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
etag := strconv.FormatInt(s.ds.GetETagForRecents(account), 10)
etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10)
w.Header()["ETag"] = []string{etag}
body, err := io.ReadAll(r.Body)
@@ -199,11 +281,22 @@ func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Requ
func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Request) {
// Simple mock token for offline use.
// In a real production environment, this would be a JWT or similar signed token.
// Some speakers might expect a specific format; soundcork uses a distinctive prefix
// Some speakers might expect a specific format; we use a distinctive prefix
// to indicate it's a locally generated token.
token := "soundcork-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
w.Header().Set("Authorization", "Bearer "+token)
tokenValue := "st-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
bearerToken := models.NewBearerToken(tokenValue)
data, err := xml.Marshal(bearerToken)
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("Authorization", bearerToken.GetAuthHeader())
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeCustomerSupport handles Marge customer support uploads.
@@ -0,0 +1,102 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMargeStockholmHandlers(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("HandleMargeAccountProfile GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/customer/account/12345")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "<accountID>12345</accountID>") {
t.Errorf("Response missing account ID: %s", string(body))
}
})
t.Run("HandleMargeUpdateAccountProfile POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/customer/account/12345", "application/xml", strings.NewReader("<profile/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("HandleMargeChangePassword POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/customer/account/12345/password", "application/xml", strings.NewReader("<password/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("HandleMargeGetEmailAddress GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/account/12345/emailaddress")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "user@example.com") {
t.Errorf("Response missing email: %s", string(body))
}
})
t.Run("HandleMargeGetDeviceSettings GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/device_setting/account/123/device/DEV1/device_settings")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "CLOCK_FORMAT") {
t.Errorf("Response missing settings: %s", string(body))
}
})
t.Run("HandleMargeUpdateDeviceSettings POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/marge/streaming/device_setting/account/123/device/DEV1/device_settings", "application/xml", strings.NewReader("<settings/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
}
+53 -41
View File
@@ -61,7 +61,7 @@ func TestMargeSoftwareUpdate(t *testing.T) {
}
func TestMargeAccountFull(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -72,7 +72,7 @@ func TestMargeAccountFull(t *testing.T) {
account := "12345"
deviceID := "ABCDE"
accountDir := filepath.Join(tempDir, account)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
@@ -125,7 +125,7 @@ func TestMargeAccountFull(t *testing.T) {
}
func TestMargePresets(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -135,12 +135,14 @@ func TestMargePresets(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "any"
accountDir := filepath.Join(tempDir, account)
err = os.MkdirAll(accountDir, 0755)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatalf("Failed to create account dir: %v", err)
t.Fatalf("Failed to create device dir: %v", err)
}
r, _ := setupRouter("http://localhost:8001", ds)
@@ -149,24 +151,17 @@ func TestMargePresets(t *testing.T) {
defer ts.Close()
// Mock Sources.xml and Presets.xml
if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
<sources>
<source id="123" type="Audio">
<createdOn>2012-09-19T12:43:00.000+00:00</createdOn>
<credential type="token"></credential>
<name>TUNEIN</name>
<sourceproviderid>1</sourceproviderid>
<sourcename>TUNEIN</sourcename>
<sourcesettings></sourcesettings>
<updatedOn>2012-09-19T12:43:00.000+00:00</updatedOn>
<username></username>
<source id="123" displayName="TUNEIN" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>
`), 0644); err != nil {
t.Fatalf("Failed to write Sources.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(`
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">
@@ -201,7 +196,7 @@ func TestMargePresets(t *testing.T) {
}
func TestMargeUpdatePreset(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -211,26 +206,28 @@ func TestMargeUpdatePreset(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "DEV1"
accountDir := filepath.Join(tempDir, account)
err = os.MkdirAll(accountDir, 0755)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatalf("Failed to create account dir: %v", err)
t.Fatalf("Failed to create device dir: %v", err)
}
// Mock Sources.xml
if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
<sources>
<source id="SRC1" type="Audio">
<sourcename>TUNEIN</sourcename>
<source id="SRC1" displayName="TUNEIN" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>
`), 0644); err != nil {
t.Fatalf("Failed to write Sources.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(`<presets></presets>`), 0644); err != nil {
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`<presets></presets>`), 0644); err != nil {
t.Fatalf("Failed to write Presets.xml: %v", err)
}
@@ -248,7 +245,7 @@ func TestMargeUpdatePreset(t *testing.T) {
<containerArt>http://example.com/new.jpg</containerArt>
</preset>`
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/presets/1", "application/xml", strings.NewReader(payload))
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/"+deviceID+"/presets/1", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
@@ -261,14 +258,14 @@ func TestMargeUpdatePreset(t *testing.T) {
}
// Verify file was saved
presetData, _ := os.ReadFile(filepath.Join(accountDir, "Presets.xml"))
presetData, _ := os.ReadFile(filepath.Join(deviceDir, "Presets.xml"))
if !strings.Contains(string(presetData), "New Preset") {
t.Error("Preset was not saved to datastore")
}
}
func TestMargeDeviceInfo(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -278,26 +275,28 @@ func TestMargeDeviceInfo(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
account := "12345"
deviceID := "DEV1"
accountDir := filepath.Join(tempDir, account)
err = os.MkdirAll(accountDir, 0755)
accountDir := filepath.Join(tempDir, "accounts", account)
deviceDir := filepath.Join(accountDir, "devices", deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatalf("Failed to create account dir: %v", err)
t.Fatalf("Failed to create device dir: %v", err)
}
// Mock Sources.xml
if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
<sources>
<source id="SRC1" type="Audio">
<sourcename>TUNEIN</sourcename>
<source id="SRC1" displayName="TUNEIN" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>
`), 0644); err != nil {
t.Fatalf("Failed to write Sources.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(accountDir, "Recents.xml"), []byte(`<recents></recents>`), 0644); err != nil {
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(`<recents></recents>`), 0644); err != nil {
t.Fatalf("Failed to write Recents.xml: %v", err)
}
@@ -314,7 +313,7 @@ func TestMargeDeviceInfo(t *testing.T) {
<contentItemType>station</contentItemType>
</recent>`
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/recents", "application/xml", strings.NewReader(payload))
res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/"+deviceID+"/recents", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
@@ -326,14 +325,14 @@ func TestMargeDeviceInfo(t *testing.T) {
}
// Verify file was saved
recentData, _ := os.ReadFile(filepath.Join(accountDir, "Recents.xml"))
recentData, _ := os.ReadFile(filepath.Join(deviceDir, "Recents.xml"))
if !strings.Contains(string(recentData), "Recent Station") {
t.Error("Recent was not saved to datastore")
}
}
func TestMargeAddRemoveDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -344,7 +343,7 @@ func TestMargeAddRemoveDevice(t *testing.T) {
account := "12345"
accountDir := filepath.Join(tempDir, account)
accountDir := filepath.Join(tempDir, "accounts", account)
err = os.MkdirAll(accountDir, 0755)
if err != nil {
@@ -428,7 +427,7 @@ func TestMargePowerOn(t *testing.T) {
}
func TestMargeAdvancedFeatures(t *testing.T) {
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -472,10 +471,23 @@ func TestMargeAdvancedFeatures(t *testing.T) {
t.Errorf("Expected status OK, got %v", res.Status)
}
contentType := res.Header.Get("Content-Type")
if contentType != "application/vnd.bose.streaming-v1.2+xml" {
t.Errorf("Invalid content type: %s", contentType)
}
token := res.Header.Get("Authorization")
if !strings.HasPrefix(token, "Bearer soundcork-local-token-") {
if !strings.HasPrefix(token, "Bearer st-local-token-") {
t.Errorf("Invalid token header: %s", token)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "<bearertoken") {
t.Errorf("Response body missing <bearertoken: %s", body)
}
if !strings.Contains(string(body), token) {
t.Errorf("Response body missing token value: %s", body)
}
})
t.Run("CustomerSupport", func(t *testing.T) {
+6 -6
View File
@@ -11,16 +11,16 @@ import (
//go:embed web/index.html
var indexHTML []byte
//go:embed web/css/* web/js/*
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
var webFS embed.FS
//go:embed soundcork/media/*
//go:embed static/media/*
var mediaFS embed.FS
//go:embed soundcork/bmx_services.json
//go:embed static/bmx_services.json
var bmxServicesJSON []byte
//go:embed soundcork/swupdate.xml
//go:embed static/swupdate.xml
var swUpdateXML []byte
// HandleRoot returns the root endpoint response.
@@ -28,7 +28,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`)
_, _ = fmt.Fprintf(w, `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`)
return
}
@@ -47,7 +47,7 @@ func (s *Server) HandleWeb() http.HandlerFunc {
// HandleMedia returns a handler for serving media files.
func (s *Server) HandleMedia() http.HandlerFunc {
subFS, _ := fs.Sub(mediaFS, "soundcork/media")
subFS, _ := fs.Sub(mediaFS, "static/media")
return func(w http.ResponseWriter, r *http.Request) {
fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS)))
+83 -13
View File
@@ -35,8 +35,8 @@ func TestRootEndpoint(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "Soundcork Management") {
t.Errorf("Expected body to contain 'Soundcork Management', got %s", string(body))
if !strings.Contains(string(body), "AfterTouch") {
t.Errorf("Expected body to contain 'AfterTouch', got %s", string(body))
}
}
@@ -67,8 +67,7 @@ func TestRootEndpointJSON(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
expected := `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`
expected := `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`
if strings.TrimSpace(string(body)) != expected {
t.Errorf("Expected body %s, got %s", expected, string(body))
}
@@ -80,7 +79,7 @@ func TestStaticMedia(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// Use a known file from soundcork/media
// Use a known file from static/media
res, err := http.Get(ts.URL + "/media/SiriusXM_Logo_Color.svg")
if err != nil {
t.Fatal(err)
@@ -104,32 +103,103 @@ 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"))
}
}
+288
View File
@@ -0,0 +1,288 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// BasicAuthMgmt returns a Basic Auth middleware using the server's management credentials.
func (s *Server) BasicAuthMgmt() func(http.Handler) http.Handler {
s.mu.RLock()
username := s.mgmtUsername
password := s.mgmtPassword
s.mu.RUnlock()
return middleware.BasicAuth("Management API", map[string]string{username: password})
}
// HandleMgmtListSpeakers returns discovered speakers for the given account.
func (s *Server) HandleMgmtListSpeakers(w http.ResponseWriter, r *http.Request) {
_ = chi.URLParam(r, "accountId")
allDevices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Mgmt] Failed to list devices: %v", err)
allDevices = nil
}
type speaker struct {
IPAddress string `json:"ipAddress"`
Name string `json:"name"`
DeviceID string `json:"deviceId"`
Type string `json:"type"`
}
speakers := make([]speaker, 0, len(allDevices))
for i := range allDevices {
d := &allDevices[i]
speakers = append(speakers, speaker{
IPAddress: d.IPAddress,
Name: d.Name,
DeviceID: d.DeviceID,
Type: d.ProductCode,
})
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"speakers": speakers,
}); err != nil {
log.Printf("[Mgmt] Failed to encode speakers: %v", err)
}
}
// HandleMgmtDeviceEvents returns events for a device (currently a placeholder).
func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
events := s.ds.GetDeviceEvents(deviceID)
if events == nil {
events = nil // will marshal as empty array via wrapper
}
w.Header().Set("Content-Type", "application/json")
// Return the events in the structure the Flutter app expects.
// Use an explicit empty slice to ensure JSON "[]" instead of "null".
type eventEntry struct {
Type string `json:"type"`
Time string `json:"time"`
Data map[string]interface{} `json:"data"`
}
result := make([]eventEntry, 0, len(events))
for _, e := range events {
result = append(result, eventEntry{
Type: e.Type,
Time: e.Time,
Data: e.Data,
})
}
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"events": result,
}); err != nil {
log.Printf("[Mgmt] Failed to encode events: %v", err)
}
}
// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL.
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
redirectURL := svc.BuildAuthorizeURL()
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(map[string]string{
"redirectUrl": redirectURL,
}); err != nil {
log.Printf("[Mgmt] Failed to encode redirect URL: %v", err)
}
}
// HandleMgmtSpotifyCallback is the browser OAuth callback from Spotify.
// Not protected by Basic Auth — Spotify redirects the user's browser here directly.
// Returns an HTML page the user can close.
func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Spotify integration not configured</p></body></html>`))
return
}
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
return
}
code := r.URL.Query().Get("code")
if code == "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Missing authorization code</h1></body></html>`))
return
}
if err := svc.ExchangeCodeAndStore(code); err != nil {
log.Printf("[Mgmt] Spotify callback failed: %v", err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Token exchange failed</p></body></html>`))
return
}
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>`))
}
// HandleMgmtSpotifyConfirm exchanges an authorization code for tokens.
// Used by the ueberboese mobile app after the deep link callback delivers the code.
// Protected by Basic Auth.
func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, `{"error":"missing code parameter"}`, http.StatusBadRequest)
return
}
if err := svc.ExchangeCodeAndStore(code); err != nil {
log.Printf("[Mgmt] Spotify confirm failed: %v", err)
http.Error(w, `{"error":"token exchange failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped).
func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
accounts := svc.GetAccounts()
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)
}
}
// HandleMgmtSpotifyToken returns a fresh Spotify access token and username.
func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
accessToken, username, err := svc.GetFreshToken()
if err != nil {
log.Printf("[Mgmt] Spotify token error: %v", err)
http.Error(w, `{"error":"no token available"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"access_token": accessToken,
"username": username,
}); err != nil {
log.Printf("[Mgmt] Failed to encode token: %v", err)
}
}
// HandleMgmtSpotifyEntity resolves a Spotify URI to name and image URL.
func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, `{"error":"failed to read body"}`, http.StatusBadRequest)
return
}
var request struct {
URI string `json:"uri"`
}
if unmarshalErr := json.Unmarshal(body, &request); unmarshalErr != nil || request.URI == "" {
http.Error(w, `{"error":"missing or invalid uri"}`, http.StatusBadRequest)
return
}
name, imageURL, err := svc.ResolveEntity(request.URI)
if err != nil {
log.Printf("[Mgmt] Spotify entity resolve error: %v", err)
http.Error(w, `{"error":"entity resolution failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"name": name,
"imageUrl": imageURL,
}); err != nil {
log.Printf("[Mgmt] Failed to encode entity: %v", err)
}
}
+160 -20
View File
@@ -1,6 +1,10 @@
package handlers
import (
"bytes"
"crypto/tls"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
@@ -33,31 +37,167 @@ func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) {
return
}
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
lp.LogBody = s.proxyLogBody
s.ServeProxy(target)(w, r)
}
proxy := httputil.NewSingleHostReverseProxy(target)
// Update director to set the correct host and path
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = target.Host
req.URL.Path = target.Path
req.URL.RawQuery = r.URL.RawQuery
lp.LogRequest(req)
}
// ServeProxy returns a handler that proxies to the given target.
func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
lp.LogBody = s.proxyLogBody
lp.RecordEnabled = s.recordEnabled
lp.SetRecorder(s.recorder)
proxy.ModifyResponse = func(res *http.Response) error {
// Generic Header Preservation
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
// Capture request body for recording, as it will be consumed by the proxy
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
lp.LogResponse(res)
rp := httputil.NewSingleHostReverseProxy(target)
rp.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return nil
// Update director to set the correct host and path
originalDirector := rp.Director
rp.Director = func(req *http.Request) {
originalDirector(req)
req.Host = target.Host
// If target has a path, we should probably append or replace.
// For Bose upstream, it's usually just the domain.
if target.Path != "" && target.Path != "/" {
req.URL.Path = target.Path
}
lp.LogRequest(req)
}
rp.ModifyResponse = func(res *http.Response) error {
res.Header.Set("X-Proxy-Origin", "upstream")
// Generic Header Preservation
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
}
// Restore captured request body for the recorder
if reqBody != nil {
res.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
lp.LogResponse(res)
return nil
}
rp.ServeHTTP(w, r)
}
}
// HandleNotFound handles requests that don't match any route.
func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) {
if s.enableSoundcorkProxy {
s.HandleSoundcorkWithFallback(w, r)
return
}
proxy.ServeHTTP(w, r)
s.HandleBoseProxy(w, r)
}
// HandleSoundcorkWithFallback tries Soundcork first, then Bose if Soundcork returns 404 or fails.
func (s *Server) HandleSoundcorkWithFallback(w http.ResponseWriter, r *http.Request) {
target, _ := url.Parse(s.soundcorkURL)
// Buffer request body if any, to allow multiple proxy attempts
var bodyBytes []byte
if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
_ = r.Body.Close()
}
// We use a custom response writer to catch 404s
rw := &fallbackResponseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
buffer: &bytes.Buffer{},
}
// Create a shallow copy of the request to avoid side effects between attempts
r2 := r.Clone(r.Context())
if bodyBytes != nil {
r2.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
} else {
r2.Body = nil
}
// Remove RequestURI as it's not allowed in client requests
r2.RequestURI = ""
s.ServeProxy(target)(rw, r2)
if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable {
log.Printf("[PROXY] Soundcork returned %d for %s, falling back to Bose", rw.statusCode, r.URL.Path)
if !rw.wroteHeader {
// Restore original body if any
if bodyBytes != nil {
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
s.HandleBoseProxy(w, r)
}
}
}
type fallbackResponseWriter struct {
http.ResponseWriter
statusCode int
wroteHeader bool
buffer *bytes.Buffer
}
func (rw *fallbackResponseWriter) WriteHeader(code int) {
rw.statusCode = code
if code != http.StatusNotFound && code != http.StatusBadGateway && code != http.StatusServiceUnavailable {
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
}
func (rw *fallbackResponseWriter) Write(b []byte) (int, error) {
if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable {
return len(b), nil // Drop the body
}
rw.wroteHeader = true
return rw.ResponseWriter.Write(b)
}
// HandleBoseProxy proxies the request to the Bose upstream.
func (s *Server) HandleBoseProxy(w http.ResponseWriter, r *http.Request) {
host := r.Host
if host == "" {
host = "streaming.bose.com"
}
// Default to HTTPS for Bose services
scheme := "https"
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "::1") {
scheme = "http"
}
targetURL := scheme + "://" + host
target, err := url.Parse(targetURL)
if err != nil {
log.Printf("[PROXY_ERR] Failed to parse target URL %s: %v", targetURL, err)
http.Error(w, "Invalid upstream host", http.StatusBadGateway)
return
}
s.ServeProxy(target)(w, r)
}
@@ -0,0 +1,96 @@
package handlers
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
)
func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "proxy-request-body-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Start a backend server to receive the proxied request
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read the body to ensure it's consumed
_, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte("<response>ok</response>"))
}))
defer backend.Close()
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
server.recordEnabled = true
server.proxyLogBody = true
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)
// Create a proxy request to the backend
requestBody := "<request>data</request>"
targetURL := backend.URL
proxyPath := "/proxy/" + targetURL
req := httptest.NewRequest("POST", proxyPath, bytes.NewBufferString(requestBody))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
server.HandleProxyRequest(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify that the interaction was recorded and contains the request body
sessionID := recorder.SessionID
// The recorder uses sanitized segments for the directory.
// Since the target URL is http://127.0.0.1:PORT, the path is empty,
// so it should be in the "root" directory under the category.
// We'll search recursively to be sure
foundBody := false
err = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
content, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(string(content), requestBody) {
foundBody = true
}
}
return nil
})
if err != nil {
t.Fatalf("failed to walk interactions dir: %v", err)
}
if !foundBody {
t.Errorf("request body %q not found in any recorded interaction file", requestBody)
// List all files found for debugging
_ = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if !info.IsDir() {
content, _ := os.ReadFile(path)
t.Logf("Found file %s with content:\n%s", path, string(content))
}
return nil
})
}
}
File diff suppressed because it is too large Load Diff

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