mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
Compare commits
@@ -1,8 +1,13 @@
|
||||
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, then open a draft PR for review.
|
||||
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, commit it to a branch, and hand the maintainer the commands to push and open a draft PR (the maintainer pushes, not you).
|
||||
|
||||
## Step 1 — Determine lookback window
|
||||
|
||||
Run:
|
||||
If the invocation arguments name an explicit starting point (a tag like `v0.93.1` or a
|
||||
date), use that as SINCE. For a tag, resolve its date:
|
||||
`git log -1 --format=%ad --date=short <tag>`. An explicit argument always overrides the
|
||||
auto-detection below.
|
||||
|
||||
Otherwise, auto-detect from the last published post:
|
||||
```
|
||||
git log --format="%ad" --date=short -- docs/content/blog/ | grep -v '_index' | head -1
|
||||
```
|
||||
@@ -63,25 +68,55 @@ sidebar:
|
||||
|
||||
Body structure:
|
||||
1. Opening paragraph (3–5 sentences) explaining what happened and why it matters to someone running AfterTouch.
|
||||
2. One `##` section per non-empty category. Use bullet points written for an operator audience — no raw git subjects, no internal Go package paths.
|
||||
3. End with: `**Current release:** vX.Y.Z`
|
||||
2. The body. Prefer a narrative that ties the changes into a story (what shifted, why it matters), not a bare aggregation of the release notes. Group related work under `##` sections (the commit categories are raw material, not the final headings). Write for an operator audience: no raw git subjects, no internal Go package paths. A short bullet list inside a section is fine, but the post should read like prose, not a changelog dump.
|
||||
3. Close with the standard footer convention used by the existing posts, so every post ends the same way:
|
||||
|
||||
Target length: 300–600 words. Never include real IPs, MAC addresses, account IDs, or device names.
|
||||
```markdown
|
||||
## Current release
|
||||
|
||||
## Step 6 — Create a branch and open a draft PR
|
||||
**vX.Y.Z**, released MONTH D, YYYY
|
||||
|
||||
This blog will be updated monthly, or whenever something significant ships.
|
||||
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
|
||||
for individual version notes.
|
||||
```
|
||||
|
||||
Get the release date with `git log -1 --format=%ad --date=format:'%B %-d, %Y' vX.Y.Z`.
|
||||
When in doubt about any recurring element (footer, release line, tags), match the most
|
||||
recent existing post under `docs/content/blog/` rather than inventing a new convention.
|
||||
Never retrofit or restyle already-published posts to fit a new convention — they are
|
||||
dated records; a new convention applies going forward only.
|
||||
|
||||
Target length: 300–600 words (longer is fine when the story warrants it). Never include
|
||||
real IPs, MAC addresses, account IDs, or device names.
|
||||
|
||||
**No em dashes.** Do not use the em dash character (`—`) anywhere in the post; use commas,
|
||||
parentheses, colons, or separate sentences. (En dashes in a period label like
|
||||
`April – May 2026` are fine.) Verify with `grep -c '—' <file>` before committing.
|
||||
|
||||
## Step 6 — Create a branch and commit (do NOT push)
|
||||
|
||||
```bash
|
||||
git checkout -b blog/YYYY-MM-update
|
||||
git add docs/content/blog/YYYY-MM-slug.md
|
||||
git commit -m "docs(blog): add PERIOD update post"
|
||||
```
|
||||
|
||||
**Do not push and do not open the PR yourself.** The maintainer always pushes over SSH
|
||||
(see the global and project instructions). Pushing on their behalf, including over HTTPS
|
||||
with a token or by switching the remote, is not allowed.
|
||||
|
||||
## Step 7 — Done
|
||||
|
||||
Hand the maintainer the ready-to-run commands to push and open the draft PR, then stop:
|
||||
|
||||
```bash
|
||||
git push -u origin blog/YYYY-MM-update
|
||||
gh pr create --draft \
|
||||
--title "Blog: PERIOD update post" \
|
||||
--body "Automated draft from /blog-update skill. Review content before merging — deployment is automatic on merge to main."
|
||||
--body "Update post covering recent changes. Review content before merging — deployment is automatic on merge to main."
|
||||
```
|
||||
|
||||
If the `documentation` label exists on the repo, add `--label documentation`.
|
||||
|
||||
## Step 7 — Done
|
||||
|
||||
Report the PR URL. Do not merge, approve, or request review.
|
||||
Do not merge, approve, or request review.
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. macOS 14.0, Windows 11, Ubuntu 22.04]
|
||||
- Go version: [e.g. 1.25.5]
|
||||
- Library version: [e.g. v1.0.0, commit hash if using main branch]
|
||||
- SoundTouch device model: [e.g. SoundTouch 10, SoundTouch 20]
|
||||
- Device firmware version: [if known]
|
||||
|
||||
**Command/Code that failed**
|
||||
```bash
|
||||
# If using CLI tool, provide the exact command
|
||||
soundtouch-cli --host 192.0.2.100 info get
|
||||
|
||||
# If using Go library, provide minimal code example
|
||||
```
|
||||
|
||||
**Error output**
|
||||
```
|
||||
Paste the complete error message here, including stack traces if available
|
||||
```
|
||||
|
||||
**Device Information (if applicable)**
|
||||
```xml
|
||||
<!-- If the issue is device-specific, include output from: -->
|
||||
<!-- soundtouch-cli --host YOUR_DEVICE_IP info get -->
|
||||
```
|
||||
|
||||
**Network Configuration**
|
||||
- Network setup: [e.g. home WiFi, corporate network, VPN]
|
||||
- Firewall/proxy: [any network restrictions]
|
||||
- Device connectivity: [how device connects to network - WiFi, Ethernet]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. For example:
|
||||
- Does this happen consistently or intermittently?
|
||||
- Did this work in a previous version?
|
||||
- Are there any workarounds?
|
||||
- Any relevant log files or debug output
|
||||
|
||||
**Logs (if applicable)**
|
||||
```
|
||||
# Enable verbose logging with --verbose flag or debug environment variable
|
||||
# and paste relevant log output here
|
||||
```
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have searched existing issues to avoid duplicates
|
||||
- [ ] I have tested with the latest version
|
||||
- [ ] I have included all relevant environment information
|
||||
- [ ] I have provided a minimal reproduction case
|
||||
- [ ] I have included complete error messages
|
||||
@@ -1,201 +1,118 @@
|
||||
name: Bug Report
|
||||
description: File a bug report to help us improve the library
|
||||
description: Something in AfterTouch isn't working the way it should
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report! Please provide as much detail as possible to help us diagnose and fix the issue.
|
||||
Thanks for helping improve **AfterTouch**! 🙏
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Library Version
|
||||
description: What version of the library are you using?
|
||||
placeholder: "v1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
AfterTouch is a community-built toolkit that keeps Bose SoundTouch speakers
|
||||
working after the Bose cloud shutdown. We both give and ask for support here,
|
||||
so don't worry about getting every field perfect.
|
||||
|
||||
- type: dropdown
|
||||
id: go-version
|
||||
attributes:
|
||||
label: Go Version
|
||||
description: What version of Go are you using?
|
||||
options:
|
||||
- "1.25.5+"
|
||||
- "1.25"
|
||||
- "1.24"
|
||||
- "1.23"
|
||||
- "Other (please specify in description)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What operating system are you running on?
|
||||
options:
|
||||
- "Linux"
|
||||
- "macOS"
|
||||
- "Windows"
|
||||
- "FreeBSD"
|
||||
- "Other (please specify in description)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device-model
|
||||
attributes:
|
||||
label: Bose Device Model
|
||||
description: What Bose SoundTouch device are you trying to control?
|
||||
placeholder: "SoundTouch 10, SoundTouch 20, etc."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device-firmware
|
||||
attributes:
|
||||
label: Device Firmware Version
|
||||
description: What firmware version is your device running? (Check in Bose app or via /info endpoint)
|
||||
placeholder: "4.8.1.4567.891234567"
|
||||
A quick look at the
|
||||
[Troubleshooting Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/TROUBLESHOOTING/)
|
||||
often saves time. For "how do I...?" questions, please use
|
||||
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) instead.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: "Describe what happened and what you expected to happen..."
|
||||
label: What happened?
|
||||
description: What went wrong, and what did you expect to happen instead?
|
||||
placeholder: "When I play a radio station from the player UI, the speaker shows an orange light and nothing plays. I expected it to start playing."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
label: Steps to reproduce
|
||||
description: How can we trigger it? Rough steps are fine.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
1. Open the player UI
|
||||
2. Search for a station
|
||||
3. Press play
|
||||
4. ...
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
id: diagnostic
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: "What should have happened instead?"
|
||||
label: Diagnostic report (the most helpful thing you can attach)
|
||||
description: |
|
||||
In the AfterTouch **admin UI**, open the **Health tab** and click
|
||||
**Download diagnostic report**. It is by far the best way to help us
|
||||
diagnose a bug. GitHub blocks `.age` uploads, so rename the file to
|
||||
`.age.txt` (or zip it) before dragging it into this box. You can also email
|
||||
it instead: aftertouch-support@gesellix.net.
|
||||
|
||||
The file is **encrypted** to the maintainer's key, so only the maintainer
|
||||
can open it. The structured summary has credentials redacted, but the raw
|
||||
datastore files (for example `Sources.xml`) are included as-is and can
|
||||
contain the access tokens your speaker uses for linked services like
|
||||
Spotify or Amazon. If that is a concern, unlink those services before
|
||||
exporting, or email the report privately instead.
|
||||
placeholder: "Attach the aftertouch-diagnostic-*.age.txt (or .zip) file here."
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: code-sample
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Code Sample
|
||||
description: Please provide a minimal code sample that reproduces the issue
|
||||
render: go
|
||||
placeholder: |
|
||||
package main
|
||||
label: Speaker model & firmware
|
||||
description: Which speaker, and (if you know it) the firmware version.
|
||||
placeholder: "SoundTouch 10, firmware 27.0.6"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
- type: dropdown
|
||||
id: how-run
|
||||
attributes:
|
||||
label: How are you running AfterTouch?
|
||||
options:
|
||||
- "Docker / docker compose"
|
||||
- "Prebuilt binary"
|
||||
- "Built from source"
|
||||
- "Not sure"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
func main() {
|
||||
// Your code that demonstrates the issue
|
||||
}
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: AfterTouch version
|
||||
description: Shown in the admin UI footer, or via the binary's `--version`.
|
||||
placeholder: "v0.111.2"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Messages / Logs
|
||||
description: Please include any relevant error messages, stack traces, or log output
|
||||
label: Logs / error messages
|
||||
description: Any relevant output from the service, CLI, or browser console. Please mask real LAN IPs if you can.
|
||||
render: shell
|
||||
placeholder: |
|
||||
Error: connection refused
|
||||
at github.com/gesellix/bose-soundtouch/pkg/client.(*Client).makeRequest
|
||||
...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which component is affected?
|
||||
multiple: true
|
||||
options:
|
||||
- "Client Library (pkg/client)"
|
||||
- "WebSocket Events"
|
||||
- "Device Discovery"
|
||||
- "CLI Tool"
|
||||
- "Models/XML Parsing"
|
||||
- "Documentation"
|
||||
- "Examples"
|
||||
- "Build/Release"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: Severity
|
||||
description: How severe is this bug?
|
||||
options:
|
||||
- "Low - Minor inconvenience"
|
||||
- "Medium - Affects functionality but workaround exists"
|
||||
- "High - Blocks major functionality"
|
||||
- "Critical - Application crashes or data loss"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: network-info
|
||||
attributes:
|
||||
label: Network Configuration
|
||||
description: Details about your network setup (if relevant to the issue)
|
||||
placeholder: |
|
||||
- Device IP: 192.0.2.100
|
||||
- Network type: WiFi/Ethernet
|
||||
- Router model:
|
||||
- Any firewalls or network restrictions:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here
|
||||
placeholder: "Screenshots, network traces, related issues, etc."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: troubleshooting
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Troubleshooting Steps
|
||||
description: Have you tried these troubleshooting steps?
|
||||
label: Before you submit
|
||||
options:
|
||||
- label: "I have checked the [Troubleshooting Guide](docs/TROUBLESHOOTING.md)"
|
||||
- label: "I have verified my device is reachable (ping test)"
|
||||
- label: "I have tested with the CLI tool"
|
||||
- label: "I have checked for similar existing issues"
|
||||
- label: "I am using the latest version of the library"
|
||||
- label: "I checked the [Troubleshooting Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/TROUBLESHOOTING/)"
|
||||
- label: "I searched [existing issues](https://github.com/gesellix/Bose-SoundTouch/issues) for a duplicate"
|
||||
- label: "I'm on a recent AfterTouch version"
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
id: coc
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our Code of Conduct
|
||||
description: This project follows a [Code of Conduct](https://github.com/gesellix/Bose-SoundTouch/blob/main/CODE_OF_CONDUCT.md).
|
||||
options:
|
||||
- label: "I agree to follow this project's Code of Conduct"
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Free-form blank issues stay enabled for anything that doesn't fit a template.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💬 Questions & Support (Discussions)
|
||||
url: https://github.com/gesellix/Bose-SoundTouch/discussions
|
||||
about: "\"How do I...?\", setup help, and general support. AfterTouch is a community effort: ask here, and help others when you can."
|
||||
- name: 🚑 Survival Guide
|
||||
url: https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/
|
||||
about: "Getting your speakers working again after the Bose cloud shutdown. Start here."
|
||||
- name: 🔧 Troubleshooting Guide
|
||||
url: https://gesellix.github.io/Bose-SoundTouch/docs/guides/TROUBLESHOOTING/
|
||||
about: "Common problems and their fixes. Please check this before filing a bug."
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
name: Device compatibility report
|
||||
about: Report compatibility with a new SoundTouch device model
|
||||
title: 'Device Compatibility: [Device Model]'
|
||||
labels: 'compatibility, documentation'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Device Information**
|
||||
- **Model**: [e.g. SoundTouch 30, Wave SoundTouch IV, SoundTouch Portable]
|
||||
- **Model Number**: [e.g. 738102-2100, found on device label]
|
||||
- **Firmware Version**: [if known, from device settings or API response]
|
||||
- **Purchase Date**: [approximate, helps identify firmware generation]
|
||||
|
||||
**Testing Results**
|
||||
|
||||
### Basic Functionality
|
||||
- [ ] Device discovery (UPnP/mDNS)
|
||||
- [ ] Basic device info (`GET /info`)
|
||||
- [ ] Now playing status (`GET /now_playing`)
|
||||
- [ ] Media controls (play/pause/stop)
|
||||
- [ ] Volume control
|
||||
- [ ] Source listing (`GET /sources`)
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Bass control (`GET/POST /bass`)
|
||||
- [ ] Balance control (`GET/POST /balance`) - if stereo device
|
||||
- [ ] Clock/time management (`GET/POST /clockTime`)
|
||||
- [ ] Network information (`GET /networkInfo`)
|
||||
- [ ] WebSocket events
|
||||
- [ ] Multiroom zones (master)
|
||||
- [ ] Multiroom zones (slave)
|
||||
|
||||
### Advanced Audio Controls (Professional/High-end Models)
|
||||
- [ ] DSP controls (`GET/POST /audiodspcontrols`)
|
||||
- [ ] Tone controls (`GET/POST /audioproducttonecontrols`)
|
||||
- [ ] Level controls (`GET/POST /audioproductlevelcontrols`)
|
||||
|
||||
### Known Issues
|
||||
List any features that don't work or behave unexpectedly:
|
||||
- Feature name: Description of issue
|
||||
- Command that fails: `soundtouch-cli command that doesn't work`
|
||||
|
||||
**Device Info Output**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP info get -->
|
||||
<!-- This helps us understand device capabilities and variants -->
|
||||
```
|
||||
|
||||
**Device Capabilities Output**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP capabilities -->
|
||||
<!-- This shows what features the device reports as available -->
|
||||
```
|
||||
|
||||
**Bass Capabilities (if supported)**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP bass capabilities -->
|
||||
<!-- Only if the device supports bass control -->
|
||||
```
|
||||
|
||||
**Available Sources**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP source list -->
|
||||
<!-- Shows what audio sources this device supports -->
|
||||
```
|
||||
|
||||
**Testing Commands Used**
|
||||
```bash
|
||||
# List the specific commands you used for testing
|
||||
soundtouch-cli --host 192.0.2.100 info get
|
||||
soundtouch-cli --host 192.0.2.100 play start
|
||||
# ... etc
|
||||
```
|
||||
|
||||
**Environment**
|
||||
- **OS**: [e.g. macOS 14.0, Windows 11, Ubuntu 22.04]
|
||||
- **Go version**: [e.g. 1.25.5]
|
||||
- **Library version**: [e.g. v1.0.0, commit hash]
|
||||
- **Network setup**: [home WiFi, corporate, etc.]
|
||||
|
||||
**Performance Notes**
|
||||
- Response times: [normal, slow, timeouts]
|
||||
- Specific timeouts: [any endpoints that timeout]
|
||||
- WebSocket stability: [connects reliably, frequent disconnects, etc.]
|
||||
|
||||
**Comparison with Tested Models**
|
||||
If you have experience with other SoundTouch models:
|
||||
- **Similar to**: [e.g. works like SoundTouch 20]
|
||||
- **Differences from**: [e.g. missing balance control compared to SoundTouch 30]
|
||||
|
||||
**Additional Notes**
|
||||
Any other observations about device behavior, quirks, or special considerations:
|
||||
- Does the device have unique features not seen in other models?
|
||||
- Are there any setup requirements or configuration notes?
|
||||
- Does it work differently in different network environments?
|
||||
|
||||
**Documentation Impact**
|
||||
- [ ] Update supported devices list
|
||||
- [ ] Add device-specific notes to documentation
|
||||
- [ ] Update compatibility matrix
|
||||
- [ ] Add to integration test suite
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have tested basic functionality (info, play, volume)
|
||||
- [ ] I have tested advanced features available on this device
|
||||
- [ ] I have provided complete device information output
|
||||
- [ ] I have noted any issues or limitations
|
||||
- [ ] I have tested in a typical network environment
|
||||
- [ ] I understand this helps improve compatibility for all users
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Device Compatibility Report
|
||||
description: Tell us how AfterTouch works (or doesn't) with your SoundTouch model
|
||||
title: "[Compatibility]: "
|
||||
labels: ["compatibility", "documentation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping map out which speakers AfterTouch supports! 📋
|
||||
|
||||
Reports like yours help everyone with the same model, and feed our
|
||||
compatibility notes in the docs.
|
||||
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Speaker model
|
||||
placeholder: "SoundTouch 20, Wave SoundTouch IV, SoundTouch Portable, ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: firmware
|
||||
attributes:
|
||||
label: Firmware version
|
||||
description: From the admin UI, the Bose app, or the device's `/info`.
|
||||
placeholder: "27.0.6"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: works
|
||||
attributes:
|
||||
label: What works?
|
||||
placeholder: |
|
||||
- Discovery
|
||||
- Playback and presets
|
||||
- Stereo pair / multiroom
|
||||
- Migration off the Bose cloud
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: broken
|
||||
attributes:
|
||||
label: What doesn't work?
|
||||
placeholder: "Anything that failed or behaved unexpectedly on this model."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: diagnostic
|
||||
attributes:
|
||||
label: Diagnostic report
|
||||
description: |
|
||||
Optional, but very helpful. In the AfterTouch **admin UI**, open the
|
||||
**Health tab** and click **Download diagnostic report**. GitHub blocks
|
||||
`.age` uploads, so rename the file to `.age.txt` (or zip it) before
|
||||
attaching, or email it to aftertouch-support@gesellix.net. It is encrypted
|
||||
to the maintainer's key. The raw datastore files inside are included as-is,
|
||||
so if you have linked services like Spotify or Amazon, unlink them first or
|
||||
send the file privately.
|
||||
placeholder: "Attach the aftertouch-diagnostic-*.age.txt (or .zip) file here."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: coc
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: This project follows a [Code of Conduct](https://github.com/gesellix/Bose-SoundTouch/blob/main/CODE_OF_CONDUCT.md).
|
||||
options:
|
||||
- label: "I agree to follow this project's Code of Conduct"
|
||||
required: true
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Use case**
|
||||
Describe your specific use case and how this feature would benefit you and other users.
|
||||
|
||||
**SoundTouch API Support**
|
||||
- [ ] This feature is supported by the official SoundTouch API
|
||||
- [ ] This feature is NOT supported by the SoundTouch API (custom enhancement)
|
||||
- [ ] I'm not sure if this is supported by the SoundTouch API
|
||||
|
||||
**API Documentation Reference (if applicable)**
|
||||
If this feature is based on a SoundTouch API endpoint, please provide:
|
||||
- Endpoint URL: [e.g. GET /newendpoint]
|
||||
- Documentation reference: [page number or section in official API docs]
|
||||
- XML request/response examples: [if known]
|
||||
|
||||
**Implementation Details (optional)**
|
||||
If you have ideas about how this could be implemented:
|
||||
- Suggested package/module: [e.g. pkg/client, cmd/soundtouch-cli]
|
||||
- Method signatures: [if you have suggestions]
|
||||
- CLI commands: [if this affects the CLI tool]
|
||||
|
||||
**Device Compatibility**
|
||||
- SoundTouch models this applies to: [e.g. all models, SoundTouch 20+, specific models]
|
||||
- Have you tested this manually: [e.g. via curl, Postman, etc.]
|
||||
|
||||
**Examples**
|
||||
Provide examples of how you would like to use this feature:
|
||||
|
||||
```go
|
||||
// Go library example
|
||||
client.NewFeature(parameters)
|
||||
```
|
||||
|
||||
```bash
|
||||
# CLI example
|
||||
soundtouch-cli --host 192.0.2.100 new-feature --param value
|
||||
```
|
||||
|
||||
**Priority**
|
||||
- [ ] Critical - blocks important functionality
|
||||
- [ ] High - would significantly improve user experience
|
||||
- [ ] Medium - nice to have enhancement
|
||||
- [ ] Low - minor improvement
|
||||
|
||||
**Additional context**
|
||||
Add any other context, screenshots, or examples about the feature request here.
|
||||
|
||||
**Related Issues**
|
||||
- Related to #[issue number]
|
||||
- Depends on #[issue number]
|
||||
- Blocks #[issue number]
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have searched existing issues to avoid duplicates
|
||||
- [ ] I have checked the documentation to ensure this feature doesn't already exist
|
||||
- [ ] I have provided a clear use case and rationale
|
||||
- [ ] I have considered the impact on existing functionality
|
||||
- [ ] I understand this may require SoundTouch API support to implement
|
||||
@@ -1,205 +1,74 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea or enhancement for this project
|
||||
description: Suggest an idea or improvement for AfterTouch
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a new feature! Please provide as much detail as possible to help us understand your request and its potential impact.
|
||||
Thanks for the idea! 💡
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Library Version
|
||||
description: What version of the library are you currently using?
|
||||
placeholder: "v1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
AfterTouch is a community-built toolkit for keeping Bose SoundTouch speakers
|
||||
alive. For open-ended "would it be possible...?" brainstorming,
|
||||
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) is often
|
||||
a better fit. Use this form when you have a concrete improvement in mind.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: Is your feature request related to a problem? Please describe what you're trying to accomplish.
|
||||
placeholder: "I'm always frustrated when... / I need to be able to... / Currently it's not possible to..."
|
||||
label: What problem would this solve?
|
||||
placeholder: "I can't ... / It's hard to ... / Currently there's no way to ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
id: idea
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see implemented.
|
||||
placeholder: "I would like to see... / A new function that... / An option to..."
|
||||
label: What would you like to see?
|
||||
placeholder: "Describe the feature or improvement you have in mind."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Which part of AfterTouch?
|
||||
multiple: true
|
||||
options:
|
||||
- "soundtouch-service (local cloud)"
|
||||
- "soundtouch-cli"
|
||||
- "soundtouch-player (web UI)"
|
||||
- "soundtouch-backup"
|
||||
- "Go library (pkg/*)"
|
||||
- "Documentation"
|
||||
- "Not sure"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered.
|
||||
placeholder: "I considered... / Another approach could be... / Workaround I'm currently using..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which component would this feature affect?
|
||||
multiple: true
|
||||
options:
|
||||
- "Client Library (pkg/client)"
|
||||
- "WebSocket Events"
|
||||
- "Device Discovery"
|
||||
- "CLI Tool"
|
||||
- "Models/XML Parsing"
|
||||
- "Documentation"
|
||||
- "Examples"
|
||||
- "New API Endpoint"
|
||||
- "Performance Optimization"
|
||||
- "Developer Experience"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: device-compatibility
|
||||
attributes:
|
||||
label: Device Compatibility
|
||||
description: Which Bose SoundTouch devices should this feature support?
|
||||
multiple: true
|
||||
options:
|
||||
- "All SoundTouch devices"
|
||||
- "SoundTouch 10"
|
||||
- "SoundTouch 20"
|
||||
- "SoundTouch 30"
|
||||
- "SoundTouch Portable"
|
||||
- "SoundTouch Wave"
|
||||
- "Other (specify in description)"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this feature to you?
|
||||
options:
|
||||
- "Low - Nice to have"
|
||||
- "Medium - Would improve my workflow"
|
||||
- "High - Important for my use case"
|
||||
- "Critical - Blocking my project"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: api-type
|
||||
attributes:
|
||||
label: API Type (if applicable)
|
||||
description: What type of API enhancement is this?
|
||||
options:
|
||||
- "Not applicable"
|
||||
- "New Bose SoundTouch endpoint"
|
||||
- "Enhancement to existing endpoint"
|
||||
- "Client library improvement"
|
||||
- "WebSocket event enhancement"
|
||||
- "Discovery enhancement"
|
||||
- "CLI command addition"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case / User Story
|
||||
description: Describe your specific use case or user story
|
||||
placeholder: |
|
||||
As a [type of user], I want to [goal] so that [benefit].
|
||||
|
||||
Example: As a home automation developer, I want to create custom zones so that I can group speakers dynamically based on user preferences.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: example-api
|
||||
attributes:
|
||||
label: Desired API Example
|
||||
description: Show how you'd like the API to work (if applicable)
|
||||
render: go
|
||||
placeholder: |
|
||||
// Example of how you envision using this feature
|
||||
client := soundtouch.New("192.0.2.100", 8090)
|
||||
|
||||
// Your desired API call
|
||||
result, err := client.NewFeature(options)
|
||||
if err != nil {
|
||||
// handle error
|
||||
}
|
||||
|
||||
// Use the result
|
||||
fmt.Println(result)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: technical-details
|
||||
attributes:
|
||||
label: Technical Details
|
||||
description: Any technical considerations, constraints, or implementation ideas?
|
||||
placeholder: |
|
||||
- Should this be backward compatible?
|
||||
- Any performance considerations?
|
||||
- Integration with existing features?
|
||||
- External dependencies needed?
|
||||
label: Alternatives or workarounds
|
||||
description: Anything you've already tried or considered. Links and references are welcome.
|
||||
placeholder: "See the API Cookbook (https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-COOKBOOK/) ..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: implementation
|
||||
id: help
|
||||
attributes:
|
||||
label: Implementation
|
||||
description: Are you willing to help implement this feature?
|
||||
label: Can you help?
|
||||
description: Completely optional. Community contributions are very welcome.
|
||||
options:
|
||||
- label: "I can help implement this feature"
|
||||
- label: "I can provide testing/feedback"
|
||||
- label: "I can help implement this"
|
||||
- label: "I can help test it"
|
||||
- label: "I can help with documentation"
|
||||
- label: "I need someone else to implement this"
|
||||
|
||||
- type: textarea
|
||||
id: research
|
||||
attributes:
|
||||
label: Research & References
|
||||
description: Have you found any relevant resources, similar implementations, or Bose documentation?
|
||||
placeholder: |
|
||||
- Links to relevant documentation
|
||||
- Similar features in other libraries
|
||||
- Bose SoundTouch API references
|
||||
- Related GitHub issues or discussions
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following
|
||||
options:
|
||||
- label: "I have searched for existing issues and feature requests"
|
||||
required: true
|
||||
- label: "I have checked the [API Cookbook](docs/API-COOKBOOK.md) for existing functionality"
|
||||
required: true
|
||||
- label: "This feature is related to Bose SoundTouch functionality"
|
||||
required: true
|
||||
- label: "I have considered backward compatibility"
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
id: coc
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this feature request, you agree to follow our Code of Conduct
|
||||
description: This project follows a [Code of Conduct](https://github.com/gesellix/Bose-SoundTouch/blob/main/CODE_OF_CONDUCT.md).
|
||||
options:
|
||||
- label: "I agree to follow this project's Code of Conduct"
|
||||
required: true
|
||||
|
||||
@@ -125,6 +125,13 @@ updates:
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
# Group all codeql-action sub-actions (init/analyze/upload-sarif)
|
||||
# so they bump together. They are separate dependencies to
|
||||
# Dependabot but must stay on the same version, or CodeQL fails
|
||||
# with "Loaded a configuration file for version X, but running Y".
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action*"
|
||||
# Group actions from the same organization
|
||||
actions-core:
|
||||
patterns:
|
||||
|
||||
@@ -1,171 +1,45 @@
|
||||
## Description
|
||||
## Summary
|
||||
|
||||
Brief description of the changes in this PR.
|
||||
What does this PR do, and why?
|
||||
|
||||
## Type of Change
|
||||
## Linked issue
|
||||
|
||||
Please check the type of change your PR introduces:
|
||||
<!-- Use "Refs #123". Reserve "Fixes #123" for a change the maintainer has confirmed
|
||||
actually resolves the issue (a merged PR is not confirmation on its own). -->
|
||||
Refs #
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Code refactoring (no functional changes)
|
||||
- [ ] Test improvements
|
||||
- [ ] Build/CI improvements
|
||||
## Type of change
|
||||
|
||||
## Related Issues
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation
|
||||
- [ ] Refactor / tests / tooling
|
||||
|
||||
- Fixes #[issue number]
|
||||
- Relates to #[issue number]
|
||||
- Part of #[issue number]
|
||||
## How was it tested?
|
||||
|
||||
## Changes Made
|
||||
- [ ] `make check` passes (fmt, vet, lint, tests)
|
||||
- [ ] Tested against a real SoundTouch device (details below)
|
||||
|
||||
### API Changes
|
||||
- [ ] Added new endpoints
|
||||
- [ ] Modified existing endpoints
|
||||
- [ ] Added new CLI commands
|
||||
- [ ] Modified existing CLI commands
|
||||
- [ ] Added new configuration options
|
||||
<!-- If you tested on hardware, note the model and what you observed. In any pasted
|
||||
output, use RFC-5737 documentation IPs (192.0.2.x), never your real LAN IPs. -->
|
||||
|
||||
### Implementation Details
|
||||
- Describe the main changes
|
||||
- List any new dependencies
|
||||
- Mention any architectural changes
|
||||
## Checklist
|
||||
|
||||
## Testing
|
||||
|
||||
### Automated Tests
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Integration tests added/updated
|
||||
- [ ] All existing tests pass
|
||||
- [ ] Test coverage maintained or improved
|
||||
|
||||
### Manual Testing
|
||||
- [ ] Tested with real SoundTouch device(s)
|
||||
- [ ] Tested CLI changes manually
|
||||
- [ ] Tested in different network environments
|
||||
|
||||
**Device(s) tested with:**
|
||||
- Device model: [e.g. SoundTouch 10]
|
||||
- Device IP: [e.g. 192.0.2.100]
|
||||
- Test results: [brief description]
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
# Commands used to test this change
|
||||
make test
|
||||
go test ./pkg/client -v -run TestNewFeature
|
||||
soundtouch-cli --host 192.0.2.100 new-command
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added code comments for complex logic
|
||||
- [ ] Updated CLI help text
|
||||
- [ ] Added usage examples
|
||||
- [ ] Updated API documentation
|
||||
|
||||
**Documentation files updated:**
|
||||
- [ ] README.md
|
||||
- [ ] docs/API-Endpoints-Overview.md
|
||||
- [ ] docs/CLI-REFERENCE.md
|
||||
- [ ] Code documentation (godoc)
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- [ ] This change is backward compatible
|
||||
- [ ] This change includes breaking changes (requires major version bump)
|
||||
- [ ] This change requires configuration migration
|
||||
|
||||
**Breaking changes (if any):**
|
||||
- Describe what breaks
|
||||
- Provide migration instructions
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- [ ] No security implications
|
||||
- [ ] Security review required
|
||||
- [ ] Added input validation
|
||||
- [ ] Updated authentication/authorization
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- [ ] No performance impact
|
||||
- [ ] Performance improvement
|
||||
- [ ] Potential performance regression (justify why)
|
||||
|
||||
**Performance notes:**
|
||||
- Measured impact: [benchmarks, timing, memory usage]
|
||||
- Optimization opportunities: [if any]
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] No linting errors
|
||||
- [ ] No security warnings
|
||||
- [ ] Memory leaks checked (if applicable)
|
||||
|
||||
### Pre-submission Checklist
|
||||
|
||||
- [ ] `make check` passes (format, lint, vet)
|
||||
- [ ] `make test` passes
|
||||
- [ ] No TODO comments left in production code
|
||||
- [ ] Error handling is comprehensive
|
||||
- [ ] Logging is appropriate (not too verbose, not too quiet)
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
Any special considerations for deployment:
|
||||
- Configuration changes required
|
||||
- Database migrations needed
|
||||
- Service restart required
|
||||
- Rollback procedures
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
If this PR includes UI changes or CLI output changes, include screenshots or terminal output examples.
|
||||
|
||||
```bash
|
||||
# Before
|
||||
$ soundtouch-cli old-command
|
||||
Old output...
|
||||
|
||||
# After
|
||||
$ soundtouch-cli new-command
|
||||
New improved output...
|
||||
```
|
||||
|
||||
## Additional Notes
|
||||
|
||||
Any additional information that reviewers should know:
|
||||
- Design decisions and trade-offs
|
||||
- Future work planned
|
||||
- Alternative approaches considered
|
||||
- References to external documentation
|
||||
|
||||
## Review Requests
|
||||
|
||||
**Areas that need special attention:**
|
||||
- [ ] Error handling logic
|
||||
- [ ] Performance critical sections
|
||||
- [ ] Security implications
|
||||
- [ ] API design choices
|
||||
- [ ] Documentation clarity
|
||||
|
||||
**Specific questions for reviewers:**
|
||||
1. Question about design choice X?
|
||||
2. Is error handling sufficient in section Y?
|
||||
3. Should we consider alternative approach Z?
|
||||
- [ ] My changes are focused, and I have read the diff myself
|
||||
- [ ] No personal data (real LAN IPs, MAC addresses, device IDs, account IDs) in code, tests, or fixtures
|
||||
- [ ] Docs or CLI help updated if behavior changed
|
||||
|
||||
---
|
||||
|
||||
**Reviewer Guidelines:**
|
||||
- Check that all tests pass
|
||||
- Verify documentation is updated
|
||||
- Test manually if device access available
|
||||
- Consider backward compatibility
|
||||
- Evaluate error handling and edge cases
|
||||
### A note on AI-assisted contributions
|
||||
|
||||
AI and agent-assisted code is welcome, we use it here too. What we cannot accept is
|
||||
unreviewed "slop": large generated diffs the author has not read, run, or understood.
|
||||
Keep PRs small and focused, make sure `make check` passes, and be ready to explain your
|
||||
changes during review.
|
||||
|
||||
By contributing, you agree that your work is licensed under the project's
|
||||
[MIT License](https://github.com/gesellix/Bose-SoundTouch/blob/main/LICENSE) and that you
|
||||
will follow the
|
||||
[Code of Conduct](https://github.com/gesellix/Bose-SoundTouch/blob/main/CODE_OF_CONDUCT.md).
|
||||
|
||||
+35
-33
@@ -17,15 +17,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -66,10 +66,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
@@ -107,15 +107,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -143,7 +143,9 @@ jobs:
|
||||
|
||||
mkdir -p build
|
||||
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
|
||||
# soundtouch-web is now a transitional alias of soundtouch-player
|
||||
# (same source); building the player is enough to verify both.
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-player soundtouch-backup; do
|
||||
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
|
||||
echo "Building $OUTPUT"
|
||||
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
|
||||
@@ -163,10 +165,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -190,7 +192,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
@@ -248,10 +250,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -303,10 +305,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Set build date
|
||||
id: build_date
|
||||
@@ -328,7 +330,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -336,7 +338,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -346,7 +348,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -360,26 +362,26 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
- name: Extract metadata (tags, labels) for soundtouch-player
|
||||
id: meta-player
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
images: ghcr.io/${{ github.repository }}-player
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
- name: Build and push soundtouch-player Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
target: soundtouch-player
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
tags: ${{ steps.meta-player.outputs.tags }}
|
||||
labels: ${{ steps.meta-player.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
@@ -390,7 +392,7 @@ jobs:
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
env:
|
||||
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
|
||||
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
|
||||
PLAYER_TAGS: ${{ steps.meta-player.outputs.tags }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
@@ -414,12 +416,12 @@ jobs:
|
||||
done <<< "$SERVICE_TAGS"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### soundtouch-web"
|
||||
echo "### soundtouch-player"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$WEB_TAGS"
|
||||
done <<< "$PLAYER_TAGS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install libpcap (required for Go build)
|
||||
if: matrix.language == 'go'
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -51,6 +51,6 @@ jobs:
|
||||
run: go build ./...
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
+103
-177
@@ -23,23 +23,27 @@ jobs:
|
||||
name: Validate Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Both triggers resolve to the same thing: the release tag. On a
|
||||
# `release` event inputs.tag is empty, so this falls back to the
|
||||
# published release's tag. Every other job checks out this same
|
||||
# tag (via needs.validate.outputs.tag) so the build is always the
|
||||
# tagged commit, never whatever branch the dispatch ran on (#525).
|
||||
ref: ${{ github.event.inputs.tag || github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag format
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
TAG_NAME="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
# Single source of truth for the tag, regardless of trigger.
|
||||
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
|
||||
|
||||
echo "Tag name: $TAG_NAME"
|
||||
|
||||
@@ -50,6 +54,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Confirm the tag actually exists in git. The dispatch path
|
||||
# re-releases an existing tag; it never creates one from a branch.
|
||||
if ! git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||
echo "❌ Tag $TAG_NAME does not exist in git. Push the tag first, then re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
# Extract version without 'v' prefix
|
||||
VERSION=${TAG_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -64,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
@@ -102,15 +115,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -125,6 +140,11 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
# Common variables
|
||||
# Single build timestamp shared across every binary in this job.
|
||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
# Commit of the checked-out tag (not GITHUB_SHA, which on a manual
|
||||
# dispatch is the branch HEAD the run started from, not the tag).
|
||||
COMMIT_SHA="$(git rev-parse HEAD)"
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [[ "${{ matrix.goarm }}" != "" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
@@ -150,9 +170,13 @@ jobs:
|
||||
# Ensure clean build environment for this binary
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
# Inject the validated version (plus commit/date) so the binary
|
||||
# reports the right version regardless of git checkout state.
|
||||
# Relying on Go's VCS stamping alone yields v0.0.0-… when built
|
||||
# from a shallow checkout or a non-tagged commit (see #525).
|
||||
if ! go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-ldflags="-s -w -X main.version=${{ needs.validate.outputs.tag }} -X main.commit=${COMMIT_SHA} -X main.date=${BUILD_DATE}" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
echo "❌ Build failed for $BINARY_NAME"
|
||||
@@ -170,8 +194,8 @@ jobs:
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
|
||||
# Build Web
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-web"
|
||||
# Build Player (formerly soundtouch-web)
|
||||
build_binary "soundtouch-player" "./cmd/soundtouch-player"
|
||||
|
||||
# Build Backup
|
||||
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
|
||||
@@ -181,7 +205,7 @@ jobs:
|
||||
run: |
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
|
||||
PLAYER_NAME="${{ steps.build.outputs.soundtouch-player }}"
|
||||
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
@@ -198,7 +222,7 @@ jobs:
|
||||
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
generate_checksums "$PLAYER_NAME"
|
||||
generate_checksums "$BCK_NAME"
|
||||
|
||||
# Cleanup
|
||||
@@ -212,7 +236,7 @@ jobs:
|
||||
path: |
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-web-v*
|
||||
build/soundtouch-player-v*
|
||||
build/soundtouch-backup-v*
|
||||
retention-days: 1
|
||||
|
||||
@@ -240,7 +264,7 @@ jobs:
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -255,8 +279,8 @@ jobs:
|
||||
# Generate combined checksums (exclude individual .sha256/.sha512 files)
|
||||
if ls soundtouch-* 1> /dev/null 2>&1; then
|
||||
# Only checksum the actual binaries, not the .sha256/.sha512 files
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
|
||||
echo "📋 Generated combined checksums:"
|
||||
cat checksums.sha256
|
||||
@@ -305,8 +329,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release assets
|
||||
@@ -318,167 +343,63 @@ jobs:
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
TAG_NAME="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
VERSION="${TAG_NAME#v}"
|
||||
|
||||
# Generate comprehensive release notes
|
||||
# Short, accurate header. GitHub's auto-generated "What's Changed"
|
||||
# + "Full Changelog" are appended after this (generate_release_notes).
|
||||
cat > release_notes.md << EOF
|
||||
# Bose SoundTouch Go Library $TAG_NAME
|
||||
# AfterTouch $TAG_NAME
|
||||
|
||||
A comprehensive Go library for controlling Bose SoundTouch speakers with 100% API coverage, real-time WebSocket events, and production-ready features.
|
||||
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
|
||||
|
||||
## 🎯 Key Features
|
||||
## What's included
|
||||
|
||||
- **100% API Coverage**: All 19 official endpoints + 6 useful extensions (25 total)
|
||||
- **Real-time Events**: WebSocket support with auto-reconnect and comprehensive event handling
|
||||
- **Multiroom Control**: Complete zone management and coordination
|
||||
- **Production Ready**: Connection pooling, error handling, circuit breakers, monitoring
|
||||
- **Excellent Documentation**: 4000+ lines including Getting Started, Cookbook, Troubleshooting, and Deployment guides
|
||||
- **CLI Tool**: Full-featured command-line interface with all endpoints
|
||||
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
|
||||
|
||||
## 🚀 Quick Start
|
||||
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
|
||||
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
|
||||
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
|
||||
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
|
||||
|
||||
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full guides, setup walkthroughs, and troubleshooting: https://gesellix.github.io/Bose-SoundTouch/
|
||||
|
||||
## Use as a Go library
|
||||
|
||||
The core client is also importable:
|
||||
|
||||
\`\`\`bash
|
||||
go get github.com/gesellix/bose-soundtouch@$TAG_NAME
|
||||
\`\`\`
|
||||
|
||||
\`\`\`go
|
||||
package main
|
||||
## Verifying downloads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create client
|
||||
c := client.New("192.0.2.100", 8090)
|
||||
|
||||
// Get device info
|
||||
info, err := c.GetInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Device: %s\\n", info.Name)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [Getting Started Guide](docs/GETTING-STARTED.md) - 10-minute tutorial from discovery to WebSocket monitoring
|
||||
- [API Cookbook](docs/API-COOKBOOK.md) - 1000+ lines of real-world patterns and examples
|
||||
- [Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Systematic issue resolution
|
||||
- [Deployment Guide](docs/DEPLOYMENT.md) - Production deployment examples (Docker, K8s, systemd)
|
||||
|
||||
## 🔧 CLI & Service Tools
|
||||
|
||||
Download the tools for your platform from the assets below:
|
||||
|
||||
### CLI Tool
|
||||
\`\`\`bash
|
||||
# Quick device discovery
|
||||
./soundtouch-cli -discover
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Service
|
||||
\`\`\`bash
|
||||
# Start the service
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Web
|
||||
\`\`\`bash
|
||||
# Start the web app
|
||||
./soundtouch-web
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Backup
|
||||
\`\`\`bash
|
||||
# Back up cloud account and all paired speakers in one go
|
||||
./soundtouch-backup all
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
|
||||
- Bose SoundTouch 10
|
||||
- Bose SoundTouch 20
|
||||
- All core functionality validated on real devices
|
||||
|
||||
## 📈 What's New in $TAG_NAME
|
||||
|
||||
$(git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 HEAD^)..HEAD 2>/dev/null || echo "- Initial release with complete feature set")
|
||||
|
||||
## 🏗️ Supported Platforms
|
||||
|
||||
This release includes pre-built binaries for:
|
||||
- Linux (amd64, arm64, armv7)
|
||||
- macOS (Intel & Apple Silicon)
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
`soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
Multiple checksum options are provided for download verification:
|
||||
|
||||
### Combined Checksums (Recommended)
|
||||
- \`checksums.sha256\` - SHA256 checksums for all binaries
|
||||
- \`checksums.sha512\` - SHA512 checksums for all binaries
|
||||
Each binary has its own \`.sha256\`/\`.sha512\`, and combined \`checksums.sha256\` / \`checksums.sha512\` cover all of them:
|
||||
|
||||
\`\`\`bash
|
||||
# Download any binary + combined checksums
|
||||
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
|
||||
curl -L -O https://github.com/.../checksums.sha256
|
||||
|
||||
# Verify your specific download
|
||||
sha256sum -c checksums.sha256 --ignore-missing
|
||||
\`\`\`
|
||||
|
||||
### Individual Checksums (Per Binary)
|
||||
Each binary also has its own dedicated checksum files:
|
||||
- \`soundtouch-cli-v$TAG_NAME-platform.sha256\`
|
||||
- \`soundtouch-cli-v$TAG_NAME-platform.sha512\`
|
||||
|
||||
\`\`\`bash
|
||||
# Download binary + its individual checksum
|
||||
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
|
||||
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
|
||||
|
||||
# Verify with individual checksum
|
||||
sha256sum -c soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
|
||||
\`\`\`
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! See our documentation for examples and patterns.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file.
|
||||
EOF
|
||||
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
name: ${{ needs.validate.outputs.tag }}
|
||||
body_path: ${{ steps.release_notes.outputs.release_notes_file }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-player-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
@@ -500,13 +421,13 @@ jobs:
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-player-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
@@ -521,17 +442,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Set build date
|
||||
- name: Set build metadata
|
||||
id: build_date
|
||||
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
|
||||
# Commit of the checked-out tag, not github.sha (the dispatch HEAD).
|
||||
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -539,7 +465,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -548,7 +474,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -557,34 +483,34 @@ jobs:
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=v${{ needs.validate.outputs.version }}
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=${{ needs.validate.outputs.tag }}
|
||||
COMMIT=${{ steps.build_date.outputs.commit }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
- name: Extract metadata (tags, labels) for soundtouch-player
|
||||
id: meta-player
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
images: ghcr.io/${{ github.repository }}-player
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
- name: Build and push soundtouch-player Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
target: soundtouch-player
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
tags: ${{ steps.meta-player.outputs.tags }}
|
||||
labels: ${{ steps.meta-player.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=v${{ needs.validate.outputs.version }}
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=${{ needs.validate.outputs.tag }}
|
||||
COMMIT=${{ steps.build_date.outputs.commit }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -599,12 +525,12 @@ jobs:
|
||||
- name: Notify success
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, Player, and Backup)"
|
||||
echo "🐳 Docker image published to ghcr.io"
|
||||
echo "🔐 Checksums generated and verified"
|
||||
echo "📋 Release notes automatically generated"
|
||||
echo ""
|
||||
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
echo "🔗 Release URL: https://github.com/${{ github.repository }}/releases/tag/${TAG_NAME}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -46,10 +46,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
|
||||
@@ -16,13 +16,13 @@ jobs:
|
||||
if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -15,11 +15,13 @@ dist/
|
||||
/soundtouch-backup
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-player
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/example-dlna-server
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ linters:
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
# Carry-over from cmd/soundtouch-web/handlers relocation: same code,
|
||||
# Carry-over from cmd/soundtouch-player/handlers relocation: same code,
|
||||
# same waiver. Tighten in a follow-up if/when the package is reviewed.
|
||||
- path: pkg/service/soundtouchweb/.*\.go
|
||||
text: "Error return value of.*is not checked"
|
||||
|
||||
@@ -18,7 +18,7 @@ Key binaries:
|
||||
(status, play, presets, groups, migration, …).
|
||||
- `soundtouch-service` — replacement for `streaming.bose.com`
|
||||
and the `bmx` services, default port `8000`.
|
||||
- `soundtouch-web` — Web UI for Radio browsing and device control.
|
||||
- `soundtouch-player` — Web UI for Radio browsing and device control.
|
||||
- `soundtouch-backup` — Helper for on-device backup and restore.
|
||||
|
||||
Per-session pickup notes live in two local files at the repo root (they are `.gitignore`d and only exist if created during a session):
|
||||
@@ -43,7 +43,7 @@ Per-session pickup notes live in two local files at the repo root (they are `.gi
|
||||
make build # All binaries
|
||||
make build-cli # Just CLI
|
||||
make build-service # Just service
|
||||
make build-web # Just web UI
|
||||
make build-player # Just web player
|
||||
make build-all # Cross-platform builds (Linux, macOS, Windows)
|
||||
make install # Install to $GOPATH/bin
|
||||
|
||||
@@ -99,13 +99,41 @@ The rotate target is non-destructive (it moves, never deletes) and
|
||||
opt-in (no other target invokes it). Old archives stay around for
|
||||
retrospective diffing whenever something goes sideways.
|
||||
|
||||
## Decrypting diagnostic reports
|
||||
|
||||
Reporters attach an encrypted diagnostic archive
|
||||
(`aftertouch-diagnostic-*.age`), usually saved under `_/i_<reporter>/`.
|
||||
Decrypt it with **this repo's own tool**, not the generic `age` CLI:
|
||||
|
||||
```bash
|
||||
go run scripts/decrypt-diagnostic.go <file.age> | tar xz -C <dir containing the .age>
|
||||
```
|
||||
|
||||
- The tool is `scripts/decrypt-diagnostic.go`; the private key lives at
|
||||
`keys/private/diagnostic` (provisioned by `scripts/setup-diagnostic-key.sh`,
|
||||
and never committed). It writes the decrypted `.tar.gz` to stdout.
|
||||
- Always decrypt/unpack next to the `.age` (not a scratch/tmp dir), into a
|
||||
**per-file subfolder** so nothing collides: e.g.
|
||||
`mkdir -p <dir>/extracted-<timestamp> && go run scripts/decrypt-diagnostic.go <dir>/<file>.age | tar xz -C <dir>/extracted-<timestamp>`.
|
||||
This matters when a reporter folder holds multiple `.age` snapshots or
|
||||
already has other files: every archive uses the same inner names
|
||||
(`diagnostic.json`, `datastore/`, `http/`, ...), so extracting two into the
|
||||
same dir overwrites and mixes them.
|
||||
- The archive contains `diagnostic.json` (health/device summary), `datastore/`
|
||||
(raw speaker XML: DeviceInfo/Presets/Recents/Sources), `http/` (service
|
||||
`full.xml`, `sourceproviders.xml`, captured speaker responses), `logs/`,
|
||||
`settings.json`, `env.txt`, `system/`, `ssh/`. See
|
||||
`docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md`.
|
||||
- Reporter data stays under `_/` and is never committed (see "What never goes
|
||||
into this repo").
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
cmd/
|
||||
soundtouch-cli/ # CLI tool for device control
|
||||
soundtouch-service/ # Local cloud service emulator
|
||||
soundtouch-web/ # Web UI (TuneIn browser, device control)
|
||||
soundtouch-player/ # Web UI (TuneIn browser, device control)
|
||||
soundtouch-backup/ # On-device backup helper
|
||||
example-*/ # Usage examples
|
||||
pkg/
|
||||
|
||||
+129
-442
@@ -1,498 +1,185 @@
|
||||
# Contributing to Bose SoundTouch API Client
|
||||
# Contributing to AfterTouch
|
||||
|
||||
Thank you for your interest in contributing to the Bose SoundTouch API Client! This project aims to provide a comprehensive, reliable, and well-tested Go library for controlling Bose SoundTouch devices.
|
||||
Thank you for your interest in contributing to **AfterTouch**!
|
||||
|
||||
## Ways to Contribute
|
||||
AfterTouch is a community-built toolkit that keeps Bose SoundTouch speakers
|
||||
usable after Bose shut down the SoundTouch cloud. It is a Go codebase that ships
|
||||
several tools plus a reusable library:
|
||||
|
||||
All contributions are welcome — large or small:
|
||||
- **soundtouch-service** the local cloud replacement (emulates `streaming.bose.com` and the `bmx` services)
|
||||
- **soundtouch-cli** command-line control of one or more speakers
|
||||
- **soundtouch-player** the web UI for radio browsing and device control
|
||||
- **soundtouch-backup** on-device backup and restore helper
|
||||
- **pkg/** the underlying Go library (HTTP + WebSocket client, models, discovery, ...)
|
||||
|
||||
- **Code suggestions** — bug fixes, new features, refactoring, performance improvements.
|
||||
- **Documentation updates** — README, guides, examples, troubleshooting notes, inline doc comments.
|
||||
- **Bug fixes** — even just a clear reproducer in an issue is a real contribution.
|
||||
- **Donations** — if the project kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation; everything in this repo stays MIT regardless.
|
||||
We are an open community: we both provide and ask for support. Contributions of
|
||||
every size are welcome, and you do not need to be a Go developer to help.
|
||||
|
||||
By submitting a code or documentation contribution you agree to license it under MIT. The detailed guides below cover the mechanics.
|
||||
## Ways to contribute
|
||||
|
||||
## Table of Contents
|
||||
- **Bug reports** even a clear reproducer is a real contribution. An attached
|
||||
diagnostic report (see [Reporting issues](#reporting-issues)) helps enormously.
|
||||
- **Device compatibility reports** tell us how AfterTouch behaves with your speaker model.
|
||||
- **Code** bug fixes, features, refactoring, tests, tooling.
|
||||
- **Documentation** guides, examples, troubleshooting notes, inline doc comments.
|
||||
- **Helping others** answering questions in [Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions).
|
||||
- **Donations** if AfterTouch kept a speaker (or several) of yours alive and you
|
||||
want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is
|
||||
open. There is no expectation, and everything here stays MIT regardless.
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [How Can I Contribute?](#how-can-i-contribute)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Testing Guidelines](#testing-guidelines)
|
||||
- [Documentation Guidelines](#documentation-guidelines)
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Device Testing](#device-testing)
|
||||
- [Community](#community)
|
||||
- [Support the Project](#support-the-project)
|
||||
By submitting a code or documentation contribution you agree to license it under
|
||||
the project's [MIT License](LICENSE).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||
This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). By participating,
|
||||
you agree to uphold it. Please report unacceptable behavior to the maintainer.
|
||||
|
||||
## Getting Started
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go 1.25.6 or later**: [Download Go](https://golang.org/dl/)
|
||||
- **Git**: For version control
|
||||
- **Make**: For build automation (optional but recommended)
|
||||
- **SoundTouch Device**: For testing (optional but valuable)
|
||||
- **Go** (version per [`go.mod`](go.mod), currently the 1.26.x series)
|
||||
- **Git**
|
||||
- **Make** (recommended; drives builds and the quality gate)
|
||||
- **Docker** (only needed for the HTTP-client integration tests)
|
||||
- A **SoundTouch device** is optional but valuable for testing
|
||||
|
||||
### First Contribution
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/Bose-SoundTouch.git
|
||||
cd Bose-SoundTouch
|
||||
```
|
||||
3. **Install dependencies**:
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
4. **Run tests** to ensure everything works:
|
||||
```bash
|
||||
make test
|
||||
# or
|
||||
go test ./...
|
||||
```
|
||||
5. **Build the CLI** to test functionality:
|
||||
```bash
|
||||
make build
|
||||
./soundtouch-cli --help
|
||||
```
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### 🐛 Reporting Bugs
|
||||
|
||||
Before creating a bug report, please:
|
||||
|
||||
1. **Check existing issues** to avoid duplicates
|
||||
2. **Test with the latest version** from the main branch
|
||||
3. **Include device information** (model, firmware version if known)
|
||||
|
||||
When filing a bug report, include:
|
||||
|
||||
- **Clear title** describing the issue
|
||||
- **Steps to reproduce** the behavior
|
||||
- **Expected behavior** vs actual behavior
|
||||
- **Environment details**: OS, Go version, device model
|
||||
- **Log output** if applicable (use `--verbose` flag)
|
||||
|
||||
### 💡 Suggesting Features
|
||||
|
||||
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/content/docs/reference/API-ENDPOINTS.md))
|
||||
3. **Explain the use case** and how it benefits users
|
||||
|
||||
### 🔧 Contributing Code
|
||||
|
||||
Areas where contributions are especially welcome:
|
||||
|
||||
#### High Priority
|
||||
- **Bug fixes** for existing functionality
|
||||
- **Device compatibility** improvements
|
||||
- **Error handling** enhancements
|
||||
- **Performance optimizations**
|
||||
|
||||
#### Medium Priority
|
||||
- **New endpoint implementations** (if officially documented)
|
||||
- **CLI improvements** (better UX, additional commands)
|
||||
- **Documentation improvements**
|
||||
- **Example applications**
|
||||
|
||||
#### Future Enhancements
|
||||
- **Web interface** development
|
||||
- **Home Assistant integration**
|
||||
- **WASM/browser support**
|
||||
- **Mobile app development**
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
Bose-SoundTouch/
|
||||
├── cmd/ # Command-line applications
|
||||
│ ├── soundtouch-cli/ # Main CLI tool
|
||||
│ └── examples/ # Example applications
|
||||
├── pkg/ # Library packages
|
||||
│ ├── client/ # HTTP client implementation
|
||||
│ ├── discovery/ # Device discovery
|
||||
│ ├── models/ # Data structures
|
||||
│ └── config/ # Configuration management
|
||||
├── docs/ # Documentation
|
||||
├── examples/ # Usage examples
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
### Build and run
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
make test
|
||||
# Clone your fork
|
||||
git clone https://github.com/YOUR-USERNAME/Bose-SoundTouch.git
|
||||
cd Bose-SoundTouch
|
||||
|
||||
# Run tests with coverage
|
||||
make test-coverage
|
||||
|
||||
# Build all binaries
|
||||
# Build all binaries into ./build/
|
||||
make build
|
||||
|
||||
# Run linting and formatting
|
||||
make check
|
||||
# Try the CLI
|
||||
./build/soundtouch-cli --help
|
||||
|
||||
# Run golangci-lint specifically
|
||||
golangci-lint run
|
||||
|
||||
# Auto-fix linting issues where possible
|
||||
golangci-lint run --fix
|
||||
|
||||
# Install CLI locally
|
||||
go install ./cmd/soundtouch-cli
|
||||
|
||||
# Run integration tests (requires real device)
|
||||
make test-integration HOST=192.0.2.100
|
||||
# Run the local service on port 8000
|
||||
make dev-service
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
Other useful targets: `make build-cli`, `make build-service`, `make build-player`,
|
||||
`make dev-discover` (find devices on the LAN). See the `Makefile` for the full list.
|
||||
|
||||
For development with real devices, create a `.env` file:
|
||||
## Development workflow
|
||||
|
||||
```env
|
||||
# Optional: Pre-configured device for testing
|
||||
SOUNDTOUCH_HOST=192.0.2.100
|
||||
SOUNDTOUCH_PORT=8090
|
||||
|
||||
# Optional: Enable debug logging
|
||||
SOUNDTOUCH_DEBUG=true
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Submitting
|
||||
|
||||
1. **Create an issue** first for significant changes
|
||||
2. **Fork and create a feature branch**:
|
||||
1. For anything non-trivial, **open an issue first** so we can agree on the approach.
|
||||
2. Create a feature branch from `main`.
|
||||
3. Make small, focused changes with tests.
|
||||
4. Run the quality gate before pushing:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
3. **Write tests** for your changes
|
||||
4. **Update documentation** if needed
|
||||
5. **Run the full test suite**:
|
||||
```bash
|
||||
make check
|
||||
make test
|
||||
make check # fmt + vet + tests (+ the Docker-based HTTP-client integration suite)
|
||||
make lint # golangci-lint, must be clean
|
||||
```
|
||||
If you do not have Docker handy, run `make test` and `make lint` and say so in
|
||||
the PR; CI runs the full gate on every PR.
|
||||
5. Open a pull request. The PR template walks you through what to include.
|
||||
|
||||
### Pull Request Guidelines
|
||||
New to the codebase? **[`CLAUDE.md`](CLAUDE.md)** is the entry point for any
|
||||
session (human or AI): it explains the layout, build/test commands, and the
|
||||
load-bearing gotchas. Please skim it before larger changes.
|
||||
|
||||
1. **Clear title** describing the change
|
||||
2. **Detailed description** explaining:
|
||||
- What the change does
|
||||
- Why it's needed
|
||||
- How it was tested
|
||||
- Any breaking changes
|
||||
3. **Link to related issues**
|
||||
4. **Update CHANGELOG.md** if applicable
|
||||
5. **Ensure CI passes**
|
||||
### A note on AI-assisted contributions
|
||||
|
||||
### Review Process
|
||||
AI and agent-assisted code is welcome, we use it here too. What we cannot accept
|
||||
is unreviewed "slop": large generated diffs the author has not read, run, or
|
||||
understood. Keep PRs small and focused, make sure `make check` passes, and be
|
||||
ready to explain your changes during review.
|
||||
|
||||
- At least one maintainer will review your PR
|
||||
- Feedback will be constructive and specific
|
||||
- Address feedback in additional commits
|
||||
- Once approved, a maintainer will merge your PR
|
||||
### Never commit personal or device data
|
||||
|
||||
## Coding Guidelines
|
||||
This repository is public. Do not commit real LAN IPs, MAC addresses, device IDs,
|
||||
Bose account IDs, tokens, firmware binaries, or Wi-Fi credentials, in code, tests,
|
||||
fixtures, commit messages, or PR text. Use the RFC-5737 documentation ranges
|
||||
(`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`) and placeholder identifiers
|
||||
in examples. See [`CLAUDE.md`](CLAUDE.md) for the full list.
|
||||
|
||||
### Go Style
|
||||
## Coding and testing guidelines
|
||||
|
||||
Follow standard Go conventions:
|
||||
- **Follow standard Go style:** `gofmt`, `go vet`, and `golangci-lint` all clean.
|
||||
`golangci-lint run --fix` auto-fixes some issues.
|
||||
- **Tests are expected** with every change. Prefer unit tests with `httptest`
|
||||
mocks; use integration tests where a unit test is impractical.
|
||||
- **Use real device data for fixtures where possible,** anonymized per the rule
|
||||
above. Reproducer tests should graduate into permanent regression or
|
||||
documentation tests rather than being deleted.
|
||||
- **Wrap errors with context** (`fmt.Errorf("...: %w", err)`) and validate inputs
|
||||
with helpful messages.
|
||||
- **Keep it simple.** Favor readable, self-explanatory code over cleverness.
|
||||
- **The SoundTouch Web API is XML on the wire;** internal service-to-service
|
||||
messages are JSON. (One sharp edge: the `ETag` response header must keep its
|
||||
exact capitalization, see `CLAUDE.md`.)
|
||||
|
||||
- **gofmt** for formatting
|
||||
- **golangci-lint** for comprehensive code quality checks
|
||||
- **go vet** for static analysis
|
||||
- **Effective Go** principles
|
||||
- **Standard library patterns** where applicable
|
||||
## Reporting issues
|
||||
|
||||
### Code Organization
|
||||
Open a [new issue](https://github.com/gesellix/Bose-SoundTouch/issues) and pick
|
||||
one of the forms; they keep reports easy to triage:
|
||||
|
||||
```go
|
||||
// Package-level documentation
|
||||
package client
|
||||
- **Bug report** something in AfterTouch is not working as it should
|
||||
- **Feature request** an idea or improvement
|
||||
- **Device compatibility report** how AfterTouch behaves with your speaker model
|
||||
|
||||
import (
|
||||
// Standard library first
|
||||
"context"
|
||||
"encoding/xml"
|
||||
|
||||
// Third-party packages
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
// Local packages
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
For bugs, the most helpful thing you can attach is an **encrypted diagnostic
|
||||
report**. In the AfterTouch admin UI, open the **Health tab** and click
|
||||
**Download diagnostic report**. The file is encrypted to the maintainer's key, so
|
||||
only the maintainer can open it.
|
||||
|
||||
// Public API should be well-documented
|
||||
// GetDeviceInfo retrieves comprehensive device information including
|
||||
// model, capabilities, network status, and current configuration.
|
||||
func (c *Client) GetDeviceInfo() (*models.DeviceInfo, error) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
To share it, the Health tab recommends **email**: send it to
|
||||
<aftertouch-support@gesellix.net>. You can also attach it to a GitHub issue, but
|
||||
GitHub blocks `.age` uploads, so rename the file to `.age.txt` (or zip it) first.
|
||||
|
||||
### Error Handling
|
||||
To be transparent about what it holds: the structured summary (`diagnostic.json`)
|
||||
and `settings.json` have credentials and OAuth secrets redacted, but the raw
|
||||
datastore files (for example `Sources.xml` and `full.xml`) are included **as-is**.
|
||||
For TuneIn, Radio Browser, and Local Internet Radio those carry only
|
||||
AfterTouch-generated placeholders, but for **linked accounts such as Spotify or
|
||||
Amazon they can contain the access tokens your speaker uses**. There is currently
|
||||
no setting that redacts the datastore files. If that is a concern, unlink those
|
||||
services before exporting, or email the report privately rather than attaching it
|
||||
to a public issue.
|
||||
|
||||
- **Return errors** instead of panicking
|
||||
- **Wrap errors** with context using `fmt.Errorf`
|
||||
- **Create custom error types** for specific conditions
|
||||
- **Validate inputs** and return helpful error messages
|
||||
Before filing, the
|
||||
[Troubleshooting Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/TROUBLESHOOTING/)
|
||||
often has the answer. For "how do I...?" questions, please use
|
||||
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) rather than
|
||||
the issue tracker.
|
||||
|
||||
```go
|
||||
// Good error handling example
|
||||
func (c *Client) SetVolume(level int) error {
|
||||
if level < 0 || level > 100 {
|
||||
return fmt.Errorf("volume level %d out of range [0-100]", level)
|
||||
}
|
||||
|
||||
if err := c.post("/volume", volumeXML); err != nil {
|
||||
return fmt.Errorf("failed to set volume to %d: %w", level, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
### Security issues
|
||||
|
||||
### API Design
|
||||
|
||||
- **Consistent method naming**: `Get*`, `Set*`, `Send*`, etc.
|
||||
- **Return pointers** for complex types, values for simple types
|
||||
- **Accept contexts** for potentially long-running operations
|
||||
- **Provide convenience methods** for common operations
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Test Structure
|
||||
|
||||
```go
|
||||
func TestClient_SetVolume(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
volume int
|
||||
expectedError string
|
||||
setupMock func(*httptest.Server)
|
||||
}{
|
||||
{
|
||||
name: "valid volume level",
|
||||
volume: 50,
|
||||
setupMock: func(server *httptest.Server) {
|
||||
// Mock setup
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volume too high",
|
||||
volume: 150,
|
||||
expectedError: "volume level 150 out of range",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test implementation
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
|
||||
1. **Unit Tests**: Test individual functions with mocks
|
||||
2. **Integration Tests**: Test with real devices (when available)
|
||||
3. **Benchmark Tests**: Performance testing for critical paths
|
||||
|
||||
### Mock Usage
|
||||
|
||||
Use `httptest.Server` for HTTP client testing:
|
||||
|
||||
```go
|
||||
func setupMockServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, mockDeviceInfoXML)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
### Real Device Testing
|
||||
|
||||
When possible, test with real SoundTouch devices:
|
||||
|
||||
```bash
|
||||
# Set device IP for integration tests
|
||||
export SOUNDTOUCH_HOST=192.0.2.100
|
||||
go test -tags integration ./pkg/client/
|
||||
```
|
||||
|
||||
## Documentation Guidelines
|
||||
|
||||
### Code Documentation
|
||||
|
||||
- **Package documentation** for every package
|
||||
- **Function documentation** for all public functions
|
||||
- **Example documentation** for complex usage
|
||||
|
||||
```go
|
||||
// Package client provides a comprehensive HTTP client for the Bose SoundTouch Web API.
|
||||
//
|
||||
// The client supports all documented SoundTouch endpoints including device information,
|
||||
// playback control, volume management, and real-time WebSocket events.
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// client := client.NewClient(&client.Config{
|
||||
// Host: "192.0.2.100",
|
||||
// Port: 8090,
|
||||
// })
|
||||
//
|
||||
// info, err := client.GetDeviceInfo()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// fmt.Printf("Device: %s\n", info.Name)
|
||||
package client
|
||||
```
|
||||
|
||||
### User Documentation
|
||||
|
||||
- **README.md**: Overview and quick start
|
||||
- **API documentation**: Comprehensive endpoint reference
|
||||
- **Examples**: Real-world usage patterns
|
||||
- **Troubleshooting**: Common issues and solutions
|
||||
|
||||
### Documentation Updates
|
||||
|
||||
When making changes:
|
||||
|
||||
1. **Update relevant docs** in the same PR
|
||||
2. **Include usage examples** for new features
|
||||
3. **Update CLI help text** if applicable
|
||||
4. **Test documentation** (ensure examples work)
|
||||
|
||||
## Device Testing
|
||||
|
||||
### Supported Devices
|
||||
|
||||
The library has been tested with:
|
||||
|
||||
- **SoundTouch 10** (firmware unknown)
|
||||
- **SoundTouch 20** (firmware unknown)
|
||||
|
||||
### Testing New Devices
|
||||
|
||||
If you have access to other SoundTouch models:
|
||||
|
||||
1. **Run discovery** to find devices:
|
||||
```bash
|
||||
./soundtouch-cli discover devices
|
||||
```
|
||||
|
||||
2. **Test basic functionality**:
|
||||
```bash
|
||||
./soundtouch-cli -h 192.0.2.100 info get
|
||||
./soundtouch-cli -h 192.0.2.100 now-playing get
|
||||
```
|
||||
|
||||
3. **Report compatibility** in your PR or issue
|
||||
4. **Include device information** from the info endpoint
|
||||
|
||||
### Testing Protocol
|
||||
|
||||
For significant changes:
|
||||
|
||||
1. **Test on multiple devices** if available
|
||||
2. **Test error scenarios** (device offline, network issues)
|
||||
3. **Test edge cases** (invalid inputs, boundary conditions)
|
||||
4. **Document any device-specific behavior**
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
### Security Issues
|
||||
|
||||
**Do not open public issues for security vulnerabilities.** Instead:
|
||||
|
||||
1. **Email the maintainers** with details
|
||||
2. **Allow reasonable time** for response
|
||||
3. **Coordinate disclosure** timing
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Use the bug report template and include:
|
||||
|
||||
- **Device model and firmware** (if known)
|
||||
- **Complete error messages and logs**
|
||||
- **Minimal reproduction case**
|
||||
- **Environment information**
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Use the feature request template and include:
|
||||
|
||||
- **Clear description** of the desired functionality
|
||||
- **Use case explanation**
|
||||
- **API documentation reference** (if applicable)
|
||||
- **Alternative solutions** you've considered
|
||||
Please do not open a public issue for a security vulnerability. Report it
|
||||
privately to the maintainer (GitHub's private vulnerability reporting on the
|
||||
repository's Security tab is the preferred channel) and allow reasonable time for
|
||||
a fix before any public disclosure.
|
||||
|
||||
## Community
|
||||
|
||||
### Communication Channels
|
||||
- **Discussions:** questions, ideas, and general support
|
||||
- **Issues:** bugs, feature requests, compatibility reports
|
||||
- **Pull requests:** code and documentation
|
||||
|
||||
- **GitHub Issues**: Bug reports, feature requests
|
||||
- **GitHub Discussions**: Questions, ideas, general discussion
|
||||
- **Pull Requests**: Code contributions and reviews
|
||||
Please be patient and respectful in all interactions. Significant contributions
|
||||
are credited in release notes.
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check existing documentation** first
|
||||
2. **Search closed issues** for similar problems
|
||||
3. **Create a new issue** with detailed information
|
||||
4. **Be patient and respectful** in all interactions
|
||||
|
||||
### Recognition
|
||||
|
||||
Contributors will be:
|
||||
|
||||
- **Listed in CONTRIBUTORS.md**
|
||||
- **Mentioned in release notes** for significant contributions
|
||||
- **Credited in documentation** where appropriate
|
||||
|
||||
## Support the Project
|
||||
|
||||
If you want to support the maintenance effort beyond code:
|
||||
## Support the project
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
Sponsorship is entirely optional. Code, docs, and bug reports remain the most useful contributions for the project itself.
|
||||
Sponsorship is entirely optional. Code, docs, bug reports, and helping others
|
||||
remain the most useful contributions.
|
||||
|
||||
## Additional Resources
|
||||
## Resources
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
- [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
- [Bose SoundTouch API Documentation](docs/content/docs/reference/API-ENDPOINTS.md)
|
||||
- [Project Architecture](docs/content/docs/appendix/PROJECT-PATTERNS.md)
|
||||
- [Development Status](docs/archive/STATUS.md)
|
||||
- [AfterTouch documentation](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/)
|
||||
- [API Cookbook](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-COOKBOOK/)
|
||||
- [API Endpoints](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-ENDPOINTS/)
|
||||
- [Go Documentation](https://golang.org/doc/) and [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
|
||||
**Thank you for contributing!** Every contribution helps keep the SoundTouch
|
||||
community's speakers playing.
|
||||
|
||||
+40
-10
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
@@ -34,22 +34,29 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
-o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
fi
|
||||
|
||||
# Build the soundtouch-web
|
||||
# Build the soundtouch-player (formerly soundtouch-web)
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
-o /soundtouch-player ./cmd/soundtouch-player; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
-o /soundtouch-player ./cmd/soundtouch-player; \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
FROM alpine:3.23 AS soundtouch-service
|
||||
FROM alpine:3.24 AS soundtouch-service
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Non-root prep (dormant). Everything below is set up so the service CAN run
|
||||
# as a fixed non-root user, but the image still runs as root by default
|
||||
# (APP_USER below) so this is not a breaking change yet. The UID/GID is pinned
|
||||
# (65532) so a mounted data volume's ownership stays predictable.
|
||||
RUN addgroup -g 65532 -S aftertouch \
|
||||
&& adduser -u 65532 -S -G aftertouch -H -h /app aftertouch
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
@@ -57,28 +64,51 @@ COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
# Verify the binary works on the target platform
|
||||
RUN /app/soundtouch-service version || echo "Binary verification complete"
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
# Create the data dir and hand /app to the non-root user.
|
||||
RUN mkdir -p /app/data && chown -R aftertouch:aftertouch /app
|
||||
|
||||
# Allow the non-root process to bind the privileged DNS port (:53) when DNS
|
||||
# Discovery is enabled, without granting the whole container extra privileges
|
||||
# at runtime. NET_BIND_SERVICE is in Docker's default capability set, so this
|
||||
# file capability is effective out of the box (no --cap-add needed). Done
|
||||
# after chown, which would otherwise clear it; the setcap tool is removed after.
|
||||
RUN apk add --no-cache --virtual .setcap libcap \
|
||||
&& setcap 'cap_net_bind_service=+ep' /app/soundtouch-service \
|
||||
&& apk del .setcap
|
||||
|
||||
ENV PORT=8000
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LOG_PROXY_BODY=false
|
||||
ENV REDACT_PROXY_LOGS=true
|
||||
|
||||
# The toggle. Defaults to root, so this image behaves exactly as before and
|
||||
# the change is non-breaking today. Enabling non-root is planned for v1.0.0
|
||||
# (BREAKING: a bind-mounted DATA_DIR must then be writable by uid 65532 — the
|
||||
# service logs the exact chown command at startup if it can't write). To
|
||||
# enable, either change this default to "aftertouch" (a one-line commit) or
|
||||
# build with --build-arg APP_USER=aftertouch.
|
||||
ARG APP_USER=root
|
||||
USER ${APP_USER}
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
|
||||
# soundtouch-web image
|
||||
FROM alpine:3.23 AS soundtouch-web
|
||||
# soundtouch-player image
|
||||
FROM alpine:3.24 AS soundtouch-player
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-web /app/soundtouch-web
|
||||
COPY --from=builder /soundtouch-player /app/soundtouch-player
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
# The player is stateless and binds an unprivileged port, so it has no reason
|
||||
# to run as root. mDNS/SSDP discovery uses unprivileged multicast.
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-player"]
|
||||
|
||||
@@ -17,8 +17,8 @@ BINARY_NAME=soundtouch-cli
|
||||
BINARY_PATH=./cmd/$(BINARY_NAME)
|
||||
SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
WEB_NAME=soundtouch-web
|
||||
WEB_PATH=./cmd/$(WEB_NAME)
|
||||
PLAYER_NAME=soundtouch-player
|
||||
PLAYER_PATH=./cmd/$(PLAYER_NAME)
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
@@ -52,7 +52,7 @@ AUTH_SERVICE_URL ?= $(BACKEND_URL)
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
build: build-cli build-service build-player build-examples build-favicon-gen build-backup
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@@ -64,10 +64,10 @@ build-service:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
build-player:
|
||||
@echo "Building $(PLAYER_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(PLAYER_NAME) $(PLAYER_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@@ -164,10 +164,8 @@ test-http-client-rotate:
|
||||
fi
|
||||
|
||||
test-http-client:
|
||||
@echo "Starting services with docker compose..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
|
||||
@echo "Waiting for services to start..."
|
||||
@sleep 10
|
||||
@echo "Starting services with docker compose (waiting for healthchecks)..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build --wait
|
||||
@echo "Running .http tests..."
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
-v "$(PWD)/tests/integration/http-client:/workdir" \
|
||||
@@ -177,11 +175,22 @@ test-http-client:
|
||||
/workdir/spotify_registration.http \
|
||||
/workdir/amazon_registration.http \
|
||||
/workdir/create_account.http \
|
||||
/workdir/get_emailaddress.http \
|
||||
/workdir/get_customer_profile.http \
|
||||
/workdir/post_customer_profile.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/post_scmudc_event.http \
|
||||
/workdir/get_speaker_auth.http \
|
||||
/workdir/get_blacklist.http \
|
||||
/workdir/post_alexa_certificate.http \
|
||||
/workdir/unsupported_routes.http \
|
||||
/workdir/spotify_full_flow.http \
|
||||
/workdir/customer_support.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_bmx_services.http \
|
||||
/workdir/get_bmx_services_availability.http \
|
||||
/workdir/get_bmx_service_descriptors.http \
|
||||
/workdir/get_ced_index.http \
|
||||
/workdir/get_sourceproviders.http \
|
||||
/workdir/get_software_update.http \
|
||||
/workdir/get_soundtouch_updates.http \
|
||||
@@ -190,8 +199,15 @@ test-http-client:
|
||||
/workdir/post_oauth_token_amazon.http \
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/tunein_playback_station.http \
|
||||
/workdir/post_tunein_report.http \
|
||||
/workdir/tunein_favorite.http \
|
||||
/workdir/get_orion_station.http \
|
||||
/workdir/get_custom_playback.http \
|
||||
/workdir/get_media_ding.http \
|
||||
/workdir/get_bmx_icon.http \
|
||||
/workdir/set_preset_6.http \
|
||||
/workdir/get_presets.http \
|
||||
/workdir/get_presets_conditional.http \
|
||||
/workdir/delete_preset_6.http \
|
||||
/workdir/set_preset_5.http \
|
||||
/workdir/post_recent.http \
|
||||
@@ -199,11 +215,14 @@ test-http-client:
|
||||
/workdir/get_account_presets.http \
|
||||
/workdir/get_account_devices.http \
|
||||
/workdir/get_account_sources.http \
|
||||
/workdir/delete_source.http \
|
||||
/workdir/get_api_versions.http \
|
||||
/workdir/post_musicprovider_is_eligible.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/get_full_account_conditional.http \
|
||||
/workdir/create_group.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/delete_group.http \
|
||||
/workdir/rename_device.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
@@ -315,17 +334,17 @@ dev-scan-http: build-examples
|
||||
@echo "Scanning for HTTP mDNS services..."
|
||||
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
|
||||
|
||||
dev-web: build-web
|
||||
@echo "Starting web UI (default port 8080)..."
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
|
||||
dev-player: build-player
|
||||
@echo "Starting web player (default port 8080)..."
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME)
|
||||
|
||||
dev-web-port: build-web
|
||||
@echo "Starting web UI on custom port..."
|
||||
dev-player-port: build-player
|
||||
@echo "Starting web player on custom port..."
|
||||
@if [ -z "$(PORT)" ]; then \
|
||||
echo "Usage: make dev-web-port PORT=8888"; \
|
||||
echo "Usage: make dev-player-port PORT=8888"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -port $(PORT)
|
||||
|
||||
dev-backup: build-backup
|
||||
@echo "Running backup tool..."
|
||||
@@ -339,19 +358,19 @@ dev-backup-local: build-backup
|
||||
@echo "Running local backup (auto-discover)..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) local --discover
|
||||
|
||||
dev-web-host: build-web
|
||||
@echo "Starting web UI with specific host..."
|
||||
dev-player-host: build-player
|
||||
@echo "Starting web player with specific host..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-web-host HOST=192.0.2.10"; \
|
||||
echo "Usage: make dev-player-host HOST=192.0.2.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-web build-backup
|
||||
install: build-cli build-service build-player build-backup
|
||||
@echo "Installing binaries to $(GOPATH)/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(PLAYER_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
|
||||
|
||||
update-static-deps:
|
||||
@@ -511,9 +530,9 @@ help:
|
||||
@echo " dev-backup - Build and show backup tool help"
|
||||
@echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)"
|
||||
@echo " dev-backup-local - Build and run local backup (auto-discover speakers)"
|
||||
@echo " dev-web - Build and run web UI (default port 8080)"
|
||||
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
|
||||
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
|
||||
@echo " dev-player - Build and run web player (default port 8080)"
|
||||
@echo " dev-player-port - Build and run web player on custom port (PORT=8888)"
|
||||
@echo " dev-player-host - Build and run web player with specific device (HOST=ip)"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " release - Create release binaries"
|
||||
@@ -538,8 +557,8 @@ help:
|
||||
@echo " make dev-upnp-timeout TIMEOUT=10s"
|
||||
@echo " make dev-scan-all"
|
||||
@echo " make dev-scan-soundtouch"
|
||||
@echo " make dev-web"
|
||||
@echo " make dev-web-port PORT=8888"
|
||||
@echo " make dev-web-host HOST=192.0.2.10"
|
||||
@echo " make dev-player"
|
||||
@echo " make dev-player-port PORT=8888"
|
||||
@echo " make dev-player-host HOST=192.0.2.10"
|
||||
@echo " make test"
|
||||
@echo " make build-all"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
Bose shut down SoundTouch cloud services on **May 6, 2026**. Presets, music service browsing, and stereo pairing no longer work through Bose's infrastructure. AfterTouch restores all of these — no Bose infrastructure required.
|
||||
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture.
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture, or jump straight to [Downloads](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) to get the tools.
|
||||
|
||||
[](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
|
||||
@@ -66,15 +66,17 @@ See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
|
||||
|
||||
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
|
||||
|
||||
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage.
|
||||
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage, and the [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) to get the `soundtouch-cli` build for your OS.
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-web
|
||||
### soundtouch-player
|
||||
|
||||
A standalone web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Complements `soundtouch-service` when you want a dedicated device-control interface separate from the setup/admin UI.
|
||||
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are no longer published; please use `soundtouch-player`. (If you still run the binary under its old name, it prints a rename notice and works as before.)
|
||||
|
||||
See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
|
||||
A standalone, LAN-resident web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Because it reaches speakers directly on your network and can delegate cloud-only features (e.g. TTS) to a remote AfterTouch service via `--service-url`, it stays useful when `soundtouch-service` runs off-LAN (for example in the cloud), where the embedded `/app` player cannot reach your speakers.
|
||||
|
||||
See the [soundtouch-player README](cmd/soundtouch-player/README.md) for usage.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
// Package main runs a LAN-visible DLNA / UPnP MediaServer backed by the
|
||||
// dlnatest in-memory content tree.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// example-dlna-server [--port 8200] [--name "My Library"]
|
||||
//
|
||||
// The server:
|
||||
// - Binds an HTTP server on 0.0.0.0:<port> (default 8200).
|
||||
// - Detects the host's primary LAN IPv4 to build the SSDP LOCATION header
|
||||
// and the absolute <res> URLs inside DIDL-Lite Browse responses.
|
||||
// - Joins the SSDP multicast group 239.255.255.250:1900 and answers
|
||||
// M-SEARCH requests whose ST matches upnp:rootdevice, ssdp:all, or
|
||||
// urn:schemas-upnp-org:device:MediaServer:1.
|
||||
// - Periodically sends ssdp:alive NOTIFY announcements.
|
||||
// - Sends ssdp:byebye on graceful shutdown (SIGINT / SIGTERM).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
|
||||
)
|
||||
|
||||
const (
|
||||
ssdpMulticastAddr = "239.255.255.250:1900"
|
||||
ssdpMulticastIP = "239.255.255.250"
|
||||
ssdpPort = 1900
|
||||
|
||||
mediaServerURN = "urn:schemas-upnp-org:device:MediaServer:1"
|
||||
contentDirURN = "urn:schemas-upnp-org:service:ContentDirectory:1"
|
||||
notifyInterval = 30 * time.Second
|
||||
ssdpMaxAge = 1800
|
||||
serverVersion = "AfterTouch/1.0 UPnP/1.0 AfterTouchDLNA/1.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8200, "HTTP port to bind")
|
||||
name := flag.String("name", "AfterTouch Test Library", "UPnP friendlyName advertised over SSDP")
|
||||
mediaDir := flag.String("media-dir", "", "serve real audio files + artwork from this directory "+
|
||||
"(searched recursively, so an artist/album tree works) instead of the built-in silent test "+
|
||||
"tracks. Audio: .mp3/.wav/.flac/.m4a/.ogg. Art per track: a sibling <name>.jpg/.png, else a "+
|
||||
"cover.jpg/cover.png/folder.jpg in the same album folder. Files are loaded into memory, so "+
|
||||
"point it at an album or a modest folder, not your whole library")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
lanIP, err := primaryLANIP()
|
||||
if err != nil {
|
||||
logger.Warn("could not detect LAN IP, falling back to loopback", "err", err)
|
||||
|
||||
lanIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", *port)
|
||||
location := fmt.Sprintf("http://%s:%d/rootDesc.xml", lanIP, *port)
|
||||
|
||||
// Join the SSDP multicast group on the interface that owns the LAN IP. On
|
||||
// macOS net.Interfaces() lists lo0 (UP+MULTICAST) first, so picking the
|
||||
// "first" multicast interface would join on loopback and never receive the
|
||||
// LAN M-SEARCH from clients like AfterTouch.
|
||||
lanIface := interfaceForIP(lanIP)
|
||||
if lanIface != nil {
|
||||
logger.Info("SSDP: will join multicast on LAN interface", "iface", lanIface.Name, "ip", lanIP)
|
||||
}
|
||||
|
||||
opts := []dlnatest.Option{dlnatest.WithFriendlyName(*name)}
|
||||
|
||||
if *mediaDir != "" {
|
||||
tree, n, err := loadTreeFromDir(*mediaDir, *name)
|
||||
if err != nil {
|
||||
logger.Error("failed to load --media-dir", "dir", *mediaDir, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
opts = append(opts, dlnatest.WithTree(tree))
|
||||
|
||||
logger.Info("serving real media from directory", "dir", *mediaDir, "tracks", n)
|
||||
}
|
||||
|
||||
srv := dlnatest.NewServer(opts...)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: withAccessLog(logger, srv.HTTPHandler()),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Start HTTP server.
|
||||
go func() {
|
||||
logger.Info("HTTP server starting", "addr", addr, "lanIP", lanIP, "location", location)
|
||||
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("HTTP server error", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give the HTTP listener a moment to bind before we advertise it.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
udn := srv.UDN
|
||||
|
||||
// Start SSDP listener + responder.
|
||||
go runSSDPListener(ctx, logger, udn, location, lanIface)
|
||||
|
||||
// Start periodic ssdp:alive announcements.
|
||||
go runSSDPAlive(ctx, logger, udn, location)
|
||||
|
||||
logger.Info("DLNA MediaServer ready", "location", location, "name", *name)
|
||||
|
||||
// Wait for shutdown signal.
|
||||
<-ctx.Done()
|
||||
|
||||
logger.Info("shutting down...")
|
||||
|
||||
// Send byebye before exiting.
|
||||
sendByebye(logger, udn)
|
||||
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := httpSrv.Shutdown(shutCtx); err != nil {
|
||||
logger.Error("HTTP shutdown error", "err", err)
|
||||
}
|
||||
|
||||
logger.Info("stopped")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// SSDP listener: answers M-SEARCH requests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string, ifi *net.Interface) {
|
||||
group := &net.UDPAddr{IP: net.ParseIP(ssdpMulticastIP), Port: ssdpPort}
|
||||
|
||||
// Join the group on the LAN interface. If we could not resolve it, fall back
|
||||
// to the first non-loopback multicast interface (never loopback, which would
|
||||
// only ever receive same-host loopback traffic).
|
||||
if ifi == nil {
|
||||
if cands, err := multicastInterfaces(); err == nil {
|
||||
for _, c := range cands {
|
||||
if c != nil && c.Flags&net.FlagLoopback == 0 {
|
||||
ifi = c
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := net.ListenMulticastUDP("udp4", ifi, group)
|
||||
if err != nil {
|
||||
logger.Warn("SSDP: ListenMulticastUDP failed (try running as root or check firewall)", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
logger.Info("SSDP: listening for M-SEARCH on multicast", "group", group.String())
|
||||
|
||||
buf := make([]byte, 2048)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
|
||||
n, src, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
// Deadline timeout is expected; just continue.
|
||||
continue
|
||||
}
|
||||
|
||||
msg := string(buf[:n])
|
||||
if !strings.HasPrefix(msg, "M-SEARCH") {
|
||||
continue
|
||||
}
|
||||
|
||||
st := extractHeader(msg, "ST")
|
||||
logger.Debug("SSDP: M-SEARCH received", "from", src, "ST", st)
|
||||
|
||||
if !stMatches(st) {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info("SSDP: answering M-SEARCH", "from", src, "ST", st)
|
||||
|
||||
reply := buildMSearchReply(udn, location, st)
|
||||
_, _ = conn.WriteToUDP([]byte(reply), src)
|
||||
}
|
||||
}
|
||||
|
||||
// multicastInterfaces returns all UP interfaces that support multicast.
|
||||
func multicastInterfaces() ([]*net.Interface, error) {
|
||||
all, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []*net.Interface
|
||||
|
||||
for i := range all {
|
||||
iface := &all[i]
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, iface)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// stMatches returns true when the ST header should receive an M-SEARCH reply.
|
||||
func stMatches(st string) bool {
|
||||
switch st {
|
||||
case "ssdp:all", "upnp:rootdevice", mediaServerURN:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// buildMSearchReply builds an HTTP/1.1 200 OK SSDP response.
|
||||
func buildMSearchReply(udn, location, st string) string {
|
||||
usn := usnForST(udn, st)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"HTTP/1.1 200 OK\r\n"+
|
||||
"CACHE-CONTROL: max-age=%d\r\n"+
|
||||
"DATE: %s\r\n"+
|
||||
"EXT:\r\n"+
|
||||
"LOCATION: %s\r\n"+
|
||||
"SERVER: %s\r\n"+
|
||||
"ST: %s\r\n"+
|
||||
"USN: %s\r\n"+
|
||||
"\r\n",
|
||||
ssdpMaxAge,
|
||||
time.Now().UTC().Format(http.TimeFormat),
|
||||
location,
|
||||
serverVersion,
|
||||
st,
|
||||
usn,
|
||||
)
|
||||
}
|
||||
|
||||
// usnForST builds the USN header value for a given ST.
|
||||
func usnForST(udn, st string) string {
|
||||
if st == "upnp:rootdevice" || st == "ssdp:all" {
|
||||
return udn + "::upnp:rootdevice"
|
||||
}
|
||||
|
||||
return udn + "::" + st
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// SSDP alive announcements
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func runSSDPAlive(ctx context.Context, logger *slog.Logger, udn, location string) {
|
||||
// Send an initial batch immediately, then repeat on the interval.
|
||||
sendAlive(logger, udn, location)
|
||||
|
||||
ticker := time.NewTicker(notifyInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
sendAlive(logger, udn, location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendAlive(logger *slog.Logger, udn, location string) {
|
||||
nts := []struct{ nt, usn string }{
|
||||
{"upnp:rootdevice", udn + "::upnp:rootdevice"},
|
||||
{udn, udn},
|
||||
{mediaServerURN, udn + "::" + mediaServerURN},
|
||||
{contentDirURN, udn + "::" + contentDirURN},
|
||||
}
|
||||
|
||||
conn, err := net.Dial("udp4", ssdpMulticastAddr)
|
||||
if err != nil {
|
||||
logger.Warn("SSDP: cannot send alive notification", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
for _, n := range nts {
|
||||
msg := fmt.Sprintf(
|
||||
"NOTIFY * HTTP/1.1\r\n"+
|
||||
"HOST: %s\r\n"+
|
||||
"CACHE-CONTROL: max-age=%d\r\n"+
|
||||
"LOCATION: %s\r\n"+
|
||||
"NT: %s\r\n"+
|
||||
"NTS: ssdp:alive\r\n"+
|
||||
"SERVER: %s\r\n"+
|
||||
"USN: %s\r\n"+
|
||||
"\r\n",
|
||||
ssdpMulticastAddr, ssdpMaxAge, location,
|
||||
n.nt, serverVersion, n.usn,
|
||||
)
|
||||
_, _ = conn.Write([]byte(msg))
|
||||
}
|
||||
|
||||
logger.Debug("SSDP: alive announcements sent")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// SSDP byebye on shutdown
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func sendByebye(logger *slog.Logger, udn string) {
|
||||
conn, err := net.Dial("udp4", ssdpMulticastAddr)
|
||||
if err != nil {
|
||||
logger.Warn("SSDP: cannot send byebye", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
nts := []struct{ nt, usn string }{
|
||||
{"upnp:rootdevice", udn + "::upnp:rootdevice"},
|
||||
{udn, udn},
|
||||
{mediaServerURN, udn + "::" + mediaServerURN},
|
||||
{contentDirURN, udn + "::" + contentDirURN},
|
||||
}
|
||||
|
||||
for _, n := range nts {
|
||||
msg := fmt.Sprintf(
|
||||
"NOTIFY * HTTP/1.1\r\n"+
|
||||
"HOST: %s\r\n"+
|
||||
"NT: %s\r\n"+
|
||||
"NTS: ssdp:byebye\r\n"+
|
||||
"USN: %s\r\n"+
|
||||
"\r\n",
|
||||
ssdpMulticastAddr, n.nt, n.usn,
|
||||
)
|
||||
_, _ = conn.Write([]byte(msg))
|
||||
}
|
||||
|
||||
logger.Info("SSDP: byebye announcements sent")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Network helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// primaryLANIP returns the first non-loopback, non-link-local IPv4 address
|
||||
// found on any UP interface.
|
||||
func primaryLANIP() (string, error) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
v4 := ipNet.IP.To4()
|
||||
if v4 == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if v4.IsLoopback() || v4.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
|
||||
return v4.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no usable LAN IPv4 address found")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// --media-dir loader
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// loadTreeFromDir walks dir recursively and builds a single flat content folder
|
||||
// from every audio file found, so an artist/album tree works. Album art for a
|
||||
// track is, in order of preference: a sibling <basename>.<img>, then a
|
||||
// cover.jpg/cover.png/folder.jpg in the track's own directory. Returns the tree
|
||||
// and track count.
|
||||
func loadTreeFromDir(dir, fallbackName string) (*dlnatest.Tree, int, error) {
|
||||
rootClean := filepath.Clean(dir)
|
||||
|
||||
// Cache the resolved cover per directory so we read each album's folder.jpg
|
||||
// once rather than for every track in it.
|
||||
type cover struct {
|
||||
data []byte
|
||||
mime string
|
||||
}
|
||||
|
||||
coverCache := map[string]cover{}
|
||||
|
||||
dirCover := func(d string) ([]byte, string) {
|
||||
if c, ok := coverCache[d]; ok {
|
||||
return c.data, c.mime
|
||||
}
|
||||
|
||||
var c cover
|
||||
|
||||
for _, n := range []string{"cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.png", "albumart.jpg", "albumart.png"} {
|
||||
if b, err := os.ReadFile(filepath.Join(d, n)); err == nil {
|
||||
c = cover{data: b, mime: imageMimeForExt(filepath.Ext(n))}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
coverCache[d] = c
|
||||
|
||||
return c.data, c.mime
|
||||
}
|
||||
|
||||
// Group tracks by their containing directory (preserving first-seen order),
|
||||
// so each real album folder becomes its own browsable + playable container
|
||||
// named after the directory, rather than one flat list named after --name.
|
||||
type group struct {
|
||||
dir string
|
||||
items []*dlnatest.Item
|
||||
}
|
||||
|
||||
groups := map[string]*group{}
|
||||
|
||||
var order []string
|
||||
|
||||
total := 0
|
||||
|
||||
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil //nolint:nilerr // skip unreadable entries and directories
|
||||
}
|
||||
|
||||
mime := audioMimeForExt(filepath.Ext(path))
|
||||
if mime == "" {
|
||||
return nil // not an audio file we recognise
|
||||
}
|
||||
|
||||
payload, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return nil //nolint:nilerr // skip unreadable file, keep walking
|
||||
}
|
||||
|
||||
trackDir := filepath.Dir(path)
|
||||
base := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))
|
||||
|
||||
// Prefer a per-track image sibling; fall back to the album-folder cover.
|
||||
art, artMime := dirCover(trackDir)
|
||||
|
||||
for _, ae := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||
if b, aerr := os.ReadFile(filepath.Join(trackDir, base+ae)); aerr == nil {
|
||||
art = b
|
||||
artMime = imageMimeForExt(ae)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
g := groups[trackDir]
|
||||
if g == nil {
|
||||
g = &group{dir: trackDir}
|
||||
groups[trackDir] = g
|
||||
order = append(order, trackDir)
|
||||
}
|
||||
|
||||
g.items = append(g.items, &dlnatest.Item{
|
||||
Title: base,
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: artistForDir(trackDir, rootClean),
|
||||
Album: albumTitle(trackDir, rootClean, fallbackName),
|
||||
MimeType: mime,
|
||||
Payload: payload,
|
||||
ArtPayload: art,
|
||||
ArtMime: artMime,
|
||||
})
|
||||
total++
|
||||
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return nil, 0, walkErr
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return nil, 0, fmt.Errorf("no audio files (.mp3/.wav/.flac/.m4a/.ogg) found under %s", dir)
|
||||
}
|
||||
|
||||
containers := make([]*dlnatest.Container, 0, len(order))
|
||||
|
||||
for ci, d := range order {
|
||||
cid := strconv.Itoa(ci + 1)
|
||||
g := groups[d]
|
||||
|
||||
for ti, it := range g.items {
|
||||
it.ID = fmt.Sprintf("%s$%d", cid, ti)
|
||||
it.ParentID = cid
|
||||
}
|
||||
|
||||
containers = append(containers, &dlnatest.Container{
|
||||
ID: cid,
|
||||
ParentID: "0",
|
||||
Title: albumTitle(d, rootClean, fallbackName),
|
||||
Class: "object.container.storageFolder",
|
||||
Children: g.items,
|
||||
})
|
||||
}
|
||||
|
||||
return &dlnatest.Tree{Containers: containers}, total, nil
|
||||
}
|
||||
|
||||
// albumTitle returns the display name for a track directory: the directory's own
|
||||
// name, or the fallback (the --name) when the tracks sit directly in the root.
|
||||
func albumTitle(trackDir, root, fallback string) string {
|
||||
if filepath.Clean(trackDir) == root {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return filepath.Base(trackDir)
|
||||
}
|
||||
|
||||
// artistForDir derives the artist from the directory above the album folder
|
||||
// (e.g. <root>/<artist>/<album>/track.mp3 → "<artist>"). Falls back to
|
||||
// "Unknown Artist" when there is no artist level (album directly under root, or
|
||||
// tracks directly in root).
|
||||
func artistForDir(trackDir, root string) string {
|
||||
clean := filepath.Clean(trackDir)
|
||||
if clean == root {
|
||||
return "Unknown Artist"
|
||||
}
|
||||
|
||||
parent := filepath.Dir(clean)
|
||||
if parent == root {
|
||||
return "Unknown Artist"
|
||||
}
|
||||
|
||||
return filepath.Base(parent)
|
||||
}
|
||||
|
||||
// audioMimeForExt maps an audio file extension to a MIME type, or "" if the
|
||||
// extension is not a recognised audio format.
|
||||
func audioMimeForExt(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".wav":
|
||||
return "audio/x-wav"
|
||||
case ".flac":
|
||||
return "audio/flac"
|
||||
case ".m4a", ".mp4":
|
||||
return "audio/mp4"
|
||||
case ".ogg":
|
||||
return "audio/ogg"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// imageMimeForExt maps an image file extension to a MIME type.
|
||||
func imageMimeForExt(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// HTTP access logging (debugging aid)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// statusRecorder captures the status code and byte count of a response.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// withAccessLog logs every HTTP request the server handles. For ContentDirectory
|
||||
// Browse POSTs it also surfaces the ObjectID and BrowseFlag so the speaker's
|
||||
// browse sequence (and whether it ever resolves a track's metadata) is visible.
|
||||
func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
var browseAttrs []any
|
||||
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "ContentDir") {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16))
|
||||
_ = r.Body.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
browseAttrs = []any{
|
||||
"objectID", between(string(body), "<ObjectID>", "</ObjectID>"),
|
||||
"browseFlag", between(string(body), "<BrowseFlag>", "</BrowseFlag>"),
|
||||
}
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
attrs := []any{
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"bytes", rec.bytes,
|
||||
"from", r.RemoteAddr,
|
||||
"dur", time.Since(start).String(),
|
||||
}
|
||||
attrs = append(attrs, browseAttrs...)
|
||||
|
||||
logger.Info("HTTP", attrs...)
|
||||
})
|
||||
}
|
||||
|
||||
// between returns the text between the first occurrence of openTag and the next
|
||||
// closeTag, or "" if not found. Used for lightweight SOAP field extraction in logs.
|
||||
func between(s, openTag, closeTag string) string {
|
||||
i := strings.Index(s, openTag)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
i += len(openTag)
|
||||
|
||||
j := strings.Index(s[i:], closeTag)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s[i : i+j]
|
||||
}
|
||||
|
||||
// interfaceForIP returns the UP, multicast-capable interface that owns the given
|
||||
// IPv4 address, or nil if none is found.
|
||||
func interfaceForIP(ip string) *net.Interface {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range ifaces {
|
||||
iface := &ifaces[i]
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, aerr := iface.Addrs()
|
||||
if aerr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok {
|
||||
if v4 := ipNet.IP.To4(); v4 != nil && v4.String() == ip {
|
||||
return iface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHeader extracts a header value from a raw HTTP-style SSDP message.
|
||||
// Key comparison is case-insensitive.
|
||||
func extractHeader(msg, key string) string {
|
||||
lower := strings.ToLower(key) + ":"
|
||||
|
||||
for _, line := range strings.Split(msg, "\n") {
|
||||
trimmed := strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), lower) {
|
||||
return strings.TrimSpace(trimmed[len(lower):])
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -17,6 +17,9 @@ func main() {
|
||||
|
||||
log.Printf("Starting mock Amazon LWA server on port %d", *port)
|
||||
|
||||
// Plaintext HTTP is intentional: this is a throwaway test mock that only
|
||||
// runs on the loopback / CI compose network, never in production.
|
||||
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ func main() {
|
||||
|
||||
log.Printf("Starting mock Spotify server on port %d", *port)
|
||||
|
||||
// Plaintext HTTP is intentional: this is a throwaway test mock that only
|
||||
// runs on the loopback / CI compose network, never in production.
|
||||
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package main provides a mock TuneIn (radiotime.com) server for testing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/tunein"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock TuneIn server on port %d", *port)
|
||||
|
||||
// Plaintext HTTP is intentional: this is a throwaway test mock that only
|
||||
// runs on the loopback / CI compose network, never in production.
|
||||
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), tunein.NewTuneInHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ var version = "dev"
|
||||
|
||||
func init() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Only fall back to build info when the version was not injected via
|
||||
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
|
||||
// This keeps an explicitly stamped release version from being clobbered
|
||||
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ func removePandoraAccount(c *cli.Context) error {
|
||||
func addStoredMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -340,13 +340,26 @@ func addStoredMusicAccount(c *cli.Context) error {
|
||||
fmt.Printf(" Display Name: %s\n", displayName)
|
||||
fmt.Printf(" Type: UPnP/DLNA Media Server\n")
|
||||
|
||||
err = client.AddStoredMusicAccount(user, displayName)
|
||||
err = stClient.AddStoredMusicAccount(user, displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add network music library: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Network music library added successfully")
|
||||
|
||||
// Send a sourcesUpdated nudge so the speaker re-fetches its account list and
|
||||
// registers the new source without requiring a power-cycle. This is
|
||||
// best-effort: a failure here does not abort the command.
|
||||
if info, infoErr := stClient.GetDeviceInfo(); infoErr == nil && info != nil && info.DeviceID != "" {
|
||||
if nudgeErr := stClient.NotifySourcesUpdated(info.DeviceID); nudgeErr == nil {
|
||||
fmt.Println(" Sent a sources refresh to the speaker (no reboot needed).")
|
||||
} else {
|
||||
fmt.Println(" Warning: could not send sources refresh; you may need to power-cycle the speaker for the new source to register.")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" Warning: could not retrieve device ID; you may need to power-cycle the speaker for the new source to register.")
|
||||
}
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
// Package main — `soundtouch-cli library` command group.
|
||||
//
|
||||
// Three subcommands:
|
||||
//
|
||||
// - library servers: discover DLNA media servers on the LAN, either via an
|
||||
// app-side SSDP sweep (default) or via the speaker's own list (--via-speaker).
|
||||
// - library browse: walk a DLNA ContentDirectory tree by UDN.
|
||||
// - library play: play a DLNA track on a speaker via native STORED_MUSIC playback.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/dlna"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// libraryCommand returns the top-level `library` command group.
|
||||
func libraryCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "library",
|
||||
Usage: "DLNA music library commands (server discovery, browse, play)",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "browse",
|
||||
Usage: "Browse a DLNA ContentDirectory tree",
|
||||
Action: libraryBrowse,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "count",
|
||||
Usage: "Page size (number of entries to request)",
|
||||
Value: 50,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "object",
|
||||
Usage: `ContentDirectory object ID to browse ("0" = root)`,
|
||||
Value: "0",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "start",
|
||||
Usage: "Page offset (starting index)",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "timeout",
|
||||
Usage: "SSDP discovery + SOAP timeout",
|
||||
Value: 5 * time.Second,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "udn",
|
||||
Usage: "UDN (uuid:...) of the DLNA media server to browse",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "play",
|
||||
Usage: "Play a DLNA track on a speaker via native STORED_MUSIC playback",
|
||||
Action: libraryPlay,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "art",
|
||||
Usage: "Container art URL (optional)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Usage: "Display name shown on the speaker (optional)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "source-account",
|
||||
Usage: "STORED_MUSIC source account (media-server UDN with /0 suffix, e.g. fa095ecc-e13e-40e7-8e6c-e0286d5bc000/0)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Usage: `ContentItem type: "track" or "dir"`,
|
||||
Value: "track",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "location",
|
||||
Usage: "Object ID from a browse result (e.g. 5:audio5:part13:3171:5 TRACK)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "servers",
|
||||
Usage: "List DLNA media servers visible on the LAN",
|
||||
Action: libraryServers,
|
||||
Flags: []cli.Flag{
|
||||
&cli.DurationFlag{
|
||||
Name: "timeout",
|
||||
Usage: "SSDP sweep timeout",
|
||||
Value: 5 * time.Second,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "via-speaker",
|
||||
Usage: "Ask the speaker (--host required) instead of doing an app-side SSDP sweep",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// libraryServers implements `library servers`.
|
||||
func libraryServers(c *cli.Context) error {
|
||||
if c.Bool("via-speaker") {
|
||||
return libraryServersViaSpeaker(c)
|
||||
}
|
||||
|
||||
return libraryServersAppSide(c)
|
||||
}
|
||||
|
||||
// libraryServersAppSide runs an SSDP sweep from the CLI process itself.
|
||||
func libraryServersAppSide(c *cli.Context) error {
|
||||
timeout := c.Duration("timeout")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
servers, err := discovery.DiscoverMediaServers(ctx, timeout)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("SSDP discovery failed: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
fmt.Println("No DLNA media servers found on the LAN.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d DLNA media server(s):\n\n", len(servers))
|
||||
|
||||
for _, srv := range servers {
|
||||
printAppSideServer(srv)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// libraryServersViaSpeaker asks the speaker for its own DLNA server list.
|
||||
func libraryServersViaSpeaker(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
speakerClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create speaker client: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := speakerClient.ListMediaServers()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to list media servers via speaker: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if len(resp.MediaServers) == 0 {
|
||||
fmt.Println("Speaker reports no DLNA media servers.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Speaker reports %d DLNA media server(s):\n\n", len(resp.MediaServers))
|
||||
|
||||
for i := range resp.MediaServers {
|
||||
printSpeakerServer(resp.MediaServers[i])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printAppSideServer prints a single server discovered by the app-side sweep.
|
||||
func printAppSideServer(srv discovery.MediaServer) {
|
||||
fmt.Printf(" Name: %s\n", srv.FriendlyName)
|
||||
fmt.Printf(" Vendor: %s / %s\n", srv.Manufacturer, srv.ModelName)
|
||||
fmt.Printf(" UDN: %s\n", srv.UDN)
|
||||
fmt.Printf(" CDS: %s\n", srv.CDSControlURL)
|
||||
|
||||
if srv.IconURL != "" {
|
||||
fmt.Printf(" Icon: %s\n", srv.IconURL)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// printSpeakerServer prints a single server as reported by the speaker.
|
||||
func printSpeakerServer(srv models.MediaServerInfo) {
|
||||
name := srv.FriendlyName
|
||||
if name == "" {
|
||||
name = "(unnamed)"
|
||||
}
|
||||
|
||||
vendor := srv.Manufacturer
|
||||
|
||||
if srv.ModelName != "" {
|
||||
if vendor != "" {
|
||||
vendor += " / " + srv.ModelName
|
||||
} else {
|
||||
vendor = srv.ModelName
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Name: %s\n", name)
|
||||
|
||||
if vendor != "" {
|
||||
fmt.Printf(" Vendor: %s\n", vendor)
|
||||
}
|
||||
|
||||
fmt.Printf(" UDN: %s\n", srv.ID)
|
||||
|
||||
if srv.IP != "" {
|
||||
fmt.Printf(" IP: %s\n", srv.IP)
|
||||
}
|
||||
|
||||
if srv.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", srv.Location)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// libraryBrowse implements `library browse`.
|
||||
func libraryBrowse(c *cli.Context) error {
|
||||
udn := strings.TrimSpace(c.String("udn"))
|
||||
objectID := c.String("object")
|
||||
start := c.Int("start")
|
||||
count := c.Int("count")
|
||||
timeout := c.Duration("timeout")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
servers, err := discovery.DiscoverMediaServers(ctx, timeout)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("SSDP discovery failed: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var target *discovery.MediaServer
|
||||
|
||||
for i := range servers {
|
||||
if servers[i].UDN == udn {
|
||||
target = &servers[i]
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if target == nil {
|
||||
var udns []string
|
||||
|
||||
for _, srv := range servers {
|
||||
udns = append(udns, fmt.Sprintf(" %s (%s)", srv.UDN, srv.FriendlyName))
|
||||
}
|
||||
|
||||
if len(udns) == 0 {
|
||||
PrintError(fmt.Sprintf("No server with UDN %q found; no servers discovered.", udn))
|
||||
} else {
|
||||
PrintError(fmt.Sprintf(
|
||||
"No server with UDN %q found.\nKnown servers:\n%s",
|
||||
udn, strings.Join(udns, "\n"),
|
||||
))
|
||||
}
|
||||
|
||||
return fmt.Errorf("server %q not found", udn)
|
||||
}
|
||||
|
||||
fmt.Printf("Browsing %q (object %q, offset %d, page %d)\n\n", target.FriendlyName, objectID, start, count)
|
||||
|
||||
browseCtx, browseCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer browseCancel()
|
||||
|
||||
result, err := dlna.Browse(browseCtx, *target, objectID, start, count)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Browse failed: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("TotalMatches: %d Returned: %d\n\n", result.TotalMatches, result.Returned)
|
||||
|
||||
for _, con := range result.Containers {
|
||||
fmt.Printf(" [dir] %s (id=%s, children=%d)\n", con.Title, con.ID, con.ChildCount)
|
||||
}
|
||||
|
||||
for i := range result.Items {
|
||||
it := &result.Items[i]
|
||||
|
||||
audio := ""
|
||||
if it.IsAudioItem() {
|
||||
audio = " [audio]"
|
||||
}
|
||||
|
||||
meta := ""
|
||||
|
||||
if it.Artist != "" || it.Album != "" {
|
||||
parts := []string{}
|
||||
if it.Artist != "" {
|
||||
parts = append(parts, it.Artist)
|
||||
}
|
||||
|
||||
if it.Album != "" {
|
||||
parts = append(parts, it.Album)
|
||||
}
|
||||
|
||||
meta = " — " + strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
dur := ""
|
||||
|
||||
if it.DurationSec > 0 {
|
||||
m := it.DurationSec / 60
|
||||
s := it.DurationSec % 60
|
||||
dur = fmt.Sprintf(" [%d:%02d]", m, s)
|
||||
}
|
||||
|
||||
fmt.Printf(" [item]%s %s%s%s\n", audio, it.Title, meta, dur)
|
||||
|
||||
if it.StreamURL != "" {
|
||||
fmt.Printf(" url: %s\n", it.StreamURL)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// libraryPlay implements `library play` using native STORED_MUSIC playback.
|
||||
func libraryPlay(c *cli.Context) error {
|
||||
sourceAccount := strings.TrimSpace(c.String("source-account"))
|
||||
location := strings.TrimSpace(c.String("location"))
|
||||
name := c.String("name")
|
||||
itemType := c.String("type")
|
||||
art := c.String("art")
|
||||
|
||||
if sourceAccount == "" {
|
||||
PrintError("--source-account is required")
|
||||
|
||||
return fmt.Errorf("--source-account is required")
|
||||
}
|
||||
|
||||
if location == "" {
|
||||
PrintError("--location is required")
|
||||
|
||||
return fmt.Errorf("--location is required")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
speakerClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create speaker client: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Check that the STORED_MUSIC source for this account is READY before
|
||||
// attempting playback. Re-registering an already-READY account can flip
|
||||
// it to UNAVAILABLE, so we intentionally do NOT auto-register here.
|
||||
sources, err := speakerClient.GetSources()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to retrieve sources: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
ready := false
|
||||
|
||||
for _, si := range sources.SourceItem {
|
||||
if si.Source == "STORED_MUSIC" && si.SourceAccount == sourceAccount {
|
||||
if si.Status.IsReady() {
|
||||
ready = true
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !ready {
|
||||
host := clientConfig.Host
|
||||
PrintError(fmt.Sprintf(
|
||||
"STORED_MUSIC source account %q is not READY on the speaker.\n"+
|
||||
"Register it first:\n"+
|
||||
" soundtouch-cli --host %s account add-nas --user %s --name <server-display-name>",
|
||||
sourceAccount, host, sourceAccount,
|
||||
))
|
||||
|
||||
return fmt.Errorf("STORED_MUSIC source account %q not ready", sourceAccount)
|
||||
}
|
||||
|
||||
PrintDeviceHeader("STORED_MUSIC play", clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" Source account: %s\n", sourceAccount)
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
fmt.Printf(" Type: %s\n", itemType)
|
||||
|
||||
if name != "" {
|
||||
fmt.Printf(" Name: %s\n", name)
|
||||
}
|
||||
|
||||
if art != "" {
|
||||
fmt.Printf(" Art: %s\n", art)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// SelectStoredMusic does not set Type, so we build the ContentItem directly
|
||||
// so we can pass the correct type ("track" or "dir") to the speaker.
|
||||
ci := &models.ContentItem{
|
||||
Source: "STORED_MUSIC",
|
||||
SourceAccount: sourceAccount,
|
||||
Location: location,
|
||||
Type: itemType,
|
||||
ItemName: name,
|
||||
ContainerArt: art,
|
||||
IsPresetable: true,
|
||||
}
|
||||
|
||||
if err = speakerClient.SelectContentItem(ci); err != nil {
|
||||
PrintError(fmt.Sprintf("Playback command failed: %v", err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
label := name
|
||||
if label == "" {
|
||||
label = location
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Playing %q (STORED_MUSIC, location=%s)", label, location))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// TestLibraryCommand_Registered checks that the library command and its three
|
||||
// subcommands are wired up with the expected names and flags. No live multicast
|
||||
// or real speaker calls are made.
|
||||
func TestLibraryCommand_Registered(t *testing.T) {
|
||||
cmd := libraryCommand()
|
||||
|
||||
if cmd.Name != "library" {
|
||||
t.Errorf("top-level command name = %q; want %q", cmd.Name, "library")
|
||||
}
|
||||
|
||||
// Index subcommands by name for easy lookup.
|
||||
sub := make(map[string]interface{})
|
||||
|
||||
for _, sc := range cmd.Subcommands {
|
||||
sub[sc.Name] = sc
|
||||
}
|
||||
|
||||
for _, name := range []string{"servers", "browse", "play"} {
|
||||
if _, ok := sub[name]; !ok {
|
||||
t.Errorf("expected subcommand %q to be registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryServersFlags checks the flags on `library servers`.
|
||||
func TestLibraryServersFlags(t *testing.T) {
|
||||
cmd := libraryCommand()
|
||||
|
||||
for _, s := range cmd.Subcommands {
|
||||
if s.Name != "servers" {
|
||||
continue
|
||||
}
|
||||
|
||||
flags := flagNames(s.Flags)
|
||||
|
||||
for _, want := range []string{"timeout", "via-speaker"} {
|
||||
if !contains(flags, want) {
|
||||
t.Errorf("servers subcommand missing flag %q; got %v", want, flags)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("servers subcommand not found")
|
||||
}
|
||||
|
||||
// TestLibraryBrowseFlags checks the flags on `library browse`.
|
||||
func TestLibraryBrowseFlags(t *testing.T) {
|
||||
cmd := libraryCommand()
|
||||
|
||||
for _, s := range cmd.Subcommands {
|
||||
if s.Name != "browse" {
|
||||
continue
|
||||
}
|
||||
|
||||
flags := flagNames(s.Flags)
|
||||
|
||||
for _, want := range []string{"udn", "object", "start", "count", "timeout"} {
|
||||
if !contains(flags, want) {
|
||||
t.Errorf("browse subcommand missing flag %q; got %v", want, flags)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("browse subcommand not found")
|
||||
}
|
||||
|
||||
// TestLibraryPlayFlags checks the flags on `library play`.
|
||||
func TestLibraryPlayFlags(t *testing.T) {
|
||||
cmd := libraryCommand()
|
||||
|
||||
for _, s := range cmd.Subcommands {
|
||||
if s.Name != "play" {
|
||||
continue
|
||||
}
|
||||
|
||||
flags := flagNames(s.Flags)
|
||||
|
||||
// source-account and location are required; name, type, art are optional.
|
||||
for _, want := range []string{"source-account", "location", "name", "type", "art"} {
|
||||
if !contains(flags, want) {
|
||||
t.Errorf("play subcommand missing flag %q; got %v", want, flags)
|
||||
}
|
||||
}
|
||||
|
||||
// Old URL-mode flags must no longer be present.
|
||||
for _, gone := range []string{"url", "mode"} {
|
||||
if contains(flags, gone) {
|
||||
t.Errorf("play subcommand should not have flag %q", gone)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("play subcommand not found")
|
||||
}
|
||||
|
||||
// flagNames extracts the primary Name from each flag in a slice.
|
||||
func flagNames(flags []cli.Flag) []string {
|
||||
names := make([]string, 0, len(flags))
|
||||
|
||||
for _, f := range flags {
|
||||
names = append(names, getFlagName(f))
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// contains reports whether needle is in haystack.
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
|
||||
setupWaitAPCmd(),
|
||||
setupWaitOnlineCmd(),
|
||||
setupSSHCheckCmd(),
|
||||
setupEnableSSHCmd(),
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
@@ -537,6 +538,213 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet,
|
||||
// printing the device transcript as it goes. With fullConfig it sends the
|
||||
// #515 sequence (all four config URLs with the injection on margeServerUrl, not
|
||||
// just envswitch) and reboots afterwards; otherwise it sends the single-
|
||||
// envswitch default that fires on the speaker's next boseurls check.
|
||||
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool) error {
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
|
||||
if fullConfig {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with the injection on margeServerUrl, then reboot)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL)
|
||||
} else {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnet(host, serviceURL)
|
||||
}
|
||||
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if !fullConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Rebooting the speaker to apply the new configuration...")
|
||||
|
||||
rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet)
|
||||
if rlogs != "" {
|
||||
fmt.Print(rlogs)
|
||||
}
|
||||
|
||||
if rerr != nil {
|
||||
PrintError(rerr.Error())
|
||||
return rerr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupEnableSSHCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enable-ssh",
|
||||
Usage: "Bootstrap SSH on a speaker with no prior access via the port-17000 envswitch trick (#471), " +
|
||||
"then restore clean URLs and persist it",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "AfterTouch service base URL to point the speaker at (e.g. https://192.0.2.10:8443). " +
|
||||
"Optional: enabling SSH does not need a live server (the injection fires when the speaker " +
|
||||
"parses its boseurls), so you can omit this now and set the real URLs later via migration",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "wait",
|
||||
Value: 90 * time.Second,
|
||||
Usage: "How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "full-config",
|
||||
Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " +
|
||||
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-reset-urls",
|
||||
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-persist",
|
||||
Usage: "Skip persisting the remote_services marker (SSH would not survive a reboot)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "authorized-key",
|
||||
Usage: "Opt-in hardening: install this SSH public key for root (key auth instead of the empty-password login). Pass the key text, e.g. --authorized-key \"$(cat id_ed25519.pub)\"",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "close-17000",
|
||||
Usage: "Opt-in hardening: block port 17000 from the LAN (firewall rule applied now + persisted); loopback access is kept",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
// The URL is only the vehicle for the command injection; the
|
||||
// SSH-enable fires when the speaker parses its boseurls, whether
|
||||
// or not anything answers there. When the user has no service URL
|
||||
// yet, use a clearly-placeholder value and tell them to set the
|
||||
// real URLs during migration.
|
||||
serviceURL := c.String("service-url")
|
||||
placeholder := serviceURL == ""
|
||||
|
||||
if placeholder {
|
||||
serviceURL = "https://aftertouch.invalid"
|
||||
}
|
||||
|
||||
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Waiting up to %s for sshd (:22) to come up...\n", c.Duration("wait"))
|
||||
|
||||
if err := setup.WaitForSSHPort(cfg.Host, c.Duration("wait")); err != nil {
|
||||
// Not a hard failure: on some devices (e.g. the Wireless Link
|
||||
// Adapter, see #471) the envswitch injection is accepted but
|
||||
// sshd only actually starts after the speaker restarts. We
|
||||
// deliberately leave the injected boseurls in place (no reset)
|
||||
// so a power-cycle re-triggers the unlock, and guide the user
|
||||
// to reboot and retry rather than exiting with an error.
|
||||
fmt.Println()
|
||||
PrintWarning(fmt.Sprintf("sshd (:22) did not come up within %s, but the speaker accepted the SSH-enable command.", c.Duration("wait")))
|
||||
fmt.Println("On some devices sshd only starts after a restart. Next steps:")
|
||||
fmt.Println(" 1. Power-cycle the speaker (unplug it, wait a few seconds, plug it back in).")
|
||||
fmt.Println(" 2. Once it is back online, run this same command again, or just connect with:")
|
||||
fmt.Printf(" ssh -o HostKeyAlgorithms=+ssh-rsa,ssh-dss root@%s\n", cfg.Host)
|
||||
fmt.Println("The temporary boseurls were left in place on purpose, so the restart re-triggers the unlock.")
|
||||
|
||||
if placeholder {
|
||||
fmt.Println("(No --service-url was given; you'll set the real service URLs later during migration.)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
PrintSuccess("SSH is up on " + cfg.Host)
|
||||
|
||||
if !c.Bool("no-reset-urls") {
|
||||
fmt.Println("Restoring clean boseurls (so the marge URL is usable again)...")
|
||||
|
||||
rlogs, rerr := m.ResetBoseURLs(cfg.Host, serviceURL)
|
||||
if rlogs != "" {
|
||||
fmt.Print(rlogs)
|
||||
}
|
||||
|
||||
if rerr != nil {
|
||||
PrintError(rerr.Error())
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
|
||||
if !c.Bool("no-persist") {
|
||||
fmt.Println("Persisting the remote_services marker (SSH survives reboot)...")
|
||||
|
||||
plogs, perr := m.EnsureRemoteServices(cfg.Host)
|
||||
if plogs != "" {
|
||||
fmt.Print(plogs)
|
||||
}
|
||||
|
||||
if perr != nil {
|
||||
PrintError(perr.Error())
|
||||
return perr
|
||||
}
|
||||
}
|
||||
|
||||
if key := c.String("authorized-key"); key != "" {
|
||||
fmt.Println("Installing authorized_keys for root (key auth)...")
|
||||
|
||||
klogs, kerr := m.InstallAuthorizedKey(cfg.Host, key)
|
||||
if klogs != "" {
|
||||
fmt.Print(klogs)
|
||||
}
|
||||
|
||||
if kerr != nil {
|
||||
PrintError(kerr.Error())
|
||||
return kerr
|
||||
}
|
||||
}
|
||||
|
||||
closed17000 := c.Bool("close-17000")
|
||||
if closed17000 {
|
||||
fmt.Println("Closing port 17000 to the LAN (loopback kept)...")
|
||||
|
||||
clogs, cerr := m.Close17000(cfg.Host)
|
||||
if clogs != "" {
|
||||
fmt.Print(clogs)
|
||||
}
|
||||
|
||||
if cerr != nil {
|
||||
PrintError(cerr.Error())
|
||||
return cerr
|
||||
}
|
||||
}
|
||||
|
||||
PrintSuccess("Done — SSH enabled on " + cfg.Host + ". From here, the usual migration / CA-install / inspect commands work.")
|
||||
|
||||
if placeholder {
|
||||
fmt.Println("No --service-url was given, so the speaker's boseurls now point at a placeholder; run your migration next to set the real service URLs.")
|
||||
}
|
||||
|
||||
if closed17000 {
|
||||
fmt.Println("Port 17000 is now blocked from the LAN (loopback kept).")
|
||||
} else {
|
||||
fmt.Println("Note: port 17000 is left open (opt-in --close-17000 to block it from the LAN).")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupRemoteServicesCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "remote-services",
|
||||
@@ -606,7 +814,7 @@ func setupInstallCACmd() *cli.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Fetched %d bytes of CA PEM from %s/setup/ca.crt\n", len(certPEM), serviceURL)
|
||||
fmt.Printf("Fetched %d bytes of CA PEM from %s/api/setup/ca.crt\n", len(certPEM), serviceURL)
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
@@ -627,11 +835,11 @@ func setupInstallCACmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchCACert pulls AfterTouch's CA bundle from /setup/ca.crt. On HTTP 401
|
||||
// fetchCACert pulls AfterTouch's CA bundle from /api/setup/ca.crt. On HTTP 401
|
||||
// it prompts interactively for basic-auth credentials (or accepts --auth)
|
||||
// and retries once.
|
||||
func fetchCACert(serviceURL, authFlag string) ([]byte, error) {
|
||||
url := serviceURL + "/setup/ca.crt"
|
||||
url := serviceURL + "/api/setup/ca.crt"
|
||||
|
||||
doRequest := func(user, pass string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
|
||||
@@ -136,6 +136,40 @@ func playURL(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// playURLUPnP plays audio from a URL via the speaker's UPnP AVTransport service.
|
||||
// Unlike `speaker url` (the /speaker play_info path), it needs no app-key and no
|
||||
// DNS interception, so it works on a plain LAN. It switches the speaker to the
|
||||
// UPNP source and replaces the current playback (no duck-and-resume), and the
|
||||
// speaker itself must be able to reach the URL.
|
||||
func playURLUPnP(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
urlStr := c.String("url")
|
||||
|
||||
if urlStr == "" {
|
||||
PrintError("URL is required")
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Playing URL via UPnP: %s", urlStr), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := client.PlayURLViaUPnP(urlStr); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to play URL via UPnP: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ URL playback started via UPnP\n")
|
||||
fmt.Printf(" URL: %s\n", urlStr)
|
||||
fmt.Printf(" Note: replaces the current source (UPNP); no app-key or DNS needed\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playNotification plays a notification sound or a local file on the speaker
|
||||
func playNotification(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
@@ -193,6 +227,11 @@ func showSpeakerHelp(_ *cli.Context) error {
|
||||
fmt.Println(" Play audio files from HTTP/HTTPS URLs")
|
||||
fmt.Println(" Example: soundtouch-cli speaker url --url \"https://example.com/audio.mp3\" --app-key YOUR_KEY")
|
||||
fmt.Println()
|
||||
fmt.Println("• URL via UPnP/AVTransport (no app key, no DNS):")
|
||||
fmt.Println(" Play an http:// audio URL directly via the speaker's UPnP renderer.")
|
||||
fmt.Println(" Replaces the current source; http:// only (https is rejected).")
|
||||
fmt.Println(" Example: soundtouch-cli speaker url-upnp --url \"http://192.0.2.10/audio.mp3\"")
|
||||
fmt.Println()
|
||||
fmt.Println("• Notification Beep:")
|
||||
fmt.Println(" Play a simple notification sound")
|
||||
fmt.Println(" Example: soundtouch-cli speaker beep")
|
||||
|
||||
@@ -101,7 +101,7 @@ func ttsCloud(c *cli.Context) error {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, serviceURL+"/setup/tts/speak", bytes.NewReader(body))
|
||||
req, err := http.NewRequest(http.MethodPost, serviceURL+"/api/setup/tts/speak", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
@@ -73,8 +73,12 @@ func getFlagName(flag cli.Flag) string {
|
||||
// updateBuildInfo extracts version information from debug.BuildInfo and updates package variables
|
||||
func updateBuildInfo() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
// Get version from module info
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Get version from module info. Only fall back to build info when the
|
||||
// version was not injected via -ldflags (i.e. still the "dev" default,
|
||||
// e.g. `go install …@vX.Y.Z`). This keeps an explicitly stamped release
|
||||
// version from being clobbered by a VCS pseudo-version (e.g. v0.0.0-…
|
||||
// from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
@@ -1922,6 +1926,20 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "url-upnp",
|
||||
Usage: "Play a URL via UPnP/AVTransport (no app-key, no DNS; replaces current source)",
|
||||
Action: playURLUPnP,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "url",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "URL of the audio content to play (must be reachable by the speaker)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "notify",
|
||||
Usage: "Play a notification sound or local file",
|
||||
@@ -2317,6 +2335,10 @@ func main() {
|
||||
// Defined in cmd_cloud.go.
|
||||
app.Commands = append(app.Commands, cloudCommand())
|
||||
|
||||
// DLNA music library (server discovery, browse, play).
|
||||
// Defined in cmd_library.go.
|
||||
app.Commands = append(app.Commands, libraryCommand())
|
||||
|
||||
// Sort commands alphabetically (including subcommands and flags recursively)
|
||||
sortCommands(app.Commands)
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
soundtouch-player
|
||||
soundtouch-player-test
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
The `soundtouch-player` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -141,10 +141,10 @@ GET /api/control/{id}/source?name=X # Select source
|
||||
### Build Commands
|
||||
```bash
|
||||
# Build the web application
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
cd cmd/soundtouch-player
|
||||
go build -o soundtouch-player
|
||||
|
||||
# Build all project components (includes soundtouch-web)
|
||||
# Build all project components (includes soundtouch-player)
|
||||
make build
|
||||
|
||||
# Cross-platform builds
|
||||
@@ -154,19 +154,19 @@ make build-all
|
||||
### Testing
|
||||
```bash
|
||||
# Run unit tests
|
||||
go test ./cmd/soundtouch-web/...
|
||||
go test ./cmd/soundtouch-player/...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./cmd/soundtouch-web/...
|
||||
go test -cover ./cmd/soundtouch-player/...
|
||||
|
||||
# Lint checking
|
||||
golangci-lint run cmd/soundtouch-web/...
|
||||
golangci-lint run cmd/soundtouch-player/...
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
# Run development server
|
||||
cd cmd/soundtouch-web
|
||||
cd cmd/soundtouch-player
|
||||
go run main.go -port 8080
|
||||
|
||||
# Access the web interface
|
||||
@@ -177,7 +177,7 @@ open http://localhost:8080
|
||||
|
||||
### Command Line Options
|
||||
```bash
|
||||
soundtouch-web [options]
|
||||
soundtouch-player [options]
|
||||
|
||||
Options:
|
||||
-port string Web server port (default "8080")
|
||||
@@ -186,9 +186,9 @@ Options:
|
||||
|
||||
### File Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
cmd/soundtouch-player/
|
||||
├── main.go # Application entry point
|
||||
├── soundtouch-web # Built binary
|
||||
├── soundtouch-player # Built binary
|
||||
├── handlers/
|
||||
│ ├── handlers.go # HTTP request handlers
|
||||
│ ├── handlers_test.go # Handler tests
|
||||
@@ -75,20 +75,20 @@ Individual device pages provide full control over:
|
||||
make build
|
||||
|
||||
# Or manually
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
cd cmd/soundtouch-player
|
||||
go build -o soundtouch-player
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
# Run with default settings (port 8080)
|
||||
./soundtouch-web
|
||||
./soundtouch-player
|
||||
|
||||
# Specify custom port
|
||||
./soundtouch-web -port 8888
|
||||
./soundtouch-player -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-web -host 192.0.2.100
|
||||
./soundtouch-player -host 192.0.2.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
@@ -105,17 +105,17 @@ go build -o soundtouch-web
|
||||
### Text-to-Speech (TTS)
|
||||
|
||||
TTS synthesis and the Bose `app_key` live in the AfterTouch service, not in
|
||||
soundtouch-web, so the "Speak" feature proxies to the service's
|
||||
`/setup/tts/speak` endpoint. To use it, point soundtouch-web at the service
|
||||
soundtouch-player, so the "Speak" feature proxies to the service's
|
||||
`/setup/tts/speak` endpoint. To use it, point soundtouch-player at the service
|
||||
with `--service-url`.
|
||||
|
||||
When the service is served over HTTPS with its own self-signed certificate
|
||||
(the default), soundtouch-web also needs to trust the service's CA, or the
|
||||
(the default), soundtouch-player also needs to trust the service's CA, or the
|
||||
proxied call fails with `x509: certificate signed by unknown authority`. Pass
|
||||
the CA with `--service-ca`; it is the service's `<dataDir>/certs/ca.crt`:
|
||||
|
||||
```bash
|
||||
soundtouch-web \
|
||||
soundtouch-player \
|
||||
--service-url https://soundtouch.fritz.box \
|
||||
--service-ca /path/to/certs/ca.crt
|
||||
```
|
||||
@@ -218,7 +218,7 @@ ws.onmessage = function(event) {
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
cmd/soundtouch-player/
|
||||
├── main.go # Application entry point and SPA routing
|
||||
├── handlers/ # HTTP and WebSocket handlers
|
||||
│ ├── handlers.go # JSON API endpoints
|
||||
@@ -244,7 +244,7 @@ cmd/soundtouch-web/
|
||||
go test ./...
|
||||
|
||||
# Manual testing with multiple devices
|
||||
./soundtouch-web -port 8080
|
||||
./soundtouch-player -port 8080
|
||||
|
||||
# API testing
|
||||
curl http://localhost:8080/api/devices
|
||||
@@ -323,7 +323,7 @@ This UI is based on extensive analysis of captured SoundTouch WebSocket interact
|
||||
Add verbose logging by setting environment variable:
|
||||
```bash
|
||||
export DEBUG=true
|
||||
./soundtouch-web
|
||||
./soundtouch-player
|
||||
```
|
||||
|
||||
## Contributing
|
||||
@@ -1,4 +1,12 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
// Package main provides soundtouch-player, the LAN-resident web player for
|
||||
// controlling Bose SoundTouch devices. It reaches speakers directly on the
|
||||
// local network and optionally delegates cloud-only features (e.g. TTS) to a
|
||||
// remote AfterTouch service via --service-url, which is why it stays useful
|
||||
// when soundtouch-service runs off-LAN (e.g. in the cloud).
|
||||
//
|
||||
// It was previously named soundtouch-web; that name is no longer published.
|
||||
// If you still run the binary under the old name, it prints a rename notice
|
||||
// and otherwise behaves identically.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -8,6 +16,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -30,7 +39,11 @@ func updateBuildInfo() {
|
||||
repoURL = "https://" + info.Main.Path
|
||||
}
|
||||
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Only fall back to build info when the version was not injected via
|
||||
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
|
||||
// This keeps an explicitly stamped release version from being clobbered
|
||||
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
@@ -47,12 +60,28 @@ func updateBuildInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// warnIfInvokedAsWeb prints a one-line deprecation notice when the binary is
|
||||
// run under its old name (soundtouch-web). That name is no longer published,
|
||||
// but anyone who renamed the binary still gets nudged to soundtouch-player.
|
||||
func warnIfInvokedAsWeb() {
|
||||
if len(os.Args) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
name := filepath.Base(os.Args[0])
|
||||
if name == "soundtouch-web" || name == "soundtouch-web.exe" {
|
||||
log.Println("notice: 'soundtouch-web' has been renamed to 'soundtouch-player'. " +
|
||||
"The 'soundtouch-web' name is no longer published; please switch to 'soundtouch-player'.")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
warnIfInvokedAsWeb()
|
||||
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
Usage: "Web UI for controlling Bose SoundTouch devices",
|
||||
Name: "soundtouch-player",
|
||||
Usage: "LAN web player for controlling Bose SoundTouch devices",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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/setup"
|
||||
)
|
||||
|
||||
// TestAdminAreaAuthGate is the wiring-level regression test for #419: it
|
||||
// exercises the real production router (setupRouter), not just the
|
||||
// BasicAuthAdmin middleware in isolation, to pin two things at once:
|
||||
// 1. /admin and /api/setup/* (and their /setup/* legacy aliases) are open
|
||||
// by default and become gated once AdminAreaAuth is "enabled".
|
||||
// 2. A handful of routes deliberately stay reachable WITHOUT credentials
|
||||
// regardless of the gate: ca.crt/tts/speak/tts/config because
|
||||
// soundtouch-cli/soundtouch-player call them directly (the whole reason
|
||||
// mountSetupAPI was split into mountSetupAPIShared/mountSetupAPIAdmin),
|
||||
// and /api/announcements because it specifically needs to reach
|
||||
// operators who haven't set up credentials yet.
|
||||
func TestAdminAreaAuthGate(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
// A real setup.Manager (with an actual CA) so /setup/ca.crt genuinely
|
||||
// succeeds instead of failing on a nil dependency for an unrelated
|
||||
// reason, which would make the "stays reachable" assertion meaningless.
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
_ = cm.EnsureCA()
|
||||
sm := setup.NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
server := handlers.NewServer(ds, sm, "http://localhost:8000", true, false, false)
|
||||
server.SetMgmtConfig("custom-admin", "custom-password")
|
||||
|
||||
r := setupRouter(server, nil, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
adminGatedPaths := []string{
|
||||
"/admin",
|
||||
"/setup/settings",
|
||||
"/api/setup/settings",
|
||||
}
|
||||
alwaysUngatedPaths := []string{
|
||||
"/setup/ca.crt",
|
||||
"/api/setup/ca.crt",
|
||||
"/setup/tts/config",
|
||||
"/api/setup/tts/config",
|
||||
"/api/announcements?target=admin",
|
||||
}
|
||||
|
||||
t.Run("open by default (AdminAreaAuth unset)", func(t *testing.T) {
|
||||
for _, path := range adminGatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "", "")
|
||||
if status == http.StatusUnauthorized {
|
||||
t.Errorf("%s: expected open access by default, got 401", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
server.SetAdminAreaAuth("enabled")
|
||||
defer server.SetAdminAreaAuth("")
|
||||
|
||||
t.Run("gated paths reject without credentials once enabled", func(t *testing.T) {
|
||||
for _, path := range adminGatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "", "")
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("%s: expected 401 without credentials once enabled, got %d", path, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gated paths accept correct credentials once enabled", func(t *testing.T) {
|
||||
for _, path := range adminGatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "custom-admin", "custom-password")
|
||||
if status == http.StatusUnauthorized {
|
||||
t.Errorf("%s: expected access with correct credentials, got 401", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("routes intentionally left outside the gate stay reachable without credentials", func(t *testing.T) {
|
||||
for _, path := range alwaysUngatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "", "")
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("%s: expected 200 without credentials even with the gate enabled, got %d", path, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func getStatus(t *testing.T, base, path, user, pass string) int {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build request for %s: %v", path, err)
|
||||
}
|
||||
if user != "" || pass != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request to %s failed: %v", path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
return res.StatusCode
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// frozenFirstSegments are the top-level path prefixes that belong to the frozen
|
||||
// speaker / app contract (category 1a/1b in
|
||||
// docs/content/docs/architecture/API-ROUTE-LAYOUT.md). Routes under these must
|
||||
// not change shape across the issue #451 refactor, so each should have at least
|
||||
// one .http contract test (the suite under tests/integration/http-client/, run
|
||||
// by `make test-http-client`). Movable surfaces (/setup, /mgmt, /web) and infra
|
||||
// (/, /health, /docs, /favicon.ico) are intentionally excluded.
|
||||
var frozenFirstSegments = map[string]bool{
|
||||
"streaming": true,
|
||||
"accounts": true,
|
||||
"customer": true,
|
||||
"bmx": true,
|
||||
"bmx-icons": true,
|
||||
"core02": true,
|
||||
"oauth": true,
|
||||
"custom": true,
|
||||
"media": true,
|
||||
"updates": true,
|
||||
"v1": true,
|
||||
"alexa": true,
|
||||
"ced": true,
|
||||
}
|
||||
|
||||
func coverageFirstSegment(p string) string {
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if i := strings.IndexByte(p, '/'); i >= 0 {
|
||||
return p[:i]
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// patternToRegexp converts a chi route pattern into an anchored regexp:
|
||||
// `{param}` becomes a single path segment (`[^/]+`) and `*` becomes `.*`.
|
||||
func patternToRegexp(pattern string) *regexp.Regexp {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("^")
|
||||
|
||||
for i, seg := range strings.Split(pattern, "/") {
|
||||
if i > 0 {
|
||||
b.WriteString("/")
|
||||
}
|
||||
|
||||
switch {
|
||||
case seg == "*":
|
||||
b.WriteString(".*")
|
||||
case strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}"):
|
||||
b.WriteString("[^/]+")
|
||||
default:
|
||||
b.WriteString(regexp.QuoteMeta(seg))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("$")
|
||||
|
||||
return regexp.MustCompile(b.String())
|
||||
}
|
||||
|
||||
// loadHTTPClientRequests extracts (method, path) pairs from every .http file in
|
||||
// the integration suite. `{{host}}` is stripped (leaving a leading `/`), query
|
||||
// strings are dropped, and `{{var}}` template segments are left intact (they
|
||||
// contain no slash, so they match a `[^/]+` route segment).
|
||||
func loadHTTPClientRequests(t *testing.T, dir string) [][2]string {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read http-client dir %s: %v", dir, err)
|
||||
}
|
||||
|
||||
reqLine := regexp.MustCompile(`^\s*(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(\S+)`)
|
||||
|
||||
var out [][2]string
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".http") {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
m := reqLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
url := strings.ReplaceAll(m[2], "{{host}}", "")
|
||||
if i := strings.IndexByte(url, '?'); i >= 0 {
|
||||
url = url[:i]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(url, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, [2]string{m[1], url})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFrozenRouteContractCoverage enforces that every frozen-contract route the
|
||||
// service registers is exercised by at least one .http integration test. The
|
||||
// set of *uncovered* frozen routes is golden-filed: adding a new frozen route
|
||||
// without a test (or adding a test that newly covers one) changes the set and
|
||||
// fails this test, forcing a conscious update of the golden file. It is the
|
||||
// machine-checked companion to tests/integration/http-client/COVERAGE.md.
|
||||
func TestFrozenRouteContractCoverage(t *testing.T) {
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server, nil, nil)
|
||||
|
||||
httpRequests := loadHTTPClientRequests(t, filepath.Join("..", "..", "tests", "integration", "http-client"))
|
||||
|
||||
// Only the request methods the contract suite actually exercises. Routes
|
||||
// registered via chi HandleFunc carry every method (CONNECT/TRACE/...); those
|
||||
// extra verbs are noise for coverage purposes.
|
||||
meaningfulMethods := map[string]bool{
|
||||
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true, http.MethodDelete: true,
|
||||
}
|
||||
|
||||
var uncovered []string
|
||||
|
||||
walkFunc := func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if !meaningfulMethods[method] {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !frozenFirstSegments[coverageFirstSegment(route)] {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := patternToRegexp(route)
|
||||
for _, req := range httpRequests {
|
||||
if req[0] == method && re.MatchString(req[1]) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
uncovered = append(uncovered, fmt.Sprintf("%-7s %s", method, route))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(r, walkFunc); err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(uncovered)
|
||||
output := strings.Join(uncovered, "\n") + "\n"
|
||||
|
||||
const goldenPath = "testdata/frozen_routes_uncovered.txt"
|
||||
|
||||
actualPath := "testdata/frozen_routes_uncovered.actual.txt"
|
||||
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("write actual: %v", err)
|
||||
}
|
||||
|
||||
golden, err := os.ReadFile(goldenPath)
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.WriteFile(goldenPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("create golden: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("created golden %s with %d uncovered frozen routes", goldenPath, len(uncovered))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
|
||||
if string(golden) != output {
|
||||
t.Errorf("Frozen-route contract coverage changed.\n"+
|
||||
"A frozen route either lost its .http test or a new one was added without one.\n"+
|
||||
"Review and, if intended, update %s from %s.", goldenPath, actualPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
)
|
||||
|
||||
// TestDeprecatedRouteSignal verifies the legacy admin paths are counted (and the
|
||||
// new /api/* twins are not), so the diagnostic export can show whether the old
|
||||
// paths are still in use before they are removed in a future major release.
|
||||
func TestDeprecatedRouteSignal(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
|
||||
r := setupRouter(server, nil, nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
hit := func(path string) {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
hit("/setup/version") // legacy — counted
|
||||
hit("/setup/version") // legacy again — count increments
|
||||
hit("/api/setup/version") // new canonical — must NOT be counted
|
||||
|
||||
hits := server.DeprecatedRouteHits()
|
||||
|
||||
if got := hits["GET /setup/version"]; got != 2 {
|
||||
t.Errorf("legacy GET /setup/version hits = %d, want 2", got)
|
||||
}
|
||||
|
||||
if _, tracked := hits["GET /api/setup/version"]; tracked {
|
||||
t.Errorf("/api/setup/version must not be tracked as deprecated; hits=%v", hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
)
|
||||
|
||||
// TestDualRouteEquivalence verifies the issue #451 step-1 aliasing invariant:
|
||||
// each admin-tier route served at both its legacy path and the new /api/* path
|
||||
// returns an identical response (same handler, same middleware). It fires the
|
||||
// same request at the old and new path and asserts equal status + body.
|
||||
//
|
||||
// The cases use endpoints whose body does not embed per-request time/random
|
||||
// values, so the only thing that can differ is the routing — which is exactly
|
||||
// what we want to pin while the routes are dual-mounted.
|
||||
func TestDualRouteEquivalence(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
|
||||
r := setupRouter(server, nil, nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
cases := []struct {
|
||||
method string
|
||||
oldPath string
|
||||
newPath string
|
||||
}{
|
||||
{http.MethodGet, "/setup/version", "/api/setup/version"},
|
||||
{http.MethodGet, "/setup/settings", "/api/setup/settings"},
|
||||
{http.MethodGet, "/setup/tts/config", "/api/setup/tts/config"},
|
||||
{http.MethodGet, "/setup/logging-settings", "/api/setup/logging-settings"},
|
||||
{http.MethodGet, "/setup/interaction-stats", "/api/setup/interaction-stats"},
|
||||
{http.MethodGet, "/setup/dns-discoveries", "/api/setup/dns-discoveries"},
|
||||
// /mgmt is Basic-Auth'd; without credentials both paths must reject
|
||||
// identically — that pins the auth gate is mirrored onto /api/mgmt too.
|
||||
{http.MethodGet, "/mgmt/accounts/", "/api/mgmt/accounts/"},
|
||||
{http.MethodGet, "/mgmt/spotify/accounts", "/api/mgmt/spotify/accounts"},
|
||||
{http.MethodGet, "/mgmt/amazon/accounts", "/api/mgmt/amazon/accounts"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.method+" "+c.newPath, func(t *testing.T) {
|
||||
oldStatus, oldBody := doEquivReq(t, ts.URL, c.method, c.oldPath)
|
||||
newStatus, newBody := doEquivReq(t, ts.URL, c.method, c.newPath)
|
||||
|
||||
if oldStatus != newStatus {
|
||||
t.Errorf("status mismatch for %s vs %s: old=%d new=%d", c.oldPath, c.newPath, oldStatus, newStatus)
|
||||
}
|
||||
|
||||
if !bytes.Equal(oldBody, newBody) {
|
||||
t.Errorf("body mismatch for %s vs %s:\n old=%q\n new=%q", c.oldPath, c.newPath, oldBody, newBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func doEquivReq(t *testing.T, base, method, path string) (int, []byte) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(method, base+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build request %s: %v", path, err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body %s: %v", path, err)
|
||||
}
|
||||
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
+389
-84
@@ -22,12 +22,14 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"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/logbuf"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -48,7 +50,11 @@ func updateBuildInfo() {
|
||||
repoURL = "https://" + info.Main.Path
|
||||
}
|
||||
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Only fall back to build info when the version was not injected via
|
||||
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
|
||||
// This keeps an explicitly stamped release version from being clobbered
|
||||
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
@@ -375,6 +381,16 @@ func main() {
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
@@ -465,10 +481,25 @@ func main() {
|
||||
config := loadConfig(c)
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
// Detect a genuinely fresh data dir by the ABSENCE of settings.json,
|
||||
// not by an empty server_url. A hand-authored settings.json (e.g. one
|
||||
// that only sets trust_forwarded_headers and leaves server_url to the
|
||||
// --server-url flag) exists but has no server_url; keying the "first
|
||||
// run" default-write off server_url would treat it as fresh and
|
||||
// clobber the operator's file, dropping fields createDefaultSettings
|
||||
// doesn't know about.
|
||||
settingsExisted := settingsFileExists(config.dataDir)
|
||||
|
||||
persisted := applyPersistedSettings(ds, &config)
|
||||
|
||||
if persisted.ServerURL == "" {
|
||||
if !settingsExisted {
|
||||
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
|
||||
log.Printf("Data directory %s looks empty (first run). If you did NOT expect this "+
|
||||
"(e.g. after recreating a Docker container), your previous settings, datastore and "+
|
||||
"CA were not persisted; mount a persistent volume at the data dir (Docker: "+
|
||||
"-v <volume>:/app/data) so device state and the CA survive restarts. A lost CA "+
|
||||
"forces re-migrating speakers and re-trusting the new CA.",
|
||||
sanitizeLog(config.dataDir))
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
@@ -487,7 +518,8 @@ func main() {
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetLogBuffer(logBuf)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetHTTPSListenAddr(config.httpsAddr)
|
||||
server.SetHTTPSSettings(config.httpsOverride, config.httpsPort, config.httpsDefaultURL)
|
||||
server.SetExpectedHosts(config.domains)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
@@ -497,9 +529,20 @@ func main() {
|
||||
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
|
||||
// Invalid values (e.g. a hand-edited settings.json) fall back to the
|
||||
// unset default rather than failing startup.
|
||||
adminAreaAuth, _ := handlers.NormalizeAdminAreaAuth(persisted.AdminAreaAuth)
|
||||
server.SetAdminAreaAuth(adminAreaAuth)
|
||||
|
||||
initMusicServices(config, server)
|
||||
initTTSService(config, server)
|
||||
|
||||
// Redirect TuneIn upstream calls when overridden (e.g. to a local
|
||||
// mock in integration tests); empty values keep the real hosts.
|
||||
if config.tuneInOpmlURL != "" || config.tuneInAPIURL != "" {
|
||||
bmx.SetTuneInEndpoints(config.tuneInOpmlURL, config.tuneInAPIURL)
|
||||
}
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
if err == nil && len(dnsDiscoveries) > 0 {
|
||||
@@ -570,7 +613,19 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
r := setupRouter(server, stockholmHandler)
|
||||
// Embedded web UI (soundtouch-player): LAN control UI under /app, control
|
||||
// API under /api/control. Same LAN-trust tier as /setup, no auth.
|
||||
// Server-side self-calls (TTS proxy) use the service's own loopback
|
||||
// HTTP listener so they never depend on TLS / the service CA.
|
||||
loopbackHost := config.bindAddr
|
||||
if loopbackHost == "" {
|
||||
loopbackHost = "127.0.0.1"
|
||||
}
|
||||
|
||||
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
|
||||
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
|
||||
|
||||
r := setupRouter(server, stockholmHandler, webApp)
|
||||
|
||||
// Bind the listener before logging so we print the true
|
||||
// effective port (handles :0 and catches "address already
|
||||
@@ -633,7 +688,10 @@ type serviceConfig struct {
|
||||
dataDir string
|
||||
hostname string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsServerURL string // effective (derived or overridden)
|
||||
httpsOverride string // explicit override; "" = derive from serverURL
|
||||
httpsPort string
|
||||
httpsDefaultURL string // hostname-based fallback
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
@@ -656,6 +714,8 @@ type serviceConfig struct {
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
tuneInOpmlURL string
|
||||
tuneInAPIURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
ttsProvider string
|
||||
@@ -693,6 +753,9 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
if serverURL == "" {
|
||||
serverURL = "http://" + hostname + ":" + port
|
||||
}
|
||||
// Strip a trailing slash so it cannot leak into the BMX registry base or the
|
||||
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
|
||||
serverURL = handlers.NormalizeServerURL(serverURL)
|
||||
|
||||
httpsPort := c.String("https-port")
|
||||
|
||||
@@ -701,10 +764,13 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
httpsAddr = ":" + httpsPort
|
||||
}
|
||||
|
||||
httpsServerURL := c.String("https-server-url")
|
||||
if httpsServerURL == "" {
|
||||
httpsServerURL = "https://" + hostname + ":" + httpsPort
|
||||
}
|
||||
// The HTTPS URL is an override (from the flag/env); when empty it is
|
||||
// derived from serverURL + https port so one setting (Target Domain)
|
||||
// drives both. httpsDefaultURL is the hostname-based fallback used
|
||||
// before a Target Domain is configured.
|
||||
httpsOverride := c.String("https-server-url")
|
||||
httpsDefaultURL := "https://" + hostname + ":" + httpsPort
|
||||
httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL)
|
||||
|
||||
tlsExtraHosts := c.StringSlice("tls-extra-host")
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
|
||||
@@ -737,6 +803,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
amazonRedirectURI := c.String("amazon-redirect-uri")
|
||||
amazonTokenURL := c.String("amazon-token-url")
|
||||
amazonProfileURL := c.String("amazon-profile-url")
|
||||
tuneInOpmlURL := c.String("tunein-opml-url")
|
||||
tuneInAPIURL := c.String("tunein-api-url")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
ttsProvider := c.String("tts-provider")
|
||||
@@ -760,6 +828,9 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
hostname: hostname,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
httpsPort: httpsPort,
|
||||
httpsDefaultURL: httpsDefaultURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
@@ -782,6 +853,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
tuneInOpmlURL: tuneInOpmlURL,
|
||||
tuneInAPIURL: tuneInAPIURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
ttsProvider: ttsProvider,
|
||||
@@ -864,6 +937,20 @@ func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string)
|
||||
return domains
|
||||
}
|
||||
|
||||
// settingsFileExists reports whether a settings.json is already present in the
|
||||
// data dir. It's the first-run discriminator: an existing file (even an
|
||||
// incomplete, hand-authored one) must never be overwritten by the default
|
||||
// seed, while a truly empty data dir gets defaults plus the lost-volume notice.
|
||||
func settingsFileExists(dataDir string) bool {
|
||||
if dataDir == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := os.Stat(filepath.Join(dataDir, "settings.json"))
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
@@ -878,13 +965,23 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
}
|
||||
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
config.serverURL = handlers.NormalizeServerURL(persisted.ServerURL)
|
||||
}
|
||||
|
||||
if persisted.HTTPServerURL != "" {
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
// persisted.HTTPServerURL is the HTTPS override (empty = derive).
|
||||
// Existing installs carry their old effective value here; if it is
|
||||
// exactly what we would derive anyway, treat it as "derive" so those
|
||||
// installs don't show a spurious override in the UI. A genuinely custom
|
||||
// value is kept as an override. Recompute either way, since serverURL
|
||||
// may have come from the persisted settings above.
|
||||
config.httpsOverride = persisted.HTTPServerURL
|
||||
if config.httpsOverride != "" &&
|
||||
config.httpsOverride == handlers.DeriveHTTPSURL(config.serverURL, "", config.httpsPort, config.httpsDefaultURL) {
|
||||
config.httpsOverride = ""
|
||||
}
|
||||
|
||||
config.httpsServerURL = handlers.DeriveHTTPSURL(config.serverURL, config.httpsOverride, config.httpsPort, config.httpsDefaultURL)
|
||||
|
||||
config.discoveryEnabled = persisted.DiscoveryEnabled
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
@@ -1003,7 +1100,7 @@ func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted data
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
HTTPServerURL: config.httpsOverride,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
@@ -1025,6 +1122,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
}
|
||||
|
||||
func initDataStore(dataDir string) *datastore.DataStore {
|
||||
warnIfDataDirNotWritable(dataDir)
|
||||
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize datastore: %v", err)
|
||||
@@ -1033,6 +1132,48 @@ func initDataStore(dataDir string) *datastore.DataStore {
|
||||
return ds
|
||||
}
|
||||
|
||||
// warnIfDataDirNotWritable probes the data dir and logs an actionable message
|
||||
// when the process can't write to it. The common cause is running the
|
||||
// container as non-root (uid 65532) while a bind-mounted host directory is
|
||||
// owned by someone else; without this the failure would surface later as a
|
||||
// cryptic permission error deep in a save. It only warns: the datastore's own
|
||||
// resilience handles the degraded state.
|
||||
func warnIfDataDirNotWritable(dataDir string) {
|
||||
if dataDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
log.Printf("WARNING: data dir %s cannot be created: %v", sanitizeLog(dataDir), err)
|
||||
logDataDirChownHint(dataDir)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
probe := filepath.Join(dataDir, ".write-probe")
|
||||
if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil {
|
||||
log.Printf("WARNING: data dir %s is not writable: %v", sanitizeLog(dataDir), err)
|
||||
logDataDirChownHint(dataDir)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(probe)
|
||||
}
|
||||
|
||||
// logDataDirChownHint prints the one-time fix for a non-writable bind-mounted
|
||||
// data dir, using the process's own uid. Skipped where uid is unavailable
|
||||
// (e.g. Windows), where the hint wouldn't apply.
|
||||
func logDataDirChownHint(dataDir string) {
|
||||
uid := os.Getuid()
|
||||
if uid < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(" The service runs as uid %d. If you bind-mounted a host directory as the data dir, "+
|
||||
"make it writable once: chown -R %d:%d %s", uid, uid, uid, sanitizeLog(dataDir))
|
||||
}
|
||||
|
||||
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
|
||||
|
||||
@@ -1057,17 +1198,94 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
|
||||
// newEmbeddedWebApp builds the soundtouch-player application for embedding in the
|
||||
// service router: release metadata from the build vars, the service's public
|
||||
// ServiceURL (used by Play URL for speaker-fetched stream URLs and shown in the
|
||||
// UI), a loopback InternalServiceURL for the player's own server-side self-calls
|
||||
// (the TTS proxy) so they never depend on TLS or the service CA, and device
|
||||
// state sourced entirely from the service.
|
||||
//
|
||||
// The web UI shares the service's discovery rather than running its own (the
|
||||
// datastore is the single source of truth): ExtraDeviceHosts reads it,
|
||||
// TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the
|
||||
// devices-changed hook re-syncs the UI registry whenever the service's
|
||||
// discovery or a manual add changes the set.
|
||||
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
|
||||
webApp := soundtouchweb.NewWebApp()
|
||||
webApp.Version = version
|
||||
webApp.Commit = commit
|
||||
webApp.Date = date
|
||||
webApp.RepoURL = repoURL
|
||||
webApp.ServiceURL = strings.TrimRight(serverURL, "/")
|
||||
|
||||
// The player's own server-side calls (the TTS proxy hits
|
||||
// /api/setup/tts/speak) go to the service's loopback HTTP listener, not the
|
||||
// public ServiceURL. That avoids the "service doesn't trust its own CA"
|
||||
// x509 failure entirely: loopback is plain HTTP, so it needs no CA and
|
||||
// works on HTTP and HTTPS deployments alike — and before the CA is even
|
||||
// generated. ServiceURL stays the public URL because Play URL bakes it into
|
||||
// stream URLs the speaker fetches and the UI displays it.
|
||||
webApp.InternalServiceURL = internalURL
|
||||
|
||||
webApp.ExtraDeviceHosts = func() []string {
|
||||
devices, listErr := ds.ListAllDevices()
|
||||
if listErr != nil {
|
||||
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(devices))
|
||||
for i := range devices {
|
||||
if devices[i].IPAddress != "" {
|
||||
hosts = append(hosts, devices[i].IPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
// UI "discover" runs the service's sweep, not a second mDNS stack.
|
||||
webApp.TriggerDiscovery = server.DiscoverDevices
|
||||
|
||||
// A removal from the player UI cascades to the datastore (the single
|
||||
// source of truth), so the device does not reappear on the next re-sync.
|
||||
webApp.RemoveDeviceHook = func(deviceID string) error {
|
||||
_, err := server.RemoveDeviceByID(deviceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep the UI registry live as the service discovers or devices are added.
|
||||
server.SetDevicesChangedHook(func() {
|
||||
webApp.SeedExtraDevices()
|
||||
webApp.BroadcastDeviceList()
|
||||
})
|
||||
|
||||
go func() {
|
||||
// Project the current device set into the UI; the devices-changed hook
|
||||
// and the service's periodic discovery keep it current from here on.
|
||||
webApp.SeedExtraDevices()
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
return webApp
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, webApp *soundtouchweb.WebApp) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
|
||||
// CleanPath collapses duplicate slashes ("//bmx/..." -> "/bmx/...") and
|
||||
// resolves . / .. before routing. Defensive net for the double-slash
|
||||
// playback bug: even if a misconfigured base URL hands a speaker a "//bmx"
|
||||
// path, it still reaches the right handler instead of 404ing. Runs first so
|
||||
// every downstream middleware and the recorder see the cleaned path.
|
||||
r.Use(middleware.CleanPath)
|
||||
|
||||
// ClientIPMiddleware must run before any handler that reads the client IP —
|
||||
// SnapshotMiddleware captures the request, and several handlers
|
||||
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
|
||||
// gated on Settings.TrustForwardedHeaders; when off (the safe default),
|
||||
// it returns nil and we skip Use'ing it entirely.
|
||||
if mw := server.TrustedRealIPMiddleware(); mw != nil {
|
||||
r.Use(mw)
|
||||
}
|
||||
// (HandleMargePowerOn, etc.) inspect the source IP via middleware.GetClientIP.
|
||||
// Always wired: at minimum the socket peer is recorded; when
|
||||
// TrustForwardedHeaders is on and the peer is trusted, XFF is resolved.
|
||||
r.Use(server.ClientIPMiddleware())
|
||||
|
||||
r.Use(server.SnapshotMiddleware)
|
||||
r.Use(server.OriginMiddleware)
|
||||
@@ -1077,13 +1295,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.With(server.BasicAuthAdmin()).Get("/admin", server.HandleAdmin)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
// Passive peer-reachability probe. Registers a device IP with the
|
||||
// in-process observer, nudges :8090/swUpdateCheck, and waits for
|
||||
// any inbound from that IP. Used post-migration where the daemon
|
||||
// caches its swUpdateUrl at boot and the active round-trip can't
|
||||
// reach it without a reboot.
|
||||
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
// Deliberately not behind BasicAuthAdmin — see HandleListAnnouncements'
|
||||
// doc comment. #419.
|
||||
r.Get("/api/announcements", server.HandleListAnnouncements)
|
||||
r.Post("/api/announcements/{id}/dismiss", server.HandleDismissAnnouncement)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
// The favicon lives in the embedded web/img bundle, not under
|
||||
// static/media — HandleMedia would 404. HandleWeb serves from
|
||||
@@ -1108,6 +1325,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
|
||||
|
||||
r.Route("/tunein", func(r chi.Router) {
|
||||
// Bare service descriptor (the registry's `self` link for TuneIn).
|
||||
r.Get("/", server.HandleTuneInService)
|
||||
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
@@ -1128,6 +1347,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
|
||||
// reach the token + station endpoints at exactly these paths under
|
||||
// either DNS-interception or URL-flip migration.
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion", server.HandleOrionService)
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
@@ -1148,6 +1368,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/account", server.HandleMargeCreateAccount)
|
||||
r.Post("/account/login", server.HandleMargeLogin)
|
||||
r.Post("/account/{account}/source", server.HandleMargeAddSource)
|
||||
r.Delete("/account/{account}/source/{sourceID}", server.HandleMargeDeleteSource)
|
||||
|
||||
r.Route("/account/{account}", func(r chi.Router) {
|
||||
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
@@ -1192,6 +1413,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
// the addGroup payload to Marge during stereo-pair formation --
|
||||
// see issue #252. Register both forms so chi accepts either.
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
// the addGroup payload to Marge during stereo-pair formation --
|
||||
// see issue #252. Register both forms so chi accepts either.
|
||||
@@ -1233,31 +1457,38 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
|
||||
})
|
||||
|
||||
// The /accounts/* group mirrored /streaming/account/* for compatibility, but
|
||||
// no speaker or app was ever observed using this prefix in the recording
|
||||
// corpus (the integration tests that exercised it were migrated onto the
|
||||
// /streaming equivalents). The whole mirror is therefore treated as unused
|
||||
// and stubbed (HandleUnsupported): it logs + 501s so any real-world use
|
||||
// surfaces instead of being silently dropped, leaving the prefix a clean
|
||||
// removal candidate for the #451 refactor.
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Route("/{account}", func(r chi.Router) {
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
r.Get("/full", server.HandleUnsupported)
|
||||
r.Get("/sources", server.HandleUnsupported)
|
||||
r.Get("/devices", server.HandleUnsupported)
|
||||
|
||||
r.Post("/devices", server.HandleMargeAddDevice)
|
||||
r.Post("/devices", server.HandleUnsupported)
|
||||
|
||||
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Delete("/devices/{device}", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group/", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group/server", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group/member", server.HandleUnsupported)
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
|
||||
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/group", server.HandleUnsupported)
|
||||
r.Post("/group/", server.HandleUnsupported)
|
||||
r.Post("/group/{groupId}", server.HandleUnsupported)
|
||||
r.Delete("/group/{groupId}", server.HandleUnsupported)
|
||||
r.Delete("/group", server.HandleUnsupported)
|
||||
r.Delete("/group/", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/presets", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/recents", server.HandleUnsupported)
|
||||
|
||||
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleUnsupported)
|
||||
r.Post("/devices/{device}/recents", server.HandleUnsupported)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1292,47 +1523,86 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/auth", server.HandleSpeakerAuth)
|
||||
})
|
||||
|
||||
// Management API (admin tier). Registered under both /mgmt (legacy) and
|
||||
// /api/mgmt (new canonical — issue #451 route-transition step 1) from one
|
||||
// shared registration so the two paths stay byte-identical; both carry the
|
||||
// same Basic Auth. The browser OAuth callbacks are externally-pinned
|
||||
// (provider redirect URIs) and therefore stay at /mgmt only, not aliased.
|
||||
mountMgmtAuthed := func(r chi.Router) {
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Get("/", server.HandleMgmtListAccounts)
|
||||
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
|
||||
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
})
|
||||
|
||||
r.Route("/spotify", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
|
||||
r.Route("/amazon", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtAmazonInit)
|
||||
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
|
||||
r.Get("/token", server.HandleMgmtAmazonToken)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
|
||||
})
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
}
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callbacks — no auth required (provider redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
// short-lived, and useless without the client_secret.
|
||||
// short-lived, and useless without the client_secret. Not aliased under
|
||||
// /api/mgmt (externally-pinned redirect URIs).
|
||||
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
|
||||
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
|
||||
|
||||
// All other management endpoints require Basic Auth.
|
||||
// All other management endpoints require Basic Auth. On the legacy mount
|
||||
// they also carry the deprecation signal (counts + one-time warning); the
|
||||
// callbacks above are excluded (externally-pinned, not deprecated).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Get("/", server.HandleMgmtListAccounts)
|
||||
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
|
||||
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
})
|
||||
|
||||
r.Route("/spotify", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
|
||||
r.Route("/amazon", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtAmazonInit)
|
||||
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
|
||||
r.Get("/token", server.HandleMgmtAmazonToken)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
|
||||
})
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
mountMgmtAuthed(r)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Route("/api/mgmt", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
mountMgmtAuthed(r)
|
||||
})
|
||||
})
|
||||
|
||||
// Setup / admin API (admin tier). Registered under both /setup (legacy) and
|
||||
// /api/setup (new canonical) from one shared registration. The Stockholm
|
||||
// setup-wizard static catch-all is a frontend concern and stays under /setup
|
||||
// only — /api/setup serves data only.
|
||||
//
|
||||
// Split in two: mountSetupAPIShared is reachable regardless of
|
||||
// AdminAreaAuth — soundtouch-cli and the embedded player call these
|
||||
// directly without Management API credentials (ca.crt for `setup
|
||||
// install-ca`, tts/speak+tts/config for the Play URL / TTS integration
|
||||
// surface). mountSetupAPIAdmin is everything else — genuinely admin-UI-only,
|
||||
// gated by BasicAuthAdmin() once #419's admin-area toggle is enabled.
|
||||
mountSetupAPIShared := func(r chi.Router) {
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
// TTS lives under /setup (LAN-trust, like the rest of the integration
|
||||
// surface and Play URL), not /mgmt: the API key is already configured
|
||||
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
|
||||
r.Post("/tts/speak", server.HandleTTSSpeak)
|
||||
r.Get("/tts/config", server.HandleTTSConfig)
|
||||
}
|
||||
|
||||
mountSetupAPIAdmin := func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
@@ -1340,11 +1610,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
// TTS lives under /setup (LAN-trust, like the rest of the integration
|
||||
// surface and Play URL), not /mgmt: the API key is already configured
|
||||
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
|
||||
r.Post("/tts/speak", server.HandleTTSSpeak)
|
||||
r.Get("/tts/config", server.HandleTTSConfig)
|
||||
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
@@ -1352,6 +1617,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
|
||||
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
|
||||
// Passive peer-reachability probe. Registers a device IP with the
|
||||
// in-process observer, nudges :8090/swUpdateCheck, and waits for any
|
||||
// inbound from that IP. Used post-migration where the daemon caches its
|
||||
// swUpdateUrl at boot and the active round-trip can't reach it without a
|
||||
// reboot.
|
||||
r.Post("/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
@@ -1360,7 +1631,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/logging-settings", server.HandleGetLoggingSettings)
|
||||
r.Post("/logging-settings", server.HandleUpdateLoggingSettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
@@ -1384,15 +1654,50 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/health/dns-path-probe", server.HandleDNSPathProbe)
|
||||
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
|
||||
r.Get("/logs", server.HandleGetLogs)
|
||||
}
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the management API.
|
||||
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
// Legacy admin API: same handlers as /api/setup, plus the deprecation
|
||||
// signal (counts + one-time warning). Scoped to the API routes only — the
|
||||
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
mountSetupAPIShared(r)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
r.Use(server.BasicAuthAdmin())
|
||||
mountSetupAPIAdmin(r)
|
||||
})
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the
|
||||
// management API. The Stockholm frontend has a setup/ directory that must
|
||||
// be accessible at /setup/*. Frontend-only — not mirrored under /api/setup.
|
||||
// Not gated by AdminAreaAuth: Stockholm is a separate, off-by-default
|
||||
// (--stockholm-dir) legacy wizard, out of scope for #419.
|
||||
if stockholmHandler != nil {
|
||||
r.Get("/*", stockholmHandler.HandleStatic)
|
||||
r.Get("/", stockholmHandler.HandleStatic)
|
||||
}
|
||||
})
|
||||
|
||||
r.Route("/api/setup", func(r chi.Router) {
|
||||
mountSetupAPIShared(r)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthAdmin())
|
||||
mountSetupAPIAdmin(r)
|
||||
})
|
||||
})
|
||||
|
||||
// Embedded web UI: control API under /api/control and the SPA under /app
|
||||
// (LAN-trust, like /setup). Additive — nothing here collides with the
|
||||
// service's own /, /health, or /static. The web app shares the service's
|
||||
// discovery (nil discovery service here), so it runs no mDNS of its own.
|
||||
// Skipped when nil, e.g. unit tests that only exercise the service surface.
|
||||
if webApp != nil {
|
||||
webApp.MountWeb(r, nil)
|
||||
}
|
||||
|
||||
if stockholmHandler != nil {
|
||||
stockholmHandler.Mount(r)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
@@ -187,3 +188,83 @@ func contains(haystack []string, needle string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSettingsFileExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if settingsFileExists(dir) {
|
||||
t.Fatal("expected false for a dir without settings.json")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("write settings.json: %v", err)
|
||||
}
|
||||
|
||||
if !settingsFileExists(dir) {
|
||||
t.Fatal("expected true once settings.json is present")
|
||||
}
|
||||
|
||||
if settingsFileExists("") {
|
||||
t.Fatal("expected false for an empty data dir")
|
||||
}
|
||||
}
|
||||
|
||||
// applyFirstRunSeed mirrors the startup gate in the CLI Action: a default
|
||||
// settings.json is written only when none exists yet, so a hand-authored file
|
||||
// is never clobbered.
|
||||
func applyFirstRunSeed(ds *datastore.DataStore, config *serviceConfig) {
|
||||
existed := settingsFileExists(config.dataDir)
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if !existed {
|
||||
createDefaultSettings(ds, *config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRunSeed_PreservesHandAuthoredSettings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Operator pre-seeds proxy trust but leaves server_url to the --server-url
|
||||
// flag. Before the fix this was treated as "first run" and overwritten.
|
||||
if err := os.WriteFile(filepath.Join(dir, "settings.json"),
|
||||
[]byte(`{"trust_forwarded_headers":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`), 0o644); err != nil {
|
||||
t.Fatalf("write settings.json: %v", err)
|
||||
}
|
||||
|
||||
ds := datastore.NewDataStore(dir)
|
||||
config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"}
|
||||
|
||||
applyFirstRunSeed(ds, config)
|
||||
|
||||
got, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
|
||||
if !got.TrustForwardedHeaders {
|
||||
t.Error("trust_forwarded_headers was clobbered on startup")
|
||||
}
|
||||
|
||||
if len(got.TrustedProxyCIDRs) != 1 || got.TrustedProxyCIDRs[0] != "10.0.0.0/8" {
|
||||
t.Errorf("trusted_proxy_cidrs was clobbered, got %v", got.TrustedProxyCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRunSeed_WritesDefaultsWhenAbsent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
ds := datastore.NewDataStore(dir)
|
||||
config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"}
|
||||
|
||||
applyFirstRunSeed(ds, config)
|
||||
|
||||
got, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
|
||||
if got.ServerURL != "http://192.0.2.1:8000" {
|
||||
t.Errorf("expected defaults to be written with server_url, got %q", got.ServerURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,16 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
// Initialize a minimal server to get the router. Pass a web app so the
|
||||
// snapshot also captures the embedded soundtouch-player surface
|
||||
// (/api/control + /app); discovery is nil since we only register routes.
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server, nil)
|
||||
r := setupRouter(server, nil, soundtouchweb.NewWebApp())
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
@@ -128,7 +131,7 @@ func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server, nil)
|
||||
r := setupRouter(server, nil, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
DELETE /streaming/account/{account}/group
|
||||
GET /bmx-icons/*
|
||||
GET /bmx/tunein/v1/navigate
|
||||
GET /bmx/tunein/v1/navigate/*
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID}
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID}
|
||||
GET /bmx/tunein/v1/search
|
||||
GET /bmx/tunein/v1/search/next
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
GET /media/tts/{id}
|
||||
GET /streaming/account/{account}/device/{device}/group
|
||||
GET /streaming/account/{account}/device/{device}/group/member
|
||||
GET /streaming/account/{account}/device/{device}/group/server
|
||||
GET /streaming/account/{account}/device/{device}/recent
|
||||
GET /streaming/account/{account}/presets
|
||||
GET /streaming/device_setting/account/{account}/device/{device}/device_settings
|
||||
POST /core02/svc-bmx-adapter-orion/prod/orion/token
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token
|
||||
POST /streaming/account/{account}/device/{device}
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber}
|
||||
POST /streaming/account/{account}/group
|
||||
POST /streaming/account/{account}/group/{groupId}
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings
|
||||
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible
|
||||
POST /streaming/stats/error
|
||||
POST /streaming/stats/usage
|
||||
POST /v1/stapp/{deviceId}
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
+134
-19
@@ -1,9 +1,16 @@
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /accounts/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleDeleteDevice-fm
|
||||
DELETE /api/control/devices/{id}/library/servers/{account} soundtouchweb.(*WebApp).HandleRemoveLibraryServer-fm
|
||||
DELETE /api/setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /api/setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /api/setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /api/setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /api/setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
@@ -17,19 +24,78 @@ DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} hand
|
||||
DELETE /streaming/account/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /streaming/account/{account}/source/{sourceID} handlers.(*Server).HandleMargeDeleteSource-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
|
||||
GET /admin handlers.(*Server).HandleAdmin-fm
|
||||
GET /api/announcements handlers.(*Server).HandleListAnnouncements-fm
|
||||
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
|
||||
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
|
||||
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
|
||||
GET /api/control/devices/{id}/library/browse soundtouchweb.(*WebApp).HandleLibraryBrowse-fm
|
||||
GET /api/control/devices/{id}/library/servers soundtouchweb.(*WebApp).HandleDeviceLibraryServers-fm
|
||||
GET /api/control/devices/{id}/power-status soundtouchweb.(*WebApp).HandleDevicePowerStatus-fm
|
||||
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm
|
||||
GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm
|
||||
GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm
|
||||
GET /api/control/providers/library/servers soundtouchweb.(*WebApp).HandleDiscoverLibraryServers-fm
|
||||
GET /api/control/providers/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm
|
||||
GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
|
||||
GET /api/control/providers/tunein/navigate/* soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
|
||||
GET /api/control/providers/tunein/search soundtouchweb.(*WebApp).HandleTuneInSearch-fm
|
||||
GET /api/control/providers/tunein/search/next soundtouchweb.(*WebApp).HandleTuneInSearchNext-fm
|
||||
GET /api/control/version soundtouchweb.(*WebApp).HandleAPIVersion-fm
|
||||
GET /api/control/ws soundtouchweb.(*WebApp).HandleWebSocket-fm
|
||||
GET /api/mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /api/mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /api/mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
GET /api/mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
|
||||
GET /api/mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
|
||||
GET /api/mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
|
||||
GET /api/mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
|
||||
GET /api/mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /api/setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /api/setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /api/setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
|
||||
GET /api/setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /api/setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
GET /api/setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /api/setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /api/setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /api/setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
|
||||
GET /api/setup/health handlers.(*Server).HandleHealthChecks-fm
|
||||
GET /api/setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /api/setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /api/setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
GET /api/setup/interactions handlers.(*Server).HandleListInteractions-fm
|
||||
GET /api/setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
|
||||
GET /api/setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
|
||||
GET /api/setup/logs handlers.(*Server).HandleGetLogs-fm
|
||||
GET /api/setup/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /api/setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /api/setup/tts/config handlers.(*Server).HandleTTSConfig-fm
|
||||
GET /api/setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
GET /app soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/device/* soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/devices soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/library soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/playurl soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/radiobrowser soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/static/* http.Handler.ServeHTTP-fm
|
||||
GET /app/tts soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/tunein soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
|
||||
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
|
||||
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
|
||||
GET /bmx/tunein/ handlers.(*Server).HandleTuneInService-fm
|
||||
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
|
||||
@@ -38,6 +104,7 @@ GET /bmx/tunein/v1/playback/station/{stationID} handlers.(
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion handlers.(*Server).HandleOrionService-fm
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
@@ -109,13 +176,59 @@ OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /api/announcements/{id}/dismiss handlers.(*Server).HandleDismissAnnouncement-fm
|
||||
POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
|
||||
POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm
|
||||
POST /api/control/devices/{id}/library/play soundtouchweb.(*WebApp).HandlePlayLibrary-fm
|
||||
POST /api/control/devices/{id}/library/servers soundtouchweb.(*WebApp).HandleAddLibraryServer-fm
|
||||
POST /api/control/devices/{id}/play soundtouchweb.(*WebApp).HandleDevicePlay-fm
|
||||
POST /api/control/devices/{id}/power soundtouchweb.(*WebApp).HandleDevicePower-fm
|
||||
POST /api/control/devices/{id}/providers/radiobrowser/play soundtouchweb.(*WebApp).HandlePlayRadioBrowser-fm
|
||||
POST /api/control/devices/{id}/providers/tts/play soundtouchweb.(*WebApp).HandleAPISpeakText-fm
|
||||
POST /api/control/devices/{id}/providers/tunein/play soundtouchweb.(*WebApp).HandlePlayTuneIn-fm
|
||||
POST /api/control/devices/{id}/providers/url/play soundtouchweb.(*WebApp).HandlePlayURL-fm
|
||||
POST /api/control/devices/{id}/volume/{volume} soundtouchweb.(*WebApp).HandleDirectVolumeControl-fm
|
||||
POST /api/control/devices/{id}/zone/add/{slaveId} soundtouchweb.(*WebApp).HandleZoneAdd-fm
|
||||
POST /api/control/devices/{id}/zone/dissolve soundtouchweb.(*WebApp).HandleZoneDissolve-fm
|
||||
POST /api/control/devices/{id}/zone/leave soundtouchweb.(*WebApp).HandleZoneLeave-fm
|
||||
POST /api/control/devices/{id}/zone/remove/{slaveId} soundtouchweb.(*WebApp).HandleZoneRemove-fm
|
||||
POST /api/control/discover soundtouchweb.(*WebApp).MountWeb
|
||||
POST /api/mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
POST /api/mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
|
||||
POST /api/mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
|
||||
POST /api/mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
|
||||
POST /api/mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
|
||||
POST /api/mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
|
||||
POST /api/mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
|
||||
POST /api/mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
|
||||
POST /api/mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
|
||||
POST /api/setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
|
||||
POST /api/setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /api/setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /api/setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /api/setup/health/dns-path-probe handlers.(*Server).HandleDNSPathProbe-fm
|
||||
POST /api/setup/health/fix handlers.(*Server).HandleHealthFix-fm
|
||||
POST /api/setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
|
||||
POST /api/setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /api/setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
POST /api/setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
|
||||
POST /api/setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /api/setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
POST /api/setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
|
||||
POST /api/setup/settings handlers.(*Server).HandleUpdateSettings-fm
|
||||
POST /api/setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
|
||||
POST /api/setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
|
||||
POST /api/setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
|
||||
POST /api/setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
|
||||
POST /api/setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
|
||||
POST /api/setup/tts/speak handlers.(*Server).HandleTTSSpeak-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
@@ -180,5 +293,7 @@ PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
QUERY /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
QUERY /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -5,5 +5,6 @@ default/
|
||||
dns/
|
||||
interactions/
|
||||
parity_mismatches/
|
||||
stats/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
+49
-2
@@ -16,9 +16,26 @@ services:
|
||||
- AMAZON_CLIENT_SECRET=mock-amazon-secret
|
||||
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
- TUNEIN_OPML_URL=http://tunein-mock:8080
|
||||
- TUNEIN_API_URL=http://tunein-mock:8080
|
||||
# Start only once every mock is actually listening (the mocks are `go run`,
|
||||
# so cold compilation can take a while); see depends_on below.
|
||||
depends_on:
|
||||
spotify-mock:
|
||||
condition: service_healthy
|
||||
amazon-mock:
|
||||
condition: service_healthy
|
||||
tunein-mock:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/health"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.4-alpine
|
||||
image: golang:1.26.5-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -28,9 +45,15 @@ services:
|
||||
- "8081:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.4-alpine
|
||||
image: golang:1.26.5-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -40,6 +63,30 @@ services:
|
||||
- "8082:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.5-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-tunein/main.go -port 8080
|
||||
ports:
|
||||
- "8083:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
|
||||
@@ -665,7 +665,7 @@ soundtouch --device 192.0.2.100 preset 1
|
||||
soundtouch interactive
|
||||
|
||||
# Web interface
|
||||
soundtouch-webapp --port 8080
|
||||
soundtouch-playerapp --port 8080
|
||||
```
|
||||
|
||||
### JavaScript/WASM Usage
|
||||
@@ -727,10 +727,10 @@ client.startEventStream((event) => {
|
||||
./soundtouch-linux-amd64 --device IP play
|
||||
|
||||
# Web Application (embedded assets)
|
||||
./soundtouch-webapp-linux-amd64 --port 8080
|
||||
./soundtouch-playerapp-linux-amd64 --port 8080
|
||||
|
||||
# Docker
|
||||
docker run -p 8080:8080 soundtouch-webapp
|
||||
docker run -p 8080:8080 soundtouch-playerapp
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
|
||||
@@ -35,7 +35,7 @@ layout: hextra-home
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="Music Browsing"
|
||||
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-web and soundtouch-cli."
|
||||
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-player and soundtouch-cli."
|
||||
icon="speakerphone"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
|
||||
@@ -46,16 +46,16 @@ selection behave the same as before.
|
||||
|
||||
Your six preset buttons work. AfterTouch stores preset bindings locally and serves them
|
||||
back to the speaker on request. You can also **save new presets** — via the API,
|
||||
via `soundtouch-cli`, or through the soundtouch-web UI.
|
||||
via `soundtouch-cli`, or through the soundtouch-player UI.
|
||||
|
||||
### ST-10 stereo pairing
|
||||
|
||||
**SoundTouch 10 stereo pairs** (and other ST pairing configurations) are supported
|
||||
end-to-end: creation, management, and playback routing all go through AfterTouch.
|
||||
|
||||
### soundtouch-web — browser UI
|
||||
### soundtouch-player — browser UI
|
||||
|
||||
**soundtouch-web** is an early-stage but functional browser UI bundled with AfterTouch.
|
||||
**soundtouch-player** is an early-stage but functional browser UI bundled with AfterTouch.
|
||||
It gives you:
|
||||
|
||||
- TuneIn and RadioBrowser browsing and playback
|
||||
@@ -65,7 +65,7 @@ It gives you:
|
||||
|
||||
It runs as part of the AfterTouch service — no separate install needed.
|
||||
|
||||

|
||||

|
||||
|
||||
### Automation with soundtouch-cli
|
||||
|
||||
@@ -110,9 +110,9 @@ right places to start.
|
||||
|
||||
## What's next
|
||||
|
||||
The soundtouch-web UI will gain richer preset management — browsing, editing, and
|
||||
The soundtouch-player UI will gain richer preset management — browsing, editing, and
|
||||
reordering presets directly from the browser. Longer term, merging
|
||||
`soundtouch-service` and `soundtouch-web` into a single binary is on the table,
|
||||
`soundtouch-service` and `soundtouch-player` into a single binary is on the table,
|
||||
which would simplify deployment to a single process with no extra flags.
|
||||
|
||||
This blog will be updated monthly — or whenever something significant ships.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "AfterTouch: From Rescue to Something Better, and the Road to 1.0"
|
||||
date: 2026-06-28
|
||||
description: "Since v0.93.1, AfterTouch grew from a cloud-shutdown rescue into a platform of its own: local music, voice prompts, sturdier internals, a growing community, and a 1.0 on the horizon."
|
||||
tags:
|
||||
- discovery
|
||||
- health
|
||||
- migration
|
||||
- fixes
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
The launch post went out under the wire. Bose pulled the plug on the SoundTouch cloud on
|
||||
May 6, and **v0.93.1** was very much a rescue: get accounts migrated, keep radio and
|
||||
presets alive, stop perfectly good speakers from turning into bricks. The weeks since,
|
||||
up through **v0.117.0**, have been about a quieter shift: turning that rescue into
|
||||
something that stands on its own, and in a few places, something better than what Bose
|
||||
offered. And almost none of that direction came from me. I use my own speakers with a
|
||||
pretty narrow set of features; nearly everything below exists because someone in the
|
||||
community described a use case I'd never have thought to build.
|
||||
|
||||
## Local music, back under your control, and a speaker that talks
|
||||
|
||||
The clearest sign of that shift is local music. Your speakers always had a native
|
||||
local-music source for playing your own library off the network, but browsing it used to
|
||||
run through the Bose app. AfterTouch brings that back on its own terms: it discovers
|
||||
DLNA / UPnP media servers on your network and drives the speaker's native source
|
||||
directly. Browse folders in the **Library** tab or from the command line, queue a whole
|
||||
folder, and next/previous and auto-advance behave like a real playlist.
|
||||
|
||||
Then there's something genuinely new: speakers can now *talk*. A text-to-speech feature
|
||||
announces arbitrary text out loud, with Google Cloud TTS as a pluggable provider you
|
||||
configure from the settings UI. It's built on the speaker's notification capability, but
|
||||
turning that into spoken prompts is the kind of thing that happens when the platform is
|
||||
open and nobody has to wait for a vendor to approve it.
|
||||
|
||||
There is more in the same spirit, smaller but useful: service-side search across TuneIn
|
||||
and Radio Browser, a "Play URL" view for arbitrary streams, save-as-preset straight from
|
||||
Now Playing, and a step toward needing no extra hardware at all, an on-device SSH unlock
|
||||
flow that opens the door to running AfterTouch directly on the speaker.
|
||||
|
||||
## The unglamorous half: earning trust
|
||||
|
||||
Features are the easy part to write about. The work that actually mattered most was
|
||||
making AfterTouch dependable enough that you stop thinking about it. Speaker data is now
|
||||
written to disk durably, so a power cut mid-write no longer wipes your presets and
|
||||
accounts, and corrupt or empty files fall back to sane defaults instead of failing.
|
||||
Recent tracks stopped vanishing and duplicating. Internet radio got steadier: Radio
|
||||
Browser plays through its proper native source, TuneIn fails over across stream
|
||||
candidates, and a stray trailing slash in a server URL no longer breaks playback.
|
||||
Multi-room grouping handles member removal correctly.
|
||||
|
||||
Under the surface, a sustained pass closed several request-forgery paths, swept the code
|
||||
for log-injection, validated identifiers on management endpoints, and removed a
|
||||
credential-logging shortcut. And the health checks grew teeth: server-URL reachability,
|
||||
CA-bundle integrity, a speaker-clock check with a one-click fix, and a DNS-path probe for
|
||||
the internet-radio escape problem, all now labelled with the device name and IP so you
|
||||
know exactly which speaker a warning is about.
|
||||
|
||||
## A community, not a product
|
||||
|
||||
The best thing to happen since launch isn't in the changelog. It's the people.
|
||||
|
||||
It's worth saying plainly: this project is driven by its users. I personally use
|
||||
SoundTouch in a fairly simple way, and most of what shipped over these weeks (features
|
||||
and bug fixes alike) is the result of friendly, constructive feedback from people who use
|
||||
their speakers very differently than I do. The DLNA library, the voice prompts, the radio
|
||||
and grouping fixes, the migration edge cases: each one started as someone taking the time
|
||||
to explain a real-world setup and point at what was missing. That feedback is the
|
||||
roadmap. Keep it coming.
|
||||
|
||||
A standout is **[Sander ten Brinke](https://x.com/sandertenbrinke)**, who is building
|
||||
**[soundtouch-maui](https://github.com/sander1095/soundtouch-maui)**, a cross-platform
|
||||
SoundTouch app designed to work hand in hand with AfterTouch. That's exactly the shape
|
||||
this project should take: not one tool trying to do everything, but independent pieces
|
||||
that fit together because they share an open, community-owned foundation. Go build a
|
||||
player, a remote, a home-automation bridge, whatever you need, and have it talk to a
|
||||
service you control.
|
||||
|
||||
An honest admission: there has been more activity in issues and discussions than one
|
||||
maintainer can keep up with, and not every thread got the reply it deserved. But the
|
||||
encouraging part is that it increasingly doesn't have to. People are answering each
|
||||
other, sharing setups (the FRITZ!Box and AdGuard DNS notes came straight from a user's
|
||||
own working configuration), and debugging together. That's the project moving in the
|
||||
right direction. AfterTouch works best as a community, not a support desk.
|
||||
|
||||
And a heartfelt thank you to everyone who sponsors AfterTouch. The project is free and
|
||||
maintained in spare time, so every contribution, recurring or one-off, directly funds the
|
||||
hosting, the test hardware, and the hours that keep these speakers alive. It genuinely
|
||||
makes a difference, and it's deeply appreciated. If you'd like to chip in, the
|
||||
[sponsor page](../sponsor.md) has the details.
|
||||
|
||||
## The road to 1.0
|
||||
|
||||
So what does **v1.0.0** mean? Mostly: stability. A version number that signals a proper,
|
||||
dependable base you can build on, with a management API that won't shift under you and a
|
||||
service that runs unprivileged and installs cleanly by default.
|
||||
|
||||
A few things are on the list to get there. The admin and account-management UI works,
|
||||
but it feels rough at the edges, and that's the part you actually touch, so it deserves
|
||||
some polish. I also want to keep a publicly deployed, cloud-hosted service in mind:
|
||||
the moment AfterTouch is reachable from the open internet, it needs proper authentication
|
||||
and authorization, so a passing script kiddie can't read your recently played songs (or
|
||||
worse). And the docs need some love and a clearer structure. One feature is likely to land
|
||||
in this stretch too: making
|
||||
[presets propagate cleanly across the speakers in one account](https://github.com/gesellix/Bose-SoundTouch/issues/495),
|
||||
without the manual "refresh sources" dance. There's probably more before it's truly
|
||||
"1.0", but none of it is blocking: there's nothing preventing us from getting there *now*.
|
||||
|
||||
It's also a natural moment for a clean slate. If your migration has accumulated quirks,
|
||||
1.0 is a good excuse to reset and re-migrate your speakers onto a known-good footing.
|
||||
|
||||
And then the interesting part begins. With the rescue done and a stable base in place, the
|
||||
focus shifts to delivering value the old Bose cloud never could. Some of that is already
|
||||
taking shape in the issue tracker: an
|
||||
[audiobook mode](https://github.com/gesellix/Bose-SoundTouch/issues/508), and deeper
|
||||
integration with external music providers such as
|
||||
[Amazon Music](https://github.com/gesellix/Bose-SoundTouch/issues/188). A service under
|
||||
community control is a rare chance to actually solve the things people ask for, instead of
|
||||
waiting on a roadmap that was discontinued. If there's something you wish your speakers
|
||||
did, the [issue tracker](https://github.com/gesellix/Bose-SoundTouch/issues) and
|
||||
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) are where it starts.
|
||||
|
||||
## Current release
|
||||
|
||||
**v0.117.0**, released June 28, 2026
|
||||
|
||||
This blog will be updated monthly, or whenever something significant ships.
|
||||
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
|
||||
for individual version notes.
|
||||
@@ -93,5 +93,6 @@ Older planning artefacts ("Enhanced State Management System", "Upstream Service
|
||||
- **Questions & Discussion**: [GitHub Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions)
|
||||
- **Documentation**: Check troubleshooting guides first
|
||||
- **Community**: Share experiences and help others
|
||||
- **Direct chat (last resort)**: There's a small Discord for the rare case where an email exchange or an issue/discussion thread needs real-time back-and-forth. It's not a primary support channel: please start with Issues or Discussions. If a conversation genuinely needs it, ask in your thread and I'll share an invite.
|
||||
|
||||
For a complete list of all documents, browse the sections in the sidebar.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Bose SoundTouch API Coverage Analysis"
|
||||
---
|
||||
**Last Updated:** February 2026
|
||||
**Last Updated:** June 2026 (reconciled against `pkg/client`)
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Implementation Status:** 100% Official Coverage + Extended Features
|
||||
**Implementation Status:** Official coverage 20/21 + extended features
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This Go implementation provides **complete coverage** of the Bose SoundTouch Web API with **100% of official endpoints implemented** (18/19) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
|
||||
This Go implementation provides near-complete coverage of the Bose SoundTouch Web API with **20 of 21 official endpoints implemented** (the one exception, `/trackInfo`, is documented but non-functional on real hardware) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
|
||||
|
||||
### Key Findings
|
||||
- ✅ **All essential user functionality implemented**
|
||||
|
||||
@@ -84,7 +84,7 @@ sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
|
||||
Name=wlan0
|
||||
|
||||
[Network]
|
||||
Address=192.168.10.1/24
|
||||
Address=198.51.100.1/24
|
||||
IPForward=yes
|
||||
ConfigureWithoutCarrier=yes
|
||||
DHCP=no
|
||||
@@ -104,7 +104,7 @@ sudo systemctl mask wpa_supplicant@wlan0
|
||||
**Verify:**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
|
||||
# Expected: ONLY inet 198.51.100.1/24 (NO second DHCP IP)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -151,9 +151,9 @@ sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
|
||||
|
||||
sudo tee /etc/dnsmasq.conf << 'EOF'
|
||||
interface=wlan0
|
||||
dhcp-range=192.168.10.100,192.168.10.200,24h
|
||||
dhcp-option=3,192.168.10.1
|
||||
dhcp-option=6,192.168.10.1
|
||||
dhcp-range=198.51.100.100,198.51.100.200,24h
|
||||
dhcp-option=3,198.51.100.1
|
||||
dhcp-option=6,198.51.100.1
|
||||
|
||||
# DNS Upstream: custom server on localhost (adjust port if necessary)
|
||||
server=127.0.0.1#5353 # Example: custom server on port 5353
|
||||
@@ -237,7 +237,7 @@ If you cannot see the `Bose-Lab` SSID on your phone:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `198.51.100.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
# If the ghost IP is still there, remove it manually:
|
||||
@@ -259,13 +259,13 @@ If you haven't created a CA yet, follow **Appendix A** first.
|
||||
# Temporarily make reachable via HTTP for easy download:
|
||||
cd /etc/my-dns-ca/
|
||||
python3 -m http.server 8080
|
||||
# → Reachable at http://192.168.10.1:8080/ca.crt
|
||||
# → Reachable at http://198.51.100.1:8080/ca.crt
|
||||
```
|
||||
|
||||
### Install on Android
|
||||
|
||||
1. Connect phone to `Bose-Lab`
|
||||
2. Open browser → `http://192.168.10.1:8080/ca.crt`
|
||||
2. Open browser → `http://198.51.100.1:8080/ca.crt`
|
||||
3. Download certificate
|
||||
4. **Settings → Security → Credentials → Install CA Certificate**
|
||||
5. Select certificate and confirm
|
||||
@@ -322,7 +322,7 @@ sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
|
||||
|
||||
# Traffic of a specific host only (filter by phone IP)
|
||||
# Read phone IP from dnsmasq.leases beforehand (see below)
|
||||
sudo tcpdump -i wlan0 -n host 192.168.10.101
|
||||
sudo tcpdump -i wlan0 -n host 198.51.100.101
|
||||
```
|
||||
|
||||
### Read SNI from TLS Traffic (without decryption)
|
||||
@@ -351,7 +351,7 @@ Transfer `.pcap` files from the Pi to the PC:
|
||||
|
||||
```bash
|
||||
# From the PC (scp)
|
||||
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
scp pi@198.51.100.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
```
|
||||
|
||||
**Important Wireshark Filters:**
|
||||
@@ -607,7 +607,7 @@ You can either configure the macOS system proxy manually or use `mitmproxy`'s au
|
||||
**Method 1: System Proxy (Manual)**
|
||||
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
|
||||
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
|
||||
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
|
||||
3. Set Server to your Pi's IP (`198.51.100.1`) and Port to `8080`.
|
||||
4. Click **OK** and **Apply**.
|
||||
|
||||
**Method 2: mitmproxy Local Redirect (Automatic)**
|
||||
@@ -667,7 +667,7 @@ If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA t
|
||||
If the **Transparent AP** setup (Steps 1–6) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
|
||||
|
||||
### 1. How it works
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `198.51.100.1:8080`.
|
||||
|
||||
* **Pros:** No complex `nftables` or NAT rules required.
|
||||
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
|
||||
@@ -683,7 +683,7 @@ mitmproxy --listen-port 8080
|
||||
1. Go to **Settings → Wi-Fi → Bose-Lab**.
|
||||
2. Select **Modify Network** (or the "i" icon).
|
||||
3. Set **Proxy** to **Manual**.
|
||||
4. **Proxy hostname:** `192.168.10.1`
|
||||
4. **Proxy hostname:** `198.51.100.1`
|
||||
5. **Proxy port:** `8080`
|
||||
6. Save and try to browse a site.
|
||||
|
||||
@@ -706,7 +706,7 @@ go get github.com/google/gopacket
|
||||
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
|
||||
|
||||
# Example: Filter for a specific speaker's IP in WebSocket messages
|
||||
go run scripts/extract-ws.go capture.pcap 192.168.100.1
|
||||
go run scripts/extract-ws.go capture.pcap 203.0.113.1
|
||||
```
|
||||
|
||||
### 2. Manual Extraction with tshark
|
||||
@@ -889,5 +889,5 @@ pgrep -a tcpdump
|
||||
dig @127.0.0.1 -p 5353 global.api.bose.io
|
||||
|
||||
# Check network connectivity from the phone (from the Pi)
|
||||
ping 192.168.10.101 # Phone IP from dnsmasq.leases
|
||||
ping 198.51.100.101 # Phone IP from dnsmasq.leases
|
||||
```
|
||||
|
||||
@@ -3,10 +3,23 @@ title: "SoundTouch supportedURLs Endpoint Analysis"
|
||||
---
|
||||
This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation.
|
||||
|
||||
> **Reconciliation note (June 2026).** The categorised lists below had drifted
|
||||
> from `pkg/client`. Verified against the code, these are **implemented** and have
|
||||
> been re-marked (some were wrongly under "Not Yet Implemented", and a few were
|
||||
> listed twice): the music-service set (`setMusicServiceAccount`,
|
||||
> `setMusicServiceOAuthAccount`, `removeMusicServiceAccount`, `serviceAvailability`),
|
||||
> presets (`storePreset`, `removePreset`), stations (`searchStation`, `addStation`,
|
||||
> `removeStation`), `navigate`, the native stereo-pair group set (`getGroup`,
|
||||
> `addGroup`, `removeGroup`, `updateGroup`), `speaker`, `playNotification`,
|
||||
> `requestToken`, `notification`. Still **not** implemented (confirmed absent from
|
||||
> `pkg/client`): `search`, `standby`, `powerManagement`, `lowPowerStandby`,
|
||||
> `language`, `listMediaServers`, `bluetoothInfo`, `userPlayControl`, and the
|
||||
> wireless / bluetooth-pairing / software-update / source-shortcut families.
|
||||
|
||||
## Discovery Summary
|
||||
|
||||
**Test Devices:**
|
||||
- Device 1: `192.0.2.11:8090` (deviceID: `08DF1F0BA325`)
|
||||
- Device 1: `192.0.2.11:8090` (deviceID: `AABBCCDDEE01`)
|
||||
- Device 2: `192.0.2.10:8090` (deviceID: `AABBCCDDEEFF`)
|
||||
|
||||
**Key Findings:**
|
||||
@@ -61,18 +74,18 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/audioproducttonecontrols` - Advanced tone controls (capability-dependent)
|
||||
- `/audioproductlevelcontrols` - Speaker level controls (capability-dependent)
|
||||
|
||||
**System Info (3/3):**
|
||||
- `/trackInfo` - Track information
|
||||
- `/bluetoothInfo` - Bluetooth information
|
||||
- `/recents` - Recently played content
|
||||
**System Info (1/3):**
|
||||
- `/recents` - Recently played content ✅
|
||||
- `/trackInfo` - Track information ❌ non-functional on real devices (use `/now_playing`)
|
||||
- `/bluetoothInfo` - Bluetooth information ❌ not implemented in `pkg/client`
|
||||
|
||||
### 🔶 Partially Implemented/Different Approach
|
||||
### ✅ Stereo-Pair Group Management (native)
|
||||
|
||||
**Zone Management:**
|
||||
- `/addGroup` ⚠️ - We use `/setZone` for group management
|
||||
- `/removeGroup` ⚠️ - We use `/setZone` for group management
|
||||
- `/getGroup` ⚠️ - We use `/getZone` for group information
|
||||
- `/updateGroup` ⚠️ - We use `/setZone` for group updates
|
||||
Implemented natively in `pkg/client` (in addition to the `/setZone` multiroom path):
|
||||
- `/addGroup` ✅ - `AddGroup()`
|
||||
- `/removeGroup` ✅ - `RemoveGroup()`
|
||||
- `/getGroup` ✅ - `GetGroup()`
|
||||
- `/updateGroup` ✅ - `UpdateGroup()`
|
||||
|
||||
### ❌ Not Yet Implemented (High Priority)
|
||||
|
||||
@@ -91,22 +104,22 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/selectLastSoundTouchSource` - Select last SoundTouch source
|
||||
- `/selectLocalSource` - Select local source
|
||||
|
||||
**Music Services Integration:**
|
||||
- `/setMusicServiceAccount` - Configure music service account
|
||||
- `/setMusicServiceOAuthAccount` - OAuth account setup
|
||||
- `/removeMusicServiceAccount` - Remove music service account
|
||||
- `/serviceAvailability` - Check service availability
|
||||
**Music Services Integration:** ✅ implemented (moved out of this list)
|
||||
- ~~`/setMusicServiceAccount`~~ ✅ `SetMusicServiceAccount()`
|
||||
- ~~`/setMusicServiceOAuthAccount`~~ ✅ `SetMusicServiceOAuthAccount()`
|
||||
- ~~`/removeMusicServiceAccount`~~ ✅ `RemoveMusicServiceAccount()`
|
||||
- ~~`/serviceAvailability`~~ ✅ `GetServiceAvailability()`
|
||||
|
||||
**Enhanced Presets:**
|
||||
- `/storePreset` - Store new preset
|
||||
- `/removePreset` - Remove existing preset
|
||||
- ~~`/storePreset`~~ ✅ `StorePreset()` (also listed under Fully Implemented)
|
||||
- ~~`/removePreset`~~ ✅ `RemovePreset()`
|
||||
- `/bookmark` - Bookmark current content
|
||||
- `/userRating` - User rating for content
|
||||
|
||||
**Station/Radio Management:**
|
||||
- `/searchStation` - Search for stations
|
||||
- `/addStation` - Add station to favorites
|
||||
- `/removeStation` - Remove station from favorites
|
||||
- ~~`/searchStation`~~ ✅ `SearchStation()`
|
||||
- ~~`/addStation`~~ ✅ `AddStation()`
|
||||
- ~~`/removeStation`~~ ✅ `RemoveStation()`
|
||||
- `/genreStations` - Browse stations by genre
|
||||
- `/stationInfo` - Station information
|
||||
|
||||
@@ -119,7 +132,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/systemtimeout` - System timeout settings
|
||||
- `/powersaving` - Power saving configuration
|
||||
- `/language` - Language settings
|
||||
- `/speaker` - Speaker configuration
|
||||
- ~~`/speaker`~~ ✅ `PlayTTS()` / `PlayURL()` (TTS & URL notifications; not "speaker configuration")
|
||||
|
||||
**Network & Connectivity:**
|
||||
- `/performWirelessSiteSurvey` - WiFi site survey
|
||||
@@ -133,7 +146,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
|
||||
**Content Discovery:**
|
||||
- `/search` - Content search
|
||||
- `/navigate` - Content navigation
|
||||
- ~~`/navigate`~~ ✅ `Navigate()`
|
||||
- `/listMediaServers` - List available media servers
|
||||
|
||||
### ❌ Not Yet Implemented (Low Priority)
|
||||
@@ -156,9 +169,9 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
|
||||
**System Utilities:**
|
||||
- `/userActivity` - User activity tracking
|
||||
- `/requestToken` - Token management
|
||||
- `/notification` - Notification management
|
||||
- `/playNotification` - Play notification sound
|
||||
- ~~`/requestToken`~~ ✅ `RequestToken()`
|
||||
- ~~`/notification`~~ ✅ `NotifySourcesUpdated()`
|
||||
- ~~`/playNotification`~~ ✅ `PlayNotification()`
|
||||
- `/introspect` - System introspection
|
||||
- `/test` - System test interface
|
||||
|
||||
@@ -229,7 +242,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
**Example Response Structure:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<supportedURLs deviceID="08DF1F0BA325">
|
||||
<supportedURLs deviceID="AABBCCDDEE01">
|
||||
<URL location="/info" />
|
||||
<URL location="/capabilities" />
|
||||
<!-- ... 101 additional endpoints ... -->
|
||||
|
||||
@@ -225,7 +225,7 @@ These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for
|
||||
|
||||
- **Firmware 1.x–7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
|
||||
- **Firmware 8.x–14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
|
||||
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
|
||||
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target; on the ST Portable (Series I, FW `27.0.6.46330.5043500`) and some CineMate 520 units the SSH-enable injection persists but `sshd` does not start via the default path, which is what `setup enable-ssh --full-config` addresses (see "What we use to enable SSH" above).
|
||||
|
||||
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
|
||||
|
||||
@@ -265,6 +265,38 @@ Reboot is **not** part of these sequences — it stays a user-initiated action v
|
||||
|
||||
---
|
||||
|
||||
## What we use to enable SSH (`setup enable-ssh`, #471)
|
||||
|
||||
To open SSH on a speaker that has never had it (no USB recovery), the CLI abuses the boseurls value as a command-injection vehicle: when the device next parses it, the appended shell snippet touches the `remote_services` marker and starts `sshd`. The injected suffix is:
|
||||
|
||||
```
|
||||
;touch /tmp/remote_services;/etc/init.d/sshd start
|
||||
```
|
||||
|
||||
**Default path** (`soundtouch-cli setup enable-ssh`) writes that injection only via the persistence layer, then waits for `:22`:
|
||||
|
||||
```
|
||||
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/update"
|
||||
```
|
||||
|
||||
This is field-confirmed on the Wireless Link Adapter and on the CineMate 520 `lisa` variant (FW 27.0.6).
|
||||
|
||||
**`--full-config` path** (`soundtouch-cli setup enable-ssh --full-config`) is for devices where the default injection is *accepted and persisted* (`getpdo` confirms the value) but `sshd` never comes up, so `:22` stays "Connection refused". It mirrors the manual telnet sequence @Henri-be confirmed by hand on issue #515: it puts the injection on the runtime `sys configuration margeServerUrl` key as well as `envswitch`, writes all four URL keys, then reboots so the device re-parses the config at boot:
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl "<serverURL>/bmx/registry/v1/services"
|
||||
sys configuration statsServerUrl "<serverURL>"
|
||||
sys configuration margeServerUrl "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start"
|
||||
sys configuration swUpdateUrl "<serverURL>/updates/soundtouch"
|
||||
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/updates/soundtouch"
|
||||
getpdo CurrentSystemConfiguration
|
||||
sys reboot
|
||||
```
|
||||
|
||||
**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope here, but worth recording
|
||||
|
||||
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "Analysis & Research"
|
||||
weight: 4
|
||||
weight: 5
|
||||
---
|
||||
|
||||
@@ -198,7 +198,7 @@ The web UI is already fully responsive — it has Bootstrap grid columns, `@medi
|
||||
### Priority 2 — RadioBrowser as a first-class provider
|
||||
|
||||
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-player` web UI as a browsable/searchable source.
|
||||
- Make discovered stations directly presetable to hardware buttons.
|
||||
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
|
||||
|
||||
@@ -242,7 +242,7 @@ These exist in soundcork but are deliberate architectural choices in AfterTouch,
|
||||
|
||||
| Area | soundcork | AfterTouch |
|
||||
|--------------------------|---------------------------------------|-----------------------------------------------------------|
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-web` component (Go + plain HTML/JS) |
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-player` component (Go + plain HTML/JS) |
|
||||
| Direct device management | SSH/SCP access into speakers | HTTP API only; no SSH |
|
||||
| Device discovery client | Python `upnpclient` library | mDNS + UPnP in Go, with dedicated DNS interception server |
|
||||
| Token delivery | Push (ZeroConf priming to port 8200) | Pull (device calls back to fetch) |
|
||||
|
||||
@@ -21,13 +21,17 @@ Most SoundTouch devices run a modified Linux distribution. Accessing these logs
|
||||
|
||||
Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services:
|
||||
|
||||
1. **USB Method**:
|
||||
1. **CLI Method (recommended, no USB needed)**:
|
||||
- `soundtouch-cli --host <device-ip> setup enable-ssh` drives the port-17000 diagnostic shell to inject the `remote_services` marker and start `sshd`, then waits for `:22`. This is the #471 bootstrap; it needs no prior SSH and no USB stick.
|
||||
- If the command is accepted (the device persists it, confirmed by `getpdo`) but `sshd` never comes up and `:22` stays "Connection refused", retry with `--full-config`. That variant mirrors the manual telnet sequence confirmed on issue #515: it puts the injection on `sys configuration margeServerUrl` as well as `envswitch`, writes all four URL keys, and reboots.
|
||||
- **`--full-config` is meant for:** the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** and some **CineMate 520** units, where the default single-`envswitch` path leaves `sshd` down. The default path is sufficient on the Wireless Link Adapter and the CineMate 520 `lisa` variant. Some units (e.g. certain ST10 and CineMate 520 firmwares) do not respond to either path and need the serial / U-Boot console route instead. See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md#what-we-use-to-enable-ssh-setup-enable-ssh-471) for the exact commands and current confirmation status.
|
||||
2. **USB Method**:
|
||||
- Format a USB stick to **FAT32**.
|
||||
- Create an empty file named `remote_services` (no extension) in the root of the USB stick.
|
||||
- Insert the stick into the SoundTouch device.
|
||||
- Reboot the device (power cycle).
|
||||
- On some models, you may need to hold **4** and **Volume -** on the device while powering on to force a USB check.
|
||||
2. **TAP Command (Legacy)**:
|
||||
3. **TAP Command (Legacy)**:
|
||||
- On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`.
|
||||
|
||||
### Making Root Access Persistent
|
||||
|
||||
@@ -22,6 +22,8 @@ The encrypted `.age` file decrypts to a `.tar.gz` archive with:
|
||||
Source, SourceID, location), device product code, firmware version, IP, name
|
||||
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
|
||||
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
|
||||
- `stats/activity/{kind}/*.json` — the local admin-UI activity log (e.g.
|
||||
announcement-banner dismissals), verbatim, one file per recorded event
|
||||
|
||||
Having both the structured JSON and the raw XML lets you compare what the
|
||||
service serves via HTTP against what is actually stored on disk.
|
||||
@@ -31,6 +33,24 @@ secrets, Spotify refresh tokens. The raw XML files are included as-is.
|
||||
|
||||
---
|
||||
|
||||
## Local activity log
|
||||
|
||||
AfterTouch records a small local activity log for admin-UI actions —
|
||||
today, just announcement-banner dismissals (e.g. the admin-area-gate notice
|
||||
from issue #419) — under `stats/activity/{kind}/` in the data directory.
|
||||
Each event is its own plain JSON file (id, timestamp, and any detail),
|
||||
readable with a text editor; there is no encoding or opaque format to
|
||||
decode.
|
||||
|
||||
This follows the same "[all data stays on your
|
||||
network](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)" principle as the rest of
|
||||
AfterTouch: nothing here is ever transmitted automatically. The only way it
|
||||
leaves the operator's network is the same as everything else in this
|
||||
document — an explicitly-triggered diagnostic export, which the operator
|
||||
has to click a button and choose to send.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer setup (one-time)
|
||||
|
||||
> This section is for the project maintainer only.
|
||||
|
||||
@@ -7,11 +7,11 @@ sidebar:
|
||||
|
||||
## Overview
|
||||
|
||||
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-web UI, the CLI, or the Go library.
|
||||
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-player UI, the CLI, or the Go library.
|
||||
|
||||
## Via soundtouch-web (browser UI)
|
||||
## Via soundtouch-player (browser UI)
|
||||
|
||||
**soundtouch-web** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
|
||||
**soundtouch-player** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
|
||||
|
||||
### ★ Star button — save from Now Playing
|
||||
|
||||
|
||||
@@ -3,13 +3,31 @@ title: "Unimplemented SoundTouch API Endpoints"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
**Last Updated:** January 2026
|
||||
**Last Updated:** June 2026 (reconciled against `pkg/client`)
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Current Implementation:** 35 endpoints (including preset & navigation management discovered via SoundTouch Plus Wiki)
|
||||
**Current Implementation:** ~41 endpoints in `pkg/client` (see reconciliation note)
|
||||
**Wiki Documentation:** 87 endpoints
|
||||
**Implementation Gap:** 52 endpoints
|
||||
**Implementation Gap:** ~46 endpoints
|
||||
|
||||
This document provides comprehensive information about SoundTouch API endpoints documented in the community wiki but not yet implemented in this Go library. All examples are based on real device responses and extensive community testing.
|
||||
This document covers SoundTouch **device** WebServices API endpoints (the
|
||||
speaker's local `:8090` API consumed by `pkg/client`) documented in the community
|
||||
wiki but not yet implemented. It is **not** about the cloud-service router
|
||||
(`cmd/soundtouch-service`); for that surface see the contract checklist
|
||||
`tests/integration/http-client/COVERAGE.md`. Examples are based on real device
|
||||
responses and community testing.
|
||||
|
||||
> **Reconciliation note (June 2026).** Verified against `pkg/client`. Since the
|
||||
> last update these are **now implemented** and have been re-marked below:
|
||||
> `setMusicServiceAccount` / `removeMusicServiceAccount` (`SetMusicServiceAccount`,
|
||||
> `RemoveMusicServiceAccount`), the full stereo-pair group set
|
||||
> `getGroup` / `addGroup` / `removeGroup` / `updateGroup`
|
||||
> (`GetGroup`, `AddGroup`, `RemoveGroup`, `UpdateGroup`), and
|
||||
> `listMediaServers` (`ListMediaServers`, with app-side SSDP in `pkg/discovery`).
|
||||
> The priority-matrix counts further down are historical and have not all been
|
||||
> recomputed; trust the per-endpoint ✅ markers over the section totals.
|
||||
> Endpoints still listed as candidates (e.g. `/search`, `/standby`,
|
||||
> `/powerManagement`, `/bluetoothInfo`, `/language`) were confirmed absent from
|
||||
> `pkg/client` (some appear only in test fixtures).
|
||||
|
||||
---
|
||||
|
||||
@@ -69,12 +87,15 @@ Specialized hardware-specific features.
|
||||
- CLI command: `preset select --slot <1-6>`
|
||||
- Alternative: Direct key commands (`SendKey("PRESET_1")` etc.)
|
||||
|
||||
### Music Service Management
|
||||
### ~~Music Service Management~~ ✅ **IMPLEMENTED**
|
||||
Critical for streaming service integration.
|
||||
|
||||
#### POST /setMusicServiceAccount 🔥 **CRITICAL**
|
||||
#### ~~POST /setMusicServiceAccount~~ ✅ **IMPLEMENTED**
|
||||
Adds a music service account to the sources list.
|
||||
|
||||
**Status:** **COMPLETE** - `pkg/client` exposes `SetMusicServiceAccount(...)`
|
||||
(and `SetMusicServiceOAuthAccount(...)` for OAuth sources like Spotify/Amazon).
|
||||
|
||||
**Request Examples:**
|
||||
|
||||
Pandora Service:
|
||||
@@ -111,9 +132,11 @@ NAS Music Library:
|
||||
- Note the `/0` suffix for STORED_MUSIC user names
|
||||
- Spotify requires PREMIUM account for most operations
|
||||
|
||||
#### POST /removeMusicServiceAccount 🔥 **CRITICAL**
|
||||
#### ~~POST /removeMusicServiceAccount~~ ✅ **IMPLEMENTED**
|
||||
Removes an existing music service account.
|
||||
|
||||
**Status:** **COMPLETE** - `pkg/client` exposes `RemoveMusicServiceAccount(...)`.
|
||||
|
||||
**Request Examples:**
|
||||
|
||||
Remove Pandora:
|
||||
@@ -272,8 +295,10 @@ Rates currently playing media (Pandora only).
|
||||
|
||||
|
||||
|
||||
#### GET /listMediaServers 🔥 **CRITICAL**
|
||||
Returns detected UPnP/DLNA media servers.
|
||||
#### ~~GET /listMediaServers~~ ✅ **IMPLEMENTED**
|
||||
~~Returns detected UPnP/DLNA media servers.~~
|
||||
|
||||
**Implementation Status:** ✅ Complete - Available in `pkg/client/client.go` as `ListMediaServers()`; response model in `pkg/models/mediaservers.go` as `ListMediaServersResponse`. The CLI exposes this via `soundtouch-cli library servers --via-speaker`. App-side SSDP discovery (without `--via-speaker`) is in `pkg/discovery`.
|
||||
|
||||
**Response Example:**
|
||||
```xml
|
||||
@@ -630,9 +655,12 @@ Selects LOCAL source (only way to select LOCAL on some devices).
|
||||
<status>/selectLocalSource</status>
|
||||
```
|
||||
|
||||
### Group Management (ST-10 Stereo Pairs Only)
|
||||
### ~~Group Management (ST-10 Stereo Pairs Only)~~ ✅ **IMPLEMENTED**
|
||||
|
||||
#### GET /getGroup 📊 **MEDIUM**
|
||||
**Status:** **COMPLETE** - the full stereo-pair set is implemented in `pkg/client`:
|
||||
`GetGroup()`, `AddGroup()`, `RemoveGroup()`, `UpdateGroup()`.
|
||||
|
||||
#### ~~GET /getGroup~~ ✅ **IMPLEMENTED**
|
||||
Gets current stereo pair configuration.
|
||||
|
||||
**Response Example (paired):**
|
||||
@@ -662,7 +690,7 @@ Gets current stereo pair configuration.
|
||||
<group />
|
||||
```
|
||||
|
||||
#### POST /addGroup 📊 **MEDIUM**
|
||||
#### ~~POST /addGroup~~ ✅ **IMPLEMENTED**
|
||||
Creates new stereo pair group.
|
||||
|
||||
**Request Example:**
|
||||
@@ -688,7 +716,7 @@ Creates new stereo pair group.
|
||||
**Response:** Same as GET /getGroup
|
||||
**WebSocket Event:** `groupUpdated` sent to both devices
|
||||
|
||||
#### GET /removeGroup 📊 **MEDIUM**
|
||||
#### ~~GET /removeGroup~~ ✅ **IMPLEMENTED**
|
||||
Removes existing stereo pair group.
|
||||
|
||||
**Response:**
|
||||
@@ -698,7 +726,7 @@ Removes existing stereo pair group.
|
||||
|
||||
**WebSocket Event:** `groupUpdated` sent to both devices
|
||||
|
||||
#### POST /updateGroup 📊 **MEDIUM**
|
||||
#### ~~POST /updateGroup~~ ✅ **IMPLEMENTED**
|
||||
Updates stereo pair group name.
|
||||
|
||||
**Request Example:**
|
||||
@@ -982,8 +1010,8 @@ func TestDeviceCompatibility(t *testing.T) {
|
||||
|
||||
### Phase 1: Essential Features (4 weeks)
|
||||
1. ✅ **Preset Management**: ~~`storePreset`, `removePreset`, `selectPreset`~~ (IMPLEMENTED)
|
||||
2. **Music Services**: `setMusicServiceAccount`, `removeMusicServiceAccount`
|
||||
3. ✅ **Content Discovery**: ~~`navigate`, `search`~~ (IMPLEMENTED), `recents`
|
||||
2. ✅ **Music Services**: ~~`setMusicServiceAccount`, `removeMusicServiceAccount`~~ (IMPLEMENTED)
|
||||
3. ✅ **Content Discovery**: ~~`navigate`~~ (IMPLEMENTED), `search`, `recents`
|
||||
4. ✅ **Station Management**: ~~`searchStation`, `addStation`, `removeStation`~~ (IMPLEMENTED)
|
||||
5. **Enhanced Controls**: `userPlayControl`, `userRating`
|
||||
|
||||
@@ -991,12 +1019,12 @@ func TestDeviceCompatibility(t *testing.T) {
|
||||
1. **Power Management**: `standby`, `powerManagement`, `lowPowerStandby`
|
||||
2. **Notifications**: `speaker`, `playNotification`
|
||||
3. **Network Management**: `performWirelessSiteSurvey`, `addWirelessProfile`
|
||||
4. **System Info**: ~~`serviceAvailability`~~ (✅ implemented), `listMediaServers`, `language`
|
||||
4. **System Info**: ~~`serviceAvailability`~~ (✅ implemented), ~~`listMediaServers`~~ (✅ implemented), `language`
|
||||
|
||||
### Phase 3: Advanced Features (3 weeks)
|
||||
1. **Bluetooth**: `enterBluetoothPairing`, `clearBluetoothPaired`
|
||||
2. **Software Updates**: `swUpdateCheck`, `swUpdateQuery`
|
||||
3. **Stereo Pairs**: `getGroup`, `addGroup`, `removeGroup`, `updateGroup`
|
||||
3. ✅ **Stereo Pairs**: ~~`getGroup`, `addGroup`, `removeGroup`, `updateGroup`~~ (IMPLEMENTED)
|
||||
4. **Source Shortcuts**: `selectLastSource`, `selectLastSoundTouchSource`
|
||||
|
||||
### Phase 4: Specialized Features (2 weeks)
|
||||
|
||||
@@ -22,7 +22,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
Name string // From UPnP friendlyName
|
||||
Host string // IP address
|
||||
Port int // Usually 8090
|
||||
ModelID string // From UPnP modelName
|
||||
ModelID string // From UPnP modelName
|
||||
SerialNo string // MAC address from UPnP
|
||||
UPnPLocation string // Device description URL
|
||||
UPnPUSN string // Unique service name
|
||||
@@ -66,7 +66,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Service as SoundTouch Service
|
||||
participant UPnP as UPnP Discovery
|
||||
participant UPnP as UPnP Discovery
|
||||
participant mDNS as mDNS Discovery
|
||||
participant Device as SoundTouch Device
|
||||
participant DataStore as Data Store
|
||||
@@ -76,28 +76,28 @@ sequenceDiagram
|
||||
|
||||
Service->>UPnP: Start SSDP Discovery
|
||||
Service->>mDNS: Start mDNS Discovery
|
||||
|
||||
|
||||
UPnP->>UPnP: Send M-SEARCH multicast
|
||||
Device->>UPnP: Respond with location URL
|
||||
UPnP->>Device: Fetch device description XML
|
||||
Device->>UPnP: Return basic device info
|
||||
|
||||
|
||||
mDNS->>mDNS: Query _soundtouch._tcp
|
||||
Device->>mDNS: Respond with service info
|
||||
|
||||
|
||||
Service->>Service: Merge discovery results
|
||||
Service->>Device: GET /info (enrich data)
|
||||
Device->>Service: Return detailed device info
|
||||
Service->>DataStore: Store discovered device
|
||||
|
||||
|
||||
Note over User,DataStore: User Registration
|
||||
User->>Service: POST /account/{id}/devices
|
||||
Note right of User: deviceId + user-friendly name
|
||||
Service->>DataStore: Link device to account
|
||||
|
||||
|
||||
Note over Service,DataStore: Migration Process
|
||||
Service->>Device: GET /info (device identification)
|
||||
Device->>Service: Return device details
|
||||
Device->>Service: Return device details
|
||||
Service->>Service: Build migration summary
|
||||
Service->>Device: Apply configuration changes
|
||||
```
|
||||
@@ -116,7 +116,7 @@ The system has distinct phases where device information is collected and enhance
|
||||
**Endpoint**: `POST /streaming/account/{accountId}/devices`
|
||||
**Request Format**:
|
||||
```xml
|
||||
<device deviceid="08DF1F0BA325">
|
||||
<device deviceid="AABBCCDDEE0A">
|
||||
<name>Living Room Speaker</name>
|
||||
</device>
|
||||
```
|
||||
@@ -199,25 +199,25 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
|
||||
|
||||
### Data Completeness Comparison
|
||||
|
||||
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|
||||
|------------|----------------|-------------|----------------|
|
||||
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
|
||||
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
|
||||
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
|
||||
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
|
||||
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
|
||||
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
|
||||
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
|
||||
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
|
||||
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
|
||||
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
|
||||
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
|
||||
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|
||||
|-----------------------|----------------------|--------------------|-------------------|
|
||||
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
|
||||
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
|
||||
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
|
||||
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
|
||||
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
|
||||
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
|
||||
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
|
||||
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
|
||||
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
|
||||
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
|
||||
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
|
||||
|
||||
### Enhancement Benefits
|
||||
|
||||
#### 1. Network Independence
|
||||
- ✅ Works across internet/WAN connections
|
||||
- ✅ No multicast/broadcast requirements
|
||||
- ✅ No multicast/broadcast requirements
|
||||
- ✅ Firewall/NAT friendly
|
||||
- ✅ Supports remote device management
|
||||
|
||||
@@ -246,21 +246,21 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
// Fallback to existing discovery
|
||||
return s.fallbackToDiscovery(r.RemoteAddr)
|
||||
}
|
||||
|
||||
|
||||
// Extract device information
|
||||
deviceMAC := powerOnData.Device.ID
|
||||
deviceIP := powerOnData.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
|
||||
// Lookup existing device data
|
||||
deviceInfo := s.lookupDeviceByMAC(deviceMAC)
|
||||
if deviceInfo == nil {
|
||||
// New device - trigger registration flow
|
||||
deviceInfo = s.createDeviceFromPowerOn(powerOnData)
|
||||
}
|
||||
|
||||
|
||||
// Update with power_on data
|
||||
s.updateDeviceFromPowerOn(deviceInfo, powerOnData)
|
||||
|
||||
|
||||
// Determine response actions
|
||||
response := s.buildPowerOnResponse(deviceInfo)
|
||||
s.sendResponse(w, response)
|
||||
@@ -280,7 +280,7 @@ Address missing data through complementary mechanisms:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Device as SoundTouch Device
|
||||
participant Service as SoundTouch Service
|
||||
participant Service as SoundTouch Service
|
||||
participant DataStore as Data Store
|
||||
participant User as User/App
|
||||
|
||||
@@ -293,7 +293,7 @@ sequenceDiagram
|
||||
alt Device Unknown
|
||||
Service->>DataStore: Create device record
|
||||
Service->>User: Notify new device found
|
||||
else Device Known
|
||||
else Device Known
|
||||
Service->>DataStore: Update device status
|
||||
end
|
||||
Service->>Device: Configuration response
|
||||
@@ -360,11 +360,11 @@ type Migration struct {
|
||||
|
||||
### Immediate Actions (Phase 1)
|
||||
1. **Enhance `/power_on` handler** to extract and store comprehensive device data
|
||||
2. **Implement device lookup by MAC address** as primary identification method
|
||||
2. **Implement device lookup by MAC address** as primary identification method
|
||||
3. **Create hybrid discovery system** using both `/power_on` and existing methods
|
||||
4. **Add network-independent device management** capabilities
|
||||
|
||||
### Medium-term Improvements (Phase 2)
|
||||
### Medium-term Improvements (Phase 2)
|
||||
1. **Implement account-device MAC mapping** for automatic association
|
||||
2. **Add IP geolocation** for regional settings inference
|
||||
3. **Create device registration UI** optimized for `/power_on` discovered devices
|
||||
@@ -372,7 +372,7 @@ type Migration struct {
|
||||
|
||||
### Long-term Enhancements (Phase 3)
|
||||
1. **Request firmware enhancement** to include missing data in `/power_on`
|
||||
2. **Implement real-time device monitoring** via `/power_on` events
|
||||
2. **Implement real-time device monitoring** via `/power_on` events
|
||||
3. **Create centralized device management** independent of network topology
|
||||
4. **Add predictive migration** based on device status patterns
|
||||
|
||||
@@ -387,8 +387,8 @@ type Migration struct {
|
||||
The `/power_on` endpoint provides a significant opportunity to reduce network dependencies while enhancing device management capabilities. By implementing a hybrid approach that leverages `/power_on` data for primary device identification and status updates while maintaining existing registration workflows for user-controlled metadata, the system can achieve:
|
||||
|
||||
- **Network independence** for core device management
|
||||
- **Enhanced real-time capabilities** through device-initiated communication
|
||||
- **Enhanced real-time capabilities** through device-initiated communication
|
||||
- **Improved scalability** across diverse network topologies
|
||||
- **Better user experience** with automatic device discovery and status updates
|
||||
|
||||
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
|
||||
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: "soundtouch-web: remaining features"
|
||||
title: "soundtouch-player: remaining features"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
Four features complete the parity gap between soundtouch-web and the Stockholm
|
||||
Four features complete the parity gap between soundtouch-player and the Stockholm
|
||||
app's local-control functionality. Everything else in Stockholm (OAuth flows,
|
||||
setup wizard, service account linking, onboarding, analytics) is cloud
|
||||
infrastructure that is either shut down or already handled by soundtouch-service.
|
||||
@@ -48,7 +48,7 @@ func (c *Client) Seek(positionSeconds int) error {
|
||||
> **Note:** This section is about the speaker's **built-in** `/favorites` API —
|
||||
> a separate concept from the 6 preset slots. Preset-slot saving (★ star /
|
||||
> **+** button) is already shipped; the native Favorites API is not yet
|
||||
> surfaced in soundtouch-web.
|
||||
> surfaced in soundtouch-player.
|
||||
|
||||
Mark or unmark the currently playing track as a device favourite directly from
|
||||
the Now Playing card. Unlike presets (maximum 6, numbered slots), the device
|
||||
@@ -103,7 +103,7 @@ rename and network/firmware info.
|
||||
|
||||
## 4. Render stereo pairs as a single device
|
||||
|
||||
Today soundtouch-web shows the two halves of a stereo pair (formed via
|
||||
Today soundtouch-player shows the two halves of a stereo pair (formed via
|
||||
`/addGroup` — see issue #252) as independent entries in the device list. The
|
||||
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
|
||||
presentation closes the perception gap BirdyBA flagged at
|
||||
@@ -139,7 +139,7 @@ end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
|
||||
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
|
||||
against the fake speaker's group routes
|
||||
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
|
||||
presentation in soundtouch-web's device list — no protocol work required.
|
||||
presentation in soundtouch-player's device list — no protocol work required.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
---
|
||||
title: "API Route Layout and Refactoring Plan"
|
||||
---
|
||||
|
||||
> **Tracking issue:** [#451 "Merge soundtouch-player into soundtouch-service"](https://github.com/gesellix/Bose-SoundTouch/issues/451).
|
||||
> This document is the architectural reference for the staged API refactoring
|
||||
> that precedes (and enables) that merge.
|
||||
|
||||
## Why this exists
|
||||
|
||||
`soundtouch-service` and `soundtouch-player` are two binaries with two routers.
|
||||
We want to:
|
||||
|
||||
1. Restructure our own routes into a layout that can stay stable.
|
||||
2. Eventually fold `soundtouch-player` into `soundtouch-service` (one binary).
|
||||
3. Stop leaking frontend (SPA) routes into the backend API.
|
||||
4. Make **cloud / remote-host a first-class, clean deployment**, not just LAN /
|
||||
on-device. This is a primary motivation: we consolidate the API *in a way
|
||||
that* closes the trust and auth gaps a public deployment exposes, rather than
|
||||
just merging two binaries. Enforced auth is therefore a real requirement, not
|
||||
an afterthought.
|
||||
|
||||
Before moving anything, every route has to be classified by **whether we are
|
||||
free to move it**, and that depends on **who the client is**. A route the
|
||||
speaker firmware calls is frozen forever; an internal admin route is ours to
|
||||
reshape.
|
||||
|
||||
## Classification criteria
|
||||
|
||||
Classify by client audience, then by what pins the path:
|
||||
|
||||
| Category | Client | Free to move? |
|
||||
|------------------------------------|-----------------------------------|----------------------------------------------------------------------------------------------------------------|
|
||||
| **(1a) Frozen, firmware-pinned** | Speaker firmware | No, ever. The path is hardcoded in the speaker (or relative to a base it fetches from us). |
|
||||
| **(1b) Frozen, externally-pinned** | OAuth providers (Spotify/Amazon) | Only with provider re-registration + device re-priming. Treat as frozen unless that cost is paid deliberately. |
|
||||
| **(2) Service-internal** | The admin/setup UI | Yes, freely. These are ours. |
|
||||
| **(3) Web/control** | The control UI (soundtouch-player) | Yes, freely. |
|
||||
| **(4) Frontend (SPA)** | Browser, client-side routing | Should not be enumerated in the backend at all (see `/app/*` below). |
|
||||
| **(Infra)** | Humans, monitoring, the SPA shell | Conventionally stable; collision-prone at merge time. |
|
||||
|
||||
Two refinements that matter in practice:
|
||||
|
||||
- **"Must stay" is not one thing.** (1a) is immovable; (1b) is movable but
|
||||
coordinated. Do not lump OAuth callbacks in with firmware paths.
|
||||
- **The merge-overlap bucket is smaller than it looks.** Verified against the
|
||||
two routers, only **`/` is a true collision** (service `HandleRoot` vs the web
|
||||
app's `serveIndex`); resolve it with a small **landing page** at `/` that lets
|
||||
the user pick Admin/Setup (service) or the App (web). **`/health` is a merge,
|
||||
not a clash** (both define it; standardise on the service's richer body, which
|
||||
carries version + timestamp, and confirm nothing depends on the web's
|
||||
`{"status":"ok","version"}` shape). **`/ws` and `/static/*` do not collide at
|
||||
all** — the service registers neither, so bringing the web's in is purely
|
||||
additive. TuneIn is **not** in this bucket either: `/bmx/tunein/*` (speaker <->
|
||||
BMX integration, frozen) and `/api/tunein/*` (the player's generalized radio
|
||||
search/play, ours to change) are two different layers.
|
||||
|
||||
**Resolve overlaps structurally, before merging, not behind a flag.** A
|
||||
conditional "only register the web routes when opt-in is on" does not fix a
|
||||
collision — it just hides it while the flag is off, and the double-registration
|
||||
returns when it's on. Do not rely on chi to detect or warn about it. Clean up
|
||||
`/` (and the `/health` merge) up front so the merged router is unambiguous
|
||||
regardless of the flag. The opt-in (below) exists only to let people optionally
|
||||
run the merged variant and give feedback, not as a collision guard.
|
||||
|
||||
## What pins the frozen routes (evidence)
|
||||
|
||||
- The speaker fetches BMX content, marge/streaming data, software updates, and
|
||||
CED config from hostnames it has hardcoded (or from a base URL we hand it).
|
||||
`/ced/*` mirrors `downloads.bose.com/ced/soundtouch/...`; `/bmx`, `/core02`,
|
||||
`/streaming`, `/accounts`, `/customer`, `/oauth`, `/v1` mirror the Bose cloud
|
||||
contract.
|
||||
- Persisted device data embeds absolute service URLs. Presets store
|
||||
`LOCAL_INTERNET_RADIO`/Orion locations like
|
||||
`https://.../core02/svc-bmx-adapter-orion/prod/orion/station?data=...`, and
|
||||
the BMX registry advertises `{MEDIA_SERVER}/media` and `/bmx-icons`. So
|
||||
`/media`, `/bmx-icons`, `/custom`, and `/core02` are effectively part of the
|
||||
firmware-facing contract: a speaker that stored a preset will replay that
|
||||
exact URL later. They cannot move without rewriting persisted state on every
|
||||
device.
|
||||
|
||||
## Service routes (`soundtouch-service`)
|
||||
|
||||
Grouped by prefix. The authoritative enumerated list is the router golden file
|
||||
`cmd/soundtouch-service/testdata/router_routes.txt`.
|
||||
|
||||
| Prefix | Category | Client | Movable? |
|
||||
|----------------------------------------------------------------------------------------------------|-----------------------|---------------------------------------|------------------------------------------|
|
||||
| `/streaming/*` | (1a) frozen | Speaker (marge / streaming.bose.com) | No |
|
||||
| `/accounts/*` | (1a) frozen | Speaker (marge, alternate paths) | No |
|
||||
| `/customer/account/*` | (1a) frozen | Speaker | No |
|
||||
| `/bmx/*` (registry + tunein) | (1a) frozen | Speaker (BMX) | No |
|
||||
| `/core02/svc-bmx-adapter-*` (Orion, SiriusXM) | (1a) frozen | Speaker (BMX adapters) | No |
|
||||
| `/oauth/*/token`<br>`/oauth/*/token/cs`<br>`/oauth/*/token/cs1`<br>`/oauth/*/token/cs3` | (1a) frozen | Speaker (music tokens) | No |
|
||||
| `/custom/v1/playback/*` | (1a) frozen | Speaker (LOCAL_INTERNET_RADIO / ding) | No |
|
||||
| `/bmx-icons/*`<br>`/media/*`<br>`/media/aftertouch-ding.wav`<br>`/media/tts/*` | (1a) frozen | Speaker (advertised base) | No |
|
||||
| `/streaming/resources/api_versions.xml`<br>`/streaming/software/update/*`<br>`/updates/soundtouch` | (1a) frozen | Speaker (SW update) | No |
|
||||
| `/v1/auth`<br>`/v1/blacklist/*`<br>`/v1/scmudc/*`<br>`/v1/stapp/*` | (1a) frozen | Speaker | No |
|
||||
| `/alexa/certificate` | (1a) frozen | Speaker / AWS | No |
|
||||
| `/ced/*` | (1a) frozen | Speaker (mirrors downloads.bose.com) | No |
|
||||
| `/mgmt/amazon/callback`<br>`/mgmt/spotify/callback` | (1b) frozen, external | OAuth providers | Only with re-registration |
|
||||
| `/setup/*` (~40 routes) | (2) service-internal | Admin UI | Yes |
|
||||
| `/mgmt/*` (except the callbacks above) | (2) service-internal | Admin UI | Yes |
|
||||
| `/web/*` (`HandleWeb`) | (4) frontend | Browser (admin SPA) | Yes; already the clean catch-all pattern |
|
||||
| `/`<br>`/docs/*`<br>`/favicon.ico`<br>`/health` | (Infra) | Humans / monitoring | Keep stable by convention |
|
||||
|
||||
## Web routes (`soundtouch-player`)
|
||||
|
||||
Defined in `pkg/service/soundtouchweb/mount.go`. Not currently mounted inside
|
||||
the service; it is a separate binary.
|
||||
|
||||
| Group | Category | Note |
|
||||
|------------------------------------------------------------------------------------------|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `/api/*` (devices, control, tunein, zone, radiobrowser, play-url, device-speak) | (3) web/control | Freely restructurable |
|
||||
| `/health`<br>`/static/*`<br>`/ws` | (Infra) | `/health` is a merge (standardise on the service's body); `/static/*` and `/ws` are additive (the service registers neither) |
|
||||
| `/`<br>`/device/*`<br>`/devices`<br>`/playurl`<br>`/radiobrowser`<br>`/tts`<br>`/tunein` | (4) frontend | `/` is the one true collision (-> landing page); the rest move under `/app/*`. The anti-pattern: each SPA route enumerated in the backend, all serving `index.html` |
|
||||
|
||||
## Deployment scenarios, reachability, and trust boundaries
|
||||
|
||||
The client-audience axis tells you *who* calls a route. The deployment tells you
|
||||
whether that caller can actually reach it and whether the surrounding network
|
||||
can be trusted. AfterTouch runs in materially different places, and that decides
|
||||
which routes are even *meaningful* and what the trust boundary is.
|
||||
|
||||
### Actors (the original Bose model)
|
||||
|
||||
The original Bose architecture had three actors, and our route surface still
|
||||
reflects all three:
|
||||
|
||||
| Actor | Where | Role |
|
||||
|---------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Speaker | Local (the device) | Calls the cloud for its data-plane (`/full`, presets, sources, software update, tokens) and is provisioned by the app. |
|
||||
| App | Local (phone / desktop), **in-between** | Creates the account, adds a speaker to an account, and teaches the speaker its cloud/marge credentials. **Authenticates itself** to the cloud. |
|
||||
| Cloud | External / public (what AfterTouch replaces) | Serves the speaker data-plane and the app's account/provisioning calls. |
|
||||
|
||||
Two things matter for our design:
|
||||
|
||||
- **The app is deployment-agnostic.** It does not care whether the cloud (our
|
||||
service) runs locally or in a datacenter; it talks to whatever cloud endpoint
|
||||
it is pointed at. So the **deployment modes below are about where the *cloud*
|
||||
role runs**, orthogonal to the app actor.
|
||||
- **AfterTouch's own tooling currently plays the app's role.** Account creation
|
||||
and "teach the speaker its marge account" are done by our migration tooling
|
||||
(today via the speaker's local WebSocket `setMargeAccount`), i.e. we are the
|
||||
provisioning agent. But the app-facing *cloud* endpoints still exist in the
|
||||
surface (account create/login, add device, profile, password, groups), and a
|
||||
real app pointed at us would use them. They are part of the frozen contract,
|
||||
but their caller and trust story differ from the speaker's data-plane (see
|
||||
below).
|
||||
|
||||
### Deployment topologies (where the cloud role runs)
|
||||
|
||||
This is descriptive (where it runs), distinct from the `deployment-mode`
|
||||
*parameter* below (the security posture). They correlate but are kept separate so
|
||||
an operator is not locked into one because of the other.
|
||||
|
||||
| Topology | Where | Reaches speakers directly? | Speaker reaches it? |
|
||||
|---------------------|-------------------------------------------|----------------------------|-------------------------------------------|
|
||||
| On-device | On the speaker itself | Itself only | Yes (loopback / LAN) |
|
||||
| LAN host | Raspberry Pi / Docker on the home network | Yes (same LAN) | Yes |
|
||||
| Cloud / remote host | External host, not on the speaker LAN | No | Yes (speaker calls out over the internet) |
|
||||
|
||||
### Two planes: speaker-direct vs data-plane
|
||||
|
||||
Routes fall into two reachability planes that behave very differently across
|
||||
deployments:
|
||||
|
||||
- **Speaker-direct (control plane):** the service opens a connection *to* the
|
||||
speaker's local API (`:8090`) right now. Discovery, migration, reboot,
|
||||
test-connection, peer-probe, and the entire `soundtouch-player`
|
||||
control/zone/volume/key/TTS-to-speaker surface. These only work where the host
|
||||
shares the LAN with the speaker. **In a cloud deployment they are dead weight**,
|
||||
and any UI that shows them is misleading.
|
||||
- **Data-plane (cloud replacement):** something calls the *service*, which works
|
||||
in every deployment because the caller reaches in. Two callers live here:
|
||||
- **Speaker-polled:** the speaker fetches its own data (`/full`, sources,
|
||||
presets, recents, provider/device settings, software update, streaming
|
||||
token, stats). No user auth; the speaker is identified by account/device.
|
||||
- **App / provisioning-called:** the app (or, today, our own tooling acting as
|
||||
the app) creates accounts, logs in, adds/updates/removes devices, edits the
|
||||
profile/password, and manages groups. In the original model the app
|
||||
**authenticates itself** here, so these endpoints carry an auth dimension the
|
||||
speaker's polling does not. They are deployment-agnostic: the app reaches the
|
||||
cloud wherever it runs.
|
||||
|
||||
So a cloud deployment is essentially the data-plane (both callers) plus
|
||||
server-side state management (accounts, presets, provider credentials,
|
||||
diagnostics of stored data). The interactive "do something to a speaker now"
|
||||
features (both the player and migration) need LAN proximity.
|
||||
|
||||
Consequence for the migration tooling (ref the #451 discussion): migration is
|
||||
**recurring**, not one-shot (you add a speaker later too), and it is
|
||||
**LAN-bound**. That argues for migration as a local mode/tool you run on the LAN
|
||||
when needed, rather than always-on code in a cloud binary that could never use
|
||||
it.
|
||||
|
||||
### Trust zones and the current state
|
||||
|
||||
The trust zones, mapped to the actors above, and today barely any is guarded:
|
||||
|
||||
| Zone | Routes | Client auth today | Should be |
|
||||
|--------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
|
||||
| Speaker contract | frozen (1a), speaker-polled | None (no user login; the app_key is validated but is not user auth) | None, but network-segmentable; in cloud these are necessarily public so the speaker can reach them |
|
||||
| App / provisioning | account create/login, add/update/remove device, profile, password, groups (`/streaming/account*`, `/customer/account*`) | None enforced (we accept; the app's self-auth from the original model is not required) | Authenticated in cloud: an open provisioning surface lets anyone create accounts or attach devices |
|
||||
| Admin / setup | `/mgmt/*` (non-callback)<br>`/setup/*`<br>`/web/*` | `/mgmt/*` has single-credential HTTP Basic Auth; **`/setup/*` has none** (explicit "LAN-trust" premise); the Basic Auth even leaks behind a proxy (#419) | Authenticated always; mandatory in cloud |
|
||||
| Control / player | `/api/control/*` (post-merge) | None | Optional auth; low blast radius |
|
||||
|
||||
The "LAN-trust" premise is defensible on a home LAN but **invalid in the cloud**:
|
||||
`/setup/*` (migration, DNS redirect, trust roots / cert state, account data,
|
||||
diagnostics, recovery) is wide open, and so is the app/provisioning surface
|
||||
(anyone could create an account or attach a device). On a public host both are a
|
||||
real exposure. Closing these gaps is a prerequisite for treating cloud as a
|
||||
supported deployment.
|
||||
|
||||
### Requirements this drives
|
||||
|
||||
- **Authentication** on everything user-facing, actually enforced (not
|
||||
bypassable behind a proxy, see #419). Mandatory for cloud; offered and
|
||||
recommended for LAN.
|
||||
- **Authorization tiers** by blast radius (the "authority boundary" from the
|
||||
#451 landing-page note): a low-privilege user may open the player, while
|
||||
setup/mgmt (trust roots, migration, accounts) require admin. The landing page
|
||||
is where that boundary is made explicit.
|
||||
- **Deployment-aware surface:** in cloud mode, hide/disable the speaker-direct
|
||||
features (they cannot work) and require auth on the rest; in LAN/on-device
|
||||
mode, expose the full surface.
|
||||
|
||||
These requirements are why the `/api/*` split below is grouped by trust tier:
|
||||
applying an auth (and later authz) middleware to a whole group is a one-liner,
|
||||
whereas per-route auth is what produced today's patchy coverage.
|
||||
|
||||
### The `deployment-mode` parameter (private / shared / public)
|
||||
|
||||
The security posture is an explicit parameter, **default `private`**. It is a
|
||||
preset over the per-tier auth machinery, not separate architecture: each value
|
||||
just sets which trust tiers require auth.
|
||||
|
||||
| Tier (caller) | private | shared | public |
|
||||
|------------------------------|---------------|-----------------------|-----------------|
|
||||
| Speaker contract | none (frozen) | none | none |
|
||||
| Control / player | open | open | **auth** |
|
||||
| Admin / setup + provisioning | open (opt-in) | **auth (min. Basic)** | **auth** |
|
||||
| Speaker-direct features | on | on | hidden/disabled |
|
||||
|
||||
- **private** (default): free-for-all, maximum insecurity, security is opt-in.
|
||||
Matches a trusted single-owner LAN or on-device.
|
||||
- **public**: opt-out of security. Everything user-facing requires auth; the
|
||||
surrounding network is untrusted (cloud / internet). Speaker-direct features
|
||||
are hidden (they cannot work off-LAN anyway).
|
||||
- **shared**: at least Basic Auth on the structural / admin routes, while the
|
||||
player stays open. The multi-user trusted-LAN case (guests, kids, roommates):
|
||||
daily playback without a login, but infrastructure is protected.
|
||||
|
||||
The **Speaker-direct features** row is UI gating, not auth. "Speaker-direct"
|
||||
means actions that reach a speaker on the LAN (discover, migrate, reboot,
|
||||
volume/play, zone). In `public` the UI hides or disables them because they cannot
|
||||
work off-LAN, so we do not show buttons that only fail; in `private` / `shared`
|
||||
they are shown. The UI derives this from the mode.
|
||||
|
||||
**Provisioning is treated like admin**, not like the player: creating accounts
|
||||
and attaching devices is structural / high-blast-radius, so it shares the admin
|
||||
trust tier (protected in `shared` and `public`). The mechanism may still be Marge
|
||||
self-auth, but the *requirement* matches admin.
|
||||
|
||||
It is a **monotone ladder**: private -> shared adds admin (+ provisioning) auth;
|
||||
shared -> public additionally locks the player.
|
||||
|
||||
**Is `shared` necessary, or just `public`?** It is necessary and distinct. The
|
||||
only difference between shared and public is the **player tier**: shared keeps it
|
||||
open (trusted network, frictionless household use), public locks it (untrusted
|
||||
network). Collapsing them forces either a password on the daily-use player at
|
||||
home, or an open player on the internet. The cost of keeping `shared` is near
|
||||
zero once tier-based auth exists (it is just "admin required, player optional"),
|
||||
so it earns its place as the trusted-LAN-with-privilege-split preset.
|
||||
|
||||
Note this 3-value enum compresses two orthogonal axes, network trust (private /
|
||||
shared = trusted; public = untrusted) and the player/admin privilege split. That
|
||||
is a deliberate usability simplification over a toggle matrix; if the presets
|
||||
ever feel too coarse, the underlying per-tier toggles are the escape hatch.
|
||||
|
||||
**Configuration and lockout-safety.** `deployment-mode` is set like the other
|
||||
config: CLI flag, env var, and persisted setting, with the same precedence as
|
||||
the rest (e.g. like `server-url`). Because of that, the host operator always has
|
||||
an out-of-band path: even if a mode change in the UI would lock them out, they
|
||||
can reset it via env / flag / the settings file on the host. The service must
|
||||
also not let a setting strand its owner: if a mode requires auth but no
|
||||
credential / provider is configured yet, warn and keep a way in (refuse to apply,
|
||||
fall back, or allow a loopback/on-host admin bypass) rather than hard-locking the
|
||||
admin surface.
|
||||
|
||||
### Auth posture: opt-none -> opt-in -> opt-out?
|
||||
|
||||
The maturity path over releases, which the `deployment-mode` parameter then
|
||||
expresses per posture:
|
||||
|
||||
- **Today: "opt-none".** Auth is not even opt-in. `/mgmt` has a single Basic-Auth
|
||||
credential (and it leaks behind a proxy, #419); `/setup` and the
|
||||
app/provisioning surface have nothing. There is effectively no usable way to
|
||||
turn real auth on. This is `private` before `private` is even a choice.
|
||||
- **0.x: prepare opt-in.** Make auth something an operator *can* enable
|
||||
(enforced, not proxy-bypassable; covering the whole admin tier, ideally the
|
||||
provisioning surface too) and introduce the `deployment-mode` parameter so
|
||||
`shared` / `public` become selectable. The default stays `private` (security
|
||||
off) so existing LAN setups are undisturbed.
|
||||
- **1.x: default still `private`?** The parameter exists, but whether the
|
||||
shipped default should ever move off `private` is the open call. A cloud-first
|
||||
stance argues for stricter defaults; the home-LAN majority argues for keeping
|
||||
`private`. Because the posture is now an explicit parameter, the default can
|
||||
stay `private` while operators opt into `shared` / `public`, so there is no
|
||||
need for a hard global flip.
|
||||
|
||||
### Auth mechanisms
|
||||
|
||||
Three identities, three mechanisms, only the last two are ours to shape:
|
||||
|
||||
- **Speaker -> data-plane: fixed, not ours to change.** The speaker authenticates
|
||||
with a long-lived **Marge account token**, provisioned as an account ID + auth
|
||||
token (`SetMargeAccount(accountID, authToken)`,
|
||||
`pkg/service/setup/init_plan.go`); it is *not* given an email/password. This is
|
||||
part of the frozen contract, so no new auth mechanism can be imposed on the
|
||||
speaker.
|
||||
- **Admin -> admin UI: HTTP Basic Auth to start, pluggable later.** We begin with
|
||||
Basic Auth as the single admin mechanism, but structure it behind one boundary
|
||||
so additional providers (OIDC, etc.) are easy to add. None of this ever reaches
|
||||
a speaker; it is purely our app's auth.
|
||||
- **User -> web app: Marge auth, delegated.** A human (not a speaker) signs into
|
||||
the player/control UI with their Marge account, and that authentication
|
||||
**delegates to the existing Marge routes** (`/streaming/account/login` and the
|
||||
app/provisioning surface). "User auth" thus reuses the same account the speaker
|
||||
belongs to, rather than a separate user store.
|
||||
- **Native / non-browser clients (CLI, desktop or mobile app, automation) ->
|
||||
service.** A whole client class, not just the CLI. Talking to a *speaker's*
|
||||
local API needs no service auth; talking to *our service*
|
||||
(cloud/admin/provisioning routes) makes them authenticated clients. Interactive
|
||||
native clients do OIDC the standard way (RFC 8252, "OAuth 2.0 for Native
|
||||
Apps"): a loopback `localhost:<port>` redirect (CLI / desktop) or a private-use
|
||||
URI-scheme redirect (`app://callback`, mobile); the system browser runs the
|
||||
flow and the client exchanges the code for a token. The case that still needs a
|
||||
**non-interactive** credential (issued token / API key, or a device-code /
|
||||
client-credentials grant) is **headless** automation: CI, scripts, no browser.
|
||||
Requirements on the provider abstraction: (a) support both an interactive path
|
||||
(browser, including native loopback / custom-scheme redirects) and a headless
|
||||
token path, and (b) allow registering those redirect URIs (the same
|
||||
externally-pinned concern as the Spotify/Amazon callbacks).
|
||||
|
||||
**Mental model: Marge is an auth provider, like EntraID would be.** The UI auth
|
||||
sits behind one provider abstraction, and Marge is simply one provider
|
||||
implementation (the built-in / legacy one) alongside Basic Auth and future OIDC
|
||||
providers (EntraID, Google, ...). "Sign in with your Marge account" is the same
|
||||
pattern as "Sign in with EntraID": the app delegates to the provider. Basic Auth,
|
||||
Marge, and any OIDC provider all implement the same interface, so they are
|
||||
interchangeable and additive.
|
||||
|
||||
Design rule: keep the UI auth pluggable behind that single provider boundary so
|
||||
new providers slot in without touching the speaker contract (which is not a
|
||||
provider and never changes) or the Marge delegation.
|
||||
|
||||
### Identity in logs
|
||||
|
||||
Request logs should carry the resolved caller identity as context, **but only
|
||||
where the request actually exposes one** (do not fabricate an id the protocol did
|
||||
not send):
|
||||
|
||||
- **Authenticated UI / native / headless clients:** once auth lands, log the
|
||||
principal (provider subject / username / client id).
|
||||
- **Speakers:** there is no single speaker login, so it depends on the route.
|
||||
Many marge/streaming routes embed `{account}` / `{device}` in the path (also
|
||||
`/v1/scmudc/{deviceId}`, `/v1/stapp/{deviceId}`), so the device/account is
|
||||
available and worth logging. Others (BMX content like `/bmx/tunein/...`,
|
||||
`/v1/auth`) carry only a token / app_key or nothing identifying; log what is
|
||||
present and otherwise leave it blank rather than guessing.
|
||||
- **Unauthenticated:** mark as anonymous.
|
||||
|
||||
Caveats: sanitise the value before logging (the existing log-injection guard,
|
||||
`sanitizeLog` / `sanitizeErr`), and remember these ids (account / device /
|
||||
principal) are sensitive, so they must follow the existing log redaction on
|
||||
diagnostic export, not leak into shared bundles.
|
||||
|
||||
## Target layout
|
||||
|
||||
```
|
||||
# Frozen compat layer (top-level, never reshape):
|
||||
/streaming /accounts /customer /bmx /core02 /oauth /custom
|
||||
/media /bmx-icons /updates /v1 /alexa /ced
|
||||
|
||||
# Our JSON API (everything movable lives here, grouped BY TRUST TIER so
|
||||
# auth/authz middleware applies per group, not per route):
|
||||
/api/setup/* (today: /setup/*) -> admin tier: auth required
|
||||
/api/mgmt/* (today: /mgmt/*, no callbacks) -> admin tier: auth required
|
||||
/api/control/* (today: soundtouch-player /api/*) -> player tier: auth optional
|
||||
/api/devices ...
|
||||
|
||||
# OAuth provider callbacks (externally-pinned; freeze in place,
|
||||
# or move only with provider re-registration):
|
||||
/mgmt/spotify/callback, /mgmt/amazon/callback
|
||||
|
||||
# Frontend (one role-gated app, single catch-all, no per-route registration):
|
||||
/app/* (the unified app; role/auth decides Player vs Setup visibility)
|
||||
/web/* (legacy admin UI; retired once /app/* subsumes it)
|
||||
|
||||
# Infra:
|
||||
/health /metrics /ws
|
||||
```
|
||||
|
||||
### The `/app/*` pattern
|
||||
|
||||
The service's admin UI already does the right thing: `/web/*` is one catch-all
|
||||
(`HandleWeb`), not one route per page. The `soundtouch-player` SPA routes
|
||||
(`mount.go`, the `/`, `/devices`, `/tunein`, ... block) are the legacy
|
||||
anti-pattern. The target:
|
||||
|
||||
- **`/app/*`** is a single catch-all that returns `index.html`. The browser does
|
||||
client-side routing within `/app/`. No frontend path appears in the backend
|
||||
router.
|
||||
- **`/api/*`** serves data only.
|
||||
- Static assets live under a fixed prefix (e.g. `/app/static/*`).
|
||||
|
||||
This keeps the backend API free of frontend routes while still avoiding any
|
||||
need for server-side SPA routing config.
|
||||
|
||||
### One app, role-gated (not two apps)
|
||||
|
||||
Decision: converge to a **single app** under `/app/*`; role/auth decides what a
|
||||
user sees (Player vs Setup are views of one app, not separate apps). This is the
|
||||
natural expression of the trust tiers, removes the duplicated shell / device
|
||||
handling the two frontends carry today, and lets them share device list and
|
||||
state (the data-sharing win from the #451 discussion). `/web/*` is retired once
|
||||
`/app/*` subsumes it.
|
||||
|
||||
Two things make this safe:
|
||||
|
||||
- **Size (the on-device concern): "one app" is not "one eager bundle."**
|
||||
Code-split the heavy Setup/Admin surface (migration, certs, DNS, diagnostics,
|
||||
the ~4.8k-line `script.js`) into a **lazily loaded chunk** that loads only when
|
||||
an admin navigates there, so the Player path stays light. If size ever gets
|
||||
tight on-device, a **build tag / flag** can produce a player-only variant that
|
||||
does not embed the Setup chunk at all. The combined *embedded* size is likely
|
||||
to *drop*, not grow, since two separate apps duplicate more than one modular
|
||||
app does; the only real risk is naive eager bundling. Guard it with a
|
||||
bundle-size / route-count acceptance check (per the #451 discussion): measure
|
||||
first.
|
||||
- **Role-gating is UX, not security.** Hiding the Setup views from non-admins is
|
||||
convenience only. The real boundary stays the **server-side auth middleware**
|
||||
on the admin / provisioning tiers, otherwise someone just loads the chunk and
|
||||
calls the routes directly.
|
||||
|
||||
## Regression safety: contract tests from the frozen recordings
|
||||
|
||||
Build the regression net **before** touching routes. We already record
|
||||
interactions (`RECORD_INTERACTIONS`) and have a large collection; frozen and
|
||||
sanitised, that collection becomes a contract suite that proves the refactor
|
||||
preserves behavior. It is stronger than the router golden file
|
||||
(`router_routes.txt`), which only checks that routes are registered, not what
|
||||
they return.
|
||||
|
||||
Two directions, matching the two consumers:
|
||||
|
||||
- **Speaker contract (highest value): provider-side replay.** The speaker is a
|
||||
consumer we do *not* control (it is Bose firmware), so this is not classic
|
||||
consumer-driven Pact: the speaker's real recorded traffic *is* the contract.
|
||||
Replay each recorded request against the service and assert the response still
|
||||
matches (body and headers). This pins category-1 byte-for-byte, exactly the
|
||||
invariant the refactor must not break, and it catches subtle wire details a
|
||||
route reshuffle could disturb (for example the case-sensitive `ETag` header).
|
||||
It aligns with the existing parity tests (local vs official Bose recordings).
|
||||
- **CLI / `/api/*` contract (optional): consumer-driven Pact.** The CLI is a
|
||||
consumer we *do* control, so real Pact fits: the CLI declares expectations and
|
||||
the service verifies them. Most useful once the new `/api/*` shape exists, and
|
||||
to assert **dual-routing equivalence** (old and new path satisfy the same
|
||||
contract). Lower priority, since this surface is intentionally changing in 0.x.
|
||||
|
||||
We are not starting from zero: the existing `tests/integration/http-client/*.http`
|
||||
suite (run in CI via `make test-http-client` against the service plus the
|
||||
spotify/amazon mocks) is already a near-consumer-driven contract from the
|
||||
speaker's perspective. The requests carry the firmware user-agent
|
||||
(`Bose_Lisa/27.0.6`) and assert status, content-type, and XML structure of the
|
||||
marge/streaming/BMX routes. It is not literally Pact (no consumer/provider broker
|
||||
or generated pacts), but it is functionally the speaker contract, and it already
|
||||
asserts structure and invariants rather than raw bytes, which is exactly the
|
||||
matcher approach that keeps contracts non-flaky. The natural path is to treat
|
||||
this suite as the seed and broaden it with the frozen recordings, rather than
|
||||
inventing a new harness.
|
||||
|
||||
How it de-risks the rebuild:
|
||||
|
||||
- Pins the frozen speaker contract so a route reshuffle cannot silently alter the
|
||||
wire.
|
||||
- During dual-routing, runs the same contract against both old and new paths to
|
||||
prove the alias is faithful.
|
||||
- Becomes the gate: the refactor lands only when the contract suite is green.
|
||||
|
||||
Caveats:
|
||||
|
||||
- **Sanitise before freezing.** Recordings carry real IPs, MACs, account /
|
||||
device ids, and tokens; per the repo rules they must be anonymised (the
|
||||
existing testdata anonymisation / rotation) before they become committed
|
||||
fixtures.
|
||||
- **Match, do not byte-compare blindly.** Legitimately dynamic fields
|
||||
(timestamps, tokens, generated ids, ETag *values*) need normalisation /
|
||||
matchers, or the contracts go flaky. Freeze structure and invariants, not the
|
||||
volatile bits.
|
||||
|
||||
## Staged migration
|
||||
|
||||
Everything below happens **within 0.x**. 1.x is only the cutover (removal). The
|
||||
frozen speaker/app contract routes (category 1) are out of scope throughout: they
|
||||
never move, so none of the aliasing / redirect / deprecation machinery touches
|
||||
them.
|
||||
|
||||
### Route-transition track (0.x)
|
||||
|
||||
1. **Add the new routes, switch the service admin UI to them, alias the old
|
||||
paths.** Mount `/setup/*` and `/mgmt/*` under the new `/api/*` grouping (chi
|
||||
`Route`/`Mount`; carve it so `/api/control/*` fits later) and point
|
||||
`script.js` at the new paths.
|
||||
- **Use aliasing (dual-mount), not HTTP redirects, for our own routes:**
|
||||
register the same handler at both the old and new path. It avoids the
|
||||
client-following and method/body pitfalls of redirects (a redirect would
|
||||
have to be 307/308 to keep a POST body) and is a no-break upgrade for any
|
||||
lagging client.
|
||||
- **Does this work for speaker/legacy routes? No, and it is not needed.** We
|
||||
never move frozen routes, and a fixed speaker firmware cannot be assumed to
|
||||
follow a redirect on its marge/BMX calls (untested; do not rely on it). This
|
||||
step is about our movable routes only.
|
||||
- **Exclude** `/mgmt/spotify/callback` and `/mgmt/amazon/callback` (1b):
|
||||
freeze, or move only with a deliberate provider re-registration.
|
||||
2. **First, migrate `soundtouch-player` in place to the target API shape.** Before
|
||||
touching the service, restructure the standalone `-web` binary's own routes to
|
||||
what they should be *after* the merge: the control API under `/api/control/*`
|
||||
and the SPA under `/app/*` (with `/ws` as e.g. `/api/control/ws`). Unlike the
|
||||
service, this is a **direct migration, not a dual-mount, and with no
|
||||
deprecation signal**: `-web`'s only client is its own bundled frontend, served
|
||||
and reloaded from the same binary, so there are no out-of-band callers to keep
|
||||
compatible — restructure the routes and update the frontend in lockstep, in
|
||||
small commits, and a stale tab is fixed by a reload. (The careful
|
||||
add-alias-then-deprecate dance is reserved for `-service`, which is central and
|
||||
serves callers we do not control.) The payoff: by the time we merge, `-web`'s
|
||||
routes already match the target and don't overlap the service's namespaces, so
|
||||
the merge below is a near-additive mount.
|
||||
|
||||
3. **Fold `soundtouch-player` into the service.** Bring the (already target-shaped)
|
||||
control API in as `/api/control/*` and the UI under `/app/*` (one role-gated
|
||||
app, see above).
|
||||
The actual overlap to clean up (verified) is small: only **`/`** truly
|
||||
collides, so replace the two competing root handlers with a **landing page**
|
||||
that routes the user to Admin/Setup or the App; **`/health`** is a merge
|
||||
(keep the service's richer body); **`/ws`** and **`/static/*`** are additive
|
||||
(the service registers neither, so no collision). Do this cleanup
|
||||
structurally and verify it (a test that builds the merged router and asserts
|
||||
no double-registration) rather than hiding overlaps behind the opt-in flag.
|
||||
Keep the two TuneIn layers separate (frozen `/bmx/tunein/*` vs the player's
|
||||
`/api/control/*` radio feature).
|
||||
- **Ship the merged variant behind an opt-in flag (default off).** Its sole
|
||||
purpose is to let people optionally run the combined binary and give
|
||||
feedback; it is **not** a collision guard and **not** a security boundary on
|
||||
its own. Until the auth track lands, default-off keeps the merged app/control
|
||||
surface from being exposed unless an operator deliberately enables it. The
|
||||
flag follows the same CLI/env/persisted precedence as `server-url`, and is
|
||||
the seam the `deployment-mode` parameter later subsumes.
|
||||
4. **Deprecate the `soundtouch-player` binary.** It keeps working in 0.x but prints
|
||||
a startup deprecation warning (along the lines of "this binary is removed in
|
||||
1.x, use soundtouch-service") so its removal is no surprise.
|
||||
5. **Warn on old-route hits in the service, observably.** When a deprecated path
|
||||
is called, log a deprecation warning **and** count it (a metric / signal), so
|
||||
the 1.x removal is data-driven: a route is only cut once it has gone quiet
|
||||
across real deployments, not on a guess. *(Done for the `/setup` and `/mgmt`
|
||||
legacy paths via `DeprecatedRouteMiddleware`; extends to any future aliased
|
||||
route.)*
|
||||
|
||||
### Auth track (0.x, parallel)
|
||||
|
||||
- Group `/setup/*` + `/mgmt/*` (+ provisioning) into one admin tier and apply a
|
||||
single auth middleware, replacing today's per-route gap (`/mgmt` has Basic
|
||||
Auth, `/setup` has none).
|
||||
- Make auth enforceable behind a reverse proxy (close #419), not dependent on a
|
||||
header a proxy can strip.
|
||||
- Land the `deployment-mode` parameter (private / shared / public) with its
|
||||
lockout-safety, and the speaker-direct UI gating.
|
||||
- Authorization (player vs admin tiers) can follow authentication; design the
|
||||
groups now so it slots in without another reshuffle.
|
||||
|
||||
### Before 1.x: definition of done
|
||||
|
||||
1.x removes the old routes and the deprecated binary, so all of this must be true
|
||||
in a 0.x release first:
|
||||
|
||||
- **Auth / `deployment-mode` actually shipped** and opt-in works. This is the
|
||||
cloud-first motivation; without it 1.x has no payoff.
|
||||
- **Every client we ship moved off the old paths:** the admin UI, the merged
|
||||
app, the **CLI**, the **HTTP-client integration tests**, **docs and examples**,
|
||||
any reverse-proxy guide. The 0.x dual-routing is their migration window, but
|
||||
someone has to actually move them.
|
||||
- **Old-route usage has gone quiet** in the step-4 signal (do not remove blind).
|
||||
- **A deprecation window of at least one release** where the warnings were live.
|
||||
- **A user-facing migration note / changelog entry.**
|
||||
- The router golden file (`router_routes.txt`) and the contract suite (above)
|
||||
kept green throughout; they are the regression guards.
|
||||
|
||||
### 1.x cutover
|
||||
|
||||
Remove the obsolete routes and retire `soundtouch-player`. Per the versioning
|
||||
section, this is the only point where anything is removed; the frozen
|
||||
speaker/app routes stay.
|
||||
|
||||
## Versioning and the 1.x cutover
|
||||
|
||||
We do **not** version our own API in the path (`/api/v1/...`). In practice path
|
||||
versioning buys little; its one real benefit is explicitness, and it can be
|
||||
retrofitted later if a hard break ever forces it. Either way, a `/v1` -> `/v2`
|
||||
bump does not remove the need to be careful when changing or breaking a route.
|
||||
|
||||
(The frozen `/v1/*` routes in the tables above are Bose's firmware contract, not
|
||||
our versioning. They are unrelated.)
|
||||
|
||||
Versioning lives at the **release level (semver)** instead:
|
||||
|
||||
- **0.x (now):** the API may evolve. When a route moves, the **old and new paths
|
||||
stay live at the same time** (the alias/redirect layer from step 1). Every
|
||||
release stays a no-break upgrade, which gives users time to follow.
|
||||
- **1.x (the cutover):** the release where we settle on the better API. At 1.x we
|
||||
**remove the obsolete routes**. That is the only point where an old route
|
||||
disappears.
|
||||
|
||||
Why this is low-risk: the service and the frontend(s) it serves ship in **one
|
||||
binary**. A user updates the service and reloads the browser tab; the reloaded
|
||||
SPA is the client for the new API, so the two always match, with no window where
|
||||
an old frontend talks to a new backend.
|
||||
|
||||
Caveat: this holds for the clients we ship (the bundled UIs). Out-of-band callers
|
||||
that hardcode paths (the CLI, user scripts, reverse-proxy rules, the HTTP-client
|
||||
integration tests) must follow by 1.x as well; the 0.x dual-routing is precisely
|
||||
the window that lets them. The frozen speaker/app contract routes are never
|
||||
removed, 1.x included.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Lockout-safety mechanism: which of refuse-to-apply / fall-back / loopback-on-
|
||||
host bypass we use when a mode requires auth but none is configured yet.
|
||||
- The form of the non-interactive credential for native / headless clients:
|
||||
issued token, API key, device-code, or client-credentials grant.
|
||||
- The shipped default at 1.x: stay `private`, or move to a stricter default
|
||||
(the parameter lets operators opt in regardless, so no hard flip is forced).
|
||||
@@ -156,7 +156,7 @@ Where today's surfaces fall short for this user:
|
||||
|
||||
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
|
||||
|
||||
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
|
||||
**Surfaces.** Physical preset buttons (always there), `soundtouch-player` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
|
||||
|
||||
### What this layer needs to be good at
|
||||
|
||||
@@ -168,14 +168,14 @@ Where today's surfaces fall short for this user:
|
||||
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
|
||||
- `soundtouch-player`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
|
||||
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
|
||||
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
|
||||
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
|
||||
- Do we keep `soundtouch-player` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
|
||||
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
|
||||
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
|
||||
|
||||
@@ -199,7 +199,7 @@ Where today's surfaces fall short for this user:
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
|
||||
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
|
||||
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-player` and by third-party automation.
|
||||
- Home Assistant: external integration; track but do not own.
|
||||
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
|
||||
|
||||
@@ -217,7 +217,7 @@ Where today's surfaces fall short for this user:
|
||||
|------------------------------------|---------------------|-------------------|-------------------|------------------------|
|
||||
| `soundtouch-cli` | partial (today) | partial (today) | no | primary |
|
||||
| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) |
|
||||
| `soundtouch-web` | no | no | primary | no |
|
||||
| `soundtouch-player` | no | no | primary | no |
|
||||
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
|
||||
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
|
||||
| Physical preset buttons | no | no | primary | no |
|
||||
@@ -229,7 +229,7 @@ The diagonal isn't full because some journeys lack a polished surface today (Jou
|
||||
|
||||
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
|
||||
|
||||
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
|
||||
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-player`.
|
||||
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
|
||||
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
|
||||
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
---
|
||||
title: "Architecture"
|
||||
weight: 5
|
||||
weight: 6
|
||||
---
|
||||
|
||||
Architecture notes and analyses:
|
||||
|
||||
- [API Route Layout and Refactoring Plan](API-ROUTE-LAYOUT.md) - route
|
||||
classification (frozen speaker contract vs our movable surface), the
|
||||
actor / deployment / trust model, auth, and the staged plan toward the
|
||||
`soundtouch-player` / `soundtouch-service` merge (issue #451).
|
||||
- [Device-Local Install: Four User Journeys](DEVICE-LOCAL-INSTALL.md) - install
|
||||
patterns and user journeys for on-device deployment.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "Concepts"
|
||||
weight: 3
|
||||
weight: 4
|
||||
---
|
||||
|
||||
@@ -279,6 +279,10 @@ Or set the equivalent environment variables: `AMAZON_CLIENT_ID`, `AMAZON_CLIENT_
|
||||
|
||||
### 3. Trigger the OAuth flow
|
||||
|
||||
> The commands below use the published default Management API credentials
|
||||
> (`admin` / `change_me!`); substitute your own if you've changed them (see
|
||||
> [Configuration Options](../guides/SOUNDTOUCH-SERVICE.md#configuration-options)).
|
||||
|
||||
```bash
|
||||
# Get the LWA authorization URL
|
||||
curl -u admin:change_me! -X POST http://localhost:8000/mgmt/amazon/init
|
||||
|
||||
@@ -118,6 +118,6 @@ sequenceDiagram
|
||||
## Security
|
||||
|
||||
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
|
||||
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
|
||||
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password` (defaults documented in [Configuration Options](../guides/SOUNDTOUCH-SERVICE.md#configuration-options)).
|
||||
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
|
||||
- The `GetAccounts` endpoint strips sensitive tokens from the response.
|
||||
|
||||
@@ -91,7 +91,7 @@ that delegates to AfterTouch for these names). The implementation lives in
|
||||
|
||||
> **IP-based `--server-url` is incompatible with OAuth (both Spotify and Amazon
|
||||
> Music).** The speaker's hostname construction appends `oauth` to the first
|
||||
> label only, so `192.168.0.30` would produce `192oauth.168.0.30` — malformed,
|
||||
> label only, so `192.0.2.30` would produce `192oauth.0.2.30` — malformed,
|
||||
> no DNS resolver will answer for it, and there is no clean workaround on the
|
||||
> AfterTouch side. **Use a real LAN hostname** before configuring Spotify or
|
||||
> Amazon Music. The Health-tab `oauth_target_reachable` check warns when this
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: "Downloads"
|
||||
weight: 1
|
||||
sidebar:
|
||||
open: true
|
||||
---
|
||||
|
||||
# Downloads
|
||||
|
||||
Everything AfterTouch ships is on the
|
||||
**[GitHub releases page](https://github.com/gesellix/Bose-SoundTouch/releases/latest)**.
|
||||
This page helps you pick the right file: choose **which tool** you need,
|
||||
then **which build** matches your computer.
|
||||
|
||||
## 1. Which tool do I need?
|
||||
|
||||
AfterTouch is a small set of separate programs. Most people run one or
|
||||
two of them.
|
||||
|
||||
| Tool | What it does | You want this if… |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
|
||||
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
|
||||
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
|
||||
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
|
||||
|
||||
> Running a migration from the command line (for example the telnet
|
||||
> re-migration in the
|
||||
> [troubleshooting guide](../guides/TROUBLESHOOTING.md#radio-sources-after-migration))
|
||||
> uses **`soundtouch-cli`**.
|
||||
|
||||
## 2. Which build matches my computer?
|
||||
|
||||
Release assets are named:
|
||||
|
||||
```
|
||||
soundtouch-<tool>-v<VERSION>-<os>-<arch>[.exe]
|
||||
```
|
||||
|
||||
Pick the `<os>-<arch>` suffix for your system:
|
||||
|
||||
| Your system | `<os>-<arch>` suffix |
|
||||
|--------------------------------------|----------------------|
|
||||
| Raspberry Pi (64-bit) / ARM64 Linux | `linux-arm64` |
|
||||
| Raspberry Pi (32-bit) / ARMv7 | `linux-armv7` |
|
||||
| Linux (64-bit PC) | `linux-amd64` |
|
||||
| macOS (Apple Silicon: M1/M2/M3/…) | `darwin-arm64` |
|
||||
| macOS (Intel) | `darwin-amd64` |
|
||||
| Windows (64-bit) | `windows-amd64.exe` |
|
||||
| FreeBSD (64-bit) | `freebsd-amd64` |
|
||||
|
||||
**Example.** To control speakers from a Raspberry Pi 4, download the CLI
|
||||
build `soundtouch-cli-vX.Y.Z-linux-arm64`. On an Apple Silicon Mac you
|
||||
would take `soundtouch-cli-vX.Y.Z-darwin-arm64` instead.
|
||||
|
||||
The download is a single executable, ready to run (no archive to extract).
|
||||
Each asset ships with `.sha256` and `.sha512` checksum files, and every
|
||||
release also has combined `checksums.sha256` / `checksums.sha512` if you
|
||||
want to verify the download.
|
||||
|
||||
> **macOS / Windows note:** because these binaries are not code-signed,
|
||||
> the OS may warn on first launch (Gatekeeper on macOS, SmartScreen on
|
||||
> Windows). Approve it in the security prompt, or use the Docker or
|
||||
> install-script routes below.
|
||||
|
||||
## 3. Other ways to install
|
||||
|
||||
### Install scripts (Linux / Raspberry Pi)
|
||||
|
||||
These download the latest release for you and set up a background service.
|
||||
|
||||
- **Service** (`soundtouch-service`):
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install.sh
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
- **Player** (`soundtouch-player`):
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
|
||||
sudo bash install-player.sh
|
||||
```
|
||||
|
||||
There is also an **on-device** installer that runs AfterTouch directly on
|
||||
the speaker; see the
|
||||
[On-Device Install Walkthrough](../guides/ON-DEVICE-INSTALL-WALKTHROUGH.md).
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# AfterTouch service
|
||||
docker pull ghcr.io/gesellix/bose-soundtouch:latest
|
||||
|
||||
# Web player
|
||||
docker pull ghcr.io/gesellix/bose-soundtouch-player:latest
|
||||
```
|
||||
|
||||
Both images are multi-arch (`linux/amd64`, `linux/arm64`, `linux/arm/v7`).
|
||||
See the [Deployment Guide](../guides/DEPLOYMENT.md) for Docker Compose
|
||||
examples.
|
||||
|
||||
### Go toolchain
|
||||
|
||||
If you have Go installed you can build from source:
|
||||
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-player@latest
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-backup@latest
|
||||
```
|
||||
|
||||
## 4. Not sure how to deploy?
|
||||
|
||||
The [Deployment Overview](../guides/DEPLOYMENT-OVERVIEW.md) compares
|
||||
running AfterTouch on a Raspberry Pi / always-on host against running it
|
||||
directly on the speaker, with step-by-step walkthroughs for each path.
|
||||
For the full migration story, start with the
|
||||
[Migration Guide](../guides/MIGRATION-GUIDE.md).
|
||||
@@ -37,8 +37,11 @@ internet-facing, those endpoints are reachable by anyone who knows the URL.
|
||||
|
||||
Minimum mitigations before going live:
|
||||
|
||||
- Enable **HTTP Basic Auth** on the management UI (set via `MGMT_USERNAME` /
|
||||
`MGMT_PASSWORD` or the `--mgmt-username` / `--mgmt-password` flags).
|
||||
- **Change the Management API password** — HTTP Basic Auth on the management
|
||||
UI is always on, but ships with a published default
|
||||
(`admin` / `change_me!`); set your own via `MGMT_USERNAME` /
|
||||
`MGMT_PASSWORD` (or the `--mgmt-username` / `--mgmt-password` flags — see
|
||||
[Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options)).
|
||||
- Run AfterTouch **behind a reverse proxy** (Nginx, Caddy, Coolify, Traefik)
|
||||
and consider blocking the `/streaming/*` paths to all but your speaker's
|
||||
IP address at the proxy level if your server/firewall allows it.
|
||||
@@ -46,6 +49,48 @@ Minimum mitigations before going live:
|
||||
|
||||
---
|
||||
|
||||
## Client IP behind a proxy or load balancer
|
||||
|
||||
Behind a reverse proxy or load balancer, the connection AfterTouch sees comes
|
||||
from the proxy, not from the speaker. A few handlers act on the source IP (for
|
||||
example the Spotify priming triggered by `/marge/streaming/support/power_on`,
|
||||
and the device IP AfterTouch records), so in a proxied setup you usually want
|
||||
it to recover the real speaker IP from the `X-Forwarded-For` header.
|
||||
|
||||
Enable it in `data/settings.json`:
|
||||
|
||||
- Set `"trust_forwarded_headers": true`.
|
||||
- Set `"trusted_proxy_cidrs"` to your proxy's own source IP range(s) **as
|
||||
AfterTouch sees them**, for example `["10.0.0.0/8"]`. It defaults to loopback
|
||||
(`127.0.0.0/8`, `::1/128`), which already covers a proxy on the same host.
|
||||
When the proxy runs in a separate Docker container, the address AfterTouch
|
||||
sees is usually the Docker bridge gateway/subnet (e.g. `172.16.0.0/12`), not
|
||||
the proxy's published address.
|
||||
|
||||
Make sure the proxy sets the header (nginx:
|
||||
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`). Only
|
||||
`X-Forwarded-For` is consulted (not `X-Real-IP` or `True-Client-IP`).
|
||||
|
||||
The trust decision is made on the **immediate TCP connection**: AfterTouch
|
||||
reads `X-Forwarded-For` only when the connecting socket's own source IP is in
|
||||
`trusted_proxy_cidrs`. That socket address is the real connection, so an
|
||||
`X-Forwarded-For` header cannot forge it.
|
||||
|
||||
| Deployment | `trust_forwarded_headers` | Client IP AfterTouch uses |
|
||||
|---------------------------------------------------------------------|---------------------------|----------------------------------------------------------------------------------------|
|
||||
| Direct LAN / on-device (no proxy) | `false` (default) | the connecting socket's IP; `X-Forwarded-For` is ignored |
|
||||
| Behind a proxy whose socket IP is in `trusted_proxy_cidrs` | `true` | the rightmost `X-Forwarded-For` entry outside `trusted_proxy_cidrs` (the real speaker) |
|
||||
| A direct connection whose socket IP is not in `trusted_proxy_cidrs` | `true` | the socket IP; `X-Forwarded-For` is ignored (spoofing protection) |
|
||||
|
||||
> **Do not enable `trust_forwarded_headers` on a flat LAN with no proxy.** A
|
||||
> malicious speaker could then send `X-Forwarded-For` itself and spoof its
|
||||
> source IP. A missing or unparseable header always falls back to the socket IP.
|
||||
|
||||
For terminating TLS at the proxy (serving the certificate on `:443`), see the
|
||||
[reverse proxy section of the HTTPS guide](HTTPS-SETUP.md#reverse-proxy-optional).
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Deploy AfterTouch on your server
|
||||
|
||||
### Docker / Docker Compose (any VPS)
|
||||
@@ -118,7 +163,7 @@ migration must be driven from `soundtouch-cli` **running on your own machine
|
||||
on the same LAN as the speaker**.
|
||||
|
||||
Download `soundtouch-cli` for your OS from the
|
||||
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases).
|
||||
[Downloads page](../downloads/_index.md).
|
||||
|
||||
### Check the migration plan first
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: "FRITZ!Box + AdGuard Home: DNS-based bose Hostname"
|
||||
---
|
||||
|
||||
This guide covers a setup that trips up a lot of people: running AfterTouch
|
||||
behind a local DNS resolver (AdGuard Home, Pi-hole, or the FRITZ!Box itself)
|
||||
and addressing it by a short hostname like `bose` instead of a raw IP. When the
|
||||
pieces don't line up, speakers report `INVALID_SOURCE` for TuneIn / internet
|
||||
radio, the Health tab warns about missing source types
|
||||
(`LOCAL_INTERNET_RADIO`, `RADIO_BROWSER`, `TUNEIN`), and pre-flight shows an
|
||||
HTTP-connection / URL-mismatch failure even though AfterTouch itself is running
|
||||
correctly.
|
||||
|
||||
The root cause is almost always the same: **the speaker cannot resolve the
|
||||
hostname you configured, or the TLS certificate doesn't cover it.** This is a
|
||||
real-world setup contributed by a user who hit exactly this and worked out the
|
||||
fix.
|
||||
|
||||
> The IP addresses below use the documentation range `192.0.2.0/24`
|
||||
> ([RFC 5737](https://datatracker.ietf.org/doc/html/rfc5737)). Substitute your
|
||||
> own AfterTouch host IP. The hostname `bose` and FQDN `bose.fritz.box` are
|
||||
> examples; any short name works as long as DNS and TLS agree on it.
|
||||
|
||||
## The setup
|
||||
|
||||
- AfterTouch runs as a container (here: Proxmox + Docker, `--network host`,
|
||||
data directory bind-mounted), reachable at `192.0.2.10`.
|
||||
- The FRITZ!Box forwards all DNS queries to **AdGuard Home** as the LAN resolver.
|
||||
- AdGuard already had DNS rewrites for the Bose cloud hostnames pointing at
|
||||
AfterTouch:
|
||||
|
||||
| Name | Answer |
|
||||
|--------------------------------|--------------|
|
||||
| `productregistration.bose.com` | `192.0.2.10` |
|
||||
| `streaming.bose.com` | `192.0.2.10` |
|
||||
| `select.bose.com` | `192.0.2.10` |
|
||||
| `update.bose.com` | `192.0.2.10` |
|
||||
|
||||
That part is the standard "intercept Bose hostnames outside AfterTouch"
|
||||
approach (see [HTTPS & Custom CA Certificate](HTTPS-SETUP.md)). What was missing
|
||||
was making the **short hostname** you point speakers at resolvable *and*
|
||||
TLS-valid.
|
||||
|
||||
## The fix
|
||||
|
||||
### 1. Add DNS rewrites for the short hostname
|
||||
|
||||
In AdGuard Home, add rewrites so the name you plan to use in the service URLs
|
||||
resolves to AfterTouch:
|
||||
|
||||
| Name | Answer |
|
||||
|------------------|--------------|
|
||||
| `bose` | `192.0.2.10` |
|
||||
| `bose.fritz.box` | `192.0.2.10` |
|
||||
|
||||
Both forms matter: speakers and clients may append the FRITZ!Box search domain
|
||||
(`.fritz.box`), so covering the bare label and the FQDN avoids surprises.
|
||||
|
||||
### 2. Include the hostname in the TLS certificate
|
||||
|
||||
If speakers (or your browser) reach AfterTouch by `bose`, that name must be in
|
||||
the certificate's SAN list, otherwise the TLS handshake is rejected
|
||||
(`CURLE_SSL_CACERT (60)`). Start the container with the host added:
|
||||
|
||||
```bash
|
||||
TLS_EXTRA_HOST="192.0.2.10,bose"
|
||||
```
|
||||
|
||||
`TLS_EXTRA_HOST` is a comma-separated (and repeatable) list of extra DNS names
|
||||
or IPs added to the certificate SAN list. You can also manage it from the web
|
||||
UI: **Settings → "TLS extra hosts"**, or the one-click **"Add <host> to TLS
|
||||
hosts"** QuickFix on the Health tab. Either path persists to `settings.json`
|
||||
(`tls_extra_hosts`) and takes effect after a service restart, which regenerates
|
||||
the certificate. See
|
||||
[Adding extra hosts to the TLS certificate](HTTPS-SETUP.md#adding-extra-hosts-to-the-tls-certificate).
|
||||
|
||||
### 3. Point the service URLs at the hostname
|
||||
|
||||
In AfterTouch, under **System Settings / Target Domain / Service URLs**, switch
|
||||
from the raw IP to the hostname:
|
||||
|
||||
```
|
||||
http://192.0.2.10:8000 → http://bose:8000
|
||||
```
|
||||
|
||||
After this, the per-device config should read:
|
||||
|
||||
```
|
||||
margeServerUrl = http://bose:8000
|
||||
statsServerUrl = http://bose:8000
|
||||
bmxRegistryUrl = http://bose:8000/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
### 4. Re-migrate the speakers
|
||||
|
||||
Re-run the migration for each speaker (XML over SSH), then reboot and send a
|
||||
`sourcesUpdated` notification so the runtime layer reconciles. See the
|
||||
[Migration Guide](MIGRATION-GUIDE.md).
|
||||
|
||||
## Verifying it worked
|
||||
|
||||
- `http://bose:8000/health` responds, and `https://bose:8443/admin` loads with a
|
||||
valid certificate.
|
||||
- The Health tab no longer warns about URL mismatch or HTTP reachability (a
|
||||
brief runtime-vs-XML hint right after migration clears on reboot).
|
||||
- TuneIn / internet radio plays again; `INVALID_SOURCE` is gone.
|
||||
- `/sources` lists the expected source types and `sources_xml_diff` is green.
|
||||
|
||||
## Why this is the stumbling block
|
||||
|
||||
Technically AfterTouch was serving correctly the whole time. The failure was
|
||||
purely in name resolution and certificate coverage: the speaker asked the
|
||||
nameserver for `bose`, got nothing usable (or reached a host whose certificate
|
||||
didn't list `bose`), and fell back toward the now-dead Bose cloud. Using a raw
|
||||
IP avoids the resolution step entirely; using a hostname is cleaner but only
|
||||
works once **DNS** and the **TLS certificate** both agree on that name.
|
||||
|
||||
> Prefer the raw IP if you want the simplest possible path with one fewer moving
|
||||
> part. Prefer the hostname if you run split-horizon DNS anyway and want a
|
||||
> stable name that survives an IP change. Either is fine, the key is that DNS,
|
||||
> the certificate, and the configured service URLs all reference the same
|
||||
> target.
|
||||
|
||||
## Related
|
||||
|
||||
- [HTTPS & Custom CA Certificate](HTTPS-SETUP.md): TLS, SAN coverage, `:443` routing
|
||||
- [Migration Guide](MIGRATION-GUIDE.md): DNS vs. SSH/XML migration methods
|
||||
- [Troubleshooting](TROUBLESHOOTING.md): `nslookup` / `dig` checks for name resolution
|
||||
@@ -34,13 +34,13 @@ sudo bash install.sh
|
||||
```
|
||||
|
||||
The installer detects your Pi's architecture (armv7, arm64, or amd64), downloads
|
||||
the binary, creates a `soundtouch` system user, and registers a systemd unit that
|
||||
starts on boot.
|
||||
the latest release binary, creates a `soundtouch` system user, and registers a
|
||||
systemd unit that starts on boot.
|
||||
|
||||
To install a specific version:
|
||||
To pin a specific version instead of the latest:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.107.0
|
||||
sudo bash install.sh v0.111.3
|
||||
```
|
||||
|
||||
Check that the service is running:
|
||||
@@ -55,7 +55,7 @@ installer defaults to port 80, not 8000) — open it in a browser.
|
||||
### Other Linux hosts (systemd)
|
||||
|
||||
Download the binary for your architecture from the
|
||||
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases), then
|
||||
[Downloads page](../downloads/_index.md), then
|
||||
install it as a systemd service — see [DEPLOYMENT.md](DEPLOYMENT.md) for the
|
||||
unit file template.
|
||||
|
||||
@@ -66,13 +66,28 @@ docker run -d \
|
||||
--name aftertouch \
|
||||
--network host \
|
||||
-e SERVER_URL=http://192.0.2.10:8000 \
|
||||
-v aftertouch-data:/data \
|
||||
-v aftertouch-data:/app/data \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
Replace `192.0.2.10` with the host machine's LAN IP. The `--network host` flag
|
||||
is required so AfterTouch can reach the speakers and respond to mDNS discovery.
|
||||
|
||||
> **Persist the data directory.** The container stores everything stateful under
|
||||
> `/app/data` (`DATA_DIR`): the datastore, `settings.json`, and the service CA.
|
||||
> Mount a volume there (`-v <volume>:/app/data`, as above) or this state is lost
|
||||
> when the container is recreated. Losing the CA forces you to re-migrate every
|
||||
> speaker and re-trust the new CA, so back this volume up before upgrading.
|
||||
|
||||
> **Windows / macOS (Docker Desktop):** `--network host` does not work the same
|
||||
> way as on Linux, so publish the ports explicitly instead, e.g.
|
||||
> `-p 8000:8000 -p 8443:8443`. mDNS discovery across the Docker Desktop network
|
||||
> boundary is unreliable; add speakers by IP in the Devices tab. If you also use
|
||||
> DNS interception (so the speaker resolves Bose hostnames to AfterTouch), you
|
||||
> additionally need to publish the DNS port (`-p 53:53/udp -p 53:53/tcp`) and
|
||||
> make AfterTouch reachable on `:443` (the hardcoded Bose hosts are plain HTTPS),
|
||||
> e.g. `-p 443:8443`. Keep the same `-v <volume>:/app/data` mount.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Note your host's LAN IP and open the Admin UI
|
||||
@@ -167,40 +182,40 @@ curl -s http://192.0.2.1:8090/sources
|
||||
|
||||
## Step 7 — Set up preset buttons (optional)
|
||||
|
||||
### Via soundtouch-web
|
||||
### Via soundtouch-player
|
||||
|
||||
The Radio Browser, TuneIn tabs, and preset saving live in
|
||||
**soundtouch-web**, a separate binary from the service. Once running,
|
||||
**soundtouch-player**, a separate binary from the service. Once running,
|
||||
open **`http://<host-ip>:8080`** in your browser (default port 8080).
|
||||
|
||||
### Installing soundtouch-web on a Raspberry Pi
|
||||
### Installing soundtouch-player on a Raspberry Pi
|
||||
|
||||
`install.sh` only installs `soundtouch-service`. Use the dedicated
|
||||
`install-web.sh` script to add soundtouch-web:
|
||||
`install-player.sh` script to add soundtouch-player:
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
|
||||
sudo bash install-web.sh
|
||||
curl -fsSL -o install-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
|
||||
sudo bash install-player.sh
|
||||
```
|
||||
|
||||
For configuration, service management, updates, and removal see the
|
||||
[Raspberry Pi guide → soundtouch-web](RASPBERRY-PI.md#soundtouch-web).
|
||||
[Raspberry Pi guide → soundtouch-player](RASPBERRY-PI.md#soundtouch-player).
|
||||
|
||||
### Installing soundtouch-web on other hosts
|
||||
### Installing soundtouch-player on other hosts
|
||||
|
||||
Download the binary for your OS and architecture from the
|
||||
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases)
|
||||
[Downloads page](../downloads/_index.md)
|
||||
and run it directly:
|
||||
|
||||
```bash
|
||||
./soundtouch-web --port 8080
|
||||
./soundtouch-player --port 8080
|
||||
```
|
||||
|
||||
Or install it as a systemd service following the same unit-file pattern
|
||||
described in [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
soundtouch-web provides two ways to save what's currently playing to a
|
||||
soundtouch-player provides two ways to save what's currently playing to a
|
||||
preset slot:
|
||||
|
||||
**★ Star button in the Now Playing card**
|
||||
@@ -224,7 +239,7 @@ slot.
|
||||
### Alternatively — storing presets via soundtouch-cli (any machine on the LAN)
|
||||
|
||||
Download the CLI for your machine from the
|
||||
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases), then:
|
||||
[Downloads page](../downloads/_index.md), then:
|
||||
|
||||
```bash
|
||||
# Play a custom radio stream on the speaker
|
||||
@@ -263,7 +278,7 @@ curl -s http://192.0.2.1:8090/presets
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # updates to latest release
|
||||
sudo bash install.sh v0.107.0 # updates to a specific version
|
||||
sudo bash install.sh v0.111.3 # updates to a specific version
|
||||
```
|
||||
|
||||
The installer stops the service, downloads the new binary, and restarts
|
||||
|
||||
@@ -21,7 +21,7 @@ The service includes a built-in HTTPS listener (default port `8443`) that presen
|
||||
- Wildcard: `*.api.bose.io`, `*.api.bosecm.com`
|
||||
- Specific: `streaming.bose.com`, `bmx.bose.com`, `stats.bose.com`, `updates.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, `media.bose.io`, `downloads.bose.com`, `voice.api.bose.io`, and more
|
||||
|
||||
> **Note**: The hostname you configure as `HTTPS_SERVER_URL` (e.g. `https://soundtouch.fritz.box:8443`) is also added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access.
|
||||
> **Note**: The HTTPS endpoint is only needed for certain features (the DNS-based redirect, Spotify/Amazon login, and certificate trust). Its URL is added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access. By default this URL is **derived from the Target Domain** (same host, `https`, on the HTTPS port), so you usually don't configure it separately. If you don't need plain HTTP at all, you can set the Target Domain itself to an `https://` URL — it is then used as the HTTPS endpoint as-is, with no separate override. Settings → **HTTPS URL** shows the effective value; set an override (`HTTPS_SERVER_URL` / `--https-server-url`, or the "advanced" field in Settings) only when a reverse proxy serves HTTPS on a different host or port.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,25 +120,16 @@ server {
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Tell the service to honour `X-Real-IP`/`X-Forwarded-For`.** When deploying
|
||||
> behind a reverse proxy on the same host as above, set
|
||||
> `"trust_forwarded_headers": true` in `data/settings.json`. With that flag
|
||||
> on, the service rewrites `r.RemoteAddr` from the proxy-supplied headers,
|
||||
> so handlers that act on the source IP (e.g. the Spotify priming triggered
|
||||
> by `/marge/streaming/support/power_on`) see the speaker's real address
|
||||
> instead of the proxy's loopback peer.
|
||||
>
|
||||
> By default only `127.0.0.0/8` and `::1/128` are trusted to set those
|
||||
> headers. If your reverse proxy lives on a different host, list its CIDR(s)
|
||||
> in `"trusted_proxy_cidrs"` (e.g. `["10.0.0.0/8"]`). Do **not** enable
|
||||
> `trust_forwarded_headers` on a flat LAN deployment without a proxy: a
|
||||
> malicious speaker on the LAN can send the headers itself and spoof its
|
||||
> source IP.
|
||||
> **Client IP behind a proxy.** A reverse proxy changes the source IP the
|
||||
> service sees, which matters for the handlers that act on it. Configuring
|
||||
> AfterTouch to recover the real speaker IP from `X-Forwarded-For`
|
||||
> (`trust_forwarded_headers` / `trusted_proxy_cidrs`) is covered under
|
||||
> [Client IP behind a proxy or load balancer](CLOUD-DEPLOY-WALKTHROUGH.md#client-ip-behind-a-proxy-or-load-balancer).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ Choose the option that fits your setup.
|
||||
|
||||
### Download a pre-built binary (no Go required)
|
||||
|
||||
Download the latest release for your platform from the
|
||||
[GitHub releases page](https://github.com/gesellix/Bose-SoundTouch/releases).
|
||||
Unzip, make executable, and run:
|
||||
Download the `soundtouch-service` build for your platform from the
|
||||
[Downloads page](../downloads/_index.md) (it explains which file to pick).
|
||||
Make it executable and run:
|
||||
|
||||
```bash
|
||||
# Linux / macOS example
|
||||
|
||||
@@ -11,6 +11,16 @@ This guide explains how to link your Spotify or Amazon Music account to AfterTou
|
||||
|
||||
---
|
||||
|
||||
> **The Local Account tab requires a login.** Authorizing a Spotify or
|
||||
> Amazon account (Step 3 below) happens on the **Local Account** tab, which
|
||||
> is protected by AfterTouch's Management API login (HTTP Basic Auth).
|
||||
> Unless you've changed it, the default is username `admin`, password
|
||||
> `change_me!` — see
|
||||
> [Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options) for
|
||||
> how to set your own (`MGMT_USERNAME` / `MGMT_PASSWORD`). Your browser will
|
||||
> prompt for this the first time you open a protected page or click a
|
||||
> management action — if nothing happens, try reloading the page.
|
||||
|
||||
## How it works
|
||||
|
||||
Connecting a music service happens in three separate steps, each done once:
|
||||
|
||||
@@ -81,14 +81,15 @@ currently running binary, and starts the service:
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
```
|
||||
|
||||
To target a specific version instead of the default:
|
||||
By default this installs the **latest release** — the script resolves it from
|
||||
GitHub's `releases/latest` redirect. To target a specific version instead:
|
||||
|
||||
```bash
|
||||
# Via environment variable (works with pipe-to-sh)
|
||||
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Via command-line flag (pass args after sh -s --)
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
```
|
||||
|
||||
Verify the installed version:
|
||||
@@ -97,7 +98,7 @@ Verify the installed version:
|
||||
wget -qO- http://localhost:8000/health
|
||||
```
|
||||
|
||||
The JSON response should include `"version":"v0.107.0"` (or whichever
|
||||
The JSON response should include `"version":"v0.111.3"` (or whichever
|
||||
version you installed).
|
||||
|
||||
---
|
||||
@@ -185,13 +186,13 @@ next reboot — which is fine for a one-time setup run):
|
||||
cd /tmp
|
||||
|
||||
curl -L --fail -o soundtouch-cli \
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.107.0/soundtouch-cli-v0.107.0-linux-armv7
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.111.3/soundtouch-cli-v0.111.3-linux-armv7
|
||||
chmod +x soundtouch-cli
|
||||
|
||||
/tmp/soundtouch-cli --version
|
||||
```
|
||||
|
||||
Replace `v0.107.0` with the version you installed.
|
||||
Replace `v0.111.3` with the version you installed.
|
||||
|
||||
---
|
||||
|
||||
@@ -304,12 +305,12 @@ older artefacts to keep `/mnt/nv` free:
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Update to a specific version — three equivalent forms
|
||||
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.107.0
|
||||
sh install.sh --version 0.111.3
|
||||
```
|
||||
|
||||
**Rollback:** the installer keeps a `.backup` file alongside the binary:
|
||||
|
||||
@@ -6,13 +6,19 @@ host) using the provided installer scripts.
|
||||
|
||||
Two scripts are available, one per binary:
|
||||
|
||||
| Script | Binary | Role | Default port |
|
||||
|------------------|----------------------|-------------------------------------|--------------|
|
||||
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
|
||||
| `install-web.sh` | `soundtouch-web` | Browser control panel | 8080 |
|
||||
| Script | Binary | Role | Default port |
|
||||
|---------------------|----------------------|-------------------------------------|--------------|
|
||||
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
|
||||
| `install-player.sh` | `soundtouch-player` | Browser control panel | 8080 |
|
||||
|
||||
Both auto-detect CPU architecture (armv7 / arm64 / amd64), create a `soundtouch`
|
||||
system user, and install a systemd unit. They are safe to re-run for updates.
|
||||
Run without a version argument, they install the **latest release** (resolved
|
||||
from GitHub's `releases/latest` redirect); pass a tag to pin a specific version.
|
||||
Each installer has a matching uninstaller (`uninstall.sh`, `uninstall-player.sh`).
|
||||
|
||||
Prefer to grab a binary by hand, or need `soundtouch-cli` / `soundtouch-backup`
|
||||
too? See the [Downloads page](../downloads/_index.md).
|
||||
|
||||
For a complete install-through-migration walkthrough see
|
||||
[EXTERNAL-HOST-WALKTHROUGH.md](EXTERNAL-HOST-WALKTHROUGH.md).
|
||||
@@ -34,14 +40,14 @@ sudo bash install.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.107.0
|
||||
sudo bash install.sh v0.111.3
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.107.0 \
|
||||
VERSION=v0.111.3 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
@@ -99,7 +105,7 @@ journalctl -u soundtouch-service -b # this boot only
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # update to latest release
|
||||
sudo bash install.sh v0.107.0 # update to a specific version
|
||||
sudo bash install.sh v0.111.3 # update to a specific version
|
||||
```
|
||||
|
||||
The script stops the service, downloads the new binary (backs up the old one to
|
||||
@@ -107,43 +113,60 @@ The script stops the service, downloads the new binary (backs up the old one to
|
||||
|
||||
### Removal
|
||||
|
||||
Use the uninstaller, which stops and disables the service and removes the unit,
|
||||
binary, and config. Your data directory is **preserved** by default:
|
||||
|
||||
```bash
|
||||
curl -fsSL -o uninstall.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall.sh
|
||||
sudo bash uninstall.sh # keep /var/lib/soundtouch-service
|
||||
sudo bash uninstall.sh --purge # also delete the data directory
|
||||
```
|
||||
|
||||
The `soundtouch:soundtouch` user/group is removed only once no other
|
||||
`soundtouch-*` install remains on the host.
|
||||
|
||||
Prefer to do it by hand? The equivalent manual steps are:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-service
|
||||
sudo rm /etc/systemd/system/soundtouch-service.service
|
||||
sudo rm -rf /etc/soundtouch-service
|
||||
sudo rm -rf /var/lib/soundtouch-service
|
||||
sudo rm /usr/local/bin/soundtouch-service
|
||||
sudo systemctl daemon-reload
|
||||
# Datastore (presets, device registrations, certs) — delete only if you are
|
||||
# sure you no longer need it:
|
||||
sudo rm -rf /var/lib/soundtouch-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## soundtouch-web
|
||||
## soundtouch-player
|
||||
|
||||
`soundtouch-web` is a stateless browser control panel — it holds no persistent
|
||||
`soundtouch-player` is a stateless browser control panel — it holds no persistent
|
||||
data and can be stopped or restarted at any time without data loss.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
|
||||
sudo bash install-web.sh
|
||||
curl -fsSL -o install-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
|
||||
sudo bash install-player.sh
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install-web.sh v0.107.0
|
||||
sudo bash install-player.sh v0.111.3
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.107.0 \
|
||||
VERSION=v0.111.3 \
|
||||
HTTP_PORT=8081 \
|
||||
bash install-web.sh
|
||||
bash install-player.sh
|
||||
```
|
||||
|
||||
Once running, open **`http://<pi-ip>:8080`** in a browser.
|
||||
@@ -151,7 +174,7 @@ Once running, open **`http://<pi-ip>:8080`** in a browser.
|
||||
### Configuration
|
||||
|
||||
```
|
||||
/etc/soundtouch-web/soundtouch-web.env
|
||||
/etc/soundtouch-player/soundtouch-player.env
|
||||
```
|
||||
|
||||
Example:
|
||||
@@ -173,7 +196,7 @@ network:
|
||||
SOUNDTOUCH_DEVICES=192.0.2.1,192.0.2.2
|
||||
```
|
||||
|
||||
`SERVICE_URL` links `soundtouch-web` to your `soundtouch-service` instance,
|
||||
`SERVICE_URL` links `soundtouch-player` to your `soundtouch-service` instance,
|
||||
which is required for Text-to-Speech ("Speak"). When the service is served
|
||||
over HTTPS with its own self-signed certificate (the default), also set
|
||||
`SERVICE_CA` to that CA certificate, or the proxied TTS call fails with
|
||||
@@ -192,7 +215,7 @@ be left empty.
|
||||
After editing the env file:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart soundtouch-web
|
||||
sudo systemctl restart soundtouch-player
|
||||
```
|
||||
|
||||
### Port conflicts
|
||||
@@ -210,35 +233,48 @@ the env file after installation and restart the service.
|
||||
### Service management
|
||||
|
||||
```bash
|
||||
systemctl status soundtouch-web
|
||||
sudo systemctl enable soundtouch-web # start on boot
|
||||
sudo systemctl disable soundtouch-web
|
||||
sudo systemctl stop soundtouch-web
|
||||
sudo systemctl start soundtouch-web
|
||||
sudo systemctl restart soundtouch-web
|
||||
systemctl status soundtouch-player
|
||||
sudo systemctl enable soundtouch-player # start on boot
|
||||
sudo systemctl disable soundtouch-player
|
||||
sudo systemctl stop soundtouch-player
|
||||
sudo systemctl start soundtouch-player
|
||||
sudo systemctl restart soundtouch-player
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
journalctl -u soundtouch-web -e --no-pager
|
||||
journalctl -u soundtouch-web -f
|
||||
journalctl -u soundtouch-player -e --no-pager
|
||||
journalctl -u soundtouch-player -f
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
sudo bash install-web.sh # update to latest release
|
||||
sudo bash install-web.sh v0.107.0 # update to a specific version
|
||||
sudo bash install-player.sh # update to latest release
|
||||
sudo bash install-player.sh v0.111.3 # update to a specific version
|
||||
```
|
||||
|
||||
### Removal
|
||||
|
||||
Use the uninstaller:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-web
|
||||
sudo rm /etc/systemd/system/soundtouch-web.service
|
||||
sudo rm -rf /etc/soundtouch-web
|
||||
sudo rm /usr/local/bin/soundtouch-web
|
||||
curl -fsSL -o uninstall-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-player.sh
|
||||
sudo bash uninstall-player.sh
|
||||
```
|
||||
|
||||
The `soundtouch:soundtouch` user/group is removed only once no other
|
||||
`soundtouch-*` install remains on the host.
|
||||
|
||||
Prefer to do it by hand? The equivalent manual steps are:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-player
|
||||
sudo rm /etc/systemd/system/soundtouch-player.service
|
||||
sudo rm -rf /etc/soundtouch-player
|
||||
sudo rm /usr/local/bin/soundtouch-player
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
@@ -258,7 +294,7 @@ Override if needed:
|
||||
|
||||
```bash
|
||||
sudo ARCH_ASSET=linux-arm64 bash install.sh
|
||||
sudo ARCH_ASSET=linux-arm64 bash install-web.sh
|
||||
sudo ARCH_ASSET=linux-arm64 bash install-player.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -21,18 +21,18 @@ Good choices: a Raspberry Pi, a NAS (like Synology or QNAP), an always-on PC or
|
||||
|
||||
## Step 1: Get the software
|
||||
|
||||
Go to the [AfterTouch releases page](https://github.com/gesellix/Bose-SoundTouch/releases) and download the latest release for your operating system:
|
||||
See the **[Downloads page](../downloads/_index.md)** for the full list of builds and how to pick the right one for your system. You want the `soundtouch-service` tool; download the build whose suffix matches your computer:
|
||||
|
||||
| Your system | File to download |
|
||||
|-----------------------|------------------------------------------|
|
||||
| Raspberry Pi (64-bit) | `soundtouch-service_linux_arm64.tar.gz` |
|
||||
| Raspberry Pi (32-bit) | `soundtouch-service_linux_arm.tar.gz` |
|
||||
| Linux (64-bit PC) | `soundtouch-service_linux_amd64.tar.gz` |
|
||||
| macOS (Apple Silicon) | `soundtouch-service_darwin_arm64.tar.gz` |
|
||||
| macOS (Intel) | `soundtouch-service_darwin_amd64.tar.gz` |
|
||||
| Windows | `soundtouch-service_windows_amd64.zip` |
|
||||
| Raspberry Pi (64-bit) | `soundtouch-service-vX.Y.Z-linux-arm64` |
|
||||
| Raspberry Pi (32-bit) | `soundtouch-service-vX.Y.Z-linux-armv7` |
|
||||
| Linux (64-bit PC) | `soundtouch-service-vX.Y.Z-linux-amd64` |
|
||||
| macOS (Apple Silicon) | `soundtouch-service-vX.Y.Z-darwin-arm64` |
|
||||
| macOS (Intel) | `soundtouch-service-vX.Y.Z-darwin-amd64` |
|
||||
| Windows | `soundtouch-service-vX.Y.Z-windows-amd64.exe` |
|
||||
|
||||
Extract the archive. You will find a single file called `soundtouch-service` (or `soundtouch-service.exe` on Windows).
|
||||
(`X.Y.Z` is the current release version.) The download is a single ready-to-run executable called `soundtouch-service` (or `soundtouch-service.exe` on Windows) — no archive to extract.
|
||||
|
||||
### Alternative: Docker
|
||||
|
||||
@@ -122,12 +122,12 @@ The easiest solution is to assign a **static (fixed) IP address** to the compute
|
||||
|
||||
## Security note
|
||||
|
||||
AfterTouch's web interface and management API have no login by default. On a typical home network this is fine, since only devices on your local network can reach it.
|
||||
The main web interface has no login by default — on a typical home network this is fine, since only devices on your local network can reach it.
|
||||
|
||||
If you want to restrict access — for example, on a shared network — start the service with a username and password:
|
||||
The Management API (Spotify/Amazon account linking, the Local Accounts page) is a separate area that's *always* protected by HTTP Basic Auth, but ships with a published default (`admin` / `change_me!`) — anyone who has read the docs can use it. If you want real protection — for example, on a shared network — set your own:
|
||||
|
||||
```
|
||||
./soundtouch-service --mgmt-username admin --mgmt-password yourpassword
|
||||
```
|
||||
|
||||
This protects the Settings tab (where your Spotify and Amazon credentials are stored) from being read or changed by others on the network.
|
||||
See [Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options) for the full list of settings and env-var equivalents. Note that this does *not* cover the Settings tab, where your Spotify/Amazon Client ID and Secret are stored — that tab has no separate protection today.
|
||||
|
||||
@@ -30,7 +30,7 @@ The service consists of several key components:
|
||||
- **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
|
||||
@@ -163,7 +163,7 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
| `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` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `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` |
|
||||
@@ -174,6 +174,8 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
@@ -390,7 +392,7 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
```json
|
||||
[
|
||||
{
|
||||
"device_id": "08DF1F0BA325",
|
||||
"device_id": "AABBCCDDEE0A",
|
||||
"name": "Living Room Speaker",
|
||||
"ip_address": "192.0.2.100",
|
||||
"product_code": "SoundTouch 20",
|
||||
|
||||
@@ -11,14 +11,14 @@ Bose shut down SoundTouch cloud services on **May 6, 2026**. Per the [official e
|
||||
|
||||
What **continues to work** regardless:
|
||||
- The official SoundTouch app for local control (play/pause/volume/source selection)
|
||||
- Local playback controls via `soundtouch-cli`, `soundtouch-web`, or any app that uses the local Web API
|
||||
- Local playback controls via `soundtouch-cli`, `soundtouch-player`, or any app that uses the local Web API
|
||||
- Bluetooth, AUX, and AirPlay inputs
|
||||
- Multiroom zones (local, peer-to-peer)
|
||||
|
||||
**AfterTouch** — the `soundtouch-service` — restores the first three:
|
||||
|
||||
- **Presets** — full preset management including long-press assignment and recently-played sync; music service presets (Spotify, TuneIn, etc.) work once the service is linked (see [Connecting Music Services](MUSIC-SERVICES.md))
|
||||
- **Music browsing and playback** — TuneIn, Internet Radio, and RadioBrowser via `soundtouch-web`; direct station/URL playback via `soundtouch-cli`; Spotify via Spotify Connect (speaker-native) or AfterTouch's OAuth integration; Amazon Music OAuth infrastructure is in place but streaming is not yet verified
|
||||
- **Music browsing and playback** — TuneIn, Internet Radio, and RadioBrowser via `soundtouch-player`; direct station/URL playback via `soundtouch-cli`; Spotify via Spotify Connect (speaker-native) or AfterTouch's OAuth integration; Amazon Music OAuth infrastructure is in place but streaming is not yet verified
|
||||
- **Stereo pairing** — via `soundtouch-cli`
|
||||
|
||||
Alexa voice commands are not currently supported.
|
||||
|
||||
@@ -154,6 +154,46 @@ logread -f | grep -v '127.0.0.1:'
|
||||
|
||||
> **Note on the firmware-internal placeholder sources.** The `<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" ...>`, `SpotifyAlexaUserName`, `UPNP/UPnPUserName`, `STORED_MUSIC_MEDIA_RENDERER/StoredMusicUserName`, and `QPLAY/QPlay{1,2}UserName` entries that appear in `/sources` even on a broken or unpaired speaker are *firmware-synthesized*. They show up regardless of AfterTouch's source list — their `status="UNAVAILABLE"` does not indicate an AfterTouch problem. Use the three checks above to diagnose the actual cause.
|
||||
|
||||
### ❌ On-device install fails with `curl: (60) ... certificate is not yet valid`
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Running the on-device installer over SSH (`curl -sSL .../install.sh | sh`)
|
||||
fails immediately, before anything is downloaded:
|
||||
|
||||
```
|
||||
curl: (60) SSL certificate problem: certificate is not yet valid
|
||||
```
|
||||
|
||||
- The same error appears for *any* HTTPS fetch from the speaker (GitHub,
|
||||
`raw.githubusercontent.com`, …).
|
||||
|
||||
**Cause:**
|
||||
|
||||
The speaker's clock is set in the past. SoundTouch speakers have no
|
||||
battery-backed clock and rely on NTP, which is no longer reliable after the Bose
|
||||
cloud shutdown, so the clock can fall back to a date years ago. TLS validation
|
||||
then rejects the (recently issued) server certificate as "not yet valid" — its
|
||||
validity period starts *after* the speaker's notion of "now". This is the same
|
||||
stuck-clock condition behind several TuneIn / TLS failures (see issue #345).
|
||||
|
||||
**Fix:**
|
||||
|
||||
Set the speaker's clock to roughly the current time over SSH, then re-run the
|
||||
installer:
|
||||
|
||||
```bash
|
||||
# On the speaker, over SSH. Replace with the current UTC date/time —
|
||||
# it only needs to be close enough to fall inside the certificate's validity
|
||||
# window, not exact.
|
||||
date -u -s "2026-06-27 12:00:00"
|
||||
```
|
||||
|
||||
Then re-run the on-device install one-liner. Once AfterTouch is installed and
|
||||
running, its **`speaker_clock` health check** (with a `set_clock` quick-fix)
|
||||
keeps the speaker's clock corrected, so this is a one-time hurdle to get the
|
||||
installer through.
|
||||
|
||||
### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests
|
||||
|
||||
**Symptoms:**
|
||||
@@ -302,6 +342,38 @@ avahi-resolve -n soundtouch.local
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Health tab: "HTTPS endpoint TLS configuration" warns about the wrong port / not reachable {#https-endpoint-tls-config}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- The Health tab's **HTTPS endpoint TLS configuration** check shows a warning like
|
||||
*"Configured HTTPS URL … uses port 443, but the service is listening on port 8443"*,
|
||||
or *"Configured HTTPS endpoint … isn't reachable from inside the service."*
|
||||
- You run AfterTouch on non-default ports (for example HTTP `8080`, HTTPS `8443`).
|
||||
|
||||
**Cause:**
|
||||
|
||||
AfterTouch advertises an HTTPS URL (used for the DNS-based redirect, Spotify/Amazon
|
||||
login, and certificate trust) separately from the HTTP one. If that URL's port
|
||||
doesn't match the port the HTTPS listener is actually bound to, the check dials the
|
||||
wrong place. This most often happened when the HTTPS URL had been set without a port
|
||||
(so it defaulted to `443`) while the listener was on `8443`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Open **Settings → Service URLs**. The **HTTPS URL** line shows the effective value.
|
||||
By default it now *derives* from the Target Domain (same host, on the HTTPS port),
|
||||
so simply saving a correct Target Domain fixes it. Expand the ⓘ next to **HTTPS URL**
|
||||
to set an **override** only if a reverse proxy serves HTTPS on a different host/port.
|
||||
- Equivalent CLI/env: set `--https-server-url` / `HTTPS_SERVER_URL` to include the
|
||||
right port, e.g. `https://<host>:8443`, then restart.
|
||||
|
||||
**Not always a problem:** if a reverse proxy intentionally terminates TLS on one port
|
||||
(e.g. `443`) and forwards to AfterTouch on another (e.g. `8443`), the warning is
|
||||
expected and can be ignored — the check can't see your proxy from inside the service.
|
||||
|
||||
---
|
||||
|
||||
## 🎵 **Playback Control Issues**
|
||||
|
||||
### ❌ "Play/Pause not working"
|
||||
@@ -316,7 +388,7 @@ avahi-resolve -n soundtouch.local
|
||||
```go
|
||||
nowPlaying, err := client.GetNowPlaying()
|
||||
if err == nil {
|
||||
fmt.Printf("Status: %s, Source: %s\n",
|
||||
fmt.Printf("Status: %s, Source: %s\n",
|
||||
nowPlaying.PlayStatus, nowPlaying.Source)
|
||||
}
|
||||
```
|
||||
@@ -326,7 +398,7 @@ if err == nil {
|
||||
sources, err := client.GetSources()
|
||||
if err == nil {
|
||||
for _, source := range sources.Sources {
|
||||
fmt.Printf("Source: %s, Status: %s\n",
|
||||
fmt.Printf("Source: %s, Status: %s\n",
|
||||
source.Source, source.Status)
|
||||
}
|
||||
}
|
||||
@@ -465,6 +537,46 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
### ❌ Radio sources never activate after an in-place migration {#radio-sources-after-migration}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- The speaker was migrated **in place** (not factory-reset first) and is reachable; account-bound sources (for example a music-streaming login) work and presets for them play.
|
||||
- **Every** radio-type source fails: selecting any `LOCAL_INTERNET_RADIO`, `TUNEIN`, or `RADIO_BROWSER` content returns `1005`, including the Health tab's "Play ding" test.
|
||||
- `curl http://<speaker-ip>:8090/sources` lists no radio source types at all.
|
||||
- The Health check warns that the speaker is "missing N source type(s) the service advertises".
|
||||
- The entries are present on disk in **both** the service-side `Sources.xml` **and** the speaker's own `/mnt/nv/BoseApp-Persistence/1/Sources.xml`, yet a reboot and a `sourcesUpdated` notification do not make them activate.
|
||||
|
||||
**Cause:**
|
||||
|
||||
After some in-place migrations the speaker's **runtime** `bmxRegistryUrl` (and often `statsServerUrl`) are still pointing at the dead Bose cloud (`content.api.bose.io` / `events.api.bosecm.com`), even though the persisted config and `Sources.xml` look correct. Radio sources (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO, …) are published through the **BMX registry**, so while `bmxRegistryUrl` points at the dead cloud the speaker can't fetch them and they never mount. On some models a full reboot reconciles all four service URLs from the stored config; on others it does not. (If you hit this, an encrypted diagnostic report taken **before** you reset the speaker is very helpful, and now includes the speaker's on-device `Sources.xml`. See the "Getting More Help" section below.)
|
||||
|
||||
**Workaround (preferred — non-destructive):**
|
||||
|
||||
Re-run the migration with the **telnet** method, which writes all four service URLs directly onto the speaker's runtime. No factory reset, no DNS, no SSH:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url http://<aftertouch-host>:8000
|
||||
```
|
||||
|
||||
Then reboot the speaker (or use "Refresh sources"). Afterwards the Migration tab's cross-check should show `bmxRegistryUrl` / `statsServerUrl` on AfterTouch, and the radio sources activate.
|
||||
|
||||
Notes:
|
||||
|
||||
- This needs the speaker's telnet diagnostic port (`17000`) to be reachable. Most SoundTouch models expose it; some hardened firmware builds do not, in which case use the factory-reset fallback below.
|
||||
- It writes AfterTouch's address (`http://<aftertouch-host>:8000`) **straight onto the speaker**, so there is no `bose:8000` hostname for the speaker to resolve. That is why pointing the service at `http://bose:8000` and adding a `bose` entry to your server's `/etc/hosts` does **not** help: the speaker is a separate device and never reads that file. If you prefer to redirect in the network instead of writing on the device, enable AfterTouch's built-in DNS (Settings) and have the speaker use AfterTouch as its resolver — see the FRITZ!Box + AdGuard guide.
|
||||
- Get `soundtouch-cli` from the [Downloads page](../downloads/_index.md) if you don't already have it.
|
||||
|
||||
**Workaround (fallback — factory reset):**
|
||||
|
||||
If the telnet method isn't available for your model, factory reset the speaker, then re-migrate it:
|
||||
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds).
|
||||
2. Reconnect the speaker to your network.
|
||||
3. Re-migrate it in AfterTouch.
|
||||
|
||||
After this the radio sources activate normally. Note the factory reset rewrites the speaker's `Sources.xml` to defaults, so any **account-bound** source (for example a music-streaming login) has to be re-added afterwards; your presets for it come back once the source is present again.
|
||||
|
||||
## 🔊 **Volume & Audio Issues**
|
||||
|
||||
### ❌ "Volume control not working"
|
||||
@@ -490,7 +602,7 @@ if err == nil {
|
||||
// Only zone master can control volume
|
||||
if zoneStatus == "MEMBER" {
|
||||
fmt.Println("Device is zone member - only master controls volume")
|
||||
|
||||
|
||||
// Find and use master device
|
||||
zone, _ := client.GetZone()
|
||||
// Connect to master device using zone.Master ID
|
||||
@@ -507,7 +619,7 @@ client.DecreaseVolume(5)
|
||||
3. **Check Current Volume:**
|
||||
```go
|
||||
volume, _ := client.GetVolume()
|
||||
fmt.Printf("Target: %d, Actual: %d, Muted: %t\n",
|
||||
fmt.Printf("Target: %d, Actual: %d, Muted: %t\n",
|
||||
volume.TargetVolume, volume.ActualVolume, volume.Muted)
|
||||
```
|
||||
|
||||
@@ -586,7 +698,7 @@ curl http://192.0.2.10:8090/playNotification
|
||||
**Causes & Solutions:**
|
||||
|
||||
#### 1. **Device Model Compatibility**
|
||||
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
|
||||
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
|
||||
- ❌ **Not Supported**: SoundTouch 300 (ST-300), older models
|
||||
|
||||
**Solution:** Verify device model with:
|
||||
@@ -619,11 +731,11 @@ Only one notification can play at a time. Wait a few seconds and retry.
|
||||
#### 2. **Check Current Playback Status**
|
||||
```go
|
||||
nowPlaying, _ := client.GetNowPlaying()
|
||||
fmt.Printf("Current source: %s, status: %s\n",
|
||||
fmt.Printf("Current source: %s, status: %s\n",
|
||||
nowPlaying.Source, nowPlaying.PlayStatus)
|
||||
```
|
||||
|
||||
### ❌ soundtouch-web TTS fails with `certificate signed by unknown authority`
|
||||
### ❌ soundtouch-player TTS fails with `certificate signed by unknown authority`
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
@@ -632,17 +744,17 @@ tls: failed to verify certificate: x509: certificate signed by unknown authority
|
||||
```
|
||||
|
||||
**Cause:** TTS synthesis and the Bose app key live in `soundtouch-service`,
|
||||
so `soundtouch-web` proxies the "Speak" action to the service. When the
|
||||
so `soundtouch-player` proxies the "Speak" action to the service. When the
|
||||
service is served over HTTPS with its own self-signed certificate (the
|
||||
default — see `GET /setup/ca.crt`), `soundtouch-web` doesn't trust that CA out
|
||||
default — see `GET /setup/ca.crt`), `soundtouch-player` doesn't trust that CA out
|
||||
of the box, so the proxied call fails verification.
|
||||
|
||||
**Solution:** start `soundtouch-web` with `--service-ca` pointing at the
|
||||
**Solution:** start `soundtouch-player` with `--service-ca` pointing at the
|
||||
service's CA certificate (its `<dataDir>/certs/ca.crt`, or the file served at
|
||||
`/setup/ca.crt`):
|
||||
|
||||
```bash
|
||||
soundtouch-web \
|
||||
soundtouch-player \
|
||||
--service-url https://soundtouch.fritz.box \
|
||||
--service-ca /path/to/certs/ca.crt
|
||||
```
|
||||
@@ -651,7 +763,7 @@ soundtouch-web \
|
||||
system trust store, so a service URL that uses a publicly trusted certificate
|
||||
needs no flag.
|
||||
|
||||
### ❌ soundtouch-web TTS returns `host ... is not a known device`
|
||||
### ❌ soundtouch-player TTS returns `host ... is not a known device`
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
@@ -664,8 +776,8 @@ verbatim from the request).
|
||||
|
||||
**Solution:** make sure the target speaker is known to `soundtouch-service`
|
||||
(discovered or manually added, and migrated to AfterTouch), not only to
|
||||
`soundtouch-web`'s own discovery. Check with `GET /setup/devices` on the
|
||||
service. (Recent `soundtouch-web` versions identify the speaker by its device
|
||||
`soundtouch-player`'s own discovery. Check with `GET /setup/devices` on the
|
||||
service. (Recent `soundtouch-player` versions identify the speaker by its device
|
||||
ID and a bare IP, so this error otherwise indicates the speaker simply isn't
|
||||
registered with the service.)
|
||||
|
||||
@@ -880,7 +992,7 @@ config.Logger = &client.DefaultLogger{} // Or custom logger
|
||||
# Capture SoundTouch traffic
|
||||
sudo tcpdump -i any host 192.0.2.100 and port 8090
|
||||
|
||||
# Monitor WebSocket traffic
|
||||
# Monitor WebSocket traffic
|
||||
sudo tcpdump -i any host 192.0.2.100 and port 8080
|
||||
|
||||
# HTTP debugging with curl
|
||||
@@ -1024,7 +1136,7 @@ Use this checklist to systematically troubleshoot issues:
|
||||
|
||||
### Network Connectivity
|
||||
- [ ] Device power LED is solid white
|
||||
- [ ] Both devices on same network subnet
|
||||
- [ ] Both devices on same network subnet
|
||||
- [ ] Firewall allows ports 8090 (HTTP) and 8080 (WebSocket)
|
||||
- [ ] Can ping device IP address
|
||||
- [ ] Can telnet to ports 8090 and 8080
|
||||
@@ -1041,7 +1153,7 @@ Use this checklist to systematically troubleshoot issues:
|
||||
- [ ] Proper error handling
|
||||
- [ ] Resource cleanup (defer statements)
|
||||
|
||||
### Multiroom Specific
|
||||
### Multiroom Specific
|
||||
- [ ] All devices support multiroom
|
||||
- [ ] Device IDs are correct (from GetDeviceInfo)
|
||||
- [ ] Devices on same network subnet
|
||||
@@ -1121,10 +1233,10 @@ In `vi`, find `block_remote_traffic()`. Press `i` to enter insert mode. After th
|
||||
first `done` line in that function, add:
|
||||
|
||||
```
|
||||
echo -A INPUT -i $IFACE -s 192.168.10.0/24 -j ACCEPT
|
||||
echo -A INPUT -i $IFACE -s 192.0.2.0/24 -j ACCEPT
|
||||
```
|
||||
|
||||
Replace `192.168.10.0/24` with the subnet your AfterTouch host is on. Press `Esc`,
|
||||
Replace `192.0.2.0/24` with the subnet your AfterTouch host is on. Press `Esc`,
|
||||
type `:wq`, press `Enter`, then reboot:
|
||||
|
||||
```bash
|
||||
@@ -1286,4 +1398,4 @@ go run ./cmd/soundtouch-cli -host <ip> -network-info
|
||||
- **Examples**: Review `/examples` for working code patterns
|
||||
- **CLI Tool**: Use built-in CLI for testing and debugging
|
||||
|
||||
Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues.
|
||||
Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "User Guides"
|
||||
weight: 1
|
||||
weight: 2
|
||||
sidebar:
|
||||
open: true
|
||||
---
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: "Playing Music from a DLNA / NAS Library"
|
||||
---
|
||||
This guide explains how to browse a DLNA/UPnP media server on your LAN (NAS,
|
||||
FRITZ!Box, minidlna, Plex, etc.) and play its tracks on a SoundTouch speaker
|
||||
using the speaker's native `STORED_MUSIC` source.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
The **speaker is the DLNA control point**, not AfterTouch. The flow is:
|
||||
|
||||
1. AfterTouch (or the CLI) discovers DLNA servers on the LAN via SSDP.
|
||||
2. You register a server on the speaker with `setMusicServiceAccount`. This
|
||||
creates a `STORED_MUSIC` source entry on the speaker.
|
||||
3. You browse the library through the speaker's `/navigate` endpoint. The
|
||||
speaker contacts the media server's ContentDirectory service and returns its
|
||||
own browse tokens (e.g. `4:cont2:150:0:0:` for folders, or a track token
|
||||
ending in `TRACK`).
|
||||
4. You select a track or folder using `/select` with a `STORED_MUSIC`
|
||||
`ContentItem`. The speaker fetches the audio directly from the media server.
|
||||
AfterTouch does not proxy the audio stream.
|
||||
|
||||
> **Why use the speaker's browse tokens instead of raw DLNA object IDs?**
|
||||
> The speaker's `/select` endpoint only accepts the tokens it produces via
|
||||
> `/navigate`. Raw DLNA ContentDirectory object IDs (like `"64"`) are not
|
||||
> accepted for playback.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A DLNA/UPnP media server running on the same LAN as the speaker (e.g. NAS
|
||||
with minidlna, FRITZ!Box media server, Plex with DLNA enabled).
|
||||
- The speaker and the media server must be on the same network segment so the
|
||||
speaker can reach the server directly for streaming.
|
||||
- `soundtouch-cli` installed and able to reach the speaker (test with
|
||||
`soundtouch-cli --host 192.0.2.10 info`).
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Discover servers on the LAN
|
||||
|
||||
Run an SSDP sweep from the machine where the CLI runs:
|
||||
|
||||
```bash
|
||||
soundtouch-cli library servers
|
||||
```
|
||||
|
||||
Sample output:
|
||||
|
||||
```
|
||||
Found 1 DLNA media server(s):
|
||||
|
||||
Name: My Music Library
|
||||
Vendor: minidlna / MiniDLNA 1.3.3
|
||||
UDN: uuid:00000000-0000-0000-0000-000000000000
|
||||
CDS: http://192.0.2.20:8200/ctl/ContentDir
|
||||
```
|
||||
|
||||
Note the **UDN** (the `uuid:...` string). You will need it in the next steps.
|
||||
|
||||
Alternatively, ask a specific speaker for its own DLNA list (the speaker runs
|
||||
its own independent UPnP sweep):
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.10 library servers --via-speaker
|
||||
```
|
||||
|
||||
> The speaker's list and the app-side list may differ. The speaker reports only
|
||||
> servers it has seen on its UPnP sweep, which can lag behind or miss servers
|
||||
> that appear after the speaker boots.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Register the server on the speaker
|
||||
|
||||
The `STORED_MUSIC` source account is the bare UUID from the UDN with a `/0`
|
||||
suffix appended. If the UDN from discovery is
|
||||
`uuid:00000000-0000-0000-0000-000000000000`, the account string is
|
||||
`00000000-0000-0000-0000-000000000000/0` (drop the `uuid:` prefix).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.10 account add-nas \
|
||||
--user 00000000-0000-0000-0000-000000000000/0 \
|
||||
--name "My Music Library"
|
||||
```
|
||||
|
||||
Verify the source is visible:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.10 source list
|
||||
```
|
||||
|
||||
The output should include a `STORED_MUSIC` entry with your display name and
|
||||
status `READY`. If the status is `UNAVAILABLE`, wait 10-20 seconds and check
|
||||
again; the speaker needs a moment to connect to the media server.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Browse the library
|
||||
|
||||
Get the top-level containers:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.10 browse stored-music \
|
||||
--source-account 00000000-0000-0000-0000-000000000000/0
|
||||
```
|
||||
|
||||
Drill into a folder using a location token returned from the previous step:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.10 browse container \
|
||||
--source STORED_MUSIC \
|
||||
--source-account 00000000-0000-0000-0000-000000000000/0 \
|
||||
--location "4:cont2:150:0:0:" \
|
||||
--type dir
|
||||
```
|
||||
|
||||
Repeat with `--type dir` for sub-folders, or `--type track` for track
|
||||
containers. The `Location` values shown in browse output are the tokens to
|
||||
pass to the next `browse container` or `library play` call.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Play a track
|
||||
|
||||
Pass the location token from a browse result to `library play`:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.10 library play \
|
||||
--source-account 00000000-0000-0000-0000-000000000000/0 \
|
||||
--location "5:audio5:part13:3171:5 TRACK" \
|
||||
--name "Track Title"
|
||||
```
|
||||
|
||||
The `--name` flag sets the display name shown on the speaker's display and in
|
||||
the web UI. It is optional but recommended for clarity.
|
||||
|
||||
The CLI checks that the `STORED_MUSIC` source is in `READY` state before
|
||||
sending the play command. If it is not ready, it prints the `account add-nas`
|
||||
command you need to run first.
|
||||
|
||||
---
|
||||
|
||||
## Player UI (BETA)
|
||||
|
||||
The soundtouch-player "Library" tab provides a browser-based interface for the
|
||||
same workflow:
|
||||
|
||||
1. Open the player at `http://<aftertouch-host>:8000`.
|
||||
2. Go to the **Library** tab.
|
||||
3. Select the target speaker from the device list.
|
||||
4. Click **Find servers** to run an SSDP sweep.
|
||||
5. Click **Add** next to a server to register it on the speaker.
|
||||
6. Open the server to browse folders and tracks.
|
||||
7. Click a track to play it on the speaker.
|
||||
|
||||
> **BETA notice:** DLNA behavior varies across server implementations. Some
|
||||
> servers expose non-standard browse trees or restrict access by IP. If a
|
||||
> server appears in discovery but does not load in the library browser, check
|
||||
> that the media server allows UPnP browsing from the speaker's IP address.
|
||||
|
||||
---
|
||||
|
||||
## Removing a server
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.10 account remove-nas \
|
||||
--user 00000000-0000-0000-0000-000000000000/0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gotchas and limitations
|
||||
|
||||
**No password needed for STORED_MUSIC.** The registration only requires the
|
||||
server UDN. The `--user` flag takes the bare UUID plus `/0`; no `--password`
|
||||
flag is accepted or needed.
|
||||
|
||||
**Source becomes UNAVAILABLE after a speaker reboot.** The speaker re-runs its
|
||||
UPnP sweep on startup. Until it rediscovers the media server (usually within
|
||||
30 seconds), the `STORED_MUSIC` source shows as `UNAVAILABLE`. Wait for it to
|
||||
return to `READY` before browsing or playing.
|
||||
|
||||
**`uuid:` prefix in UDN.** Discovery output may show the full UDN as
|
||||
`uuid:00000000-0000-0000-0000-000000000000`. Drop the `uuid:` prefix when
|
||||
passing it to `--user`; the account string is just the UUID plus `/0`.
|
||||
|
||||
**Format support.** The SoundTouch firmware decodes MP3 and AAC streams. HLS
|
||||
(`.m3u8`) playlists and formats the firmware cannot decode (e.g. FLAC, ALAC,
|
||||
OGG) will not play. If a track starts and immediately stops, the audio format
|
||||
is likely unsupported.
|
||||
|
||||
**Do not re-register a READY source.** Calling `account add-nas` on a source
|
||||
that is already `READY` can flip it to `UNAVAILABLE`. Check `source list`
|
||||
first.
|
||||
@@ -3,6 +3,8 @@ title: "Bose SoundTouch Web API - Endpoints Overview"
|
||||
---
|
||||
This document provides a comprehensive overview of the available API endpoints verified against the official Bose SoundTouch Web API v1.0 specification (January 7, 2026).
|
||||
|
||||
> **Note:** This documents the *speaker device* Web API (port 8090). For the AfterTouch *service's* own route layout (cloud emulation vs admin/control surface) and the planned refactoring, see [API Route Layout and Refactoring Plan](../architecture/API-ROUTE-LAYOUT.md).
|
||||
|
||||
**Acknowledgment**: Additional endpoints beyond the official API were discovered through the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) maintained by the SoundTouch Plus community. Special thanks to @thlucas1 and contributors for documenting these working endpoints that enable full preset management and content navigation functionality.
|
||||
|
||||
## Implementation Status Legend
|
||||
@@ -336,6 +338,19 @@ Configures the clock display.
|
||||
### POST /speaker ✅ **Implemented**
|
||||
Plays TTS messages or URL content for notifications (ST-10 Series only).
|
||||
|
||||
> **Requires DNS interception.** Before playing a `play_info` notification the
|
||||
> speaker validates the `app_key` by calling `GET /v1/auth` against a hardcoded
|
||||
> Bose host (`audionotification.api.bosecm.com`, on some firmware the
|
||||
> `...dev...` variant). After the cloud shutdown that host no longer exists, so
|
||||
> unless the speaker resolves Bose hostnames through AfterTouch (DNS server +
|
||||
> the `/etc/resolv.conf` hook, so `*.api.bosecm.com` points at AfterTouch, which
|
||||
> answers `/v1/auth`), the request hangs and returns
|
||||
> `ALLEGROWEBSERVER_TIMEOUT` (error `1046`) after ~60s. If you cannot use DNS
|
||||
> interception, play the clip via the `LOCAL_INTERNET_RADIO` path instead (the
|
||||
> "radio" method used by the web player's TTS): it needs no `app_key` and no DNS
|
||||
> redirection, but it replaces the current source rather than ducking and
|
||||
> resuming it.
|
||||
|
||||
**TTS Request XML:**
|
||||
```xml
|
||||
<play_info>
|
||||
@@ -371,6 +386,47 @@ Plays TTS messages or URL content for notifications (ST-10 Series only).
|
||||
- Custom metadata for NowPlaying display
|
||||
- Pauses current content, plays notification, then resumes
|
||||
|
||||
#### Alternative: play a URL via UPnP / AVTransport (no app key, no DNS)
|
||||
|
||||
If the `play_info` DNS requirement above is a problem (for example a home
|
||||
automation hub that just wants to push a TTS or notification clip), the speaker's
|
||||
UPnP `AVTransport` service can play a URL directly with no app key and no DNS
|
||||
interception. POST a SOAP `SetAVTransportURI` to the MediaRenderer control
|
||||
endpoint on port **8091** (not 8090), then `Play`:
|
||||
|
||||
```
|
||||
POST http://<speaker-ip>:8091/AVTransport/Control
|
||||
Content-Type: text/xml; charset="utf-8"
|
||||
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
<CurrentURI>http://<host>/clip.mp3</CurrentURI>
|
||||
<CurrentURIMetaData></CurrentURIMetaData>
|
||||
</u:SetAVTransportURI>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
```
|
||||
|
||||
The URL must be plain **`http://`**: the speaker's AVTransport rejects `https://`
|
||||
("URI must start with http://, qplay:// or Stored Music XML") and then reports a
|
||||
misleading `402 "No URI supplied"`. For an `https`-only source, host the clip
|
||||
over HTTP or use a method that proxies it (the service TTS / `LOCAL_INTERNET_RADIO`
|
||||
path).
|
||||
|
||||
Trade-offs versus `play_info`: this switches the speaker to the `UPNP` source and
|
||||
**replaces** the current playback (it does not duck and resume), and the speaker
|
||||
itself must be able to reach the URL. The CLI wraps both steps:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> speaker url-upnp --url http://<host>/clip.mp3
|
||||
```
|
||||
|
||||
(Thanks to @dagrider in #517 for surfacing this approach.)
|
||||
|
||||
### GET /playNotification ✅ **Implemented**
|
||||
Plays a notification beep sound (ST-10 Series only).
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ title: "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.
|
||||
|
||||
> **See also:** [API Route Layout and Refactoring Plan](../architecture/API-ROUTE-LAYOUT.md) - how these cloud-emulation routes are classified (frozen speaker contract vs our own movable surface) and the planned API consolidation toward a single binary.
|
||||
|
||||
## Marge API (Account & Configuration)
|
||||
|
||||
Base path: `/marge`
|
||||
|
||||
@@ -71,7 +71,7 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
|
||||
```
|
||||
|
||||
`requestID` is a monotonically increasing integer per connection (client-side sequence).
|
||||
`{device_id}` is the speaker's MAC address with colons removed (e.g. `08DF1F0BA325`).
|
||||
`{device_id}` is the speaker's MAC address with colons removed (e.g. `AABBCCDDEE0A`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ Device system settings:
|
||||
$ soundtouch-cli --host 192.0.2.100 analyze
|
||||
|
||||
🔍 Device Capability Analysis:
|
||||
Device ID: 08DF1F0BA325
|
||||
Device ID: AABBCCDDEE0A
|
||||
Feature Coverage: 87% (13/15 features)
|
||||
Device Type: Premium SoundTouch Speaker (Full Feature Set)
|
||||
|
||||
@@ -161,27 +161,27 @@ import (
|
||||
func analyzeDevice(host string) {
|
||||
// Create client
|
||||
c := client.NewClient(&client.Config{Host: host})
|
||||
|
||||
|
||||
// Get supported URLs with feature mapping
|
||||
supportedURLs, err := c.GetSupportedURLs()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Get device capabilities overview
|
||||
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
|
||||
fmt.Printf("Device supports %d%% of features (%d/%d)\n",
|
||||
fmt.Printf("Device supports %d%% of features (%d/%d)\n",
|
||||
completeness, supported, total)
|
||||
|
||||
|
||||
// Check specific capabilities
|
||||
if supportedURLs.HasMultiroomSupport() {
|
||||
fmt.Println("✅ Device can create multiroom zones")
|
||||
}
|
||||
|
||||
|
||||
if supportedURLs.HasAdvancedAudioSupport() {
|
||||
fmt.Println("✅ Device has advanced audio controls")
|
||||
}
|
||||
|
||||
|
||||
// Get missing essential features
|
||||
missing := supportedURLs.GetMissingEssentialFeatures()
|
||||
if len(missing) > 0 {
|
||||
@@ -190,13 +190,13 @@ func analyzeDevice(host string) {
|
||||
fmt.Printf(" • %s\n", feature.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get features by category
|
||||
featuresByCategory := supportedURLs.GetFeaturesByCategory()
|
||||
for category, features := range featuresByCategory {
|
||||
fmt.Printf("%s: %d features available\n", category, len(features))
|
||||
}
|
||||
|
||||
|
||||
// Check for partial implementations
|
||||
partial := supportedURLs.GetPartiallyImplementedFeatures()
|
||||
for _, feature := range partial {
|
||||
@@ -211,10 +211,10 @@ func analyzeDevice(host string) {
|
||||
func canDoAdvancedAudio(supportedURLs *models.SupportedURLsResponse) bool {
|
||||
requiredEndpoints := []string{
|
||||
"/audiodspcontrols",
|
||||
"/audioproducttonecontrols",
|
||||
"/audioproducttonecontrols",
|
||||
"/audioproductlevelcontrols",
|
||||
}
|
||||
|
||||
|
||||
for _, endpoint := range requiredEndpoints {
|
||||
if !supportedURLs.HasURL(endpoint) {
|
||||
return false
|
||||
@@ -226,19 +226,19 @@ func canDoAdvancedAudio(supportedURLs *models.SupportedURLsResponse) bool {
|
||||
// Get device-specific recommendations
|
||||
func getPersonalizedTips(supportedURLs *models.SupportedURLsResponse) []string {
|
||||
var tips []string
|
||||
|
||||
|
||||
if supportedURLs.HasURL("/presets") {
|
||||
tips = append(tips, "Set up presets for your favorite stations")
|
||||
}
|
||||
|
||||
|
||||
if supportedURLs.HasURL("/setZone") {
|
||||
tips = append(tips, "Create multiroom zones for whole-home audio")
|
||||
}
|
||||
|
||||
|
||||
if supportedURLs.HasURL("/search") && supportedURLs.HasURL("/addStation") {
|
||||
tips = append(tips, "Search and save new radio stations")
|
||||
}
|
||||
|
||||
|
||||
return tips
|
||||
}
|
||||
```
|
||||
@@ -289,7 +289,7 @@ soundtouch-cli audio level get # Get level controls
|
||||
```bash
|
||||
# Basic Playback (Essential)
|
||||
soundtouch-cli play start # Start playback
|
||||
soundtouch-cli play stop # Stop playback
|
||||
soundtouch-cli play stop # Stop playback
|
||||
soundtouch-cli play pause # Pause playback
|
||||
soundtouch-cli play now # Get now playing info
|
||||
|
||||
@@ -306,7 +306,7 @@ soundtouch-cli key mute # Mute toggle
|
||||
# Audio Sources
|
||||
soundtouch-cli source list # List available sources
|
||||
soundtouch-cli source select --source SPOTIFY # Select Spotify
|
||||
soundtouch-cli source bluetooth # Select Bluetooth
|
||||
soundtouch-cli source bluetooth # Select Bluetooth
|
||||
soundtouch-cli source aux # Select AUX input
|
||||
|
||||
# Service Availability
|
||||
@@ -321,7 +321,7 @@ soundtouch-cli browse tunein # Browse TuneIn content
|
||||
soundtouch-cli browse pandora --source-account <account> # Browse Pandora
|
||||
soundtouch-cli browse spotify --source-account <account> # Browse Spotify
|
||||
|
||||
# Station Management
|
||||
# Station Management
|
||||
soundtouch-cli station search-tunein --query "jazz" # Search TuneIn
|
||||
soundtouch-cli station search-pandora --query "rock" --source-account <account>
|
||||
soundtouch-cli station add --source TUNEIN --token <token> --name "Jazz FM"
|
||||
@@ -354,7 +354,7 @@ soundtouch-cli zone remove --member 192.0.2.103 # Remove from zone
|
||||
# Quick capability check
|
||||
soundtouch-cli supported-urls | grep "Feature Coverage"
|
||||
|
||||
# Essential features verification
|
||||
# Essential features verification
|
||||
soundtouch-cli analyze | grep -A 5 "Missing Essential Features"
|
||||
|
||||
# Advanced features check
|
||||
@@ -368,7 +368,7 @@ soundtouch-cli supported-urls --features | grep "Multiroom"
|
||||
Based on feature support, devices are automatically classified:
|
||||
|
||||
- **Premium SoundTouch Speaker**: Multiroom + Advanced Audio + Full Feature Set
|
||||
- **Standard SoundTouch Speaker**: Multiroom Capable + Core Features
|
||||
- **Standard SoundTouch Speaker**: Multiroom Capable + Core Features
|
||||
- **Basic SoundTouch Speaker**: Streaming + Presets + Core Features
|
||||
- **Essential SoundTouch Device**: Core Playback Features Only
|
||||
- **Limited SoundTouch Device**: Minimal Feature Set
|
||||
@@ -384,7 +384,7 @@ soundtouch-cli supported-urls --features | grep -i "bass control"
|
||||
# If not listed, device doesn't support bass control
|
||||
```
|
||||
|
||||
**Issue**: "Multiroom not available"
|
||||
**Issue**: "Multiroom not available"
|
||||
```bash
|
||||
# Verify multiroom support
|
||||
soundtouch-cli analyze | grep "Multiroom"
|
||||
@@ -405,7 +405,7 @@ soundtouch-cli supported-urls --features | grep "Content Navigation"
|
||||
The feature mapping system provides personalized recommendations:
|
||||
|
||||
- **Missing Balance Control**: "No balance control available on this device"
|
||||
- **Multiroom Available**: "Create speaker groups with other devices"
|
||||
- **Multiroom Available**: "Create speaker groups with other devices"
|
||||
- **Advanced Audio**: "Fine-tune sound with DSP controls"
|
||||
- **Limited Features**: "Consider upgrading for full functionality"
|
||||
|
||||
@@ -417,4 +417,4 @@ The feature mapping system provides personalized recommendations:
|
||||
4. **Review recommendations** for optimal device usage
|
||||
5. **Monitor feature completeness** to understand device limitations
|
||||
|
||||
This comprehensive feature mapping system ensures you get the most out of your SoundTouch device by understanding exactly what it can do and how to use it effectively.
|
||||
This comprehensive feature mapping system ensures you get the most out of your SoundTouch device by understanding exactly what it can do and how to use it effectively.
|
||||
|
||||
@@ -333,7 +333,7 @@ soundtouch-cli speaker tts-cloud \
|
||||
--method speaker
|
||||
```
|
||||
|
||||
Web UI: the TTS source view has a "Say something…" box. soundtouch-web proxies
|
||||
Web UI: the TTS source view has a "Say something…" box. soundtouch-player proxies
|
||||
it to the service, so it must be started with `--service-url` (the target is
|
||||
server-configured, not entered in the browser, to avoid an SSRF proxy).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "Technical Reference"
|
||||
weight: 2
|
||||
weight: 3
|
||||
---
|
||||
|
||||
@@ -43,6 +43,8 @@ curl -v -X POST http://<speaker-ip>:8090/notification \
|
||||
|
||||
Replace `<speaker-ip>` with your speaker's IP address and `<deviceID>` with its device ID (visible in `/info`). After this call the speaker re-fetches its sources from the service. Step 2 (source-type registration) still requires a reboot.
|
||||
|
||||
> **If a reboot doesn't help after an in-place migration:** on some speakers the radio source types (`LOCAL_INTERNET_RADIO`, `TUNEIN`, `RADIO_BROWSER`) never activate after an in-place migration, even though the entries are present in the device-local `Sources.xml` and you have rebooted and sent `sourcesUpdated`. The cause isn't fully understood; the confirmed remedy is a factory reset + re-migrate. See [Troubleshooting: Radio sources never activate after an in-place migration](../guides/TROUBLESHOOTING.md#radio-sources-after-migration).
|
||||
|
||||
### Search for stations
|
||||
|
||||
- Go to https://www.radio-browser.info and find a station you like.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user