mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57: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).
|
||||
|
||||
+22
-22
@@ -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
|
||||
@@ -165,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"
|
||||
|
||||
@@ -192,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: |
|
||||
@@ -250,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"
|
||||
|
||||
@@ -305,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
|
||||
@@ -330,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 }}
|
||||
@@ -338,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: |
|
||||
@@ -348,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
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-player
|
||||
id: meta-player
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-player
|
||||
tags: |
|
||||
@@ -374,7 +374,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-player Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-player
|
||||
|
||||
@@ -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
|
||||
|
||||
+90
-204
@@ -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"
|
||||
@@ -173,10 +197,6 @@ jobs:
|
||||
# Build Player (formerly soundtouch-web)
|
||||
build_binary "soundtouch-player" "./cmd/soundtouch-player"
|
||||
|
||||
# Build Web: transitional alias of the player, built from the same
|
||||
# source. Dropped in a future release; keep in sync with player.
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-player"
|
||||
|
||||
# Build Backup
|
||||
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
|
||||
id: build
|
||||
@@ -186,7 +206,6 @@ jobs:
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
PLAYER_NAME="${{ steps.build.outputs.soundtouch-player }}"
|
||||
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
|
||||
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
@@ -204,7 +223,6 @@ jobs:
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$PLAYER_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
generate_checksums "$BCK_NAME"
|
||||
|
||||
# Cleanup
|
||||
@@ -219,7 +237,6 @@ jobs:
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-player-v*
|
||||
build/soundtouch-web-v*
|
||||
build/soundtouch-backup-v*
|
||||
retention-days: 1
|
||||
|
||||
@@ -247,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-player-*" -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
|
||||
@@ -262,14 +279,14 @@ 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-player-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* 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
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=35 # 7 platforms * 5 binaries (player + its web alias)
|
||||
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
@@ -312,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
|
||||
@@ -325,172 +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 Player (formerly soundtouch-web)
|
||||
\`\`\`bash
|
||||
# Start the LAN web player
|
||||
./soundtouch-player
|
||||
\`\`\`
|
||||
> Note: \`soundtouch-web\` has been renamed to \`soundtouch-player\`.
|
||||
> The \`soundtouch-web\` assets are still published as a transitional
|
||||
> alias and will be removed in a future release. Please switch your
|
||||
> downloads and scripts to \`soundtouch-player\`.
|
||||
|
||||
### 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-player` (with `soundtouch-web` as a transitional alias), 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-player-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
@@ -512,14 +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-player-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
@@ -534,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 }}
|
||||
@@ -552,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: |
|
||||
@@ -561,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
|
||||
@@ -570,15 +483,15 @@ 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-player
|
||||
id: meta-player
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-player
|
||||
tags: |
|
||||
@@ -587,7 +500,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-player Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-player
|
||||
@@ -596,35 +509,8 @@ jobs:
|
||||
tags: ${{ steps.meta-player.outputs.tags }}
|
||||
labels: ${{ steps.meta-player.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=v${{ needs.validate.outputs.version }}
|
||||
COMMIT=${{ github.sha }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Transitional alias image (formerly the only web image). Dropped later.
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
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
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
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 }}
|
||||
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
|
||||
@@ -639,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'
|
||||
|
||||
@@ -21,6 +21,7 @@ dist/
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/example-dlna-server
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
|
||||
@@ -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,6 +99,34 @@ 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
|
||||
|
||||
```
|
||||
|
||||
+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.
|
||||
|
||||
+3
-22
@@ -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
|
||||
@@ -46,7 +46,7 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
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
|
||||
|
||||
@@ -95,7 +95,7 @@ EXPOSE 8000
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
|
||||
# soundtouch-player image
|
||||
FROM alpine:3.23 AS soundtouch-player
|
||||
FROM alpine:3.24 AS soundtouch-player
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
@@ -112,22 +112,3 @@ EXPOSE 8080
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-player"]
|
||||
|
||||
# soundtouch-web image: transitional alias of soundtouch-player. Built from the
|
||||
# same binary; the entrypoint name makes the binary print a rename notice on
|
||||
# start. Will be dropped in a future release.
|
||||
FROM alpine:3.23 AS soundtouch-web
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-player /app/soundtouch-web
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
|
||||
@@ -19,9 +19,6 @@ SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
PLAYER_NAME=soundtouch-player
|
||||
PLAYER_PATH=./cmd/$(PLAYER_NAME)
|
||||
# WEB_NAME is the previous name for the player, kept as a transitional alias
|
||||
# built from the same PLAYER_PATH source. It will be dropped in a future release.
|
||||
WEB_NAME=soundtouch-web
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
@@ -55,7 +52,7 @@ AUTH_SERVICE_URL ?= $(BACKEND_URL)
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-player 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)..."
|
||||
@@ -72,13 +69,6 @@ build-player:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(PLAYER_NAME) $(PLAYER_PATH)
|
||||
|
||||
# Transitional alias: builds the same source as build-player under the old
|
||||
# soundtouch-web name. Drop this target once the alias is retired.
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME) (transitional alias of $(PLAYER_NAME))..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(PLAYER_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@@ -376,12 +366,11 @@ dev-player-host: build-player
|
||||
fi
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-player 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)/$(PLAYER_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
|
||||
|
||||
update-static-deps:
|
||||
|
||||
@@ -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,13 +66,13 @@ 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-player
|
||||
|
||||
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are still published as a transitional alias and will be removed in a future release; please switch to `soundtouch-player`.
|
||||
> 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.)
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -538,6 +538,53 @@ 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",
|
||||
@@ -556,6 +603,11 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
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)",
|
||||
@@ -589,23 +641,32 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
serviceURL = "https://aftertouch.invalid"
|
||||
}
|
||||
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", cfg.Host)
|
||||
|
||||
logs, err := m.EnableSSHViaTelnet(cfg.Host, serviceURL)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
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 {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
// 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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// 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 still published as a
|
||||
// transitional alias and will be dropped in a future release.
|
||||
// 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 (
|
||||
@@ -38,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
|
||||
}
|
||||
|
||||
@@ -56,9 +61,8 @@ func updateBuildInfo() {
|
||||
}
|
||||
|
||||
// warnIfInvokedAsWeb prints a one-line deprecation notice when the binary is
|
||||
// run under its old name (soundtouch-web). The soundtouch-web artifact is a
|
||||
// transitional alias built from this same source; this nudges operators to
|
||||
// switch to soundtouch-player before the alias is dropped.
|
||||
// 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
|
||||
@@ -67,8 +71,7 @@ func warnIfInvokedAsWeb() {
|
||||
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'. " +
|
||||
"This name is a transitional alias and will stop being published in a future release; " +
|
||||
"please switch to 'soundtouch-player'.")
|
||||
"The 'soundtouch-web' name is no longer published; please switch to 'soundtouch-player'.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+109
-28
@@ -50,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
|
||||
}
|
||||
|
||||
@@ -477,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)
|
||||
}
|
||||
|
||||
@@ -499,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)
|
||||
@@ -509,6 +529,11 @@ 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)
|
||||
|
||||
@@ -663,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
|
||||
@@ -736,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)
|
||||
@@ -797,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,
|
||||
@@ -903,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 {
|
||||
@@ -920,10 +968,20 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
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 {
|
||||
@@ -1042,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,
|
||||
@@ -1222,14 +1280,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
// every downstream middleware and the recorder see the cleaned path.
|
||||
r.Use(middleware.CleanPath)
|
||||
|
||||
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
|
||||
// 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)
|
||||
@@ -1239,8 +1295,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.Get("/admin", server.HandleAdmin)
|
||||
r.With(server.BasicAuthAdmin()).Get("/admin", server.HandleAdmin)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
// 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
|
||||
@@ -1526,7 +1586,23 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
// /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.
|
||||
mountSetupAPI := func(r chi.Router) {
|
||||
//
|
||||
// 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)
|
||||
@@ -1534,11 +1610,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
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)
|
||||
@@ -1560,7 +1631,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
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)
|
||||
@@ -1592,12 +1662,19 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
mountSetupAPI(r)
|
||||
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)
|
||||
@@ -1605,7 +1682,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
})
|
||||
|
||||
r.Route("/api/setup", func(r chi.Router) {
|
||||
mountSetupAPI(r)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ DELETE /accounts/{account}/group handlers.(
|
||||
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
|
||||
@@ -35,13 +36,17 @@ GET /accounts/{account}/devices/{device}/recents handlers.(
|
||||
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
|
||||
@@ -81,6 +86,7 @@ GET /api/setup/version handlers.(
|
||||
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
|
||||
@@ -177,8 +183,11 @@ POST /accounts/{account}/group handlers.(
|
||||
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
|
||||
@@ -284,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
|
||||
|
||||
@@ -5,5 +5,6 @@ default/
|
||||
dns/
|
||||
interactions/
|
||||
parity_mismatches/
|
||||
stats/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
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:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
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:
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.4-alpine
|
||||
image: golang:1.26.5-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,14 +19,15 @@ 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`) and the full stereo-pair group set
|
||||
> `RemoveMusicServiceAccount`), the full stereo-pair group set
|
||||
> `getGroup` / `addGroup` / `removeGroup` / `updateGroup`
|
||||
> (`GetGroup`, `AddGroup`, `RemoveGroup`, `UpdateGroup`). 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`, `/listMediaServers`) were confirmed absent from `pkg/client`
|
||||
> (some appear only in test fixtures).
|
||||
> (`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).
|
||||
|
||||
---
|
||||
|
||||
@@ -294,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
|
||||
@@ -1016,7 +1019,7 @@ 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`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Architecture"
|
||||
weight: 5
|
||||
weight: 6
|
||||
---
|
||||
|
||||
Architecture notes and analyses:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -176,12 +191,12 @@ open **`http://<host-ip>:8080`** in your browser (default port 8080).
|
||||
### Installing soundtouch-player on a Raspberry Pi
|
||||
|
||||
`install.sh` only installs `soundtouch-service`. Use the dedicated
|
||||
`install-web.sh` script to add soundtouch-player:
|
||||
`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
|
||||
@@ -190,7 +205,7 @@ For configuration, service management, updates, and removal see the
|
||||
### 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
|
||||
@@ -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-player` | 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,13 +113,30 @@ 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -126,24 +149,24 @@ 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.
|
||||
@@ -228,12 +251,25 @@ 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
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)* |
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -338,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>
|
||||
@@ -373,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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.5
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.107.0
|
||||
require github.com/gesellix/bose-soundtouch v0.118.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.5
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.107.0
|
||||
require github.com/gesellix/bose-soundtouch v0.118.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/chromedp/chromedp v0.16.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.6
|
||||
github.com/hashicorp/mdns v1.0.7
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/term v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
golang.org/x/image v0.44.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,10 +6,10 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
|
||||
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
|
||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
@@ -17,10 +17,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
|
||||
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
@@ -33,14 +33,13 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
|
||||
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
|
||||
github.com/hashicorp/mdns v1.0.7 h1:yWoQVMW5JOiDxQnIUcm3IDt0kCjf3TuXHDbdEKPsbAY=
|
||||
github.com/hashicorp/mdns v1.0.7/go.mod h1:yjuhYhZyPDqXXL48xC7cdpGwGUMwu7OViDmsuT5COvg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
@@ -62,101 +61,36 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
Generated
+12
-4
@@ -8,7 +8,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.2"
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
@@ -21,13 +21,21 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.2",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
|
||||
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
|
||||
"version": "10.29.8",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact-render-to-string": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"preact-render-to-string": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.2"
|
||||
"preact": "10.29.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// avTransportControlPath is the UPnP AVTransport control endpoint on the
|
||||
// speaker's MediaRenderer (served on speaker.UPnPPort, not HTTPPort).
|
||||
const avTransportControlPath = "/AVTransport/Control"
|
||||
|
||||
// avTransportServiceType is the UPnP service type used in the SOAPAction header
|
||||
// and the action element namespace.
|
||||
const avTransportServiceType = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
|
||||
// soapEnvelope wraps a SOAP action body in the standard envelope.
|
||||
const soapEnvelope = `<?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>%s</s:Body></s:Envelope>`
|
||||
|
||||
// PlayURLViaUPnP plays an audio URL on the speaker through its UPnP AVTransport
|
||||
// service: SetAVTransportURI followed by Play.
|
||||
//
|
||||
// Unlike the /speaker play_info path (PlayURL/PlayCustom), this needs no app_key
|
||||
// and no DNS interception, so it works on a plain LAN. The trade-offs: it
|
||||
// switches the speaker to the UPNP source and replaces the current playback
|
||||
// (it does not duck and resume like a notification), and the speaker itself must
|
||||
// be able to reach mediaURL. The speaker auto-plays on SetAVTransportURI on
|
||||
// current firmware; the explicit Play afterwards makes it robust regardless of
|
||||
// the speaker's prior transport state.
|
||||
func (c *Client) PlayURLViaUPnP(mediaURL string) error {
|
||||
mediaURL = strings.TrimSpace(mediaURL)
|
||||
if mediaURL == "" {
|
||||
return fmt.Errorf("media URL cannot be empty")
|
||||
}
|
||||
|
||||
// The speaker's AVTransport rejects https:// outright ("URI must start with
|
||||
// http://, qplay:// or Stored Music XML") and then reports a misleading
|
||||
// "No URI supplied" 402. Fail fast with an actionable message instead.
|
||||
if strings.HasPrefix(strings.ToLower(mediaURL), "https://") {
|
||||
return fmt.Errorf("the speaker's UPnP AVTransport only accepts plain http:// URLs, not https:// — host the clip over HTTP, or use a method that proxies it (e.g. the service TTS/radio path): %s", mediaURL)
|
||||
}
|
||||
|
||||
if err := c.SetAVTransportURI(mediaURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.AVTransportPlay()
|
||||
}
|
||||
|
||||
// SetAVTransportURI points the speaker's AVTransport at mediaURL (UPnP
|
||||
// SetAVTransportURI action). Metadata is sent empty, which the speaker accepts.
|
||||
// Note the speaker only accepts http:// (and qplay:// / Stored Music) URIs, not
|
||||
// https://; PlayURLViaUPnP guards against that.
|
||||
func (c *Client) SetAVTransportURI(mediaURL string) error {
|
||||
body := `<u:SetAVTransportURI xmlns:u="` + avTransportServiceType + `">` +
|
||||
`<InstanceID>0</InstanceID>` +
|
||||
`<CurrentURI>` + escapeXMLText(mediaURL) + `</CurrentURI>` +
|
||||
`<CurrentURIMetaData></CurrentURIMetaData>` +
|
||||
`</u:SetAVTransportURI>`
|
||||
|
||||
return c.soapAVTransport("SetAVTransportURI", body)
|
||||
}
|
||||
|
||||
// AVTransportPlay starts playback (UPnP Play action, Speed 1).
|
||||
func (c *Client) AVTransportPlay() error {
|
||||
body := `<u:Play xmlns:u="` + avTransportServiceType + `">` +
|
||||
`<InstanceID>0</InstanceID><Speed>1</Speed>` +
|
||||
`</u:Play>`
|
||||
|
||||
return c.soapAVTransport("Play", body)
|
||||
}
|
||||
|
||||
// soapAVTransport POSTs a SOAP action to the speaker's AVTransport control URL.
|
||||
func (c *Client) soapAVTransport(action, innerBody string) error {
|
||||
controlURL, err := c.avTransportControlURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := fmt.Sprintf(soapEnvelope, innerBody)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, controlURL, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s request: %w", action, err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
// UPnP control points send a SOAPAction header. Write it through the map
|
||||
// directly to preserve the exact casing the UPnP convention uses (Set would
|
||||
// canonicalise it to "Soapaction"), mirroring how this codebase preserves
|
||||
// the speaker-facing ETag header casing.
|
||||
req.Header["SOAPAction"] = []string{`"` + avTransportServiceType + "#" + action + `"`}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute %s: %w", action, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
|
||||
return fmt.Errorf("UPnP %s failed with status %d: %s", action, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// avTransportControlURL derives the UPnP AVTransport control URL
|
||||
// (http://<host>:<UPnPPort>/AVTransport/Control) from the client's base URL,
|
||||
// which targets the :8090 local API. UPnP control lives on a different port.
|
||||
func (c *Client) avTransportControlURL() (string, error) {
|
||||
if c.avTransportURLOverride != "" {
|
||||
return c.avTransportURLOverride, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse base URL %q: %w", c.baseURL, err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("no host in base URL %q", c.baseURL)
|
||||
}
|
||||
|
||||
hostPort := net.JoinHostPort(host, strconv.Itoa(speaker.UPnPPort))
|
||||
|
||||
return "http://" + hostPort + avTransportControlPath, nil
|
||||
}
|
||||
|
||||
// escapeXMLText XML-escapes a string for safe inclusion as element character
|
||||
// data (e.g. the media URL inside <CurrentURI>).
|
||||
func escapeXMLText(s string) string {
|
||||
var b bytes.Buffer
|
||||
|
||||
_ = xml.EscapeText(&b, []byte(s))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAVTransportControlURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
want string
|
||||
}{
|
||||
{name: "host only", host: "192.0.2.10", want: "http://192.0.2.10:8091/AVTransport/Control"},
|
||||
{name: "host with api port", host: "http://192.0.2.10:8090", want: "http://192.0.2.10:8091/AVTransport/Control"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := NewClientFromHost(tt.host)
|
||||
|
||||
got, err := c.avTransportControlURL()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("control URL = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayURLViaUPnPRejectsHTTPS(t *testing.T) {
|
||||
called := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClientFromHost("192.0.2.10")
|
||||
c.avTransportURLOverride = server.URL
|
||||
|
||||
// The speaker rejects https:// URIs, so we should fail fast without even
|
||||
// contacting it, with a message that names the constraint.
|
||||
err := c.PlayURLViaUPnP("https://example.com/clip.mp3")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an https:// URL")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "http://") {
|
||||
t.Errorf("error should explain the http:// requirement, got: %v", err)
|
||||
}
|
||||
|
||||
if called {
|
||||
t.Error("no SOAP request should be sent for an https:// URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayURLViaUPnP(t *testing.T) {
|
||||
type capture struct {
|
||||
path string
|
||||
soapAction string
|
||||
contentType string
|
||||
body string
|
||||
}
|
||||
|
||||
var calls []capture
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
calls = append(calls, capture{
|
||||
path: r.URL.Path,
|
||||
soapAction: r.Header.Get("SOAPAction"),
|
||||
contentType: r.Header.Get("Content-Type"),
|
||||
body: string(b),
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClientFromHost("192.0.2.10")
|
||||
c.avTransportURLOverride = server.URL // route SOAP at the test server
|
||||
|
||||
mediaURL := "http://192.0.2.99/tts/hello.mp3?a=1&b=2"
|
||||
if err := c.PlayURLViaUPnP(mediaURL); err != nil {
|
||||
t.Fatalf("PlayURLViaUPnP: %v", err)
|
||||
}
|
||||
|
||||
// Two SOAP actions in order: SetAVTransportURI then Play.
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 SOAP calls, got %d", len(calls))
|
||||
}
|
||||
|
||||
set, play := calls[0], calls[1]
|
||||
|
||||
if !strings.Contains(set.soapAction, "AVTransport:1#SetAVTransportURI") {
|
||||
t.Errorf("first SOAPAction = %q, want SetAVTransportURI", set.soapAction)
|
||||
}
|
||||
|
||||
if !strings.Contains(play.soapAction, "AVTransport:1#Play") {
|
||||
t.Errorf("second SOAPAction = %q, want Play", play.soapAction)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(set.contentType, "text/xml") {
|
||||
t.Errorf("Content-Type = %q, want text/xml", set.contentType)
|
||||
}
|
||||
|
||||
// The media URL must be XML-escaped inside <CurrentURI> (the & becomes &).
|
||||
if !strings.Contains(set.body, "http://192.0.2.99/tts/hello.mp3?a=1&b=2") {
|
||||
t.Errorf("SetAVTransportURI body missing escaped media URL, got: %s", set.body)
|
||||
}
|
||||
|
||||
if strings.Contains(set.body, "a=1&b=2") {
|
||||
t.Errorf("media URL was not XML-escaped in the body: %s", set.body)
|
||||
}
|
||||
}
|
||||
+39
-5
@@ -162,6 +162,11 @@ type Client struct {
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
userAgent string
|
||||
|
||||
// avTransportURLOverride, when set, replaces the UPnP AVTransport control
|
||||
// URL that is otherwise derived from baseURL (host + speaker.UPnPPort). Used
|
||||
// only by tests to point the SOAP requests at an httptest server.
|
||||
avTransportURLOverride string
|
||||
}
|
||||
|
||||
// Config holds configuration for the SoundTouch client
|
||||
@@ -301,6 +306,19 @@ func (c *Client) GetServiceAvailability() (*models.ServiceAvailability, error) {
|
||||
return &serviceAvailability, nil
|
||||
}
|
||||
|
||||
// ListMediaServers returns the DLNA media servers that the speaker itself has
|
||||
// discovered on the LAN (via its own UPnP sweep). The response may be empty
|
||||
// when the speaker has not yet discovered any servers; that is not an error.
|
||||
func (c *Client) ListMediaServers() (*models.ListMediaServersResponse, error) {
|
||||
var resp models.ListMediaServersResponse
|
||||
|
||||
if err := c.get("/listMediaServers", &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to list media servers: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetName retrieves the device name from the /name endpoint
|
||||
func (c *Client) GetName() (*models.Name, error) {
|
||||
var name models.Name
|
||||
@@ -1325,7 +1343,13 @@ func (c *Client) AddToZone(deviceID, ipAddress string) error {
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// RemoveFromZone removes a device from the current zone
|
||||
// RemoveFromZone removes a device from the current zone.
|
||||
//
|
||||
// It uses the dedicated /removeZoneSlave endpoint rather than rebuilding the
|
||||
// zone with /setZone and the remaining members: /setZone does not drop a member
|
||||
// from a multi-member zone (the speaker only goes standalone when the resulting
|
||||
// member set is empty), so a setZone rebuild silently fails to remove one of
|
||||
// several members. See #511.
|
||||
func (c *Client) RemoveFromZone(deviceID string) error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
@@ -1333,11 +1357,21 @@ func (c *Client) RemoveFromZone(deviceID string) error {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Convert to zone request and remove member
|
||||
zoneRequest := currentZone.ToZoneRequest()
|
||||
zoneRequest.RemoveMember(deviceID)
|
||||
if currentZone.IsStandalone() {
|
||||
return nil // nothing to remove
|
||||
}
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
// Carry the member's IP (as the speaker expects) when we know it.
|
||||
slaveIP := ""
|
||||
|
||||
for i := range currentZone.Members {
|
||||
if currentZone.Members[i].DeviceID == deviceID {
|
||||
slaveIP = currentZone.Members[i].IP
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c.RemoveZoneSlave(currentZone.Master, deviceID, slaveIP)
|
||||
}
|
||||
|
||||
// DissolveZone dissolves the current zone, making all devices standalone
|
||||
|
||||
+30
-6
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -295,12 +296,15 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
var removeBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
switch {
|
||||
case r.URL.Path == "/getZone" && r.Method == http.MethodGet:
|
||||
getZoneCalled = true
|
||||
// Return existing zone with members
|
||||
// Return existing zone with two members.
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
|
||||
@@ -309,11 +313,16 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
case r.URL.Path == "/removeZoneSlave" && r.Method == http.MethodPost:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
removeBody = string(b)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.URL.Path == "/setZone" && r.Method == http.MethodPost:
|
||||
setZoneCalled = true
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
@@ -321,6 +330,9 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
// Removing one of two members must target that member via /removeZoneSlave,
|
||||
// not rebuild the zone via /setZone (which does not drop a member from a
|
||||
// multi-member zone). Regression for #511.
|
||||
err := client.RemoveFromZone("EFGH5678IJKL")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
@@ -330,8 +342,20 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
if setZoneCalled {
|
||||
t.Error("RemoveFromZone must not use /setZone to drop a member from a multi-member zone")
|
||||
}
|
||||
|
||||
if !strings.Contains(removeBody, "EFGH5678IJKL") {
|
||||
t.Errorf("removeZoneSlave body should target the member, got: %s", removeBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(removeBody, `master="ABCD1234EFGH"`) {
|
||||
t.Errorf("removeZoneSlave body should name the master, got: %s", removeBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(removeBody, `ipaddress="192.0.2.11"`) {
|
||||
t.Errorf("removeZoneSlave body should carry the member IP from the zone, got: %s", removeBody)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mediaServerDeviceType = "urn:schemas-upnp-org:device:MediaServer:1"
|
||||
cdsServiceType = "urn:schemas-upnp-org:service:ContentDirectory:1"
|
||||
// descFetchTimeout is a separate budget for description fetches so the
|
||||
// overall SSDP sweep timing does not cut them off.
|
||||
descFetchTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
// MediaServer is a discovered DLNA UPnP MediaServer that exposes a
|
||||
// ContentDirectory service.
|
||||
type MediaServer struct {
|
||||
// UDN is the stable unique device name (uuid:...) from the UPnP description.
|
||||
UDN string
|
||||
// FriendlyName is the human-readable device name, e.g. "FRITZ!Box 7590".
|
||||
FriendlyName string
|
||||
// Manufacturer and ModelName let callers show a useful device subtitle.
|
||||
Manufacturer string
|
||||
ModelName string
|
||||
// Address is the "host:port" of the device description endpoint.
|
||||
Address string
|
||||
// CDSControlURL is the fully resolved URL for ContentDirectory SOAP actions.
|
||||
// Empty string means the device does not expose ContentDirectory.
|
||||
CDSControlURL string
|
||||
// IconURL is the first icon the device advertised, resolved to absolute form.
|
||||
IconURL string
|
||||
}
|
||||
|
||||
// DiscoverMediaServers sends SSDP M-SEARCH requests for MediaServer devices,
|
||||
// fetches each device description concurrently, and returns only the servers
|
||||
// that expose a ContentDirectory service. Deduplicated by UDN.
|
||||
func DiscoverMediaServers(ctx context.Context, timeout time.Duration) ([]MediaServer, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
opts := SearchOptions{
|
||||
Targets: []string{
|
||||
mediaServerDeviceType,
|
||||
"ssdp:all",
|
||||
},
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
responses, err := SearchSSDP(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responses) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fetch descriptions concurrently. Use a fresh context so that the
|
||||
// description fetches are not cut off by the already-elapsed SSDP timeout.
|
||||
fctx, fcancel := context.WithTimeout(ctx, descFetchTimeout)
|
||||
defer fcancel()
|
||||
|
||||
type fetchResult struct {
|
||||
srv MediaServer
|
||||
ok bool
|
||||
}
|
||||
|
||||
results := make(chan fetchResult, len(responses))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, resp := range responses {
|
||||
wg.Add(1)
|
||||
|
||||
go func(loc string) {
|
||||
defer wg.Done()
|
||||
|
||||
desc, err := FetchDescription(fctx, loc)
|
||||
if err != nil {
|
||||
slog.Warn("mediaserver: description fetch failed", "location", loc, "err", err.Error())
|
||||
|
||||
results <- fetchResult{}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
srv, ok := mediaServerFromDescription(desc)
|
||||
results <- fetchResult{srv: srv, ok: ok}
|
||||
}(resp.Location)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
var out []MediaServer
|
||||
|
||||
for r := range results {
|
||||
if !r.ok || r.srv.CDSControlURL == "" || r.srv.UDN == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, dup := seen[r.srv.UDN]; dup {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[r.srv.UDN] = struct{}{}
|
||||
out = append(out, r.srv)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mediaServerFromDescription maps a parsed Description to a MediaServer.
|
||||
// Returns ok=false when the description does not expose a ContentDirectory
|
||||
// service (i.e. the device is not a usable DLNA media server).
|
||||
//
|
||||
// It walks the device tree so that nested MediaServer sub-devices (e.g.
|
||||
// FRITZ!Box root device nesting the NAS MediaServer) are found correctly.
|
||||
func mediaServerFromDescription(desc *Description) (MediaServer, bool) {
|
||||
if desc == nil {
|
||||
return MediaServer{}, false
|
||||
}
|
||||
|
||||
svc, ok := desc.FindService(cdsServiceType)
|
||||
if !ok || svc.ControlURL == "" {
|
||||
return MediaServer{}, false
|
||||
}
|
||||
|
||||
srv := MediaServer{
|
||||
UDN: desc.Root.UDN,
|
||||
FriendlyName: desc.Root.FriendlyName,
|
||||
Manufacturer: desc.Root.Manufacturer,
|
||||
ModelName: desc.Root.ModelName,
|
||||
CDSControlURL: svc.ControlURL,
|
||||
}
|
||||
|
||||
// Populate Address from the CDS control URL host so callers know which
|
||||
// host:port to reach the device on. The control URL is absolute after
|
||||
// FetchDescription resolves it; parse errors leave Address empty.
|
||||
if u, err := url.Parse(svc.ControlURL); err == nil {
|
||||
srv.Address = u.Host
|
||||
}
|
||||
|
||||
// Walk sub-devices to fill in UDN / FriendlyName if the root is sparse
|
||||
// (some devices put it all in the sub-device, e.g. FRITZ!Box).
|
||||
fillFromTree(desc, &srv)
|
||||
|
||||
if ic, ok := desc.FirstIcon(); ok {
|
||||
srv.IconURL = ic.URL
|
||||
}
|
||||
|
||||
return srv, true
|
||||
}
|
||||
|
||||
// fillFromTree walks the description tree to fill in missing fields on srv
|
||||
// from sub-devices. Only fills in fields that are still empty.
|
||||
func fillFromTree(desc *Description, srv *MediaServer) {
|
||||
walkDevice(&desc.Root, srv)
|
||||
}
|
||||
|
||||
func walkDevice(dev *Device, srv *MediaServer) {
|
||||
if srv.FriendlyName == "" && dev.FriendlyName != "" {
|
||||
srv.FriendlyName = dev.FriendlyName
|
||||
}
|
||||
|
||||
if srv.UDN == "" && dev.UDN != "" {
|
||||
srv.UDN = dev.UDN
|
||||
}
|
||||
|
||||
if srv.Manufacturer == "" && dev.Manufacturer != "" {
|
||||
srv.Manufacturer = dev.Manufacturer
|
||||
}
|
||||
|
||||
if srv.ModelName == "" && dev.ModelName != "" {
|
||||
srv.ModelName = dev.ModelName
|
||||
}
|
||||
|
||||
for i := range dev.Devices {
|
||||
walkDevice(&dev.Devices[i], srv)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMediaServerFromDescription_WithCDS verifies that a description that
|
||||
// includes a ContentDirectory service produces a valid MediaServer (ok=true)
|
||||
// with all fields populated.
|
||||
func TestMediaServerFromDescription_WithCDS(t *testing.T) {
|
||||
// Use the canned XML defined in ssdp_test.go (same package).
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
srv, ok := mediaServerFromDescription(desc)
|
||||
if !ok {
|
||||
t.Fatal("mediaServerFromDescription: ok=false, want true")
|
||||
}
|
||||
|
||||
if srv.UDN == "" {
|
||||
t.Error("UDN is empty")
|
||||
}
|
||||
|
||||
if srv.FriendlyName == "" {
|
||||
t.Error("FriendlyName is empty")
|
||||
}
|
||||
|
||||
if srv.CDSControlURL == "" {
|
||||
t.Error("CDSControlURL is empty")
|
||||
}
|
||||
|
||||
// Control URL must be absolute.
|
||||
if !isAbsoluteURL(srv.CDSControlURL) {
|
||||
t.Errorf("CDSControlURL %q is not absolute", srv.CDSControlURL)
|
||||
}
|
||||
|
||||
// Icon must be resolved.
|
||||
if srv.IconURL == "" {
|
||||
t.Error("IconURL is empty")
|
||||
}
|
||||
|
||||
if !isAbsoluteURL(srv.IconURL) {
|
||||
t.Errorf("IconURL %q is not absolute", srv.IconURL)
|
||||
}
|
||||
|
||||
// Address must be the host:port from the CDS control URL.
|
||||
// cannedDescriptionXML has URLBase http://192.0.2.1:49000 and CDS
|
||||
// controlURL /ctl/ContentDir, so Address = "192.0.2.1:49000".
|
||||
if srv.Address == "" {
|
||||
t.Error("Address is empty")
|
||||
}
|
||||
|
||||
if srv.Address != "192.0.2.1:49000" {
|
||||
t.Errorf("Address = %q, want %q", srv.Address, "192.0.2.1:49000")
|
||||
}
|
||||
|
||||
t.Logf("MediaServer: UDN=%q FriendlyName=%q CDSControlURL=%q IconURL=%q Address=%q",
|
||||
srv.UDN, srv.FriendlyName, srv.CDSControlURL, srv.IconURL, srv.Address)
|
||||
}
|
||||
|
||||
// TestMediaServerFromDescription_WithoutCDS verifies that a description
|
||||
// without a ContentDirectory service returns ok=false.
|
||||
func TestMediaServerFromDescription_WithoutCDS(t *testing.T) {
|
||||
const xmlNoCDS = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>SoundTouch 20</friendlyName>
|
||||
<manufacturer>Bose</manufacturer>
|
||||
<modelName>SoundTouch 20</modelName>
|
||||
<UDN>uuid:bose-st20-0001</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/ctl/AVTransport</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
desc, err := parseDescription([]byte(xmlNoCDS), "http://192.0.2.10:8200/desc.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
_, ok := mediaServerFromDescription(desc)
|
||||
if ok {
|
||||
t.Error("mediaServerFromDescription: ok=true, want false (no ContentDirectory)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaServerFromDescription_Nil ensures a nil Description returns ok=false
|
||||
// without panicking.
|
||||
func TestMediaServerFromDescription_Nil(t *testing.T) {
|
||||
_, ok := mediaServerFromDescription(nil)
|
||||
if ok {
|
||||
t.Error("mediaServerFromDescription(nil): ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaServerFromDescription_FlatServer verifies a flat description (no
|
||||
// sub-devices, CDS in root) maps correctly.
|
||||
func TestMediaServerFromDescription_FlatServer(t *testing.T) {
|
||||
const xmlFlat = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<URLBase>http://198.51.100.20:8200</URLBase>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>MiniDLNA</friendlyName>
|
||||
<manufacturer>Justin Maggard</manufacturer>
|
||||
<modelName>MiniDLNA</modelName>
|
||||
<UDN>uuid:minidlna-0001</UDN>
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>48</width>
|
||||
<height>48</height>
|
||||
<url>/icons/sm.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<controlURL>/ctl/ContentDir</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
desc, err := parseDescription([]byte(xmlFlat), "http://198.51.100.20:8200/rootDesc.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
srv, ok := mediaServerFromDescription(desc)
|
||||
if !ok {
|
||||
t.Fatal("mediaServerFromDescription: ok=false, want true")
|
||||
}
|
||||
|
||||
if srv.FriendlyName != "MiniDLNA" {
|
||||
t.Errorf("FriendlyName = %q, want %q", srv.FriendlyName, "MiniDLNA")
|
||||
}
|
||||
|
||||
if srv.UDN != "uuid:minidlna-0001" {
|
||||
t.Errorf("UDN = %q, want %q", srv.UDN, "uuid:minidlna-0001")
|
||||
}
|
||||
|
||||
wantCDS := "http://198.51.100.20:8200/ctl/ContentDir"
|
||||
if srv.CDSControlURL != wantCDS {
|
||||
t.Errorf("CDSControlURL = %q, want %q", srv.CDSControlURL, wantCDS)
|
||||
}
|
||||
|
||||
wantIcon := "http://198.51.100.20:8200/icons/sm.png"
|
||||
if srv.IconURL != wantIcon {
|
||||
t.Errorf("IconURL = %q, want %q", srv.IconURL, wantIcon)
|
||||
}
|
||||
|
||||
// Address must reflect the host:port of the CDS control URL.
|
||||
// URLBase is http://198.51.100.20:8200 and CDS controlURL is /ctl/ContentDir.
|
||||
if srv.Address != "198.51.100.20:8200" {
|
||||
t.Errorf("Address = %q, want %q", srv.Address, "198.51.100.20:8200")
|
||||
}
|
||||
}
|
||||
|
||||
// isAbsoluteURL returns true when s starts with "http://" or "https://".
|
||||
func isAbsoluteURL(s string) bool {
|
||||
return len(s) > 7 && (s[:7] == "http://" || (len(s) > 8 && s[:8] == "https://"))
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// Package discovery provides device discovery functionality for Bose SoundTouch
|
||||
// devices using mDNS and UPnP protocols.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearchOptions configures a generic SSDP M-SEARCH sweep.
|
||||
// Targets lists the ST values to search for (e.g.
|
||||
// "urn:schemas-upnp-org:device:MediaServer:1" and "ssdp:all").
|
||||
// Timeout is how long to listen for responses.
|
||||
// Interface, when non-empty, pins multicast to that NIC by name;
|
||||
// when empty, all non-loopback IPv4 interfaces are used.
|
||||
type SearchOptions struct {
|
||||
Targets []string
|
||||
Timeout time.Duration
|
||||
Interface string
|
||||
}
|
||||
|
||||
// SSDPResponse holds the raw fields from one SSDP HTTP/1.1 200 OK response.
|
||||
// Responses are deduped by Location before being returned by SearchSSDP.
|
||||
type SSDPResponse struct {
|
||||
Location string
|
||||
USN string
|
||||
ST string
|
||||
Server string
|
||||
}
|
||||
|
||||
// Description is the parsed content of a UPnP device description XML document.
|
||||
type Description struct {
|
||||
// URLBase is the base URL declared in the document (may be empty).
|
||||
URLBase string
|
||||
Root Device
|
||||
}
|
||||
|
||||
// Device represents one UPnP device node (root or sub-device).
|
||||
type Device struct {
|
||||
DeviceType string
|
||||
FriendlyName string
|
||||
Manufacturer string
|
||||
ModelName string
|
||||
SerialNumber string
|
||||
UDN string
|
||||
Icons []Icon
|
||||
Services []UPnPService
|
||||
Devices []Device // embedded sub-devices (e.g. FRITZ!Box nests MediaServer)
|
||||
}
|
||||
|
||||
// UPnPService is a single UPnP service advertisement inside a Device.
|
||||
type UPnPService struct {
|
||||
ServiceType string
|
||||
ControlURL string
|
||||
EventSubURL string
|
||||
SCPDURL string
|
||||
}
|
||||
|
||||
// Icon is one entry from a UPnP iconList.
|
||||
type Icon struct {
|
||||
MimeType string
|
||||
Width int
|
||||
Height int
|
||||
URL string
|
||||
}
|
||||
|
||||
// FindService walks the description tree (root device and all sub-devices) and
|
||||
// returns the first UPnPService whose ServiceType equals serviceType.
|
||||
func (d *Description) FindService(serviceType string) (UPnPService, bool) {
|
||||
return findServiceInDevice(&d.Root, serviceType)
|
||||
}
|
||||
|
||||
func findServiceInDevice(dev *Device, serviceType string) (UPnPService, bool) {
|
||||
for _, svc := range dev.Services {
|
||||
if svc.ServiceType == serviceType {
|
||||
return svc, true
|
||||
}
|
||||
}
|
||||
|
||||
for i := range dev.Devices {
|
||||
if svc, ok := findServiceInDevice(&dev.Devices[i], serviceType); ok {
|
||||
return svc, true
|
||||
}
|
||||
}
|
||||
|
||||
return UPnPService{}, false
|
||||
}
|
||||
|
||||
// FirstIcon walks the device tree depth-first and returns the first icon it
|
||||
// finds (which is the icon advertised in the root device, or its first
|
||||
// sub-device if the root has none).
|
||||
func (d *Description) FirstIcon() (Icon, bool) {
|
||||
return firstIconInDevice(&d.Root)
|
||||
}
|
||||
|
||||
func firstIconInDevice(dev *Device) (Icon, bool) {
|
||||
if len(dev.Icons) > 0 {
|
||||
return dev.Icons[0], true
|
||||
}
|
||||
|
||||
for i := range dev.Devices {
|
||||
if ic, ok := firstIconInDevice(&dev.Devices[i]); ok {
|
||||
return ic, true
|
||||
}
|
||||
}
|
||||
|
||||
return Icon{}, false
|
||||
}
|
||||
|
||||
// ssdpDefaultMXSecs is the M-SEARCH MX header value (seconds the device may
|
||||
// wait before answering). Keep it generous so slower NAS boxes are not missed.
|
||||
const ssdpDefaultMXSecs = 3
|
||||
|
||||
// SearchSSDP sends SSDP M-SEARCH requests for each target in opts.Targets,
|
||||
// collects responses until opts.Timeout expires, and returns the unique
|
||||
// responses deduped by LOCATION. When opts.Interface is empty, the search is
|
||||
// sent from every non-loopback IPv4 interface; when set, only that interface
|
||||
// is used.
|
||||
func SearchSSDP(ctx context.Context, opts SearchOptions) ([]SSDPResponse, error) {
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = defaultTimeout
|
||||
}
|
||||
|
||||
sctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
mcAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssdp: resolve multicast addr: %w", err)
|
||||
}
|
||||
|
||||
// Build M-SEARCH packets for each target.
|
||||
var msgs [][]byte
|
||||
|
||||
for _, st := range opts.Targets {
|
||||
msgs = append(msgs, buildMSearchPacket(st))
|
||||
}
|
||||
|
||||
// Determine which source IPs to send from.
|
||||
var srcIPs []net.IP
|
||||
|
||||
if opts.Interface != "" {
|
||||
ip, err := interfaceIPv4(opts.Interface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srcIPs = []net.IP{ip}
|
||||
} else {
|
||||
srcIPs = candidateIPv4Addrs()
|
||||
|
||||
if len(srcIPs) == 0 {
|
||||
slog.Warn("ssdp: no usable IPv4 interfaces, falling back to wildcard")
|
||||
|
||||
srcIPs = []net.IP{net.IPv4zero}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("ssdp: M-SEARCH starting",
|
||||
"targets", opts.Targets,
|
||||
"interfaces", len(srcIPs),
|
||||
"timeout", opts.Timeout.String(),
|
||||
)
|
||||
|
||||
// Collect unique locations across all goroutines.
|
||||
mu := sync.Mutex{}
|
||||
byLocation := map[string]SSDPResponse{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, srcIP := range srcIPs {
|
||||
wg.Add(1)
|
||||
|
||||
go func(ip net.IP) {
|
||||
defer wg.Done()
|
||||
|
||||
ssdpSendRecv(sctx, ip, mcAddr, msgs, func(resp SSDPResponse) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if _, exists := byLocation[resp.Location]; !exists {
|
||||
byLocation[resp.Location] = resp
|
||||
slog.Info("ssdp: new location", "location", resp.Location, "st", resp.ST)
|
||||
}
|
||||
})
|
||||
}(srcIP)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
out := make([]SSDPResponse, 0, len(byLocation))
|
||||
|
||||
for _, r := range byLocation {
|
||||
out = append(out, r)
|
||||
}
|
||||
|
||||
slog.Info("ssdp: M-SEARCH done", "locations", len(out))
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildMSearchPacket returns an SSDP M-SEARCH request for the given ST value.
|
||||
func buildMSearchPacket(st string) []byte {
|
||||
return []byte(strings.Join([]string{
|
||||
"M-SEARCH * HTTP/1.1",
|
||||
"HOST: " + ssdpAddr,
|
||||
"MAN: \"ssdp:discover\"",
|
||||
fmt.Sprintf("MX: %d", ssdpDefaultMXSecs),
|
||||
"ST: " + st,
|
||||
"USER-AGENT: AfterTouch/1 UPnP/1.0",
|
||||
"", "",
|
||||
}, "\r\n"))
|
||||
}
|
||||
|
||||
// ssdpSendRecv opens a UDP socket bound to srcIP, sends all msgs to mcAddr,
|
||||
// reads responses until sctx is done or a UDP timeout, and calls notify for
|
||||
// each response that carries a non-empty LOCATION header.
|
||||
func ssdpSendRecv(sctx context.Context, srcIP net.IP, mcAddr *net.UDPAddr, msgs [][]byte, notify func(SSDPResponse)) {
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: srcIP, Port: 0})
|
||||
if err != nil {
|
||||
slog.Warn("ssdp: ListenUDP failed", "src", srcIP.String(), "err", err.Error())
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Send all messages in 2 rounds with an 80 ms gap between rounds.
|
||||
// The spacing lets slower NAS/router boxes that drop back-to-back bursts
|
||||
// still answer, rather than sending the whole batch as one burst.
|
||||
for range 2 {
|
||||
for _, msg := range msgs {
|
||||
if _, err := conn.WriteToUDP(msg, mcAddr); err != nil {
|
||||
slog.Warn("ssdp: WriteToUDP failed", "src", srcIP.String(), "err", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
|
||||
deadline, ok := sctx.Deadline()
|
||||
if ok {
|
||||
_ = conn.SetReadDeadline(deadline)
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, _, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
// Timeout or context done.
|
||||
return
|
||||
}
|
||||
|
||||
loc := ssdpHeaderValue(buf[:n], "LOCATION")
|
||||
if loc == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
notify(SSDPResponse{
|
||||
Location: loc,
|
||||
USN: ssdpHeaderValue(buf[:n], "USN"),
|
||||
ST: ssdpHeaderValue(buf[:n], "ST"),
|
||||
Server: ssdpHeaderValue(buf[:n], "SERVER"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ssdpHeaderValue finds the value of header in a raw SSDP UDP packet.
|
||||
// Header matching is case-insensitive.
|
||||
func ssdpHeaderValue(packet []byte, header string) string {
|
||||
lines := bytes.Split(packet, []byte("\r\n"))
|
||||
prefix := strings.ToLower(header) + ":"
|
||||
|
||||
for _, line := range lines {
|
||||
if len(line) <= len(prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(string(line[:len(prefix)]), prefix) {
|
||||
return strings.TrimSpace(string(line[len(prefix):]))
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// candidateIPv4Addrs returns the routable IPv4 source addresses to send SSDP
|
||||
// M-SEARCH from. Excludes loopback, link-local, and interfaces that are down.
|
||||
// Rationale: a host with two Wi-Fi adapters on different networks needs to
|
||||
// probe both.
|
||||
func candidateIPv4Addrs() []net.IP {
|
||||
var out []net.IP
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ip4 := ipnet.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if ip4.IsLoopback() || ip4.IsLinkLocalUnicast() || ip4.IsLinkLocalMulticast() {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, ip4)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// interfaceIPv4 returns the first non-loopback IPv4 address of the named
|
||||
// interface, or an error if not found.
|
||||
func interfaceIPv4(ifaceName string) (net.IP, error) {
|
||||
iface, err := net.InterfaceByName(ifaceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssdp: interface %q not found: %w", ifaceName, err)
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssdp: read addrs for %q: %w", ifaceName, err)
|
||||
}
|
||||
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ip4 := ipnet.IP.To4()
|
||||
if ip4 == nil || ip4.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
return ip4, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("ssdp: interface %q has no usable IPv4 address", ifaceName)
|
||||
}
|
||||
|
||||
// FetchDescription fetches the UPnP device description at location and parses
|
||||
// it into a Description tree. Relative URLs in the tree (controlURL, icon URL)
|
||||
// are resolved to absolute form using URLBase or location as the base.
|
||||
func FetchDescription(ctx context.Context, location string) (*Description, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssdp: build request for %s: %w", location, err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("ssdp: fetch description failed", "location", location, "err", err.Error())
|
||||
return nil, fmt.Errorf("ssdp: fetch %s: %w", location, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssdp: read body from %s: %w", location, err)
|
||||
}
|
||||
|
||||
return parseDescription(body, location)
|
||||
}
|
||||
|
||||
// parseDescription parses raw UPnP device description XML bytes and resolves
|
||||
// relative URLs against the given location. It is a pure function (no I/O)
|
||||
// so it can be unit-tested on canned bytes.
|
||||
func parseDescription(body []byte, location string) (*Description, error) {
|
||||
// Raw XML types that mirror the UPnP device description schema.
|
||||
type xmlIcon struct {
|
||||
MimeType string `xml:"mimetype"`
|
||||
Width int `xml:"width"`
|
||||
Height int `xml:"height"`
|
||||
URL string `xml:"url"`
|
||||
}
|
||||
|
||||
type xmlService struct {
|
||||
ServiceType string `xml:"serviceType"`
|
||||
ControlURL string `xml:"controlURL"`
|
||||
EventSubURL string `xml:"eventSubURL"`
|
||||
SCPDURL string `xml:"SCPDURL"`
|
||||
}
|
||||
|
||||
// xmlDevice is defined as a named type so it can reference itself.
|
||||
type xmlDevice struct {
|
||||
DeviceType string `xml:"deviceType"`
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
Manufacturer string `xml:"manufacturer"`
|
||||
ModelName string `xml:"modelName"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
UDN string `xml:"UDN"`
|
||||
Icons []xmlIcon `xml:"iconList>icon"`
|
||||
Services []xmlService `xml:"serviceList>service"`
|
||||
SubDevices []xmlDevice `xml:"deviceList>device"`
|
||||
}
|
||||
|
||||
type xmlRoot struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
URLBase string `xml:"URLBase"`
|
||||
Device xmlDevice `xml:"device"`
|
||||
}
|
||||
|
||||
var root xmlRoot
|
||||
|
||||
if err := xml.Unmarshal(body, &root); err != nil {
|
||||
return nil, fmt.Errorf("ssdp: parse description XML: %w", err)
|
||||
}
|
||||
|
||||
// Determine base URL for resolving relative references.
|
||||
baseURL, _ := url.Parse(location)
|
||||
|
||||
if root.URLBase != "" {
|
||||
if u, err := url.Parse(root.URLBase); err == nil {
|
||||
baseURL = u
|
||||
}
|
||||
}
|
||||
|
||||
// Recursive mapper from xmlDevice to Device.
|
||||
var mapDevice func(xd xmlDevice) Device
|
||||
|
||||
mapDevice = func(xd xmlDevice) Device {
|
||||
d := Device{
|
||||
DeviceType: xd.DeviceType,
|
||||
FriendlyName: xd.FriendlyName,
|
||||
Manufacturer: xd.Manufacturer,
|
||||
ModelName: xd.ModelName,
|
||||
SerialNumber: xd.SerialNumber,
|
||||
UDN: xd.UDN,
|
||||
}
|
||||
|
||||
for _, xi := range xd.Icons {
|
||||
d.Icons = append(d.Icons, Icon{
|
||||
MimeType: xi.MimeType,
|
||||
Width: xi.Width,
|
||||
Height: xi.Height,
|
||||
URL: absURL(baseURL, xi.URL),
|
||||
})
|
||||
}
|
||||
|
||||
for _, xs := range xd.Services {
|
||||
d.Services = append(d.Services, UPnPService{
|
||||
ServiceType: xs.ServiceType,
|
||||
ControlURL: absURL(baseURL, xs.ControlURL),
|
||||
EventSubURL: absURL(baseURL, xs.EventSubURL),
|
||||
SCPDURL: absURL(baseURL, xs.SCPDURL),
|
||||
})
|
||||
}
|
||||
|
||||
for i := range xd.SubDevices {
|
||||
d.Devices = append(d.Devices, mapDevice(xd.SubDevices[i]))
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
desc := &Description{
|
||||
URLBase: root.URLBase,
|
||||
Root: mapDevice(root.Device),
|
||||
}
|
||||
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
// absURL resolves ref relative to base. If ref is already absolute, or if
|
||||
// parsing fails, ref is returned unchanged.
|
||||
func absURL(base *url.URL, ref string) string {
|
||||
if ref == "" || base == nil {
|
||||
return ref
|
||||
}
|
||||
|
||||
u, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
|
||||
return base.ResolveReference(u).String()
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// cannedDescriptionXML is a realistic UPnP device description that includes
|
||||
// a root device with one icon, a ContentDirectory service, and one sub-device
|
||||
// (mimicking the FRITZ!Box nesting pattern). Used to exercise parseDescription
|
||||
// and FindService without any network I/O.
|
||||
const cannedDescriptionXML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion><major>1</major><minor>0</minor></specVersion>
|
||||
<URLBase>http://192.0.2.1:49000</URLBase>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
|
||||
<friendlyName>FRITZ!Box 7590</friendlyName>
|
||||
<manufacturer>AVM</manufacturer>
|
||||
<modelName>FRITZ!Box 7590</modelName>
|
||||
<serialNumber>SN-001</serialNumber>
|
||||
<UDN>uuid:root-device-0001</UDN>
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>48</width>
|
||||
<height>48</height>
|
||||
<url>/icons/root.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>
|
||||
<controlURL>/ctl/L3Fwd</controlURL>
|
||||
<eventSubURL>/evt/L3Fwd</eventSubURL>
|
||||
<SCPDURL>/L3Fwd.xml</SCPDURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
<deviceList>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>FRITZ!Box NAS</friendlyName>
|
||||
<manufacturer>AVM</manufacturer>
|
||||
<modelName>FRITZ!NAS</modelName>
|
||||
<serialNumber>SN-002</serialNumber>
|
||||
<UDN>uuid:media-server-0001</UDN>
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>32</width>
|
||||
<height>32</height>
|
||||
<url>/icons/nas.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<controlURL>/ctl/ContentDir</controlURL>
|
||||
<eventSubURL>/evt/ContentDir</eventSubURL>
|
||||
<SCPDURL>/ContentDir.xml</SCPDURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</deviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// TestParseDescription_Fields checks that parseDescription populates the
|
||||
// root-device fields correctly from the canned XML.
|
||||
func TestParseDescription_Fields(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
if desc.URLBase != "http://192.0.2.1:49000" {
|
||||
t.Errorf("URLBase = %q, want %q", desc.URLBase, "http://192.0.2.1:49000")
|
||||
}
|
||||
|
||||
root := desc.Root
|
||||
|
||||
if root.FriendlyName != "FRITZ!Box 7590" {
|
||||
t.Errorf("FriendlyName = %q, want %q", root.FriendlyName, "FRITZ!Box 7590")
|
||||
}
|
||||
|
||||
if root.UDN != "uuid:root-device-0001" {
|
||||
t.Errorf("UDN = %q, want %q", root.UDN, "uuid:root-device-0001")
|
||||
}
|
||||
|
||||
if root.Manufacturer != "AVM" {
|
||||
t.Errorf("Manufacturer = %q, want %q", root.Manufacturer, "AVM")
|
||||
}
|
||||
|
||||
if root.ModelName != "FRITZ!Box 7590" {
|
||||
t.Errorf("ModelName = %q, want %q", root.ModelName, "FRITZ!Box 7590")
|
||||
}
|
||||
|
||||
if len(root.Devices) != 1 {
|
||||
t.Fatalf("root sub-devices = %d, want 1", len(root.Devices))
|
||||
}
|
||||
|
||||
sub := root.Devices[0]
|
||||
|
||||
if sub.FriendlyName != "FRITZ!Box NAS" {
|
||||
t.Errorf("sub FriendlyName = %q, want %q", sub.FriendlyName, "FRITZ!Box NAS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_FindService confirms that FindService recurses into
|
||||
// sub-devices and resolves the controlURL to an absolute form using URLBase.
|
||||
func TestParseDescription_FindService(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
// ContentDirectory is in the sub-device, not the root.
|
||||
svc, ok := desc.FindService("urn:schemas-upnp-org:service:ContentDirectory:1")
|
||||
if !ok {
|
||||
t.Fatal("FindService(ContentDirectory:1): not found")
|
||||
}
|
||||
|
||||
// URLBase is http://192.0.2.1:49000, controlURL is /ctl/ContentDir.
|
||||
wantControlURL := "http://192.0.2.1:49000/ctl/ContentDir"
|
||||
if svc.ControlURL != wantControlURL {
|
||||
t.Errorf("ControlURL = %q, want %q", svc.ControlURL, wantControlURL)
|
||||
}
|
||||
|
||||
// Root-only service should also be found.
|
||||
l3, ok := desc.FindService("urn:schemas-upnp-org:service:Layer3Forwarding:1")
|
||||
if !ok {
|
||||
t.Fatal("FindService(Layer3Forwarding:1): not found")
|
||||
}
|
||||
|
||||
if l3.ControlURL != "http://192.0.2.1:49000/ctl/L3Fwd" {
|
||||
t.Errorf("Layer3Forwarding ControlURL = %q", l3.ControlURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_FindService_Missing ensures false is returned when the
|
||||
// service does not exist in the tree.
|
||||
func TestParseDescription_FindService_Missing(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
_, ok := desc.FindService("urn:schemas-upnp-org:service:DoesNotExist:1")
|
||||
if ok {
|
||||
t.Error("FindService(DoesNotExist:1) returned ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_FirstIcon checks that icon URLs are resolved to
|
||||
// absolute form and that FirstIcon returns the root-device icon.
|
||||
func TestParseDescription_FirstIcon(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
ic, ok := desc.FirstIcon()
|
||||
if !ok {
|
||||
t.Fatal("FirstIcon: not found")
|
||||
}
|
||||
|
||||
wantURL := "http://192.0.2.1:49000/icons/root.png"
|
||||
if ic.URL != wantURL {
|
||||
t.Errorf("icon URL = %q, want %q", ic.URL, wantURL)
|
||||
}
|
||||
|
||||
if ic.Width != 48 {
|
||||
t.Errorf("icon Width = %d, want 48", ic.Width)
|
||||
}
|
||||
|
||||
if ic.MimeType != "image/png" {
|
||||
t.Errorf("icon MimeType = %q, want %q", ic.MimeType, "image/png")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_RelativeURLResolution tests URL resolution without
|
||||
// URLBase (falls back to the location URL).
|
||||
func TestParseDescription_RelativeURLResolution(t *testing.T) {
|
||||
const xmlNoURLBase = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>Mini NAS</friendlyName>
|
||||
<UDN>uuid:mini-001</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<controlURL>/ctl/CDS</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
location := "http://198.51.100.5:8200/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(xmlNoURLBase), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
svc, ok := desc.FindService("urn:schemas-upnp-org:service:ContentDirectory:1")
|
||||
if !ok {
|
||||
t.Fatal("FindService: not found")
|
||||
}
|
||||
|
||||
// Without URLBase, base URL comes from location.
|
||||
want := "http://198.51.100.5:8200/ctl/CDS"
|
||||
if svc.ControlURL != want {
|
||||
t.Errorf("ControlURL = %q, want %q", svc.ControlURL, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbsURL_Variants exercises the absURL helper with several input
|
||||
// combinations.
|
||||
func TestAbsURL_Variants(t *testing.T) {
|
||||
cases := []struct {
|
||||
base string
|
||||
ref string
|
||||
want string
|
||||
}{
|
||||
// Relative path resolved against explicit-port base.
|
||||
{"http://192.0.2.1:49000/desc.xml", "/ctl/CDS", "http://192.0.2.1:49000/ctl/CDS"},
|
||||
// Already absolute: returned unchanged.
|
||||
{"http://192.0.2.1:49000/", "http://198.51.100.5:8200/ctl/CDS", "http://198.51.100.5:8200/ctl/CDS"},
|
||||
// Empty ref: returned as-is.
|
||||
{"http://192.0.2.1:49000/", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
base, err := url.Parse(tc.base)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse(%q): %v", tc.base, err)
|
||||
}
|
||||
|
||||
got := absURL(base, tc.ref)
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("absURL(%q, %q) = %q, want %q", tc.base, tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Package dlna is a minimal DLNA / UPnP ContentDirectory browse client.
|
||||
// It talks to a MediaServer's ContentDirectory:1 service via SOAP Browse
|
||||
// actions, and parses the DIDL-Lite responses into structured Go types.
|
||||
//
|
||||
// Device discovery lives in pkg/discovery; this package is only the browse half.
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
// BrowseResult holds one page of a ContentDirectory Browse response.
|
||||
type BrowseResult struct {
|
||||
Containers []Container
|
||||
Items []Item
|
||||
TotalMatches int
|
||||
Returned int
|
||||
}
|
||||
|
||||
// Container is a folder / album / playlist node in the DLNA content tree.
|
||||
type Container struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Title string
|
||||
ChildCount int
|
||||
}
|
||||
|
||||
// Item is a single playable object (track, photo, video). Use IsAudioItem to
|
||||
// check whether a SoundTouch renderer can play it.
|
||||
type Item struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
Class string
|
||||
MimeType string
|
||||
StreamURL string
|
||||
AlbumArtURL string
|
||||
DurationSec int
|
||||
}
|
||||
|
||||
// IsAudioItem reports whether the item is an audio track. Photos, videos, and
|
||||
// unrecognised items return false.
|
||||
func (it Item) IsAudioItem() bool {
|
||||
if strings.HasPrefix(strings.ToLower(it.MimeType), "audio/") {
|
||||
return true
|
||||
}
|
||||
|
||||
c := strings.ToLower(it.Class)
|
||||
|
||||
return strings.Contains(c, "audioitem") || strings.Contains(c, "musictrack")
|
||||
}
|
||||
|
||||
// Browse calls ContentDirectory:Browse on srv and returns one page of results.
|
||||
// objectID "0" is the server root. start is the page offset, count the page
|
||||
// size (0 defaults to 50 on the caller side so the request is always bounded).
|
||||
func Browse(ctx context.Context, srv discovery.MediaServer, objectID string, start, count int) (BrowseResult, error) {
|
||||
if srv.CDSControlURL == "" {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: server %q has no ContentDirectory control URL", srv.FriendlyName)
|
||||
}
|
||||
|
||||
if objectID == "" {
|
||||
objectID = "0"
|
||||
}
|
||||
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<?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:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">`+
|
||||
`<ObjectID>%s</ObjectID>`+
|
||||
`<BrowseFlag>BrowseDirectChildren</BrowseFlag>`+
|
||||
`<Filter>*</Filter>`+
|
||||
`<StartingIndex>%d</StartingIndex>`+
|
||||
`<RequestedCount>%d</RequestedCount>`+
|
||||
`<SortCriteria></SortCriteria>`+
|
||||
`</u:Browse>`+
|
||||
`</s:Body>`+
|
||||
`</s:Envelope>`,
|
||||
xmlEscape(objectID), start, count,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.CDSControlURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: build Browse request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||
req.Header.Set("SOAPACTION", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: Browse request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: read Browse response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: Browse status %d: %s", resp.StatusCode, truncate(string(raw), 240))
|
||||
}
|
||||
|
||||
return parseBrowseResponse(raw)
|
||||
}
|
||||
|
||||
// soapBrowseEnvelope is the relevant subset of the Browse SOAP response.
|
||||
type soapBrowseEnvelope struct {
|
||||
XMLName xml.Name `xml:"Envelope"`
|
||||
Body struct {
|
||||
BrowseResponse struct {
|
||||
Result string `xml:"Result"`
|
||||
NumberReturned int `xml:"NumberReturned"`
|
||||
TotalMatches int `xml:"TotalMatches"`
|
||||
} `xml:"BrowseResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
// didlLite mirrors the embedded DIDL-Lite XML returned in the <Result> element.
|
||||
type didlLite struct {
|
||||
XMLName xml.Name `xml:"DIDL-Lite"`
|
||||
Containers []didlContainer `xml:"container"`
|
||||
Items []didlItem `xml:"item"`
|
||||
}
|
||||
|
||||
type didlContainer struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
ChildCount int `xml:"childCount,attr"`
|
||||
Title string `xml:"title"`
|
||||
}
|
||||
|
||||
type didlItem struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
Title string `xml:"title"`
|
||||
Class string `xml:"class"`
|
||||
Artist string `xml:"artist"`
|
||||
Album string `xml:"album"`
|
||||
AlbumArt string `xml:"albumArtURI"`
|
||||
Res []didlR `xml:"res"`
|
||||
}
|
||||
|
||||
type didlR struct {
|
||||
ProtocolInfo string `xml:"protocolInfo,attr"`
|
||||
Duration string `xml:"duration,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// parseBrowseResponse is a pure function: it parses raw SOAP Browse response
|
||||
// bytes (including the nested DIDL-Lite inside <Result>) into a BrowseResult.
|
||||
// Testable without HTTP.
|
||||
func parseBrowseResponse(raw []byte) (BrowseResult, error) {
|
||||
var env soapBrowseEnvelope
|
||||
|
||||
if err := xml.Unmarshal(raw, &env); err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: parse SOAP envelope: %w", err)
|
||||
}
|
||||
|
||||
resultXML := env.Body.BrowseResponse.Result
|
||||
|
||||
if resultXML == "" {
|
||||
return BrowseResult{
|
||||
TotalMatches: env.Body.BrowseResponse.TotalMatches,
|
||||
Returned: env.Body.BrowseResponse.NumberReturned,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var didl didlLite
|
||||
|
||||
if err := xml.Unmarshal([]byte(resultXML), &didl); err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: parse DIDL-Lite: %w", err)
|
||||
}
|
||||
|
||||
out := BrowseResult{
|
||||
TotalMatches: env.Body.BrowseResponse.TotalMatches,
|
||||
Returned: env.Body.BrowseResponse.NumberReturned,
|
||||
}
|
||||
|
||||
for _, c := range didl.Containers {
|
||||
out.Containers = append(out.Containers, Container{
|
||||
ID: c.ID,
|
||||
ParentID: c.ParentID,
|
||||
Title: c.Title,
|
||||
ChildCount: c.ChildCount,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range didl.Items {
|
||||
it := &didl.Items[i]
|
||||
stream := ""
|
||||
mime := ""
|
||||
duration := 0
|
||||
|
||||
if len(it.Res) > 0 {
|
||||
stream = strings.TrimSpace(it.Res[0].Value)
|
||||
mime = MimeFromProtocolInfo(it.Res[0].ProtocolInfo)
|
||||
duration = ParseHMS(it.Res[0].Duration)
|
||||
}
|
||||
|
||||
out.Items = append(out.Items, Item{
|
||||
ID: it.ID,
|
||||
ParentID: it.ParentID,
|
||||
Title: it.Title,
|
||||
Class: it.Class,
|
||||
Artist: it.Artist,
|
||||
Album: it.Album,
|
||||
AlbumArtURL: it.AlbumArt,
|
||||
StreamURL: stream,
|
||||
MimeType: mime,
|
||||
DurationSec: duration,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MimeFromProtocolInfo extracts the MIME type from a DLNA protocolInfo string.
|
||||
// Format is "protocol:network:contentType:additionalInfo", e.g.
|
||||
// "http-get:*:audio/mpeg:*". Returns the third colon-separated field.
|
||||
func MimeFromProtocolInfo(pi string) string {
|
||||
parts := strings.Split(pi, ":")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parts[2]
|
||||
}
|
||||
|
||||
// ParseHMS converts a DIDL-Lite duration string in "H:MM:SS[.mmm]" format
|
||||
// to a total number of seconds.
|
||||
func ParseHMS(d string) int {
|
||||
if d == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Strip optional fractional seconds ("0:03:42.000" -> "0:03:42").
|
||||
if idx := strings.Index(d, "."); idx >= 0 {
|
||||
d = d[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(d, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
h, m, s := 0, 0, 0
|
||||
_, _ = fmt.Sscanf(parts[0], "%d", &h)
|
||||
_, _ = fmt.Sscanf(parts[1], "%d", &m)
|
||||
_, _ = fmt.Sscanf(parts[2], "%d", &s)
|
||||
|
||||
return h*3600 + m*60 + s
|
||||
}
|
||||
|
||||
// xmlEscape returns s as XML-safe text (escapes &, <, >, ", ').
|
||||
func xmlEscape(s string) string {
|
||||
var b strings.Builder
|
||||
|
||||
xml.EscapeText(&b, []byte(s)) //nolint:errcheck // strings.Builder never errors
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncate returns the first n bytes of s followed by "..." when len(s) > n.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package dlna_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/dlna"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Integration tests using the in-process DLNA test fixture
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// TestBrowse_Root checks that Browse("0") returns the Music container from the
|
||||
// default test tree.
|
||||
func TestBrowse_Root(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
srv := discovery.MediaServer{
|
||||
FriendlyName: "Test Server",
|
||||
CDSControlURL: ts.URL + "/ctl/ContentDir",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := dlna.Browse(ctx, srv, "0", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Browse root: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Containers) == 0 {
|
||||
t.Fatal("Browse root: got 0 containers, want at least 1")
|
||||
}
|
||||
|
||||
var musicContainer *dlna.Container
|
||||
|
||||
for i := range result.Containers {
|
||||
if result.Containers[i].Title == "Music" {
|
||||
musicContainer = &result.Containers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if musicContainer == nil {
|
||||
t.Fatalf("Browse root: Music container not found; got %v", result.Containers)
|
||||
}
|
||||
|
||||
if musicContainer.ID == "" {
|
||||
t.Error("Music container has empty ID")
|
||||
}
|
||||
|
||||
t.Logf("Music container: id=%q parentID=%q childCount=%d",
|
||||
musicContainer.ID, musicContainer.ParentID, musicContainer.ChildCount)
|
||||
}
|
||||
|
||||
// TestBrowse_MusicFolder checks that browsing into the Music container returns
|
||||
// exactly 2 audio items with non-empty StreamURLs that are fetchable.
|
||||
func TestBrowse_MusicFolder(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
srv := discovery.MediaServer{
|
||||
FriendlyName: "Test Server",
|
||||
CDSControlURL: ts.URL + "/ctl/ContentDir",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First, browse root to find the Music folder ID.
|
||||
root, err := dlna.Browse(ctx, srv, "0", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Browse root: %v", err)
|
||||
}
|
||||
|
||||
var musicID string
|
||||
|
||||
for _, c := range root.Containers {
|
||||
if c.Title == "Music" {
|
||||
musicID = c.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if musicID == "" {
|
||||
t.Fatal("Music container not found in root browse")
|
||||
}
|
||||
|
||||
// Now browse the Music folder.
|
||||
result, err := dlna.Browse(ctx, srv, musicID, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Browse music folder: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Items) != 2 {
|
||||
t.Fatalf("expected 2 audio items, got %d", len(result.Items))
|
||||
}
|
||||
|
||||
for _, item := range result.Items {
|
||||
t.Run(item.Title, func(t *testing.T) {
|
||||
if item.Title == "" {
|
||||
t.Error("item has empty Title")
|
||||
}
|
||||
|
||||
if !item.IsAudioItem() {
|
||||
t.Errorf("IsAudioItem() = false for item %q (MimeType=%q Class=%q)",
|
||||
item.Title, item.MimeType, item.Class)
|
||||
}
|
||||
|
||||
if item.Artist == "" {
|
||||
t.Errorf("item %q has empty Artist", item.Title)
|
||||
} else if item.Artist != "Test Artist" {
|
||||
t.Errorf("item %q: Artist = %q, want %q", item.Title, item.Artist, "Test Artist")
|
||||
}
|
||||
|
||||
if item.Album == "" {
|
||||
t.Errorf("item %q has empty Album", item.Title)
|
||||
} else if item.Album != "Test Album" {
|
||||
t.Errorf("item %q: Album = %q, want %q", item.Title, item.Album, "Test Album")
|
||||
}
|
||||
|
||||
if item.StreamURL == "" {
|
||||
t.Fatalf("item %q has empty StreamURL", item.Title)
|
||||
}
|
||||
|
||||
// Fetch the stream URL and verify it returns audio bytes.
|
||||
resp, err := http.Get(item.StreamURL) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", item.StreamURL, err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s: status %d", item.StreamURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read media body: %v", err)
|
||||
}
|
||||
|
||||
if len(data) < 44 {
|
||||
t.Errorf("audio payload too small (%d bytes), expected at least a WAV header", len(data))
|
||||
}
|
||||
|
||||
// Verify RIFF/WAVE header (silentWAV always produces PCM WAV).
|
||||
if string(data[0:4]) != "RIFF" {
|
||||
t.Errorf("expected RIFF header, got %q", data[0:4])
|
||||
}
|
||||
|
||||
if string(data[8:12]) != "WAVE" {
|
||||
t.Errorf("expected WAVE marker, got %q", data[8:12])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowse_NoCDSControlURL verifies that Browse returns an error when the
|
||||
// server has no CDSControlURL set.
|
||||
func TestBrowse_NoCDSControlURL(t *testing.T) {
|
||||
srv := discovery.MediaServer{FriendlyName: "Empty"}
|
||||
_, err := dlna.Browse(context.Background(), srv, "0", 0, 50)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Browse with empty CDSControlURL: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pure function unit tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// TestMimeFromProtocolInfo checks the DLNA protocolInfo MIME extraction.
|
||||
func TestMimeFromProtocolInfo(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"http-get:*:audio/x-wav:*", "audio/x-wav"},
|
||||
{"http-get:*:audio/mpeg:*", "audio/mpeg"},
|
||||
{"http-get:*:audio/ogg:DLNA.ORG_PN=OGG", "audio/ogg"},
|
||||
{"http-get:*:image/jpeg:*", "image/jpeg"},
|
||||
// Fewer than 3 colons: return empty string.
|
||||
{"http-get", ""},
|
||||
{"http-get:*", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := dlna.MimeFromProtocolInfo(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("MimeFromProtocolInfo(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseHMS checks duration string parsing to seconds.
|
||||
func TestParseHMS(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"0:00:01.000", 1},
|
||||
{"0:00:01", 1},
|
||||
{"0:03:42", 222},
|
||||
{"0:03:42.000", 222},
|
||||
{"1:00:00", 3600},
|
||||
{"1:30:00", 5400},
|
||||
{"0:00:00", 0},
|
||||
{"", 0},
|
||||
// Malformed: return 0.
|
||||
{"99:99", 0},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := dlna.ParseHMS(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseHMS(%q) = %d, want %d", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsAudioItem checks the audio-item classifier.
|
||||
func TestIsAudioItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
item dlna.Item
|
||||
want bool
|
||||
}{
|
||||
// MimeType prefix "audio/" is sufficient.
|
||||
{dlna.Item{MimeType: "audio/x-wav"}, true},
|
||||
{dlna.Item{MimeType: "audio/mpeg"}, true},
|
||||
// Class "audioitem" (any case).
|
||||
{dlna.Item{Class: "object.item.audioItem.musicTrack"}, true},
|
||||
{dlna.Item{Class: "object.item.musicTrack"}, true},
|
||||
// Video and image MIME types: not audio.
|
||||
{dlna.Item{MimeType: "video/mp4"}, false},
|
||||
{dlna.Item{MimeType: "image/jpeg"}, false},
|
||||
// Empty item.
|
||||
{dlna.Item{}, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := tc.item.IsAudioItem()
|
||||
if got != tc.want {
|
||||
t.Errorf("Item{MimeType:%q Class:%q}.IsAudioItem() = %v, want %v",
|
||||
tc.item.MimeType, tc.item.Class, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
// Package dlnatest provides an in-process DLNA / UPnP MediaServer for use in
|
||||
// unit tests (via httptest.Server) and as a real LAN-visible server.
|
||||
//
|
||||
// The server handles:
|
||||
// - GET /rootDesc.xml device description (UPnP root device)
|
||||
// - POST /ctl/ContentDir ContentDirectory Browse SOAP action
|
||||
// - GET /MediaItems/*.wav synthesised audio bytes (1 s silent WAV)
|
||||
// - GET /icons/sm.png minimal 1x1 PNG so icon fetches do not 404
|
||||
//
|
||||
// All absolute URLs in DIDL-Lite <res> elements are built from the
|
||||
// incoming request's Host header, so the same handler works unchanged
|
||||
// behind httptest.Server and a real net.Listener.
|
||||
package dlnatest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serveModTime is a fixed modification time used for ServeContent so that
|
||||
// range requests and caching headers behave deterministically.
|
||||
var serveModTime = time.Unix(1136214245, 0)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Content tree model
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Container represents a DLNA object.container node.
|
||||
type Container struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Title string
|
||||
Class string // upnp:class value, e.g. "object.container.storageFolder"
|
||||
Children []*Item
|
||||
}
|
||||
|
||||
// Item represents a DLNA object.item.audioItem node.
|
||||
type Item struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Title string
|
||||
Class string // upnp:class value, e.g. "object.item.audioItem.musicTrack"
|
||||
Artist string
|
||||
Album string
|
||||
MimeType string
|
||||
DurSec float64 // duration in seconds
|
||||
Payload []byte // raw audio bytes served at /MediaItems/<ID>.<ext>
|
||||
|
||||
// ArtPayload, when non-empty, is album-art image bytes served at
|
||||
// /AlbumArt/<ID>.<ext> and advertised in DIDL-Lite via <upnp:albumArtURI>.
|
||||
// ArtMime is the art image MIME type (e.g. "image/jpeg").
|
||||
ArtPayload []byte
|
||||
ArtMime string
|
||||
}
|
||||
|
||||
// mediaExt returns the file extension for this item's MIME type.
|
||||
func (it *Item) mediaExt() string {
|
||||
switch it.MimeType {
|
||||
case "audio/x-wav", "audio/wav":
|
||||
return "wav"
|
||||
case "audio/mpeg":
|
||||
return "mp3"
|
||||
case "audio/flac", "audio/x-flac":
|
||||
return "flac"
|
||||
case "audio/mp4", "audio/m4a", "audio/x-m4a":
|
||||
return "m4a"
|
||||
case "audio/ogg":
|
||||
return "ogg"
|
||||
default:
|
||||
return "bin"
|
||||
}
|
||||
}
|
||||
|
||||
// artExt returns the file extension for an album-art MIME type.
|
||||
func artExt(mime string) string {
|
||||
switch mime {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
default:
|
||||
return "img"
|
||||
}
|
||||
}
|
||||
|
||||
// Tree is the in-memory content tree. Root containers are stored by ID.
|
||||
type Tree struct {
|
||||
Containers []*Container // ordered; first container is the default music folder
|
||||
}
|
||||
|
||||
// DefaultTree returns a minimal two-track music library that matches the
|
||||
// structure used in the spec/capture comments.
|
||||
func DefaultTree() *Tree {
|
||||
track01 := silentWAV(1, 8000, 1)
|
||||
track02 := silentWAV(1, 8000, 1)
|
||||
|
||||
music := &Container{
|
||||
ID: "1",
|
||||
ParentID: "0",
|
||||
Title: "Music",
|
||||
Class: "object.container.storageFolder",
|
||||
Children: []*Item{
|
||||
{
|
||||
ID: "1$4$0",
|
||||
ParentID: "1$4",
|
||||
Title: "track01",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track01,
|
||||
ArtPayload: tinyPNG,
|
||||
ArtMime: "image/png",
|
||||
},
|
||||
{
|
||||
ID: "1$4$1",
|
||||
ParentID: "1$4",
|
||||
Title: "track02",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track02,
|
||||
ArtPayload: tinyPNG,
|
||||
ArtMime: "image/png",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &Tree{Containers: []*Container{music}}
|
||||
}
|
||||
|
||||
// containerByID returns the container with the given ID, or nil.
|
||||
func (t *Tree) containerByID(id string) *Container {
|
||||
for _, c := range t.Containers {
|
||||
if c.ID == id {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// itemByID returns the first item in any container whose ID matches.
|
||||
func (t *Tree) itemByID(id string) *Item {
|
||||
for _, c := range t.Containers {
|
||||
for _, it := range c.Children {
|
||||
if it.ID == id {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Server
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Option is a functional option for NewServer.
|
||||
type Option func(*Server)
|
||||
|
||||
// WithFriendlyName overrides the UPnP friendlyName.
|
||||
func WithFriendlyName(name string) Option {
|
||||
return func(s *Server) { s.FriendlyName = name }
|
||||
}
|
||||
|
||||
// WithUDN overrides the UPnP Unique Device Name (UUID).
|
||||
func WithUDN(udn string) Option {
|
||||
return func(s *Server) { s.UDN = udn }
|
||||
}
|
||||
|
||||
// WithTree replaces the entire content tree.
|
||||
func WithTree(tree *Tree) Option {
|
||||
return func(s *Server) { s.tree = tree }
|
||||
}
|
||||
|
||||
// Server is the DLNA / UPnP MediaServer implementation.
|
||||
type Server struct {
|
||||
FriendlyName string
|
||||
UDN string
|
||||
tree *Tree
|
||||
}
|
||||
|
||||
// NewServer creates a Server with the supplied options applied.
|
||||
func NewServer(opts ...Option) *Server {
|
||||
s := &Server{
|
||||
FriendlyName: "AfterTouch Test Library",
|
||||
UDN: "uuid:4d696e69-444c-164e-9d41-72ecda78e4c1",
|
||||
tree: DefaultTree(),
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// NewHTTPTest starts an httptest.Server backed by s and returns both.
|
||||
// Call ts.Close() when the test is done.
|
||||
func NewHTTPTest(opts ...Option) (*httptest.Server, *Server) {
|
||||
s := NewServer(opts...)
|
||||
ts := httptest.NewServer(s.HTTPHandler())
|
||||
|
||||
return ts, s
|
||||
}
|
||||
|
||||
// HTTPHandler returns an http.Handler that serves all DLNA endpoints.
|
||||
func (s *Server) HTTPHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/rootDesc.xml", s.serveRootDesc)
|
||||
mux.HandleFunc("/ctl/ContentDir", s.serveContentDir)
|
||||
mux.HandleFunc("/icons/sm.png", s.serveIcon)
|
||||
mux.HandleFunc("/MediaItems/", s.serveMediaItem)
|
||||
mux.HandleFunc("/AlbumArt/", s.serveAlbumArt)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// /rootDesc.xml
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) serveRootDesc(w http.ResponseWriter, _ *http.Request) {
|
||||
type specVersion struct {
|
||||
Major int `xml:"major"`
|
||||
Minor int `xml:"minor"`
|
||||
}
|
||||
|
||||
type icon struct {
|
||||
MimeType string `xml:"mimetype"`
|
||||
Width int `xml:"width"`
|
||||
Height int `xml:"height"`
|
||||
Depth int `xml:"depth"`
|
||||
URL string `xml:"url"`
|
||||
}
|
||||
|
||||
type service struct {
|
||||
ServiceType string `xml:"serviceType"`
|
||||
ServiceID string `xml:"serviceId"`
|
||||
ControlURL string `xml:"controlURL"`
|
||||
EventSubURL string `xml:"eventSubURL,omitempty"`
|
||||
SCPDURL string `xml:"SCPDURL,omitempty"`
|
||||
}
|
||||
|
||||
type device struct {
|
||||
DeviceType string `xml:"deviceType"`
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
Manufacturer string `xml:"manufacturer"`
|
||||
ModelName string `xml:"modelName"`
|
||||
ModelNumber string `xml:"modelNumber"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
UDN string `xml:"UDN"`
|
||||
IconList []icon `xml:"iconList>icon"`
|
||||
ServiceList []service `xml:"serviceList>service"`
|
||||
}
|
||||
|
||||
type rootDesc struct {
|
||||
XMLName xml.Name `xml:"urn:schemas-upnp-org:device-1-0 root"`
|
||||
SpecVersion specVersion `xml:"specVersion"`
|
||||
Device device `xml:"device"`
|
||||
}
|
||||
|
||||
desc := rootDesc{
|
||||
SpecVersion: specVersion{Major: 1, Minor: 0},
|
||||
Device: device{
|
||||
DeviceType: "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
FriendlyName: s.FriendlyName,
|
||||
Manufacturer: "AfterTouch",
|
||||
ModelName: "AfterTouch Test MediaServer",
|
||||
ModelNumber: "1",
|
||||
SerialNumber: "00000000",
|
||||
UDN: s.UDN,
|
||||
IconList: []icon{
|
||||
{MimeType: "image/png", Width: 48, Height: 48, Depth: 24, URL: "/icons/sm.png"},
|
||||
},
|
||||
ServiceList: []service{
|
||||
{
|
||||
ServiceType: "urn:schemas-upnp-org:service:ContentDirectory:1",
|
||||
ServiceID: "urn:upnp-org:serviceId:ContentDirectory",
|
||||
ControlURL: "/ctl/ContentDir",
|
||||
EventSubURL: "/evt/ContentDir",
|
||||
SCPDURL: "/ContentDir.xml",
|
||||
},
|
||||
{
|
||||
ServiceType: "urn:schemas-upnp-org:service:ConnectionManager:1",
|
||||
ServiceID: "urn:upnp-org:serviceId:ConnectionManager",
|
||||
ControlURL: "/ctl/ConnectionMgr",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
|
||||
if _, err := fmt.Fprint(w, xml.Header); err != nil {
|
||||
http.Error(w, "write error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
enc := xml.NewEncoder(w)
|
||||
enc.Indent("", "")
|
||||
|
||||
if err := enc.Encode(desc); err != nil {
|
||||
// Headers already sent; best effort.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// /ctl/ContentDir (ContentDirectory Browse SOAP action)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// soapBrowseRequest is the envelope we parse from the incoming POST.
|
||||
type soapBrowseRequest struct {
|
||||
Body struct {
|
||||
Browse struct {
|
||||
ObjectID string `xml:"ObjectID"`
|
||||
BrowseFlag string `xml:"BrowseFlag"`
|
||||
StartingIndex int `xml:"StartingIndex"`
|
||||
RequestedCount int `xml:"RequestedCount"`
|
||||
} `xml:"Browse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
func (s *Server) serveContentDir(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the SOAP envelope (lenient: ignore namespace prefixes via xml.Unmarshal).
|
||||
var req soapBrowseRequest
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad soap envelope", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
objectID := req.Body.Browse.ObjectID
|
||||
startIndex := req.Body.Browse.StartingIndex
|
||||
reqCount := req.Body.Browse.RequestedCount
|
||||
|
||||
base := baseURL(r)
|
||||
|
||||
var didl string
|
||||
|
||||
var total int
|
||||
|
||||
if req.Body.Browse.BrowseFlag == "BrowseMetadata" {
|
||||
// Metadata for a single object (the speaker resolves a track's <res>
|
||||
// this way before playing it).
|
||||
didl, total = s.browseMetadata(objectID, base)
|
||||
} else {
|
||||
switch objectID {
|
||||
case "0":
|
||||
// Root: return containers.
|
||||
didl, total = s.browseRoot(startIndex, reqCount)
|
||||
default:
|
||||
// Try as a container ID.
|
||||
if c := s.tree.containerByID(objectID); c != nil {
|
||||
didl, total = s.browseContainer(c, startIndex, reqCount, base)
|
||||
} else {
|
||||
// Unknown object: return empty result.
|
||||
didl = emptyDIDL()
|
||||
total = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NumberReturned is the count of items in this page.
|
||||
var returned int
|
||||
if reqCount <= 0 || reqCount > total-startIndex {
|
||||
returned = total - startIndex
|
||||
} else {
|
||||
returned = reqCount
|
||||
}
|
||||
|
||||
if returned < 0 {
|
||||
returned = 0
|
||||
}
|
||||
|
||||
writeSOAPBrowseResponse(w, didl, returned, total)
|
||||
}
|
||||
|
||||
// baseURL builds an absolute http://host:port prefix from the request.
|
||||
func baseURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// browseRoot returns DIDL-Lite for the root container (ObjectID "0").
|
||||
func (s *Server) browseRoot(start, count int) (string, int) {
|
||||
containers := s.tree.Containers
|
||||
total := len(containers)
|
||||
page := page(containers, start, count)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(`<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" `)
|
||||
b.WriteString(`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" `)
|
||||
b.WriteString(`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" `)
|
||||
b.WriteString(`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`)
|
||||
|
||||
for _, c := range page {
|
||||
childCount := len(c.Children)
|
||||
_, _ = fmt.Fprintf(&b,
|
||||
`<container id=%s parentID=%s restricted="1" childCount="%d">`,
|
||||
xmlAttr(c.ID), xmlAttr(c.ParentID), childCount,
|
||||
)
|
||||
b.WriteString(`<dc:title>` + xmlEsc(c.Title) + `</dc:title>`)
|
||||
b.WriteString(`<upnp:class>` + xmlEsc(c.Class) + `</upnp:class>`)
|
||||
b.WriteString(`</container>`)
|
||||
}
|
||||
|
||||
b.WriteString(`</DIDL-Lite>`)
|
||||
|
||||
return b.String(), total
|
||||
}
|
||||
|
||||
// didlOpen is the opening tag (with namespaces) shared by all DIDL-Lite results.
|
||||
const didlOpen = `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
|
||||
`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
|
||||
`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ` +
|
||||
`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`
|
||||
|
||||
// writeItemDIDL writes a single DIDL-Lite <item> (title, artist/album, class,
|
||||
// optional albumArtURI, and the <res> media URL) into b.
|
||||
func writeItemDIDL(b *strings.Builder, it *Item, base string) {
|
||||
size := len(it.Payload)
|
||||
dur := formatDuration(it.DurSec)
|
||||
resURL := fmt.Sprintf("%s/MediaItems/%s.%s", base, urlPathEsc(it.ID), it.mediaExt())
|
||||
|
||||
_, _ = fmt.Fprintf(b, `<item id=%s parentID=%s restricted="1">`, xmlAttr(it.ID), xmlAttr(it.ParentID))
|
||||
b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)
|
||||
|
||||
if it.Artist != "" {
|
||||
b.WriteString(`<upnp:artist>` + xmlEsc(it.Artist) + `</upnp:artist>`)
|
||||
}
|
||||
|
||||
if it.Album != "" {
|
||||
b.WriteString(`<upnp:album>` + xmlEsc(it.Album) + `</upnp:album>`)
|
||||
}
|
||||
|
||||
b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)
|
||||
|
||||
if len(it.ArtPayload) > 0 {
|
||||
artURL := fmt.Sprintf("%s/AlbumArt/%s.%s", base, urlPathEsc(it.ID), artExt(it.ArtMime))
|
||||
b.WriteString(`<upnp:albumArtURI>` + xmlEsc(artURL) + `</upnp:albumArtURI>`)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(b,
|
||||
`<res size="%d" duration="%s" bitrate="128000" sampleFrequency="8000" nrAudioChannels="1" protocolInfo="http-get:*:%s:*">%s</res>`,
|
||||
size, dur, xmlEsc(it.MimeType), xmlEsc(resURL),
|
||||
)
|
||||
b.WriteString(`</item>`)
|
||||
}
|
||||
|
||||
// browseContainer returns DIDL-Lite for the items inside a container.
|
||||
func (s *Server) browseContainer(c *Container, start, count int, base string) (string, int) {
|
||||
items := c.Children
|
||||
total := len(items)
|
||||
pageItems := pageItems(items, start, count)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(didlOpen)
|
||||
|
||||
for _, it := range pageItems {
|
||||
writeItemDIDL(&b, it, base)
|
||||
}
|
||||
|
||||
b.WriteString(`</DIDL-Lite>`)
|
||||
|
||||
return b.String(), total
|
||||
}
|
||||
|
||||
// browseMetadata returns DIDL-Lite describing a single object (BrowseMetadata),
|
||||
// which speakers request to resolve a track's <res> URL before playing it.
|
||||
// Without this, a STORED_MUSIC select of a track ID returns empty metadata and
|
||||
// the speaker reports INVALID_SOURCE.
|
||||
func (s *Server) browseMetadata(objectID, base string) (string, int) {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(didlOpen)
|
||||
|
||||
switch {
|
||||
case objectID == "0":
|
||||
_, _ = fmt.Fprintf(&b,
|
||||
`<container id="0" parentID="-1" restricted="1" childCount="%d"><dc:title>Root</dc:title><upnp:class>object.container.storageFolder</upnp:class></container>`,
|
||||
len(s.tree.Containers),
|
||||
)
|
||||
case s.tree.containerByID(objectID) != nil:
|
||||
c := s.tree.containerByID(objectID)
|
||||
_, _ = fmt.Fprintf(&b,
|
||||
`<container id=%s parentID=%s restricted="1" childCount="%d">`,
|
||||
xmlAttr(c.ID), xmlAttr(c.ParentID), len(c.Children),
|
||||
)
|
||||
b.WriteString(`<dc:title>` + xmlEsc(c.Title) + `</dc:title>`)
|
||||
b.WriteString(`<upnp:class>` + xmlEsc(c.Class) + `</upnp:class>`)
|
||||
b.WriteString(`</container>`)
|
||||
case s.tree.itemByID(objectID) != nil:
|
||||
writeItemDIDL(&b, s.tree.itemByID(objectID), base)
|
||||
default:
|
||||
b.WriteString(`</DIDL-Lite>`)
|
||||
|
||||
return b.String(), 0
|
||||
}
|
||||
|
||||
b.WriteString(`</DIDL-Lite>`)
|
||||
|
||||
return b.String(), 1
|
||||
}
|
||||
|
||||
func emptyDIDL() string {
|
||||
return `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
|
||||
`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
|
||||
`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ` +
|
||||
`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"></DIDL-Lite>`
|
||||
}
|
||||
|
||||
// writeSOAPBrowseResponse writes the full SOAP envelope around the DIDL-Lite result.
|
||||
func writeSOAPBrowseResponse(w http.ResponseWriter, didl string, returned, total int) {
|
||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
|
||||
// The DIDL-Lite result must appear as XML-escaped text inside the <Result> element.
|
||||
escaped := xmlEsc(didl)
|
||||
|
||||
body := `<?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:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">` +
|
||||
`<Result>` + escaped + `</Result>` +
|
||||
`<NumberReturned>` + strconv.Itoa(returned) + `</NumberReturned>` +
|
||||
`<TotalMatches>` + strconv.Itoa(total) + `</TotalMatches>` +
|
||||
`<UpdateID>0</UpdateID>` +
|
||||
`</u:BrowseResponse>` +
|
||||
`</s:Body>` +
|
||||
`</s:Envelope>`
|
||||
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// /MediaItems/<id>.<ext>
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) serveMediaItem(w http.ResponseWriter, r *http.Request) {
|
||||
// Path: /MediaItems/<id>.<ext>
|
||||
rel := strings.TrimPrefix(r.URL.Path, "/MediaItems/")
|
||||
// Strip extension.
|
||||
dot := strings.LastIndexByte(rel, '.')
|
||||
id := rel
|
||||
|
||||
if dot >= 0 {
|
||||
id = rel[:dot]
|
||||
}
|
||||
|
||||
// The ID may contain '$' which is percent-encoded in URLs.
|
||||
// url.PathUnescape would normally handle this, but the mux already decoded it.
|
||||
item := s.tree.itemByID(id)
|
||||
if item == nil {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ServeContent gives us byte-range support, which real speakers use when
|
||||
// streaming audio (raw io.Writer with a fixed Content-Length does not).
|
||||
if item.MimeType != "" {
|
||||
w.Header().Set("Content-Type", item.MimeType)
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "media."+item.mediaExt(), serveModTime, bytes.NewReader(item.Payload))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// /AlbumArt/<id>.<ext>
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) serveAlbumArt(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, "/AlbumArt/")
|
||||
|
||||
dot := strings.LastIndexByte(rel, '.')
|
||||
id := rel
|
||||
|
||||
if dot >= 0 {
|
||||
id = rel[:dot]
|
||||
}
|
||||
|
||||
item := s.tree.itemByID(id)
|
||||
if item == nil || len(item.ArtPayload) == 0 {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if item.ArtMime != "" {
|
||||
w.Header().Set("Content-Type", item.ArtMime)
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "art."+artExt(item.ArtMime), serveModTime, bytes.NewReader(item.ArtPayload))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// /icons/sm.png
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// tinyPNG is a 1x1 white pixel PNG (67 bytes, entirely static).
|
||||
var tinyPNG = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR length + type
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit depth=8, color=RGB, ...
|
||||
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IHDR CRC; IDAT length + type
|
||||
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0x3f, // IDAT data (deflate)
|
||||
0x00, 0x05, 0xfe, 0x02, 0xfe, 0xdc, 0xcc, 0x59, // IDAT continued
|
||||
0xe7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IDAT CRC; IEND length + type
|
||||
0x44, 0xae, 0x42, 0x60, 0x82, // IEND CRC
|
||||
}
|
||||
|
||||
func (s *Server) serveIcon(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(tinyPNG)))
|
||||
_, _ = w.Write(tinyPNG)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// WAV synthesis
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// silentWAV generates a minimal PCM WAV file: mono, 16-bit, given sample rate
|
||||
// and duration in seconds. All samples are zero (silence).
|
||||
func silentWAV(durationSec float64, sampleRate, channels int) []byte {
|
||||
numSamples := int(float64(sampleRate) * durationSec)
|
||||
bitsPerSample := 16
|
||||
byteRate := sampleRate * channels * bitsPerSample / 8
|
||||
blockAlign := channels * bitsPerSample / 8
|
||||
dataSize := numSamples * blockAlign
|
||||
fileSize := 36 + dataSize
|
||||
|
||||
buf := make([]byte, 44+dataSize)
|
||||
|
||||
// RIFF header
|
||||
copy(buf[0:], "RIFF")
|
||||
le32(buf[4:], uint32(fileSize))
|
||||
copy(buf[8:], "WAVE")
|
||||
|
||||
// fmt chunk
|
||||
copy(buf[12:], "fmt ")
|
||||
le32(buf[16:], 16) // chunk size
|
||||
le16(buf[20:], 1) // PCM
|
||||
le16(buf[22:], uint16(channels))
|
||||
le32(buf[24:], uint32(sampleRate))
|
||||
le32(buf[28:], uint32(byteRate))
|
||||
le16(buf[32:], uint16(blockAlign))
|
||||
le16(buf[34:], uint16(bitsPerSample))
|
||||
|
||||
// data chunk
|
||||
copy(buf[36:], "data")
|
||||
le32(buf[40:], uint32(dataSize))
|
||||
// samples are already zero
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func le16(b []byte, v uint16) {
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
}
|
||||
|
||||
func le32(b []byte, v uint32) {
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Utility helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// xmlEsc escapes s for use as XML text content.
|
||||
func xmlEsc(s string) string {
|
||||
var b strings.Builder
|
||||
xml.EscapeText(&b, []byte(s)) //nolint:errcheck // strings.Builder never errors
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// xmlAttr returns s as a double-quoted XML attribute value with proper escaping.
|
||||
func xmlAttr(s string) string {
|
||||
return `"` + xmlEsc(s) + `"`
|
||||
}
|
||||
|
||||
// urlPathEsc percent-encodes characters that are not safe in a URL path
|
||||
// segment. We only need to encode '$' (which appears in item IDs).
|
||||
func urlPathEsc(s string) string {
|
||||
return strings.ReplaceAll(s, "$", "%24")
|
||||
}
|
||||
|
||||
// formatDuration converts seconds to "h:mm:ss.mmm" as used in DIDL-Lite.
|
||||
func formatDuration(sec float64) string {
|
||||
ms := int(sec * 1000)
|
||||
h := ms / 3600000
|
||||
ms -= h * 3600000
|
||||
m := ms / 60000
|
||||
ms -= m * 60000
|
||||
s := ms / 1000
|
||||
ms -= s * 1000
|
||||
|
||||
return fmt.Sprintf("%d:%02d:%02d.%03d", h, m, s, ms)
|
||||
}
|
||||
|
||||
// page returns a slice of containers for the requested page.
|
||||
func page(containers []*Container, start, count int) []*Container {
|
||||
if start >= len(containers) {
|
||||
return nil
|
||||
}
|
||||
|
||||
end := len(containers)
|
||||
if count > 0 && start+count < end {
|
||||
end = start + count
|
||||
}
|
||||
|
||||
return containers[start:end]
|
||||
}
|
||||
|
||||
// pageItems returns a slice of items for the requested page.
|
||||
func pageItems(items []*Item, start, count int) []*Item {
|
||||
if start >= len(items) {
|
||||
return nil
|
||||
}
|
||||
|
||||
end := len(items)
|
||||
if count > 0 && start+count < end {
|
||||
end = start + count
|
||||
}
|
||||
|
||||
return items[start:end]
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package dlnatest_test
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// rootDesc.xml
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestRootDesc_Parses(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/rootDesc.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /rootDesc.xml: %v", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var root struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Device struct {
|
||||
DeviceType string `xml:"deviceType"`
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
ServiceList []struct {
|
||||
ServiceType string `xml:"serviceType"`
|
||||
ServiceID string `xml:"serviceId"`
|
||||
ControlURL string `xml:"controlURL"`
|
||||
} `xml:"serviceList>service"`
|
||||
} `xml:"device"`
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &root); err != nil {
|
||||
t.Fatalf("xml.Unmarshal: %v\nbody: %s", err, body)
|
||||
}
|
||||
|
||||
wantType := "urn:schemas-upnp-org:device:MediaServer:1"
|
||||
if root.Device.DeviceType != wantType {
|
||||
t.Errorf("deviceType = %q, want %q", root.Device.DeviceType, wantType)
|
||||
}
|
||||
|
||||
if root.Device.FriendlyName == "" {
|
||||
t.Error("friendlyName is empty")
|
||||
}
|
||||
|
||||
// Find ContentDirectory service.
|
||||
var cdControlURL string
|
||||
|
||||
for _, svc := range root.Device.ServiceList {
|
||||
if svc.ServiceType == "urn:schemas-upnp-org:service:ContentDirectory:1" {
|
||||
cdControlURL = svc.ControlURL
|
||||
}
|
||||
}
|
||||
|
||||
if cdControlURL == "" {
|
||||
t.Fatal("ContentDirectory service not found in rootDesc")
|
||||
}
|
||||
|
||||
if cdControlURL != "/ctl/ContentDir" {
|
||||
t.Errorf("ContentDirectory controlURL = %q, want %q", cdControlURL, "/ctl/ContentDir")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// ContentDirectory Browse
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// soapBrowse sends a SOAP Browse request for the given ObjectID and returns
|
||||
// the raw <Result> string and the parsed DIDL-Lite document.
|
||||
func soapBrowse(t *testing.T, baseURL, objectID string) (resultRaw string, didl didlLite) {
|
||||
t.Helper()
|
||||
|
||||
body := `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">` +
|
||||
`<s:Body>` +
|
||||
`<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">` +
|
||||
`<ObjectID>` + objectID + `</ObjectID>` +
|
||||
`<BrowseFlag>BrowseDirectChildren</BrowseFlag>` +
|
||||
`<StartingIndex>0</StartingIndex>` +
|
||||
`<RequestedCount>0</RequestedCount>` +
|
||||
`</u:Browse>` +
|
||||
`</s:Body>` +
|
||||
`</s:Envelope>`
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, baseURL+"/ctl/ContentDir", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/xml; charset=utf-8")
|
||||
req.Header.Set("SOAPACTION", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /ctl/ContentDir: %v", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
|
||||
// Parse the SOAP envelope.
|
||||
var envelope struct {
|
||||
Body struct {
|
||||
BrowseResponse struct {
|
||||
Result string `xml:"Result"`
|
||||
NumberReturned int `xml:"NumberReturned"`
|
||||
TotalMatches int `xml:"TotalMatches"`
|
||||
} `xml:"BrowseResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("xml.Unmarshal SOAP: %v\nraw: %s", err, raw)
|
||||
}
|
||||
|
||||
resultRaw = envelope.Body.BrowseResponse.Result
|
||||
|
||||
// The Result is XML-escaped DIDL-Lite; unescape + parse.
|
||||
if err := xml.Unmarshal([]byte(resultRaw), &didl); err != nil {
|
||||
t.Fatalf("xml.Unmarshal DIDL-Lite: %v\nresult: %s", err, resultRaw)
|
||||
}
|
||||
|
||||
return resultRaw, didl
|
||||
}
|
||||
|
||||
// didlLite is a minimal parse target for DIDL-Lite responses.
|
||||
type didlLite struct {
|
||||
XMLName xml.Name `xml:"DIDL-Lite"`
|
||||
Containers []didlContainer `xml:"container"`
|
||||
Items []didlItem `xml:"item"`
|
||||
}
|
||||
|
||||
type didlContainer struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
ChildCount string `xml:"childCount,attr"`
|
||||
Title string `xml:"title"`
|
||||
Class string `xml:"class"`
|
||||
}
|
||||
|
||||
type didlItem struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
Title string `xml:"title"`
|
||||
Class string `xml:"class"`
|
||||
Res []didlRes `xml:"res"`
|
||||
}
|
||||
|
||||
type didlRes struct {
|
||||
ProtocolInfo string `xml:"protocolInfo,attr"`
|
||||
URL string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func TestBrowseRoot_ReturnsMusicContainer(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
_, didl := soapBrowse(t, ts.URL, "0")
|
||||
|
||||
if len(didl.Containers) == 0 {
|
||||
t.Fatal("Browse root returned no containers")
|
||||
}
|
||||
|
||||
var found bool
|
||||
|
||||
for _, c := range didl.Containers {
|
||||
if c.Title == "Music" {
|
||||
found = true
|
||||
|
||||
if c.ID == "" {
|
||||
t.Error("Music container has empty id")
|
||||
}
|
||||
|
||||
if c.ParentID != "0" {
|
||||
t.Errorf("Music container parentID = %q, want \"0\"", c.ParentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("no Music container in root browse; got containers: %v", didl.Containers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseMusicFolder_ReturnsTwoItems(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
// First get the Music container ID from root.
|
||||
_, rootDIDL := soapBrowse(t, ts.URL, "0")
|
||||
|
||||
var musicID string
|
||||
|
||||
for _, c := range rootDIDL.Containers {
|
||||
if c.Title == "Music" {
|
||||
musicID = c.ID
|
||||
}
|
||||
}
|
||||
|
||||
if musicID == "" {
|
||||
t.Fatal("could not find Music container in root browse")
|
||||
}
|
||||
|
||||
_, didl := soapBrowse(t, ts.URL, musicID)
|
||||
|
||||
if len(didl.Items) != 2 {
|
||||
t.Fatalf("expected 2 audio items in Music folder, got %d", len(didl.Items))
|
||||
}
|
||||
|
||||
for _, item := range didl.Items {
|
||||
if item.Title == "" {
|
||||
t.Error("item has empty title")
|
||||
}
|
||||
|
||||
if len(item.Res) == 0 {
|
||||
t.Errorf("item %q has no <res> element", item.Title)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
resURL := strings.TrimSpace(item.Res[0].URL)
|
||||
if resURL == "" {
|
||||
t.Errorf("item %q has empty <res> URL", item.Title)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify the resource URL is fetchable and returns audio bytes.
|
||||
t.Run("fetch_"+item.Title, func(t *testing.T) {
|
||||
resp, err := http.Get(resURL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", resURL, err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s: status %d", resURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("reading media body: %v", err)
|
||||
}
|
||||
|
||||
if len(data) < 44 {
|
||||
t.Errorf("audio payload too small (%d bytes); expected at least a WAV header", len(data))
|
||||
}
|
||||
|
||||
// Verify RIFF header.
|
||||
if string(data[0:4]) != "RIFF" {
|
||||
t.Errorf("expected RIFF header, got %q", data[0:4])
|
||||
}
|
||||
|
||||
if string(data[8:12]) != "WAVE" {
|
||||
t.Errorf("expected WAVE marker, got %q", data[8:12])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Icon
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestIcon_ReturnsPNG(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/icons/sm.png")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /icons/sm.png: %v", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "image/png") {
|
||||
t.Errorf("Content-Type = %q, want image/png", ct)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("reading icon body: %v", err)
|
||||
}
|
||||
|
||||
// PNG magic bytes.
|
||||
if len(data) < 8 || string(data[0:4]) != "\x89PNG" {
|
||||
t.Errorf("response does not look like a PNG (first bytes: %x)", data[:min8(len(data))])
|
||||
}
|
||||
}
|
||||
|
||||
func min8(n int) int {
|
||||
if n < 8 {
|
||||
return n
|
||||
}
|
||||
|
||||
return 8
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Custom tree
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestCustomTree(t *testing.T) {
|
||||
customTree := &dlnatest.Tree{
|
||||
Containers: []*dlnatest.Container{
|
||||
{
|
||||
ID: "99",
|
||||
ParentID: "0",
|
||||
Title: "CustomFolder",
|
||||
Class: "object.container.storageFolder",
|
||||
Children: []*dlnatest.Item{
|
||||
{
|
||||
ID: "99$0",
|
||||
ParentID: "99",
|
||||
Title: "custom-track",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 0.5,
|
||||
Payload: []byte("RIFF\x00\x00\x00\x00WAVEfmt "),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ts, _ := dlnatest.NewHTTPTest(dlnatest.WithTree(customTree))
|
||||
defer ts.Close()
|
||||
|
||||
_, didl := soapBrowse(t, ts.URL, "0")
|
||||
|
||||
if len(didl.Containers) != 1 || didl.Containers[0].Title != "CustomFolder" {
|
||||
t.Errorf("custom tree root browse: got %v", didl.Containers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// ListMediaServersResponse is the XML response from the speaker's
|
||||
// /listMediaServers endpoint. Real speakers can return an empty self-closing
|
||||
// element when no servers are visible, so MediaServers may be nil or empty.
|
||||
type ListMediaServersResponse struct {
|
||||
XMLName xml.Name `xml:"ListMediaServersResponse"`
|
||||
MediaServers []MediaServerInfo `xml:"media_server"`
|
||||
}
|
||||
|
||||
// MediaServerInfo describes a single DLNA media server as reported by the
|
||||
// speaker's own UPnP/DLNA discovery layer.
|
||||
type MediaServerInfo struct {
|
||||
// ID is the UDN (uuid:...) of the server.
|
||||
ID string `xml:"id,attr"`
|
||||
// MAC is the server's MAC address when reported by the speaker.
|
||||
MAC string `xml:"mac,attr,omitempty"`
|
||||
// IP is the LAN IP address the speaker resolved for the server.
|
||||
IP string `xml:"ip,attr,omitempty"`
|
||||
// Manufacturer is the vendor string from the UPnP description.
|
||||
Manufacturer string `xml:"manufacturer,attr,omitempty"`
|
||||
// ModelName is the model string from the UPnP description.
|
||||
ModelName string `xml:"model_name,attr,omitempty"`
|
||||
// FriendlyName is the human-readable server name.
|
||||
FriendlyName string `xml:"friendly_name,attr,omitempty"`
|
||||
// ModelDescription is the optional long model description.
|
||||
ModelDescription string `xml:"model_description,attr,omitempty"`
|
||||
// Location is the URL of the device's UPnP root description document.
|
||||
Location string `xml:"location,attr,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListMediaServersResponse_Populated(t *testing.T) {
|
||||
raw := `<ListMediaServersResponse>` +
|
||||
`<media_server id="uuid:1234-5678" mac="AA:BB:CC:DD:EE:FF" ip="192.0.2.5"` +
|
||||
` manufacturer="ExampleCorp" model_name="NAS-3000" friendly_name="My NAS"` +
|
||||
` model_description="Home NAS" location="http://192.0.2.5:8200/rootDesc.xml" />` +
|
||||
`<media_server id="uuid:AAAA-BBBB" mac="11:22:33:44:55:66" ip="192.0.2.6"` +
|
||||
` manufacturer="OtherCorp" model_name="Media-1" friendly_name="Living Room NAS"` +
|
||||
` location="http://192.0.2.6:8200/rootDesc.xml" />` +
|
||||
`</ListMediaServersResponse>`
|
||||
|
||||
var resp ListMediaServersResponse
|
||||
|
||||
if err := xml.Unmarshal([]byte(raw), &resp); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.MediaServers) != 2 {
|
||||
t.Fatalf("expected 2 servers, got %d", len(resp.MediaServers))
|
||||
}
|
||||
|
||||
first := resp.MediaServers[0]
|
||||
|
||||
if first.ID != "uuid:1234-5678" {
|
||||
t.Errorf("server[0].ID = %q; want %q", first.ID, "uuid:1234-5678")
|
||||
}
|
||||
|
||||
if first.MAC != "AA:BB:CC:DD:EE:FF" {
|
||||
t.Errorf("server[0].MAC = %q; want %q", first.MAC, "AA:BB:CC:DD:EE:FF")
|
||||
}
|
||||
|
||||
if first.IP != "192.0.2.5" {
|
||||
t.Errorf("server[0].IP = %q; want %q", first.IP, "192.0.2.5")
|
||||
}
|
||||
|
||||
if first.FriendlyName != "My NAS" {
|
||||
t.Errorf("server[0].FriendlyName = %q; want %q", first.FriendlyName, "My NAS")
|
||||
}
|
||||
|
||||
if first.Manufacturer != "ExampleCorp" {
|
||||
t.Errorf("server[0].Manufacturer = %q; want %q", first.Manufacturer, "ExampleCorp")
|
||||
}
|
||||
|
||||
if first.ModelName != "NAS-3000" {
|
||||
t.Errorf("server[0].ModelName = %q; want %q", first.ModelName, "NAS-3000")
|
||||
}
|
||||
|
||||
if first.ModelDescription != "Home NAS" {
|
||||
t.Errorf("server[0].ModelDescription = %q; want %q", first.ModelDescription, "Home NAS")
|
||||
}
|
||||
|
||||
if first.Location != "http://192.0.2.5:8200/rootDesc.xml" {
|
||||
t.Errorf("server[0].Location = %q; want %q", first.Location, "http://192.0.2.5:8200/rootDesc.xml")
|
||||
}
|
||||
|
||||
second := resp.MediaServers[1]
|
||||
|
||||
if second.ID != "uuid:AAAA-BBBB" {
|
||||
t.Errorf("server[1].ID = %q; want %q", second.ID, "uuid:AAAA-BBBB")
|
||||
}
|
||||
|
||||
if second.ModelDescription != "" {
|
||||
t.Errorf("server[1].ModelDescription should be empty for omitted attr, got %q", second.ModelDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaServersResponse_Empty(t *testing.T) {
|
||||
// Speakers can return a self-closing element when no servers are visible.
|
||||
for _, raw := range []string{
|
||||
`<ListMediaServersResponse />`,
|
||||
`<ListMediaServersResponse></ListMediaServersResponse>`,
|
||||
} {
|
||||
var resp ListMediaServersResponse
|
||||
|
||||
if err := xml.Unmarshal([]byte(raw), &resp); err != nil {
|
||||
t.Fatalf("unmarshal %q failed: %v", raw, err)
|
||||
}
|
||||
|
||||
if len(resp.MediaServers) != 0 {
|
||||
t.Errorf("expected 0 servers for %q, got %d", raw, len(resp.MediaServers))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,6 +672,21 @@ type ErrorStats struct {
|
||||
Details string `json:"details,omitempty" xml:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ActivityRecord is one entry in AfterTouch's local, append-only admin-UI
|
||||
// activity log (e.g. an announcement banner dismissal). Local-only: written
|
||||
// to plain JSON on disk, never transmitted automatically — the only way it
|
||||
// leaves the operator's network is an explicitly-triggered diagnostic
|
||||
// export. The same ID can recur with a new Timestamp (e.g. a dismissed
|
||||
// notification shown and dismissed again later); this is a log, not a
|
||||
// keyed map. Intentionally generic so it can back other admin-UI action
|
||||
// kinds beyond dismissals later, not just this one feature.
|
||||
type ActivityRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Detail map[string]interface{} `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceEvent represents an event that occurred on a device.
|
||||
type DeviceEvent struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
@@ -86,32 +86,6 @@ func (zr *ZoneRequest) AddMemberByDeviceID(deviceID string) {
|
||||
zr.Members = append(zr.Members, member)
|
||||
}
|
||||
|
||||
// RemoveMember removes a device from the zone configuration
|
||||
func (zr *ZoneRequest) RemoveMember(deviceID string) {
|
||||
for i, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
zr.Members = append(zr.Members[:i], zr.Members[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearMembers removes all members from the zone (creates standalone configuration)
|
||||
func (zr *ZoneRequest) ClearMembers() {
|
||||
zr.Members = []MemberEntry{}
|
||||
}
|
||||
|
||||
// HasMember checks if a device is in the zone configuration
|
||||
func (zr *ZoneRequest) HasMember(deviceID string) bool {
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetMemberCount returns the number of members in the zone
|
||||
func (zr *ZoneRequest) GetMemberCount() int {
|
||||
return len(zr.Members)
|
||||
|
||||
@@ -56,63 +56,6 @@ func TestZoneRequest_AddMemberByDeviceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_RemoveMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.0.2.10")
|
||||
zr.AddMember("DEVICE789", "192.0.2.11")
|
||||
zr.AddMember("DEVICEABC", "192.0.2.12")
|
||||
|
||||
// Remove middle member
|
||||
zr.RemoveMember("DEVICE789")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removal, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
// Check that the correct member was removed
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == "DEVICE789" {
|
||||
t.Error("DEVICE789 should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove non-existent member (should not change anything)
|
||||
zr.RemoveMember("NONEXISTENT")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removing non-existent, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_ClearMembers(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.0.2.10")
|
||||
zr.AddMember("DEVICE789", "192.0.2.11")
|
||||
|
||||
zr.ClearMembers()
|
||||
|
||||
if len(zr.Members) != 0 {
|
||||
t.Errorf("Expected 0 members after clear, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_HasMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.0.2.10")
|
||||
|
||||
if !zr.HasMember("DEVICE456") {
|
||||
t.Error("Expected HasMember to return true for DEVICE456")
|
||||
}
|
||||
|
||||
if zr.HasMember("NONEXISTENT") {
|
||||
t.Error("Expected HasMember to return false for non-existent device")
|
||||
}
|
||||
|
||||
if zr.HasMember("MASTER123") {
|
||||
t.Error("Expected HasMember to return false for master device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_GetMemberCount(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
|
||||
@@ -67,8 +67,10 @@ func RadioBrowserSearchPage(query string, offset int) (*models.BmxNavResponse, e
|
||||
subtitle += tags
|
||||
}
|
||||
|
||||
// SoundTouch format location for RadioBrowser
|
||||
location := fmt.Sprintf("%s/soundtouch/stations/byuuid/%s", radioBrowserBaseURL, uuid)
|
||||
// Relative SoundTouch playback location for RadioBrowser. The speaker
|
||||
// prepends the BMX-registry base URL (radioBrowserBaseURL + "/soundtouch")
|
||||
// when it follows a RADIO_BROWSER source, so the href must stay relative.
|
||||
location := fmt.Sprintf("/stations/byuuid/%s", uuid)
|
||||
|
||||
item := models.BmxNavItem{
|
||||
Name: name,
|
||||
|
||||
@@ -45,6 +45,16 @@ func TestRadioBrowserSearch(t *testing.T) {
|
||||
if item.Name != "Radio Paradise" {
|
||||
t.Errorf("expected name 'Radio Paradise', got %q", item.Name)
|
||||
}
|
||||
|
||||
// The playback href must be the relative /stations/byuuid/<uuid> form so a
|
||||
// RADIO_BROWSER select resolves against the BMX-registry base URL (#479).
|
||||
if item.Links == nil || item.Links.BmxPlayback == nil {
|
||||
t.Fatal("expected a bmx_playback link on the station item")
|
||||
}
|
||||
|
||||
if want := "/stations/byuuid/123-456"; item.Links.BmxPlayback.Href != want {
|
||||
t.Errorf("expected playback href %q, got %q", want, item.Links.BmxPlayback.Href)
|
||||
}
|
||||
}
|
||||
|
||||
// makeStationsJSON returns a JSON array of n station objects.
|
||||
|
||||
@@ -1436,6 +1436,27 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
Recents []RecentXML `xml:"recent"`
|
||||
}
|
||||
|
||||
// Deduplicate by ID before saving; first occurrence wins. A speaker<->marge
|
||||
// recents sync can otherwise re-store the same recent (same ID) multiple
|
||||
// times — it then crowds the capped list and evicts other sources from the
|
||||
// speaker's recents. Mirrors SaveConfiguredSources.
|
||||
seen := make(map[string]bool)
|
||||
deduped := make([]models.ServiceRecent, 0, len(recents))
|
||||
|
||||
for i := range recents {
|
||||
if id := recents[i].ID; id != "" {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
deduped = append(deduped, recents[i])
|
||||
}
|
||||
|
||||
recents = deduped
|
||||
|
||||
wrap := RecentsXML{
|
||||
Recents: make([]RecentXML, 0, len(recents)),
|
||||
}
|
||||
@@ -2592,17 +2613,17 @@ type Settings struct {
|
||||
TTSVolume int `json:"tts_volume,omitempty"`
|
||||
|
||||
// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
|
||||
// immediate TCP peer is one of the TrustedProxyCIDRs, the X-Real-IP /
|
||||
// X-Forwarded-For / True-Client-IP headers are honoured and replace
|
||||
// r.RemoteAddr. Required when the service is fronted by nginx, Caddy, or
|
||||
// any other reverse proxy. Default false — direct LAN deployments must
|
||||
// not enable this, otherwise a malicious LAN-resident client could spoof
|
||||
// its source IP via these headers.
|
||||
// immediate TCP peer is one of the TrustedProxyCIDRs, the client IP is
|
||||
// resolved from the X-Forwarded-For header (read via the request context;
|
||||
// it does not rewrite r.RemoteAddr). Required when the service is fronted
|
||||
// by nginx, Caddy, or any other reverse proxy. Default false - direct LAN
|
||||
// deployments must not enable this, otherwise a malicious LAN-resident
|
||||
// client could spoof its source IP via the X-Forwarded-For header.
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
|
||||
// TrustedProxyCIDRs is the list of CIDR blocks whose immediate TCP peers
|
||||
// are allowed to set X-Forwarded-* headers when TrustForwardedHeaders is
|
||||
// true. Defaults to loopback (127.0.0.0/8 and ::1/128) — i.e. only a
|
||||
// are allowed to set X-Forwarded-For headers when TrustForwardedHeaders is
|
||||
// true. Defaults to loopback (127.0.0.0/8 and ::1/128) - i.e. only a
|
||||
// reverse proxy on the same host. Override only if the proxy lives on a
|
||||
// different host within a known-good private subnet.
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
@@ -2636,6 +2657,23 @@ type Settings struct {
|
||||
// API/speaker clients (non-HTML Accept) always get the version JSON
|
||||
// regardless of this setting.
|
||||
DefaultLanding string `json:"default_landing,omitempty"`
|
||||
|
||||
// AdminAreaAuth is a tri-state toggle for gating the entire admin area
|
||||
// (/admin, /setup, /api/setup — minus a small set of routes shared with
|
||||
// soundtouch-cli/soundtouch-player) behind the same Basic Auth used for
|
||||
// /api/mgmt/*, rather than just the Local Account / Spotify / Amazon
|
||||
// linking endpoints as today. Values:
|
||||
// "" — unset (default). Today this means "not enforced"; a
|
||||
// later release is expected to flip the *meaning* of ""
|
||||
// to "enforced" as the project moves the entire admin
|
||||
// area to require login by default. See #419.
|
||||
// "enabled" — the whole admin area requires Basic Auth now.
|
||||
// "disabled" — explicit opt-out. Kept open even after the default
|
||||
// flips, so an operator's deliberate choice survives
|
||||
// the upgrade.
|
||||
// The tri-state (rather than a plain bool) is what lets "never decided"
|
||||
// be told apart from "explicitly chose off" once that default flips.
|
||||
AdminAreaAuth string `json:"admin_area_auth,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
@@ -2700,6 +2738,82 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// RecordActivity appends one entry to the local admin-UI activity log, under
|
||||
// DataDir/stats/activity/<kind>/, one file per event (same shape as
|
||||
// SaveUsageStats/SaveErrorStats above). kind is meant to be a small,
|
||||
// developer-defined constant (e.g. "notification_dismissed") used directly
|
||||
// as a directory name — callers must not pass untrusted/user-supplied
|
||||
// values. id may recur across calls with a new timestamp each time; this is
|
||||
// an append-only log, not a keyed store. See models.ActivityRecord for the
|
||||
// local-only/never-transmitted-automatically guarantee this backs.
|
||||
func (ds *DataStore) RecordActivity(kind, id string, detail map[string]interface{}) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
record := models.ActivityRecord{
|
||||
Kind: kind,
|
||||
ID: id,
|
||||
Timestamp: now.UTC().Format(time.RFC3339Nano),
|
||||
Detail: detail,
|
||||
}
|
||||
|
||||
// The random suffix guards against two events for the same id landing in
|
||||
// the same nanosecond (observed as flaky on coarser-resolution clocks)
|
||||
// silently overwriting one another instead of both being recorded.
|
||||
filename := fmt.Sprintf("%d_%d_%s.json", now.UnixNano(), rand.Int63n(1_000_000), id) //nolint:gosec
|
||||
path := filepath.Join(dir, filename)
|
||||
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// GetActivityRecords reads back every entry recorded via RecordActivity for
|
||||
// the given kind. Unreadable or malformed files are skipped rather than
|
||||
// failing the whole read — a single corrupt event shouldn't make the rest of
|
||||
// the log unreadable. Returns an empty slice (not an error) when the
|
||||
// directory doesn't exist yet, matching the "nothing recorded yet" case.
|
||||
func (ds *DataStore) GetActivityRecords(kind string) ([]models.ActivityRecord, error) {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
|
||||
|
||||
entries, err := ds.rootReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make([]models.ActivityRecord, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
data, readErr := ds.rootReadFile(filepath.Join(dir, entry.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var record models.ActivityRecord
|
||||
if unmarshalErr := json.Unmarshal(data, &record); unmarshalErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// SaveErrorStats saves error statistics to the datastore.
|
||||
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "error")
|
||||
|
||||
@@ -458,6 +458,88 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_EmptyKindReturnsNilNotError verifies GetActivityRecords
|
||||
// for a kind that was never recorded returns an empty, non-error result —
|
||||
// the "nothing recorded yet" case, not a failure.
|
||||
func TestRecordActivity_EmptyKindReturnsNilNotError(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "activity-empty-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
records, err := ds.GetActivityRecords("notification_dismissed")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords on empty kind should not error, got: %v", err)
|
||||
}
|
||||
if len(records) != 0 {
|
||||
t.Errorf("Expected no records, got %d", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_SameIDRecursWithNewTimestamp is the regression test for
|
||||
// the append-only shape agreed in the #419 design: dismissing the same
|
||||
// announcement twice must produce two records, not overwrite one — this is
|
||||
// a log, not a keyed map.
|
||||
func TestRecordActivity_SameIDRecursWithNewTimestamp(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "activity-recur-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
if err := ds.RecordActivity("notification_dismissed", "admin-gate-notice", nil); err != nil {
|
||||
t.Fatalf("First RecordActivity failed: %v", err)
|
||||
}
|
||||
if err := ds.RecordActivity("notification_dismissed", "admin-gate-notice", nil); err != nil {
|
||||
t.Fatalf("Second RecordActivity failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords("notification_dismissed")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("Expected 2 records for the same recurring id, got %d: %+v", len(records), records)
|
||||
}
|
||||
for _, r := range records {
|
||||
if r.ID != "admin-gate-notice" || r.Kind != "notification_dismissed" || r.Timestamp == "" {
|
||||
t.Errorf("Unexpected record shape: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_DetailRoundTrips verifies the optional detail payload
|
||||
// survives a write/read round trip.
|
||||
func TestRecordActivity_DetailRoundTrips(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "activity-detail-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
if err := ds.RecordActivity("some_kind", "some-id", map[string]interface{}{"note": "hello"}); err != nil {
|
||||
t.Fatalf("RecordActivity failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords("some_kind")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("Expected 1 record, got %d", len(records))
|
||||
}
|
||||
if records[0].Detail["note"] != "hello" {
|
||||
t.Errorf("Expected detail to round-trip, got: %+v", records[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveDeviceMigratesData(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TestSaveRecents_DeduplicatesByID is a regression test for the recents
|
||||
// duplication bug: a speaker<->marge sync could re-store the same recent (same
|
||||
// ID) multiple times, crowding the capped list and evicting other sources from
|
||||
// the speaker's recents. SaveRecents must dedup by ID (first occurrence wins).
|
||||
func TestSaveRecents_DeduplicatesByID(t *testing.T) {
|
||||
tmp, err := os.MkdirTemp("", "recents-dedup-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmp) }()
|
||||
|
||||
ds := NewDataStore(tmp)
|
||||
account, device := "6919733", "A81B6A536A98"
|
||||
|
||||
mk := func(id, name string) models.ServiceRecent {
|
||||
var r models.ServiceRecent
|
||||
r.ID = id
|
||||
r.Name = name
|
||||
r.Source = "7"
|
||||
r.SourceAccount = "4d696e69-444c-164e-9d41-72ecda78e4c1/0"
|
||||
r.Location = "1$4$2 TRACK"
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Same ID four times (the observed live state), plus two distinct recents.
|
||||
in := []models.ServiceRecent{
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614004", "06 - Back Burner"),
|
||||
mk("260613001", "Artifact"),
|
||||
}
|
||||
|
||||
if err := ds.SaveRecents(account, device, in); err != nil {
|
||||
t.Fatalf("SaveRecents: %v", err)
|
||||
}
|
||||
|
||||
out, err := ds.GetRecents(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecents: %v", err)
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, r := range out {
|
||||
counts[r.ID]++
|
||||
}
|
||||
|
||||
if counts["260614006"] != 1 {
|
||||
t.Errorf("duplicate recent not deduped: id 260614006 appears %d times (want 1)", counts["260614006"])
|
||||
}
|
||||
|
||||
if len(out) != 3 {
|
||||
t.Errorf("expected 3 distinct recents, got %d: %+v", len(out), counts)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -147,6 +148,17 @@ func clientHostFromRemoteAddr(remoteAddr string) string {
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// clientHost returns the resolved client IP for r: chi's middleware.GetClientIP
|
||||
// (populated by the ClientIP middleware) when set, falling back to the socket
|
||||
// peer host from r.RemoteAddr. Returns a bare IP (no port).
|
||||
func clientHost(r *http.Request) string {
|
||||
if ip := middleware.GetClientIP(r.Context()); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
return clientHostFromRemoteAddr(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// dnsProbeSpeakerRequest is the JSON body for POST /setup/health/dns-path-probe.
|
||||
type dnsProbeSpeakerRequest struct {
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *Server) DeprecatedRouteMiddleware(next http.Handler) http.Handler {
|
||||
if s.deprecatedRoutes.record(key) {
|
||||
log.Printf("[deprecated-route] %s used by client=%s — use /api%s instead; "+
|
||||
"the legacy path still works but is slated for removal in a future major release",
|
||||
sanitizeLog(key), sanitizeLog(clientHostFromRemoteAddr(r.RemoteAddr)), sanitizeLog(pattern))
|
||||
sanitizeLog(key), sanitizeLog(clientHost(r)), sanitizeLog(pattern))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestIsAnnouncementDismissed_EmptyByDefault verifies a freshly-constructed
|
||||
// server (no prior activity log) reports nothing as dismissed.
|
||||
func TestIsAnnouncementDismissed_EmptyByDefault(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-empty-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected no announcement to be dismissed on a fresh install")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordDismissal_UpdatesCacheAndPersists is a regression test for the
|
||||
// #419 design's performance requirement: after RecordDismissal, the
|
||||
// in-memory cache must reflect it immediately (no disk re-read needed), and
|
||||
// it must also be durably persisted via the activity log.
|
||||
func TestRecordDismissal_UpdatesCacheAndPersists(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-record-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
if !server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected admin-gate-notice to be dismissed after RecordDismissal")
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].ID != "admin-gate-notice" {
|
||||
t.Errorf("Expected exactly 1 persisted dismissal record, got: %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDismissedAnnouncements_ReadsPriorHistoryAtStartup verifies a
|
||||
// restarted server picks up dismissals recorded in a previous run — the
|
||||
// startup scan, not just the live write-through path.
|
||||
func TestLoadDismissedAnnouncements_ReadsPriorHistoryAtStartup(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-startup-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Simulate a dismissal recorded in a prior run, before this process's
|
||||
// Server ever existed.
|
||||
if err := ds.RecordActivity(activityKindNotificationDismissed, "admin-gate-notice", nil); err != nil {
|
||||
t.Fatalf("Seeding activity record failed: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if !server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected startup scan to pick up a dismissal recorded in a prior run")
|
||||
}
|
||||
if server.IsAnnouncementDismissed("some-other-notice") {
|
||||
t.Error("Expected an unrelated id to not be reported as dismissed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordDismissal_SameIDTwiceAppendsBothKeepsCacheSane verifies dismissing
|
||||
// the same announcement twice (e.g. re-shown, dismissed again) appends two
|
||||
// log entries but the in-memory cache still reports it dismissed exactly
|
||||
// once (a boolean check, not a count).
|
||||
func TestRecordDismissal_SameIDTwiceAppendsBothKeepsCacheSane(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-recur-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
|
||||
t.Fatalf("First RecordDismissal failed: %v", err)
|
||||
}
|
||||
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
|
||||
t.Fatalf("Second RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Errorf("Expected 2 append-only log entries for a recurring dismissal, got %d", len(records))
|
||||
}
|
||||
if !server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected admin-gate-notice to still be reported dismissed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Announcement target values. Mirrors the vocabulary Settings.DefaultLanding
|
||||
// already uses (see defaultLanding() in handlers_media.go) rather than
|
||||
// inventing new names — "chooser" is the neutral welcome page, "app" is the
|
||||
// embedded player, "admin" is this admin console.
|
||||
const (
|
||||
announcementTargetChooser = "chooser"
|
||||
announcementTargetApp = "app"
|
||||
announcementTargetAdmin = "admin"
|
||||
)
|
||||
|
||||
// Announcement is one entry in the small, in-code (not admin-authored)
|
||||
// announcement list — see #419 design doc,
|
||||
// _/i419/design-admin-area-auth-gate.md. ShowWhile lets an entry key off
|
||||
// live server state (e.g. "only while the admin-area gate hasn't been
|
||||
// decided yet"); nil means always show (until dismissed).
|
||||
type Announcement struct {
|
||||
ID string
|
||||
Message string
|
||||
Level string
|
||||
Targets []string
|
||||
ShowWhile func(*Server) bool
|
||||
}
|
||||
|
||||
// announcements is the full, in-code list. announcementTargetChooser is
|
||||
// prepared as a valid target value (see the constant above) but no entry
|
||||
// here uses it yet: the chooser landing page (handlers_media.go, landingHTML)
|
||||
// is currently fully static with no JS at all, unlike /admin and /app, so it
|
||||
// can't render or dismiss a banner yet. Wire a chooser-targeted entry only
|
||||
// once that client-side logic exists.
|
||||
var announcements = []Announcement{
|
||||
{
|
||||
ID: "admin-area-auth-419",
|
||||
Level: "info",
|
||||
Targets: []string{announcementTargetAdmin},
|
||||
Message: "A future release will require login for this entire admin area by default (today, only " +
|
||||
"Spotify/Amazon linking and the Local Account tab do). You can opt in now in Settings, or " +
|
||||
"dismiss this once you've decided. See issue #419 for details.",
|
||||
ShowWhile: func(s *Server) bool {
|
||||
return s.AdminAreaAuthMode() == ""
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// announcementDTO is the JSON shape returned by HandleListAnnouncements —
|
||||
// deliberately smaller than Announcement (no ShowWhile func, no Targets;
|
||||
// the caller already asked for a specific target).
|
||||
type announcementDTO struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, v := range haystack {
|
||||
if v == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleListAnnouncements returns the announcements currently active for
|
||||
// the requested target (query param, one of "app" or "admin" — "chooser" is
|
||||
// a reserved value, not yet wired to any handler), filtered by ShowWhile and
|
||||
// excluding anything already dismissed. Deliberately NOT behind
|
||||
// BasicAuthAdmin: the admin-area-gate notice specifically needs to reach
|
||||
// operators who haven't set up credentials yet, the exact audience an
|
||||
// admin-only endpoint would exclude.
|
||||
func (s *Server) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.URL.Query().Get("target")
|
||||
|
||||
switch target {
|
||||
case announcementTargetApp, announcementTargetAdmin:
|
||||
default:
|
||||
http.Error(w, "target must be app or admin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
active := make([]announcementDTO, 0, len(announcements))
|
||||
|
||||
for _, a := range announcements {
|
||||
if !containsString(a.Targets, target) {
|
||||
continue
|
||||
}
|
||||
|
||||
if a.ShowWhile != nil && !a.ShowWhile(s) {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.IsAnnouncementDismissed(a.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
active = append(active, announcementDTO{ID: a.ID, Message: a.Message, Level: a.Level})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"announcements": active}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDismissAnnouncement records a dismissal for the given announcement
|
||||
// id (see Server.RecordDismissal). The id is validated against the known
|
||||
// announcements list rather than accepted as arbitrary input — it ends up
|
||||
// as part of a filename in the local activity log (datastore.RecordActivity),
|
||||
// and this is the one call site where the id comes from an HTTP request
|
||||
// rather than a compile-time constant. Also not behind BasicAuthAdmin, for
|
||||
// the same reason as HandleListAnnouncements.
|
||||
func (s *Server) HandleDismissAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
found := false
|
||||
|
||||
for _, a := range announcements {
|
||||
if a.ID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
http.Error(w, "Unknown announcement id", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.RecordDismissal(id); err != nil {
|
||||
http.Error(w, "Failed to record dismissal: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newAnnouncementsTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "announcements-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
return NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
}
|
||||
|
||||
func listAnnouncements(t *testing.T, s *Server, target string) (int, []announcementDTO) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.HandleListAnnouncements(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
return rr.Code, nil
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Announcements []announcementDTO `json:"announcements"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
return rr.Code, body.Announcements
|
||||
}
|
||||
|
||||
func TestHandleListAnnouncements_InvalidTarget(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
for _, target := range []string{"", "chooser", "bogus"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.HandleListAnnouncements(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("target=%q: expected 400 (chooser is reserved, not wired yet), got %d", target, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleListAnnouncements_AdminGateNotice is a regression test for the
|
||||
// #419 admin-gate announcement's ShowWhile/Targets/dismissal behavior end to
|
||||
// end: visible for "admin" while AdminAreaAuth is unset, invisible for
|
||||
// "app", invisible once the mode is set, and invisible once dismissed.
|
||||
func TestHandleListAnnouncements_AdminGateNotice(t *testing.T) {
|
||||
const noticeID = "admin-area-auth-419"
|
||||
|
||||
t.Run("visible for admin target while unset", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
status, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
if !containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to be active for target=admin while unset, got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible for app target", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetApp)
|
||||
if containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to NOT be active for target=app (Targets is admin-only), got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible once AdminAreaAuth is decided", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
s.SetAdminAreaAuth("enabled")
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to disappear once the mode is decided, got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible once dismissed", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
if err := s.RecordDismissal(noticeID); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to disappear once dismissed, got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func containsAnnouncementID(active []announcementDTO, id string) bool {
|
||||
for _, a := range active {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func TestHandleDismissAnnouncement_UnknownID(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/announcements/not-a-real-id/dismiss", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for an unknown announcement id, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDismissAnnouncement_Success verifies dismissing a known
|
||||
// announcement both succeeds and is reflected by a subsequent list call —
|
||||
// end-to-end through the HTTP handlers, not just the underlying
|
||||
// Server.RecordDismissal/IsAnnouncementDismissed pair.
|
||||
func TestHandleDismissAnnouncement_Success(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/announcements/admin-area-auth-419/dismiss", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "admin-area-auth-419") {
|
||||
t.Errorf("expected the notice to be gone from the list after dismissal, got %+v", active)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/export"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
speakerssh "github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/telnet"
|
||||
)
|
||||
@@ -188,6 +190,7 @@ func (s *Server) buildDiagnosticArchive() ([]byte, error) {
|
||||
s.addSystemFiles(tw)
|
||||
s.addServiceLog(tw)
|
||||
s.addSettingsJSON(tw)
|
||||
s.addActivityLog(tw)
|
||||
addEnvVars(tw)
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
@@ -312,6 +315,11 @@ var speakerSSHPaths = []string{
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/pki/tls/certs/ca-bundle.crt.original", // pre-migration CA bundle backup
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
// The speaker's own persisted source list. The firmware registers source
|
||||
// types from this file at boot; comparing it against the service's
|
||||
// Sources.xml (and the runtime /sources) is the key evidence when radio
|
||||
// source types won't activate after an in-place migration.
|
||||
speaker.SourcesFileLocation,
|
||||
// Redirection-relevant state: where the speaker resolves Bose hostnames
|
||||
// and what it had before migration. (The live marge/BMX URL config in
|
||||
// SoundTouchSdkPrivateCfg.xml is handled by collectSpeakerRedirectConfig,
|
||||
@@ -587,6 +595,36 @@ func (s *Server) collectSpeakerRedirectConfig(tw *tar.Writer) map[string]*redire
|
||||
return out
|
||||
}
|
||||
|
||||
// readSpeakerBmxRegistryURL reads a single speaker's runtime bmxRegistryUrl from
|
||||
// its on-device SoundTouchSdkPrivateCfg.xml (via SSH), falling back to `getpdo
|
||||
// CurrentSystemConfiguration` over telnet. It returns the URL and whether the
|
||||
// runtime config could be read at all (SSH or telnet succeeded). Unlike
|
||||
// collectSpeakerRedirectConfig it archives nothing; it's the lightweight read
|
||||
// used by the runtime_bmx_url_stale health check.
|
||||
func (s *Server) readSpeakerBmxRegistryURL(ip string) (string, bool) {
|
||||
if ip == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 1. Prefer the persisted XML over SSH.
|
||||
sc := speakerssh.NewClient(ip)
|
||||
if data, err := sc.ReadFile(setup.SoundTouchSdkPrivateCfgPath); err == nil {
|
||||
var cfg setup.PrivateCfg
|
||||
if xml.Unmarshal(data, &cfg) == nil {
|
||||
return cfg.BmxRegistryUrl, true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to telnet getpdo when SSH gave us nothing.
|
||||
if raw, ok := readTelnetSystemConfig(ip); ok {
|
||||
if fields := setup.ParseGetpdoConfig(raw); len(fields) > 0 {
|
||||
return fields["bmxRegistryUrl"], true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// addServiceLog appends the in-memory service log buffer as logs/service.txt.
|
||||
// Each entry is formatted as "2006-01-02T15:04:05Z <message>".
|
||||
func (s *Server) addServiceLog(tw *tar.Writer) {
|
||||
@@ -635,32 +673,83 @@ func (s *Server) addSystemFiles(tw *tar.Writer) {
|
||||
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
|
||||
// struct can be marshalled into the archive without exposing credentials.
|
||||
type diagSettings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
HTTPSServerURL string `json:"https_server_url,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
||||
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
||||
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
ServerURL string `json:"server_url"`
|
||||
HTTPSServerURL string `json:"https_server_url,omitempty"`
|
||||
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
||||
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
||||
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
}
|
||||
|
||||
// addSettingsJSON serialises the service settings into the archive as
|
||||
// settings.json. OAuth client secrets are replaced with "[REDACTED]" so the
|
||||
// file is safe to share.
|
||||
// addActivityLog bundles the local admin-UI activity log (announcement
|
||||
// dismissals, and any other kind recorded via datastore.RecordActivity)
|
||||
// into the diagnostic archive verbatim, one file per event — same idea as
|
||||
// the per-device XML bundling above, but for stats/activity/. This is what
|
||||
// makes the "local-only, but included in an explicitly-triggered diagnostic
|
||||
// export" claim in DIAGNOSTIC-EXPORT.md actually true. A missing directory
|
||||
// (nothing recorded yet) is not an error.
|
||||
func (s *Server) addActivityLog(tw *tar.Writer) {
|
||||
if s.ds == nil || s.ds.DataDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
root := filepath.Join(s.ds.DataDir, "stats", "activity")
|
||||
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
log.Printf("[Export] read activity log %s: %v", sanitizeLog(path), readErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, relErr := filepath.Rel(s.ds.DataDir, path)
|
||||
if relErr != nil {
|
||||
log.Printf("[Export] rel path for %s: %v", sanitizeLog(path), relErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
if addErr := addTarBytes(tw, rel, data); addErr != nil {
|
||||
log.Printf("[Export] add %s: %v", sanitizeLog(rel), addErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
log.Printf("[Export] walk activity log: %v", walkErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addSettingsJSON(tw *tar.Writer) {
|
||||
st, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
@@ -677,28 +766,35 @@ func (s *Server) addSettingsJSON(tw *tar.Writer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// st.HTTPServerURL is the HTTPS override (empty = derive). Report the
|
||||
// effective URL actually in use plus the override, so a diagnostic
|
||||
// makes an advertised-URL/listener mismatch (issue #355) legible
|
||||
// instead of showing an empty field when the URL is derived.
|
||||
_, effectiveHTTPSURL := s.GetSettings()
|
||||
|
||||
ds := diagSettings{
|
||||
ServerURL: st.ServerURL,
|
||||
HTTPSServerURL: st.HTTPServerURL,
|
||||
RedactLogs: st.RedactLogs,
|
||||
LogBodies: st.LogBodies,
|
||||
RecordInteractions: st.RecordInteractions,
|
||||
DiscoveryInterval: st.DiscoveryInterval,
|
||||
DiscoveryEnabled: st.DiscoveryEnabled,
|
||||
DNSEnabled: st.DNSEnabled,
|
||||
DNSUpstream: st.DNSUpstream,
|
||||
DNSBindAddr: st.DNSBindAddr,
|
||||
InternalPaths: st.InternalPaths,
|
||||
Shortcuts: st.Shortcuts,
|
||||
SpotifyClientID: st.SpotifyClientID,
|
||||
SpotifyClientSecret: redact(st.SpotifyClientSecret),
|
||||
SpotifyRedirectURI: st.SpotifyRedirectURI,
|
||||
AmazonClientID: st.AmazonClientID,
|
||||
AmazonClientSecret: redact(st.AmazonClientSecret),
|
||||
AmazonRedirectURI: st.AmazonRedirectURI,
|
||||
TrustForwardedHeaders: st.TrustForwardedHeaders,
|
||||
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
|
||||
TuneInStreamFormats: st.TuneInStreamFormats,
|
||||
ServerURL: st.ServerURL,
|
||||
HTTPSServerURL: effectiveHTTPSURL,
|
||||
HTTPSServerURLOverride: st.HTTPServerURL,
|
||||
RedactLogs: st.RedactLogs,
|
||||
LogBodies: st.LogBodies,
|
||||
RecordInteractions: st.RecordInteractions,
|
||||
DiscoveryInterval: st.DiscoveryInterval,
|
||||
DiscoveryEnabled: st.DiscoveryEnabled,
|
||||
DNSEnabled: st.DNSEnabled,
|
||||
DNSUpstream: st.DNSUpstream,
|
||||
DNSBindAddr: st.DNSBindAddr,
|
||||
InternalPaths: st.InternalPaths,
|
||||
Shortcuts: st.Shortcuts,
|
||||
SpotifyClientID: st.SpotifyClientID,
|
||||
SpotifyClientSecret: redact(st.SpotifyClientSecret),
|
||||
SpotifyRedirectURI: st.SpotifyRedirectURI,
|
||||
AmazonClientID: st.AmazonClientID,
|
||||
AmazonClientSecret: redact(st.AmazonClientSecret),
|
||||
AmazonRedirectURI: st.AmazonRedirectURI,
|
||||
TrustForwardedHeaders: st.TrustForwardedHeaders,
|
||||
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
|
||||
TuneInStreamFormats: st.TuneInStreamFormats,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(ds, "", " ")
|
||||
@@ -775,7 +871,7 @@ func (s *Server) buildDiagnosticReport(redirectCfgs map[string]*redirectConfig)
|
||||
}
|
||||
|
||||
if s.healthRegistry != nil {
|
||||
report.HealthChecks = s.healthRegistry.RunAll()
|
||||
report.HealthChecks = s.runHealthChecks()
|
||||
}
|
||||
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// tarEntries reads every file name + content out of a tar written by
|
||||
// addActivityLog, for assertions.
|
||||
func tarEntries(t *testing.T, tw *tar.Writer, buf *bytes.Buffer) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Failed to close tar writer: %v", err)
|
||||
}
|
||||
|
||||
entries := make(map[string]string)
|
||||
tr := tar.NewReader(buf)
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry content: %v", err)
|
||||
}
|
||||
|
||||
entries[hdr.Name] = string(data)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// TestAddActivityLog_EmptyByDefault verifies a fresh install (nothing
|
||||
// recorded via datastore.RecordActivity yet — the common case, since
|
||||
// stats/activity/ won't exist at all) doesn't error and adds nothing.
|
||||
func TestAddActivityLog_EmptyByDefault(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "export-activity-empty-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
server.addActivityLog(tw)
|
||||
|
||||
entries := tarEntries(t, tw, &buf)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Expected no tar entries for an empty activity log, got %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddActivityLog_IncludesRecordedDismissal is the regression test for
|
||||
// the #419 design's stated privacy guarantee: a dismissal recorded locally
|
||||
// must actually show up in the diagnostic export, not just in theory. This
|
||||
// closes the loop DIAGNOSTIC-EXPORT.md documents.
|
||||
func TestAddActivityLog_IncludesRecordedDismissal(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "export-activity-dismissal-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if err := server.RecordDismissal("admin-area-auth-419"); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
server.addActivityLog(tw)
|
||||
|
||||
entries := tarEntries(t, tw, &buf)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("Expected exactly 1 tar entry, got %+v", entries)
|
||||
}
|
||||
|
||||
var (
|
||||
name string
|
||||
content string
|
||||
)
|
||||
for n, c := range entries {
|
||||
name, content = n, c
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(name, "stats/activity/notification_dismissed/") {
|
||||
t.Errorf("Expected entry under stats/activity/notification_dismissed/, got %q", name)
|
||||
}
|
||||
if !strings.Contains(content, "admin-area-auth-419") {
|
||||
t.Errorf("Expected entry content to reference the dismissed id, got %q", content)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user