mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +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.
|
||||
|
||||
@@ -86,7 +86,7 @@ body:
|
||||
attributes:
|
||||
label: AfterTouch version
|
||||
description: Shown in the admin UI footer, or via the binary's `--version`.
|
||||
placeholder: "v0.111.2"
|
||||
placeholder: "v0.123.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+23
-23
@@ -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,12 +192,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
npm install -g markdown-link-check
|
||||
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
|
||||
./scripts/check-doc-links.sh
|
||||
|
||||
- name: Warn on pending images
|
||||
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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
+3
-22
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.6-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:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
@@ -13,7 +12,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 +65,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.
|
||||
|
||||
@@ -113,6 +112,7 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/referenc
|
||||
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
|
||||
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
|
||||
- **[STR, SoundTouch Reborn](https://github.com/JRpersonal/streborn)** ([st-reborn.de](https://st-reborn.de)) — on-device agent plus desktop app; its published `iptables` REDIRECT technique is what makes AfterTouch's on-device install reachable over the LAN on co-processor chassis (see [Model Support Matrix](https://gesellix.github.io/Bose-SoundTouch/docs/reference/MODEL-SUPPORT-MATRIX/))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// updateCheckRepo is the GitHub repo checked for newer releases, matching
|
||||
// soundtouch-service's periodic background check (#591,
|
||||
// _/i591/design-update-check.md).
|
||||
const updateCheckRepo = "gesellix/Bose-SoundTouch"
|
||||
|
||||
// updateCheckCommand assembles the on-demand `soundtouch-backup
|
||||
// update-check` command, the CLI-side answer to that design doc's open
|
||||
// question 2 (CLI-only users get no update notice from the service's
|
||||
// background checker). Unlike the service's opt-in periodic check, running
|
||||
// this command *is* the opt-in: no config flag, no persisted state, just
|
||||
// one GitHub API request each time it's invoked.
|
||||
func updateCheckCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "update-check",
|
||||
Usage: "Check GitHub for a newer soundtouch-backup release",
|
||||
Action: runUpdateCheck,
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(c *cli.Context) error {
|
||||
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
|
||||
|
||||
result, err := checker.CheckNow(c.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update check failed: %w", err)
|
||||
}
|
||||
|
||||
printUpdateCheckResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUpdateCheckResult(result updatecheck.Result) {
|
||||
if result.LatestVersion == "" {
|
||||
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
|
||||
fmt.Println(result.ReleaseURL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckCommand_Registered checks the command is wired up with the
|
||||
// expected name and an Action, without making any real GitHub API calls.
|
||||
func TestUpdateCheckCommand_Registered(t *testing.T) {
|
||||
cmd := updateCheckCommand()
|
||||
|
||||
if cmd.Name != "update-check" {
|
||||
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
|
||||
}
|
||||
|
||||
if cmd.Action == nil {
|
||||
t.Error("expected an Action to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
|
||||
// (unparseable current version, update available, up to date) purely for
|
||||
// the "does not panic" guarantee; updatecheck.Checker's own tests already
|
||||
// cover the comparison logic itself.
|
||||
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
}{
|
||||
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
|
||||
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
|
||||
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
printUpdateCheckResult(tc.result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -29,6 +33,7 @@ func main() {
|
||||
allCommand(),
|
||||
cloudCommand(),
|
||||
localCommand(),
|
||||
updateCheckCommand(),
|
||||
},
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+319
-13
@@ -52,10 +52,12 @@ func setupCommand() *cli.Command {
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
setupRevertCmd(),
|
||||
setupRebootCmd(),
|
||||
setupVerifyCmd(),
|
||||
setupPlanCmd(),
|
||||
setupPairCmd(),
|
||||
setupSyncCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -517,8 +519,13 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("port 22 not reachable: %v", err))
|
||||
fmt.Println()
|
||||
fmt.Println("Modern SoundTouch firmware (27.x) does not let us enable SSH from")
|
||||
fmt.Println("telnet — those commands were removed. To enable SSH on the speaker:")
|
||||
fmt.Println("Try enabling it over telnet first — this works on many (not all) FW 27.x")
|
||||
fmt.Println("speakers via the port-17000 envswitch trick (#471):")
|
||||
fmt.Println(" soundtouch-cli setup enable-ssh")
|
||||
fmt.Println("For stubborn devices (ST Portable, CineMate 520) where the default")
|
||||
fmt.Println("injection is accepted but sshd never starts, add --full-config.")
|
||||
fmt.Println()
|
||||
fmt.Println("If enable-ssh doesn't work on this device, fall back to the USB-stick method:")
|
||||
fmt.Println(" 1. Format a FAT32 USB stick.")
|
||||
fmt.Println(" 2. Create an empty file named `remote_services` at its root.")
|
||||
fmt.Println(" 3. Plug the stick into the speaker (rear USB port) while it is on.")
|
||||
@@ -538,6 +545,98 @@ 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), pausing commandDelay between each of the 6 steps (5
|
||||
// commands + reboot) — see setup.DefaultTelnetCommandDelay for why the pause
|
||||
// exists — then reboots; otherwise it sends the single-envswitch default that
|
||||
// fires on the speaker's next boseurls check (no pause needed, it's one
|
||||
// command).
|
||||
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool, commandDelay time.Duration) error {
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
|
||||
if fullConfig {
|
||||
// 6 steps total (5 commands + reboot), so 6 gaps between/around them.
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with "+
|
||||
"the injection on margeServerUrl, %s between each of 6 steps — about %s before the reboot fires "+
|
||||
"— then reboot)...\n", host, commandDelay, 6*commandDelay)
|
||||
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL, commandDelay)
|
||||
} 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
|
||||
}
|
||||
|
||||
if commandDelay > 0 {
|
||||
time.Sleep(commandDelay)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ensureMargeAccountPaired checks /info and pairs an unpaired device before
|
||||
// the SSH-enable injection runs — see setup.EnsureMargeAccountPaired for why.
|
||||
// Pairing failure is logged as a warning, not fatal: the claim that an
|
||||
// unpaired device never polls margeServerUrl is not yet confirmed on every
|
||||
// device this command targets, so the injection is still worth attempting
|
||||
// even if the pairing step itself couldn't be verified.
|
||||
func ensureMargeAccountPaired(m *setup.Manager, deviceIP, wantAccountID string) {
|
||||
var t setup.TelnetClient
|
||||
|
||||
if m.NewTelnet != nil {
|
||||
t = m.NewTelnet(deviceIP)
|
||||
|
||||
if dialErr := t.Dial(); dialErr != nil {
|
||||
t = nil
|
||||
} else {
|
||||
defer func() { _ = t.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
accountID, alreadyPaired, logs, err := m.EnsureMargeAccountPaired(deviceIP, wantAccountID, t)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
PrintWarning(fmt.Sprintf("Pairing check failed (%v) — continuing anyway; the SSH-enable injection may not "+
|
||||
"fire on an unpaired device (#515).", err))
|
||||
case alreadyPaired:
|
||||
fmt.Printf("Device already paired (margeAccountUUID=%s).\n", accountID)
|
||||
default:
|
||||
fmt.Printf("Device was unpaired — paired it with generated account %s so margeServerUrl gets polled (#515).\n", accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func setupEnableSSHCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enable-ssh",
|
||||
@@ -556,6 +655,29 @@ 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.DurationFlag{
|
||||
Name: "command-delay",
|
||||
Value: setup.DefaultTelnetCommandDelay,
|
||||
Usage: "Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). " +
|
||||
"Raise this if the default doesn't work on your device; 0 sends everything back-to-back",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-auto-pair",
|
||||
Usage: "Skip the automatic pairing check: by default, enable-ssh reads /info first and pairs an unpaired " +
|
||||
"(factory-reset) device with an account ID, since an unpaired device reportedly never " +
|
||||
"polls margeServerUrl at all (#515) — the injection would have nothing to fire on otherwise",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: 7-digit account ID to pair " +
|
||||
"with (empty = generate one). Use this if you already know which account this device should end up " +
|
||||
"on (e.g. to match one already in the datastore) rather than getting a random one now",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-reset-urls",
|
||||
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
|
||||
@@ -589,23 +711,36 @@ 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 !c.Bool("no-auto-pair") {
|
||||
ensureMargeAccountPaired(m, cfg.Host, c.String("account"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config"), c.Duration("command-delay")); 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)
|
||||
@@ -880,6 +1015,116 @@ func promptBasicAuth() (string, string, error) {
|
||||
return user, string(pass), nil
|
||||
}
|
||||
|
||||
// setupSyncCmd wraps POST /api/setup/sync/{deviceId} — the same operation
|
||||
// as the web UI's Devices → Sync Data button. It only reads from the
|
||||
// speaker (presets, recents, sources) into AfterTouch's datastore; it never
|
||||
// writes anything back to the speaker. Useful for scripting or reproducing
|
||||
// what Sync does in isolation (see issue #614: Sync's own code cannot wipe
|
||||
// the speaker's preset table, since it never sends anything back).
|
||||
func setupSyncCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "Pull presets/recents/sources from the speaker into AfterTouch's datastore (same as the web UI's \"Sync Data\" button)",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"},
|
||||
&cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := CreateSoundTouchClient(cfg)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
deviceInfo, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get device info from speaker: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if deviceInfo.DeviceID == "" {
|
||||
err := fmt.Errorf("speaker at %s did not report a DeviceID", cfg.Host)
|
||||
PrintError(err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Syncing %s into AfterTouch", deviceInfo.DeviceID), cfg.Host, cfg.Port)
|
||||
|
||||
if err := postSetupSync(serviceURL, deviceInfo.DeviceID, c.String("auth")); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Synced presets, recents, and sources for %s.", deviceInfo.DeviceID))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postSetupSync POSTs to AfterTouch's /api/setup/sync/{deviceId}, prompting
|
||||
// for basic-auth credentials on 401 (matches fetchCACert's pattern).
|
||||
func postSetupSync(serviceURL, deviceID, authFlag string) error {
|
||||
endpoint := fmt.Sprintf("%s/api/setup/sync/%s", serviceURL, deviceID)
|
||||
|
||||
doRequest := func(user, pass string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
user, pass := splitAuth(authFlag)
|
||||
|
||||
resp, err := doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
fmt.Printf("%s requires basic auth.\n", endpoint)
|
||||
|
||||
user, pass, err = promptBasicAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err = doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s (with auth): %w", endpoint, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s returned %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupMigrateCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "migrate",
|
||||
@@ -890,6 +1135,10 @@ func setupMigrateCmd() *cli.Command {
|
||||
&cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"},
|
||||
&cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"},
|
||||
&cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"},
|
||||
&cli.StringFlag{Name: "marge-url", Usage: "Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"},
|
||||
&cli.StringFlag{Name: "stats-url", Usage: "Override statsServerUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "sw-update-url", Usage: "Override swUpdateUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "bmx-url", Usage: "Override bmxRegistryUrl (telnet/xml)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
@@ -901,6 +1150,13 @@ func setupMigrateCmd() *cli.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
options := map[string]string{
|
||||
"marge_url": c.String("marge-url"),
|
||||
"stats_url": c.String("stats-url"),
|
||||
"sw_update_url": c.String("sw-update-url"),
|
||||
"bmx_url": c.String("bmx-url"),
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
// For DNS-redirect methods check that AfterTouch's DNS listener
|
||||
@@ -924,7 +1180,7 @@ func setupMigrateCmd() *cli.Command {
|
||||
|
||||
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
|
||||
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), options, method)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
@@ -1253,6 +1509,45 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
|
||||
}
|
||||
}
|
||||
|
||||
// setupRevertCmd wraps setup.Manager.RevertMigration — the same operation
|
||||
// as the web UI's "Revert to Defaults" button (Migrate tab). Restores
|
||||
// SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their
|
||||
// .original backups, removes the AfterTouch DNS-hook artifacts, and strips
|
||||
// just the AfterTouch-labeled cert out of the trust bundle. No --service-url
|
||||
// needed: everything it touches already lives on the speaker.
|
||||
//
|
||||
// Deliberately out of scope (matches the web UI button): SSH/remote_services
|
||||
// persistence (use `setup remote-services --remove`) and account pairing
|
||||
// (use `account unpair`) — see #614 self-test notes for the full checklist.
|
||||
func setupRevertCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "revert",
|
||||
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
|
||||
Before: RequireHost,
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
|
||||
|
||||
logs, err := m.RevertMigration(cfg.Host)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
|
||||
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupRebootCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reboot",
|
||||
@@ -1786,6 +2081,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
|
||||
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
m := setup.NewManager(c.String("service-url"), nil, nil)
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(deviceIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("preflight: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if !needed {
|
||||
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
|
||||
return nil
|
||||
}
|
||||
|
||||
plan := setup.InitPlan{
|
||||
DeviceIP: deviceIP,
|
||||
ServiceURL: c.String("service-url"),
|
||||
@@ -1799,7 +2105,7 @@ func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
ctx, cancel := context.WithTimeout(c.Context, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
_, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
switch e.Status {
|
||||
case setup.StatusOK:
|
||||
fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name)
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -43,6 +45,46 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PostsToDeviceScopedURL(t *testing.T) {
|
||||
var gotMethod, gotPath string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := postSetupSync(srv.URL, "DEVICEID01", ""); err != nil {
|
||||
t.Fatalf("postSetupSync: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", gotMethod)
|
||||
}
|
||||
|
||||
if want := "/api/setup/sync/DEVICEID01"; gotPath != want {
|
||||
t.Errorf("expected path %q, got %q", want, gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PropagatesServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "device not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := postSetupSync(srv.URL, "DEVICEID01", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 404 response")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "device not found") {
|
||||
t.Errorf("expected error to include server body, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
|
||||
items := []models.SourceItem{
|
||||
// displayName != account → kept as "AUX (AUX IN)"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// updateCheckRepo is the GitHub repo checked for newer releases, matching
|
||||
// soundtouch-service's periodic background check (#591,
|
||||
// _/i591/design-update-check.md).
|
||||
const updateCheckRepo = "gesellix/Bose-SoundTouch"
|
||||
|
||||
// updateCheckCommand assembles the on-demand `soundtouch-cli update-check`
|
||||
// command, the CLI-side answer to that design doc's open question 2
|
||||
// (CLI-only users get no update notice from the service's background
|
||||
// checker). Unlike the service's opt-in periodic check, running this
|
||||
// command *is* the opt-in: no config flag, no persisted state, just one
|
||||
// GitHub API request each time it's invoked.
|
||||
func updateCheckCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "update-check",
|
||||
Usage: "Check GitHub for a newer soundtouch-cli release",
|
||||
Action: runUpdateCheck,
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(c *cli.Context) error {
|
||||
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
|
||||
|
||||
result, err := checker.CheckNow(c.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update check failed: %w", err)
|
||||
}
|
||||
|
||||
printUpdateCheckResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUpdateCheckResult(result updatecheck.Result) {
|
||||
if result.LatestVersion == "" {
|
||||
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
|
||||
fmt.Println(result.ReleaseURL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckCommand_Registered checks the command is wired up with the
|
||||
// expected name and an Action, without making any real GitHub API calls.
|
||||
func TestUpdateCheckCommand_Registered(t *testing.T) {
|
||||
cmd := updateCheckCommand()
|
||||
|
||||
if cmd.Name != "update-check" {
|
||||
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
|
||||
}
|
||||
|
||||
if cmd.Action == nil {
|
||||
t.Error("expected an Action to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
|
||||
// (unparseable current version, update available, up to date) purely for
|
||||
// the "does not panic" guarantee; updatecheck.Checker's own tests already
|
||||
// cover the comparison logic itself.
|
||||
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
}{
|
||||
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
|
||||
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
|
||||
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
printUpdateCheckResult(tc.result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,13 @@ 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())
|
||||
|
||||
// On-demand GitHub release check (#591). Defined in cmd_updatecheck.go.
|
||||
app.Commands = append(app.Commands, updateCheckCommand())
|
||||
|
||||
// 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
|
||||
}
|
||||
+589
-280
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newTestServiceContext builds a real *cli.Context against serviceFlags (the
|
||||
// exact flags soundtouch-service registers), so loadConfig tests exercise the
|
||||
// same parsing/env-var wiring production code does, instead of a hand-rolled
|
||||
// stand-in that could silently drift from it.
|
||||
func newTestServiceContext(t *testing.T, args ...string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
app := &cli.App{Flags: serviceFlags}
|
||||
set := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
|
||||
for _, f := range serviceFlags {
|
||||
if err := f.Apply(set); err != nil {
|
||||
t.Fatalf("apply flag %v: %v", f.Names(), err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := set.Parse(args); err != nil {
|
||||
t.Fatalf("parse args %v: %v", args, err)
|
||||
}
|
||||
|
||||
return cli.NewContext(app, set, nil)
|
||||
}
|
||||
|
||||
func TestResolveFallbackHost(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deploymentMode string
|
||||
wantHost string
|
||||
wantWarn bool
|
||||
}{
|
||||
{"on-device uses localhost, no warning", "on-device", "localhost", false},
|
||||
{"public-network returns no fallback, no warning (caller must fail fast)", "public-network", "", false},
|
||||
{"private-network uses this host's own hostname, with warning", "private-network", hostname, true},
|
||||
{"unset/legacy behaves like private-network", "", hostname, true},
|
||||
{"unrecognized mode behaves like private-network", "some-typo", hostname, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotHost, gotWarn := resolveFallbackHost(tc.deploymentMode)
|
||||
if gotHost != tc.wantHost {
|
||||
t.Errorf("host: got %q, want %q", gotHost, tc.wantHost)
|
||||
}
|
||||
|
||||
if gotWarn != tc.wantWarn {
|
||||
t.Errorf("warnOnUse: got %v, want %v", gotWarn, tc.wantWarn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DeploymentMode(t *testing.T) {
|
||||
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://localhost:8000" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "http://localhost:8000")
|
||||
}
|
||||
|
||||
if config.httpsDefaultURL != "https://localhost:8443" {
|
||||
t.Errorf("httpsDefaultURL: got %q, want %q", config.httpsDefaultURL, "https://localhost:8443")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with no --server-url fails fast instead of guessing", func(t *testing.T) {
|
||||
_, err := loadConfig(newTestServiceContext(t, "--deployment-mode=public-network"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "public-network") {
|
||||
t.Errorf("expected error to mention public-network, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with an explicit --server-url succeeds", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode=public-network", "--server-url=https://soundtouch.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "https://soundtouch.example.com" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "https://soundtouch.example.com")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset deployment-mode with no --server-url keeps today's hostname fallback", func(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
config, err := loadConfig(newTestServiceContext(t, "--port=8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := "http://" + hostname + ":8000"
|
||||
if config.serverURL != want {
|
||||
t.Errorf("serverURL: got %q, want %q (legacy installs must keep working without --deployment-mode)", config.serverURL, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit --server-url always wins regardless of deployment-mode", func(t *testing.T) {
|
||||
for _, mode := range []string{"", "on-device", "private-network", "public-network"} {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode="+mode, "--server-url=http://198.51.100.7:8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("mode %q: loadConfig: unexpected error: %v", mode, err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://198.51.100.7:8000" {
|
||||
t.Errorf("mode %q: serverURL: got %q, want explicit override unchanged", mode, config.serverURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyPersistedSettings(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "main-test")
|
||||
if err != nil {
|
||||
@@ -187,3 +322,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
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
func TestShouldCheckImmediately(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
interval := 24 * time.Hour
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lastCheckedAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"never checked", time.Time{}, true},
|
||||
{"stale (older than interval)", now.Add(-25 * time.Hour), true},
|
||||
{"exactly one interval ago", now.Add(-interval), true},
|
||||
{"recent (within interval)", now.Add(-1 * time.Hour), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := shouldCheckImmediately(tc.lastCheckedAt, interval, now); got != tc.want {
|
||||
t.Errorf("%s: shouldCheckImmediately() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipDueToBackoff(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lastErrorAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"no recent failure", time.Time{}, false},
|
||||
{"failed 30 minutes ago", now.Add(-30 * time.Minute), true},
|
||||
{"failed exactly 1 hour ago", now.Add(-time.Hour), false},
|
||||
{"failed 2 hours ago", now.Add(-2 * time.Hour), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := shouldSkipDueToBackoff(tc.lastErrorAt, now); got != tc.want {
|
||||
t.Errorf("%s: shouldSkipDueToBackoff() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogUpdateIfNewlyAvailable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
lastLoggedVersion string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nothing available",
|
||||
result: updatecheck.Result{Available: false},
|
||||
lastLoggedVersion: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "newly available",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
|
||||
lastLoggedVersion: "",
|
||||
want: "v1.1.0",
|
||||
},
|
||||
{
|
||||
name: "already logged this version",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
|
||||
lastLoggedVersion: "v1.1.0",
|
||||
want: "v1.1.0",
|
||||
},
|
||||
{
|
||||
name: "a newer version than what was logged",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.2.0"},
|
||||
lastLoggedVersion: "v1.1.0",
|
||||
want: "v1.2.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := logUpdateIfNewlyAvailable(tc.result, tc.lastLoggedVersion); got != tc.want {
|
||||
t.Errorf("%s: logUpdateIfNewlyAvailable() = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomJitter(t *testing.T) {
|
||||
if got := randomJitter(0); got != 0 {
|
||||
t.Errorf("randomJitter(0) = %v, want 0", got)
|
||||
}
|
||||
|
||||
upperBound := 5 * time.Minute
|
||||
for i := 0; i < 20; i++ {
|
||||
got := randomJitter(upperBound)
|
||||
if got < 0 || got >= upperBound {
|
||||
t.Fatalf("randomJitter(%v) = %v, want in [0, %v)", upperBound, got, upperBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There is deliberately no test for startUpdateCheck itself, matching
|
||||
// startDeviceDiscovery (its equally untested sibling): both are thin,
|
||||
// forever-looping goroutine wrappers whose only decisions live in pure
|
||||
// helpers, which is what the tests above and below cover. The former
|
||||
// TestStartUpdateCheck_DisabledIsANoOp asserted a contract that no longer
|
||||
// exists — the goroutine now always starts, precisely so that enabling the
|
||||
// check from the Settings page takes effect without a restart, and an
|
||||
// early return for "disabled" would defeat that.
|
||||
func TestShouldRunUpdateCheckNow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
|
||||
interval := 24 * time.Hour
|
||||
stale := now.Add(-25 * time.Hour)
|
||||
fresh := now.Add(-1 * time.Hour)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
lastCheckedAt time.Time
|
||||
interval time.Duration
|
||||
lastErrorAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"disabled, never checked", false, time.Time{}, interval, time.Time{}, false},
|
||||
{"disabled, due", false, stale, interval, time.Time{}, false},
|
||||
{"enabled, never checked", true, time.Time{}, interval, time.Time{}, true},
|
||||
{"enabled, due", true, stale, interval, time.Time{}, true},
|
||||
{"enabled, not due yet", true, fresh, interval, time.Time{}, false},
|
||||
{"enabled and due, but in error backoff", true, stale, interval, now.Add(-30 * time.Minute), false},
|
||||
{"enabled and due, backoff expired", true, stale, interval, now.Add(-2 * time.Hour), true},
|
||||
// A zero interval must not turn every poll tick into a GitHub request.
|
||||
{"enabled with a zero interval", true, stale, 0, time.Time{}, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := shouldRunUpdateCheckNow(tc.enabled, tc.lastCheckedAt, tc.interval, tc.lastErrorAt, now)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: shouldRunUpdateCheckNow() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckPollTickIsShorterThanTheDefaultInterval guards the property
|
||||
// that makes the Settings-page toggle feel live: the goroutine must re-read
|
||||
// the settings far more often than the check interval itself, otherwise
|
||||
// switching the check on would appear to do nothing for up to a day.
|
||||
func TestUpdateCheckPollTickIsShorterThanTheDefaultInterval(t *testing.T) {
|
||||
if updateCheckPollTick >= 24*time.Hour {
|
||||
t.Errorf("updateCheckPollTick = %v, want well below the 24h default interval", updateCheckPollTick)
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,7 @@ default/
|
||||
dns/
|
||||
interactions/
|
||||
parity_mismatches/
|
||||
stats/
|
||||
patterns.json
|
||||
settings.json
|
||||
update-check.json
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.4-alpine
|
||||
image: golang:1.26.6-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.6-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.6-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.
|
||||
|
||||
@@ -112,6 +112,14 @@ Factory-reset the same speaker again and run the full state machine — the same
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
> **Update (#615):** `--mode=full` now preflights via `Manager.PreflightInitPlan`
|
||||
> before opening the WebSocket — it checks `/supportedURLs` for
|
||||
> `/setMargeAccount` and requires `/soundTouchConfigurationStatus` to read
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, and no-ops on an already-configured device.
|
||||
> A freshly factory-reset speaker (as in this experiment) reports
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged;
|
||||
> see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`.
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
|
||||
@@ -100,16 +100,16 @@ also visible on the ST 20/300/Wave captures in #221. Different from the
|
||||
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
|
||||
the device's own remote sends.
|
||||
|
||||
| Command | Effect | Source |
|
||||
|---------------------------------|---------------------------------------------------------------------------------------|--------|
|
||||
| `key prefix_1` … `key prefix_6` | Triggers preset 1–6 (same as a remote preset press). | S5 |
|
||||
| `key play` | Begin / resume playback. | S5 |
|
||||
| `key pause` | Pause playback. | S5 |
|
||||
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
|
||||
| `key prev` | Restart current song / previous track. | S5 |
|
||||
| `key next` | Next track. | S5 |
|
||||
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
|
||||
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
|
||||
| Command | Effect | Source |
|
||||
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `key prefix_1` … `key prefix_6` | Triggers preset 1–6 (same as a remote preset press). | S5 |
|
||||
| `key play` | Begin / resume playback. | S5 |
|
||||
| `key pause` | Pause playback. | S5 |
|
||||
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
|
||||
| `key prev` | Restart current song / previous track. | S5 |
|
||||
| `key next` | Next track. | S5 |
|
||||
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
|
||||
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. On Lifestyle/CineMate console devices this is **not** a no-op: it puts the console into standby and, on waking, returns it to the console's own input rather than SoundTouch — see [Lifestyle / Console Device Behavior](../guides/TROUBLESHOOTING.md#lifestyle-console-devices) and #597. | S5 |
|
||||
|
||||
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
|
||||
|
||||
@@ -150,11 +150,15 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
|
||||
|
||||
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
|
||||
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
|
||||
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
|
||||
**It's a commit point, not just a two-field setter.** `envswitch boseurls set` persists whatever is currently in the runtime layer at the moment it runs — not only its own two arguments. Confirmed on five variants (`lisa`, `mojo`, `spotty`, `ginger`, `taigan`; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): a `sys configuration` write survives a reboot **if and only if** an `envswitch boseurls set` runs after it. The same command sequence in reverse order silently loses the later `sys configuration` values on reboot — every command still answers, nothing looks wrong until the reboot. This is why our migration and SSH-enable sequences always issue all four `sys configuration` writes first and `envswitch boseurls set` last (see `telnetURLs.Commands()` / `EnableSSHViaTelnetFullConfig`).
|
||||
|
||||
**It does not acknowledge with `OK`.** Unlike `sys configuration` (which does), `envswitch boseurls set` responds with a different string (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK` substring). An implementation that waits for the literal token `OK` will hit its own timeout on this exact command. Our `pkg/telnet.Client.SendCommand` doesn't string-match at all — it reads until the connection goes idle — so this only matters if you're hand-typing the sequence or reimplementing the client elsewhere.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs, committing the runtime layer as it stands (see above). **Two arguments**, in that order. | S6 |
|
||||
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
|
||||
| `envswitch accountid get`, bare `envswitch`, `envswitch boseurls` | **Confirmed unsupported** — all answer `Invalid Command Option` on `lisa`/`mojo`/`spotty` ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). `envswitch` has no read form on any variant tested; the persisted layer can only be written, then observed indirectly after a reboot (e.g. via `getpdo`, which then reflects the *new* value). | (probe) |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,6 +170,8 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
|
||||
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
|
||||
|
||||
**The two layers are inverted in `getpdo` visibility around a reboot** ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): *before* a reboot, `getpdo` shows the runtime (`sys configuration`) values immediately, while an `envswitch`-written value isn't visible yet; *after* a reboot, the `sys configuration` values are gone and the `envswitch`-persisted values are what's now applied. So a `getpdo` check run before rebooting confirms the writes were accepted, but it is **not** evidence the configuration will survive the reboot — only the `envswitch` write (in the right order, see above) determines that. This is why our own migration verification (`migrateViaTelnet`) checks `getpdo` before reboot only to confirm the runtime layer accepted the values, and never claims persistence from it.
|
||||
|
||||
---
|
||||
|
||||
## The `scm` family — service control
|
||||
@@ -225,7 +231,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 +271,46 @@ 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).
|
||||
|
||||
**On the `--command-delay` between steps:** originally added because a reporter's back-to-back run left `sshd` down while a ~7s-gapped run succeeded ([#515 comment 5228449448](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5228449448)). That theory was **retracted** by the same reporter after a controlled A/B across three variants showed identical outcomes at 0s and 5s gaps ([comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)) — the delay itself doesn't appear to matter. The default is kept small and non-zero (`setup.DefaultTelnetCommandDelay`) as a low-cost hedge for untested variants, not because the delay is known to help.
|
||||
|
||||
**The account-pairing precondition** (raised by `Henri-be`, [#515 comment 5230785528](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5230785528), tracing back to [#471 comment 4903016740](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-4903016740); confirmed empirically by `bitranox`, [#515 comment 5232241580](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5232241580)): a genuinely unpaired (factory-reset, empty `margeAccountUUID`) device does not poll `margeServerUrl` **at all** — confirmed by pointing a reset device's marge URL at a listener and observing zero requests over 10+ minutes. The SSH-enable injection has no read cycle to fire on until the device is paired. `enable-ssh` handles this automatically by default (`EnsureMargeAccountPaired`, `--no-auto-pair` to skip).
|
||||
|
||||
**Factory reset does not remove root access, if it was ever persisted.** Confirmed on a genuinely factory-reset `spotty` ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)): after the reset, `margeAccountUUID` was empty, all four service URLs were back to `streaming.bose.com`, and presets were gone — but `/etc/remote_services` and `/mnt/nv/remote_services` **survived**, and SSH (:22) and telnet (:17000) stayed open. So once a device has been through `setup enable-ssh` with persistence (`EnsureRemoteServices`, the default), a later factory reset only wipes configuration, not root access — recovery is re-migrate + re-pair + rename + restore presets, with **no USB stick and no re-running the injection**.
|
||||
|
||||
**Readiness after a reboot is per-port, not a single moment.** `JRpersonal` first measured that the firmware needs roughly 60s after a cold boot before `:8090`'s `/info` answers and marge state is ready — a booting device answers a bare `HTTP 400` with an empty body before its services are up, which is easy to misread as a rejection rather than "too early" ([#471 comment 5231997551](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231997551)). `bitranox` refined this across three variants: `:8090` and the diagnostic `:17000` shell (and the config subsystem behind it that `getpdo` reads) do **not** become ready at the same time — waiting for `:8090` and then immediately reading over `:17000` returned an empty response even though the box was otherwise up. Ten observed reboots: down in 2.3–5.3s, ready (able to answer `getpdo` correctly) in 55.1–91.8s, median ~69.8s ([#471 comment 5232046477](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232046477)). Anything automated should wait for the specific interface it's about to use, not for a different port to answer first — see the troubleshooting guide's [power-cycle retry note](../guides/TROUBLESHOOTING.md) for the user-facing version of this.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -82,6 +82,16 @@ Three important details from the discussion:
|
||||
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
|
||||
layer that wins on next boot if you don't also write to it. **We must always
|
||||
issue both.**
|
||||
|
||||
A later, more precise measurement ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569), confirmed on
|
||||
five variants: `lisa`/`mojo`/`spotty`/`ginger`/`taigan`) explains *why*
|
||||
order matters: `envswitch boseurls set` is not just a two-field setter, it
|
||||
**commits whatever is currently in the runtime layer at the moment it
|
||||
runs**. A `sys configuration` write only survives a reboot if `envswitch
|
||||
boseurls set` runs **after** it; the same commands in reverse order lose
|
||||
the `sys configuration` values silently on reboot, with every individual
|
||||
command still answering normally. This is why the sequence above is
|
||||
ordered all-four-`sys-configuration`-then-`envswitch`, never the reverse.
|
||||
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
|
||||
endpoints at the **root** of port 8000, matching what the existing XML
|
||||
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
|
||||
@@ -91,8 +101,15 @@ Three important details from the discussion:
|
||||
routes marge under that sub-path. **For our service: bare URL. For users
|
||||
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
|
||||
the first argument of `envswitch boseurls set`.
|
||||
3. **Each command must be sent one at a time, waiting for the device's `OK`
|
||||
response** before sending the next one (`foob61451`'s explicit warning).
|
||||
3. **Each command must be sent one at a time, waiting for the device's
|
||||
response** before sending the next one (`foob61451`'s original warning).
|
||||
Note the exception: `sys configuration` commands ack with `OK`, but
|
||||
`envswitch boseurls set` does **not** — it acks with a different string
|
||||
entirely (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK`
|
||||
substring; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). An implementation that waits for the
|
||||
literal token `OK` will time out on exactly that command. Wait for the
|
||||
shell's prompt (or, as our own `pkg/telnet.Client` does, for the
|
||||
connection to go idle) rather than string-matching `OK`.
|
||||
|
||||
### 2.2 Account pairing fallback
|
||||
|
||||
@@ -106,7 +123,11 @@ in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
|
||||
about.
|
||||
- Useful read-only verification command: `getpdo CurrentSystemConfiguration` —
|
||||
prints the URLs after the changes have been applied so we can verify before
|
||||
rebooting.
|
||||
rebooting. **It only reflects the runtime (`sys configuration`) layer, not
|
||||
the `envswitch`-persisted layer, so a matching `getpdo` here confirms the
|
||||
writes were accepted, not that they will survive the reboot** — see the
|
||||
layer-visibility caveat in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md).
|
||||
- `sys reboot` is the trigger that re-reads both layers.
|
||||
|
||||
### 2.4 What Telnet:17000 cannot do
|
||||
@@ -145,6 +166,22 @@ The values are not validated by the local service, so any numeric `accountId`
|
||||
will work — soundcork's runbook (#228) literally calls the token
|
||||
`soundcorkdoesntcare` to make the point.
|
||||
|
||||
> **Booby trap, confirmed on hardware: never send an empty or truncated body
|
||||
> to this endpoint.** On one firmware, a `POST /setMargeAccount` with an
|
||||
> empty body returned `HTTP 200` and cleared `margeAccountUUID`, un-pairing
|
||||
> an already-working speaker
|
||||
> ([#471 comment 5231977172](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231977172)).
|
||||
> A later retry on the same device instead returned `400` and changed
|
||||
> nothing, so the same reporter corrected the finding to
|
||||
> **state-dependent, not a reliable rule you can rely on either way**
|
||||
> ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)). A `400` is not proof the
|
||||
> endpoint rejected a bad request (a booting device also answers a bare
|
||||
> `400` with an empty body before its services are ready, per
|
||||
> `JRpersonal`), and a `200` is not proof it did what you wanted. Practical
|
||||
> takeaway: our own `postSetMargeAccount` always sends a well-formed XML
|
||||
> body, so this doesn't affect the CLI/service — but don't probe this
|
||||
> endpoint by hand against a speaker that currently works.
|
||||
|
||||
### 3.2 Why it's broken in practice
|
||||
|
||||
There are **three independent failure modes** observed:
|
||||
@@ -189,10 +226,16 @@ control:
|
||||
recipes).
|
||||
3. **Randomize.** A "Generate" button that picks a 7-digit number and
|
||||
re-rolls if it collides with an existing account in the local datastore.
|
||||
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
|
||||
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
|
||||
across firmwares. We will probe it during preflight; if it returns a value
|
||||
we cross-check it against `:8090/info` and warn on mismatch.
|
||||
- **Telnet read-back: confirmed unsupported.** `envswitch accountid get` was
|
||||
originally listed as "plausible by symmetry with `envswitch accountid set`
|
||||
(#221), not yet confirmed." It's now confirmed the other way: on
|
||||
`lisa`/`mojo`/`spotty`, `envswitch` has **no read form at all** — both bare
|
||||
`envswitch` and `envswitch boseurls` answer `Invalid Command Option`
|
||||
([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)).
|
||||
The persisted layer can only be written, then
|
||||
observed indirectly after a reboot (e.g. via `getpdo`, mindful of the
|
||||
layer-visibility caveat in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)).
|
||||
|
||||
This means the user is never *forced* to invent a number — the common path is
|
||||
"the device already has an ID, reuse it" — and the manual/randomize controls
|
||||
|
||||
@@ -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).
|
||||
@@ -592,6 +592,9 @@ soundtouch-cli --host <device> account remove-amazon --user <USER>
|
||||
soundtouch-cli --host <device> account remove-deezer --user <USER>
|
||||
soundtouch-cli --host <device> account remove-iheart --user <USER>
|
||||
soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>]
|
||||
|
||||
# Unpair the device from its Marge cloud account entirely
|
||||
soundtouch-cli --host <device> account unpair
|
||||
```
|
||||
|
||||
**Supported Services:**
|
||||
@@ -648,6 +651,11 @@ soundtouch-cli --host 192.0.2.10 account remove \
|
||||
- Network music libraries (STORED_MUSIC) don't require passwords, only the UPnP server GUID
|
||||
- After adding an account, use `source list` to verify it appears as available
|
||||
- Some services may require additional authentication steps through their mobile apps
|
||||
- `account unpair` is different from the above: it sends `UnPairDeviceWithAccount`
|
||||
over the speaker's own local WebSocket to remove its **Marge cloud account**
|
||||
pairing entirely (`margeAccountUUID`), not a single streaming-service login.
|
||||
See `setup revert` for the related "undo a migration" operation, which
|
||||
deliberately does *not* call this — the two are separate steps.
|
||||
|
||||
### Bass Control
|
||||
|
||||
@@ -1157,6 +1165,290 @@ soundtouch-cli --host 192.0.2.10 events subscribe --filter zone --no-reconnect
|
||||
- Events are displayed in real-time with emoji indicators
|
||||
- Verbose mode shows additional technical details
|
||||
|
||||
### Update Check
|
||||
|
||||
#### `update-check`
|
||||
|
||||
Check GitHub Releases for a newer `soundtouch-cli` version. Unlike
|
||||
`soundtouch-service`'s periodic background check, this doesn't need a
|
||||
`--host` or any device on the network: it's a single, on-demand GitHub API
|
||||
request. Running the command is itself the opt-in, so there's no config
|
||||
flag or persisted state.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
soundtouch-cli update-check
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
A newer version is available: v1.3.0 (you're on v1.2.0)
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/tag/v1.3.0
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `soundtouch-backup` has the same `update-check` command.
|
||||
- If the running binary isn't a released version (e.g. a dev build),
|
||||
the command reports that and skips the comparison.
|
||||
|
||||
### Setup & Migration
|
||||
|
||||
The `setup <subcommand>` group provisions a speaker end-to-end: enabling
|
||||
SSH, factory-reset + Wi-Fi re-provisioning, pointing it at AfterTouch, CA
|
||||
trust, account pairing, reverting, and one-shot data sync. Each subcommand
|
||||
wraps an existing `pkg/service/setup` helper directly — there's no separate
|
||||
business logic in the CLI layer. Manual provisioning-loop background:
|
||||
[docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md](../analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
and [Device Initial Setup](DEVICE-INITIAL-SETUP.md).
|
||||
|
||||
#### `setup inspect`
|
||||
|
||||
Non-destructive snapshot of the speaker: identity, pairing state, Wi-Fi,
|
||||
sources, presets, and (with `--telnet`) the runtime URL configuration via
|
||||
`getpdo`. Good first command to run against an unfamiliar speaker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup inspect
|
||||
soundtouch-cli --host <device> setup inspect --telnet # also reads runtime URLs (slower)
|
||||
```
|
||||
|
||||
#### `setup ssh-check`
|
||||
|
||||
Probes whether port 22 is reachable. On failure, prints the `enable-ssh`
|
||||
suggestion and the USB-stick fallback procedure.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup ssh-check [--timeout 3s]
|
||||
```
|
||||
|
||||
#### `setup enable-ssh`
|
||||
|
||||
Bootstraps SSH on a speaker with no prior access, via the port-17000
|
||||
`envswitch` trick (#471) — no USB stick needed. Auto-pairs an unpaired
|
||||
(factory-reset) device first by default (the injection needs something to
|
||||
poll), waits for `:22`, and persists the `remote_services` marker so SSH
|
||||
survives a reboot.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup enable-ssh
|
||||
soundtouch-cli --host <device> setup enable-ssh --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--service-url` — optional; only the vehicle for the injection, no live
|
||||
server required. Set the real URL later via `setup migrate`.
|
||||
- `--wait` (default `90s`) — how long to wait for `:22` after injection.
|
||||
- `--full-config` — for stubborn devices (ST Portable, CineMate 520) where
|
||||
the default injection is accepted but `sshd` never starts: writes all
|
||||
four config URLs (the #515 sequence) and reboots.
|
||||
- `--command-delay` — only affects `--full-config`; pause between its 6
|
||||
steps.
|
||||
- `--no-auto-pair` / `--account` — skip or control the automatic pairing
|
||||
check.
|
||||
- `--no-reset-urls` — skip restoring clean `boseurls` after SSH is up.
|
||||
- `--no-persist` — skip persisting `remote_services` (SSH won't survive a
|
||||
reboot).
|
||||
- `--authorized-key` — opt-in hardening: install an SSH public key instead
|
||||
of relying on the empty-password login.
|
||||
- `--close-17000` — opt-in hardening: firewall off port 17000 from the LAN
|
||||
(loopback access kept).
|
||||
|
||||
#### `setup remote-services`
|
||||
|
||||
Enables (default) or removes the `remote_services` SSH-enablement marker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup remote-services # ensure it's present
|
||||
soundtouch-cli --host <device> setup remote-services --remove # disable SSH after next reboot
|
||||
```
|
||||
|
||||
#### `setup factory-reset`
|
||||
|
||||
Issues `sys factorydefault` over telnet — wipes account, presets, and
|
||||
Wi-Fi, and reboots the speaker into its own setup-mode AP. Prints the next
|
||||
steps (`wait-ap`, then `wifi-push`).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup factory-reset
|
||||
```
|
||||
|
||||
> **Heads-up:** just before resetting, the speaker sends
|
||||
> `DELETE /streaming/account/{id}/device/{id}` to whatever `margeURL` is
|
||||
> *currently* configured. If that still points at `streaming.bose.com`
|
||||
> (not AfterTouch), AfterTouch keeps a stale datastore entry — migrate
|
||||
> first if you want a clean record.
|
||||
|
||||
#### `setup wait-ap`
|
||||
|
||||
Polls the speaker's setup-mode AP (default `192.0.2.1`) until `/info`
|
||||
responds, after a factory reset.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-ap [--ap-host 192.0.2.1] [--interval 2s] [--timeout 5m]
|
||||
```
|
||||
|
||||
#### `setup wifi-push`
|
||||
|
||||
POSTs `AddWirelessProfile` to the speaker's setup-mode endpoint — pushes
|
||||
your home Wi-Fi credentials while connected to the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-password'
|
||||
```
|
||||
|
||||
Flags: `--security` (default `wpa_or_wpa2`), `--ap-host` (default
|
||||
`192.0.2.1`), `--request-timeout` (default `30s` — the speaker can be slow
|
||||
to ACK before tearing down AP mode; 10s often races).
|
||||
|
||||
#### `setup wait-online`
|
||||
|
||||
Polls mDNS until a speaker matching `--match` comes online on the home
|
||||
network — run this after switching back from the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-online --match=<last-6-hex-of-deviceID>
|
||||
```
|
||||
|
||||
`--match` is empty by default (first speaker seen); `--interval` (`3s`) and
|
||||
`--timeout` (`5m`) control the poll.
|
||||
|
||||
#### `setup install-ca`
|
||||
|
||||
Fetches AfterTouch's CA cert from `/api/setup/ca.crt` and injects it into
|
||||
the speaker's trust store via SSH.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup install-ca --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
#### `setup migrate`
|
||||
|
||||
Applies a migration method to point the speaker at AfterTouch — the CLI
|
||||
equivalent of the web UI's Migrate tab.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --service-url http://192.0.2.10:8000 --method telnet
|
||||
```
|
||||
|
||||
`--method` is one of `telnet` (default) | `hosts` | `resolv` | `xml`.
|
||||
`--proxy-url` sets an optional upstream proxy (only used by `--method=xml`).
|
||||
`--skip-preflight` skips AfterTouch's settings preflight check (useful when
|
||||
that endpoint is unreachable).
|
||||
|
||||
`--marge-url`/`--stats-url`/`--sw-update-url`/`--bmx-url` override the
|
||||
corresponding field instead of deriving it from `--service-url` (applies to
|
||||
both `--method=telnet` and `--method=xml`). Useful beyond soundcork-style
|
||||
setups: e.g. pointing a speaker back at the **original Bose cloud URLs**
|
||||
without a full `setup revert` — telnet writes both the runtime and
|
||||
persisted layers in a single connection, no SSH or `.original` backup
|
||||
needed:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
#### `setup revert`
|
||||
|
||||
Undoes a migration — the CLI equivalent of the web UI's "Revert to
|
||||
Defaults" button. Restores `SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and
|
||||
`/etc/resolv.conf` from their `.original` backups, removes the AfterTouch
|
||||
DNS-hook artifacts, and strips just the AfterTouch-labeled certificate out
|
||||
of the trust bundle. No `--service-url` needed — everything it touches
|
||||
already lives on the speaker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup revert
|
||||
```
|
||||
|
||||
**Out of scope for this command** (matches the web UI button): SSH /
|
||||
`remote_services` persistence (use `setup remote-services --remove`) and
|
||||
account pairing (use `account unpair`) are untouched — revert them
|
||||
separately if you want a fully clean speaker.
|
||||
|
||||
#### `setup reboot`
|
||||
|
||||
Reboots the speaker — useful to force the envswitch parallel-persistence
|
||||
layer to apply after a migration.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup reboot [--method telnet|ssh]
|
||||
```
|
||||
|
||||
`--method` defaults to `telnet`, which works without SSH on modern
|
||||
firmware.
|
||||
|
||||
#### `setup verify`
|
||||
|
||||
Read-only status probe across every migration axis (transports, URL
|
||||
configuration, DNS interception, CA/TLS, pairing) — doubles as a preflight
|
||||
check before applying changes and a verification step afterward. Exits
|
||||
non-zero if nothing reports migrated, so it's usable as a CI gate.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup verify --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
#### `setup plan`
|
||||
|
||||
Recommends the next setup/migration steps based on `inspect` + `verify`
|
||||
state — prints a ready-to-run command for each recommended step.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000 --reset # plan a full factory-reset → Wi-Fi → migrate → pair flow
|
||||
```
|
||||
|
||||
`--wifi-ssid` overrides the SSID used for the `wifi-push` step in a reset
|
||||
plan (default: reuse the SSID `inspect` found). `--include-pair` (default
|
||||
`true`) can be disabled if you'll pair manually.
|
||||
|
||||
#### `setup pair`
|
||||
|
||||
Pairs the speaker with an account via the WebSocket `SETUP` state machine
|
||||
(`--mode=full`, matching the Bose app's own flow) or a minimal
|
||||
`setMargeAccount`-only call (`--mode=bare`, the same underlying call the
|
||||
Health tab's "empty margeAccountUUID" QuickFix uses).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup pair --mode=full --account=1111111 --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup pair --mode=bare --account=1111111 --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--account` empty generates a fresh 7-digit ID. `--name` sets the speaker
|
||||
name during pairing (empty keeps current). `--language` defaults to `2`
|
||||
(English). `--token` defaults to a built-in placeholder matching the Bose
|
||||
app's token shape.
|
||||
|
||||
`--mode=full` first reads `/supportedURLs` and `/soundTouchConfigurationStatus`
|
||||
and only runs the state machine when the device reports
|
||||
`SOUNDTOUCH_NOT_CONFIGURED` (see [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615):
|
||||
a speaker can be reachable, named, and already account-paired yet still
|
||||
report `SOUNDTOUCH_NOT_CONFIGURED`, leaving the "install the Bose app"
|
||||
prompt on screen — only a full pass through the state machine clears it).
|
||||
An already-configured device is a no-op; an unsupported route or an
|
||||
unrecognised status value fails the command instead of guessing.
|
||||
|
||||
#### `setup sync`
|
||||
|
||||
Pulls presets, recents, and sources from the speaker into AfterTouch's
|
||||
datastore — the CLI equivalent of the web UI's Devices → Sync Data button.
|
||||
Read-only towards the speaker: it never writes anything back.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup sync --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
## Common Usage Patterns
|
||||
|
||||
### Quick Device Setup
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -99,18 +99,28 @@ After factory restore the speaker enters setup mode automatically; no power-cycl
|
||||
|
||||
## 6. AP Mode Wi-Fi Provisioning via Console
|
||||
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the command line. The HTTP steps below (6.2, 6.3) are OS-agnostic; only the Wi-Fi-network-switching commands (6.1, 6.4) are platform-specific — macOS is shown inline, with Linux and Windows equivalents alongside.
|
||||
|
||||
### 6.1 Connect Mac to Speaker AP
|
||||
### 6.1 Connect your machine to the Speaker AP
|
||||
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect to it:
|
||||
|
||||
```bash
|
||||
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
|
||||
# Connect (replace with actual SSID)
|
||||
# macOS — list nearby SSIDs via System Settings → Wi-Fi (the `airport`
|
||||
# command was removed in macOS Sequoia+); connect (replace with actual SSID):
|
||||
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — one-shot connect, no password (open AP):
|
||||
nmcli device wifi connect "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows — connect via the built-in Wi-Fi menu, or from PowerShell:
|
||||
netsh wlan connect name="Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
|
||||
|
||||
```bash
|
||||
@@ -143,20 +153,37 @@ Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileRe
|
||||
|
||||
The speaker will disconnect from AP mode and join the home network within ~15–30 s.
|
||||
|
||||
### 6.4 Reconnect Mac to Home Network
|
||||
### 6.4 Reconnect to your Home Network
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — assumes the connection profile already exists
|
||||
# (e.g. from a prior manual connect); use `nmcli device wifi connect
|
||||
# "MyHomeNetwork" password "MyPassword"` instead for a first-time connect.
|
||||
nmcli connection up "MyHomeNetwork"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
netsh wlan connect name="MyHomeNetwork"
|
||||
```
|
||||
|
||||
Wait ~15 s for the speaker to join the home network, then verify:
|
||||
|
||||
```bash
|
||||
# Discover the speaker's new IP via mDNS
|
||||
dns-sd -B _soundtouch._tcp local &
|
||||
sleep 5 ; kill %1
|
||||
# macOS/Linux — discover the speaker's new IP via mDNS.
|
||||
# macOS: dns-sd ships with the OS. Linux: use avahi-browse (avahi-utils package).
|
||||
dns-sd -B _soundtouch._tcp local & # macOS
|
||||
avahi-browse -r _soundtouch._tcp # Linux — Ctrl-C to stop
|
||||
sleep 5 ; kill %1 2>/dev/null # only needed for the dns-sd form
|
||||
```
|
||||
|
||||
Windows has no equivalent built-in mDNS browser; use `soundtouch-cli discover devices` (this repo's own mDNS/UPnP discovery, cross-platform) or check your router's DHCP client list instead.
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
@@ -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.123.0
|
||||
```
|
||||
|
||||
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.123.0 # 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
|
||||
@@ -107,6 +107,10 @@ Open `http://<server>:8000` and go to the **Settings** tab.
|
||||
|
||||
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
|
||||
|
||||
> **Changing this later?** Saving Settings only updates AfterTouch's own record of its address — it does **not** reach out to any already-migrated speaker. Each speaker only learns a new address when you (re-)run Migrate for it (Step 5 below), regardless of migration method. If you change Target Domain after some speakers are already migrated, re-migrate each of them too, or they'll keep using whatever address they were originally migrated with. See [Troubleshooting: Changing Target Domain doesn't change what a speaker actually uses](TROUBLESHOOTING.md#settings-vs-migrate).
|
||||
|
||||
> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. Installs built after issue #546's fix set this automatically (via `DEPLOYMENT_MODE=on-device`); on older installs, or if the field still shows the speaker's own unresolvable Linux hostname (e.g. `http://spotty:8000`), set it here by hand.
|
||||
|
||||
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
|
||||
|
||||
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
|
||||
@@ -127,6 +131,14 @@ The XML migration writes updated configuration to the speaker's filesystem, whic
|
||||
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
|
||||
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
|
||||
|
||||
**Or, without a USB stick:** `soundtouch-cli setup enable-ssh` (#471) bootstraps SSH purely over the network, using the speaker's telnet:17000 diagnostic shell (open by default on most firmware) to inject the SSH-enable command:
|
||||
|
||||
```shell
|
||||
soundtouch-cli --host <SPEAKER-IP> setup enable-ssh
|
||||
```
|
||||
|
||||
It waits for `:22` to come up and persists the change (survives a reboot) by default. Falls back to the USB-stick method above if telnet:17000 is closed or the injection doesn't take on your model.
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
**To disable SSH after migration:**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,7 +14,9 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SSH enabled on the speaker (the usual "Stick with remote_services" procedure).
|
||||
- SSH enabled on the speaker — either the usual "USB stick with
|
||||
`remote_services`" procedure, or `soundtouch-cli setup enable-ssh`
|
||||
(no stick needed, see Step 1).
|
||||
- Your machine can reach the speaker on the LAN.
|
||||
- The speaker's LAN IP address — replace `192.0.2.1` throughout with the
|
||||
actual address shown in your router or `arp -a`.
|
||||
@@ -29,6 +31,23 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Step 1 — Connect to the speaker via SSH
|
||||
|
||||
If SSH isn't enabled yet, you don't need a USB stick: `soundtouch-cli` can
|
||||
bootstrap it purely over the network (#471), using the speaker's
|
||||
telnet:17000 diagnostic shell (open by default on most firmware) to inject
|
||||
the SSH-enable command:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.1 setup enable-ssh
|
||||
```
|
||||
|
||||
This waits for `:22` to come up and persists it (survives a reboot) by
|
||||
default. The USB-stick method (format FAT32, create an empty
|
||||
`remote_services` file in its root, insert, power-cycle) still works as a
|
||||
fallback if telnet:17000 is closed or the injection doesn't take on your
|
||||
model.
|
||||
|
||||
Either way, connect the same way:
|
||||
|
||||
```bash
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
```
|
||||
@@ -65,7 +84,7 @@ rm -f /mnt/nv/aftertouch/soundtouch-cli
|
||||
df -h /mnt/nv # confirm space recovered
|
||||
```
|
||||
|
||||
> **From v0.89.0 onwards the installer prunes stale artefacts automatically**
|
||||
> **From v0.93.0 onwards the installer prunes stale artefacts automatically**
|
||||
> during every upgrade — manual cleanup should no longer be necessary on
|
||||
> fresh installs.
|
||||
|
||||
@@ -81,14 +100,18 @@ 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
|
||||
# Via environment variable — note it goes on `sh`, not `curl`: shell
|
||||
# variable-assignment prefixes only apply to the one command they're
|
||||
# attached to, and in a pipe each command is a separate process.
|
||||
# `VERSION=0.123.0 curl ... | sh` silently does NOT set it for `sh`.
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 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.123.0
|
||||
```
|
||||
|
||||
Verify the installed version:
|
||||
@@ -97,7 +120,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.123.0"` (or whichever
|
||||
version you installed).
|
||||
|
||||
---
|
||||
@@ -130,13 +153,69 @@ ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@192.0.2.1
|
||||
Keep this terminal open. Navigate to **http://localhost:8000** in your
|
||||
browser.
|
||||
|
||||
> Skip this step if your speaker's firmware exposes port 8000 on the LAN
|
||||
> directly — you can reach `http://192.0.2.1:8000` without a tunnel in that
|
||||
> case.
|
||||
> **You may not need the tunnel at all.** Try `http://192.0.2.1:8000` first.
|
||||
> If that doesn't load, try **`http://192.0.2.1:17008`**: on speakers whose
|
||||
> Wi-Fi co-processor refuses to pass `:8000` through (the ST20 and likely
|
||||
> others), the installer automatically redirects port `17008` to AfterTouch,
|
||||
> so the Admin UI is reachable from the LAN without any tunnel. Check with
|
||||
> `/etc/init.d/aftertouch status` on the speaker, which reports the LAN port
|
||||
> when the redirect is active. Details and per-model status:
|
||||
> [Model Support Matrix](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
>
|
||||
> Keep the tunnel in mind anyway for **linking music-service accounts**:
|
||||
> Spotify only accepts `https://` or *loopback* OAuth redirect URIs, so
|
||||
> `http://localhost:8000` through a tunnel succeeds where a plain LAN
|
||||
> address is rejected.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
## Step 6 — Migrate (point the speaker at itself)
|
||||
|
||||
The speaker isn't pointed at the AfterTouch instance you just installed yet
|
||||
— this step does that. On-device, the speaker and the AfterTouch instance
|
||||
are the same machine, so **loopback is the correct and recommended Target
|
||||
Domain value**: `http://localhost:8000`. This is the one case where the
|
||||
general migration guide's "must not be `localhost`" warning does not
|
||||
apply — that warning is about the external-host/cloud scenarios, where
|
||||
`localhost` would resolve on the wrong machine (the service host, not the
|
||||
speaker). Here there is no wrong machine to resolve on.
|
||||
|
||||
> **Note:** as of the fix for issue #546, the on-device init script already
|
||||
> sets `DEPLOYMENT_MODE=on-device`, so a fresh (or reinstalled/updated)
|
||||
> on-device install's own Target Domain already defaults to
|
||||
> `http://localhost:8000` automatically — no manual Settings-tab step
|
||||
> needed for that part. Older installs still default to the speaker's own
|
||||
> unresolvable Linux hostname (e.g. `http://spotty:8000`) until reinstalled
|
||||
> with a build that includes the fix, or until the Target Domain is
|
||||
> corrected by hand. Either way, you still need to run Migrate below — that
|
||||
> step tells the *speaker* to use this address, which is separate from what
|
||||
> the service defaults its own identity to.
|
||||
|
||||
**Via the Admin UI:**
|
||||
|
||||
1. Go to **Settings**, set **Target Domain** to `http://localhost:8000`.
|
||||
2. Go to **Devices**, find your speaker (it self-discovers on its own LAN
|
||||
IP), click **Migrate**.
|
||||
3. Accept the suggested plan and let it apply.
|
||||
4. Reboot to apply the change:
|
||||
```bash
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
**Or via the CLI** (equivalent, no browser needed — grab `soundtouch-cli`
|
||||
from Step 9 below first if you want this path):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup migrate \
|
||||
--service-url http://localhost:8000 --method telnet
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
|
||||
In the AfterTouch UI:
|
||||
|
||||
@@ -147,6 +226,14 @@ In the AfterTouch UI:
|
||||
4. Click the **QuickFix** button (labelled "Fix", "Pair account", or
|
||||
"Apply QuickFix" depending on the version) and confirm.
|
||||
|
||||
Or via the CLI (same underlying pairing call, `--mode=bare` matches what
|
||||
the QuickFix does — see Step 9 to grab `soundtouch-cli` first):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup pair \
|
||||
--mode=bare --account=1111111 --service-url http://localhost:8000
|
||||
```
|
||||
|
||||
Then reboot again to let the pairing take effect:
|
||||
|
||||
```bash
|
||||
@@ -156,7 +243,7 @@ reboot
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Verify pairing and sources
|
||||
## Step 8 — Verify pairing and sources
|
||||
|
||||
After the reboot reconnect via SSH and check:
|
||||
|
||||
@@ -170,32 +257,37 @@ wget -qO- http://localhost:8090/info | grep margeAccountUUID
|
||||
wget -qO- http://localhost:8090/sources
|
||||
```
|
||||
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 6)
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 7)
|
||||
and reboot again.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Download soundtouch-cli (optional, for preset setup)
|
||||
## Step 9 — Download soundtouch-cli (optional, for preset setup)
|
||||
|
||||
If you want to program preset buttons from the command line, download the
|
||||
CLI binary to the speaker's `/tmp` (tmpfs, so it survives only until the
|
||||
next reboot — which is fine for a one-time setup run):
|
||||
CLI binary to `/mnt/nv/aftertouch` (the same persistent partition
|
||||
AfterTouch itself lives on) rather than `/tmp`: `/tmp` is tmpfs and gets
|
||||
wiped on every reboot, and if you used the CLI alternatives in Steps 6/7
|
||||
above, it needs to survive those steps' reboots too, not just the final
|
||||
one:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
cd /mnt/nv/aftertouch
|
||||
|
||||
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.123.0/soundtouch-cli-v0.123.0-linux-armv7
|
||||
chmod +x soundtouch-cli
|
||||
|
||||
/tmp/soundtouch-cli --version
|
||||
/mnt/nv/aftertouch/soundtouch-cli --version
|
||||
```
|
||||
|
||||
Replace `v0.107.0` with the version you installed.
|
||||
Replace `v0.123.0` with the version you installed. If you want the CLI
|
||||
alternatives in Steps 6/7, download it here first, before doing those
|
||||
steps — it'll be in place and already persistent either way.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Store custom radio streams to preset buttons
|
||||
## Step 10 — Store custom radio streams to preset buttons
|
||||
|
||||
Each station must be playing before it can be saved. The `sleep 5` gives
|
||||
the speaker time to buffer and confirm the stream before storing.
|
||||
@@ -205,52 +297,52 @@ the speaker time to buffer and confirm the stream before storing.
|
||||
|
||||
```bash
|
||||
# Preset 1 — Hitradio OE3
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/oe3-q2a" \
|
||||
--name "Hitradio OE3" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
|
||||
# Preset 2 — Lounge FM
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://188.138.9.183/digital.mp3" \
|
||||
--name "Lounge FM" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
|
||||
# Preset 3 — Country Nonstop
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://stream.laut.fm/country-nonstop" \
|
||||
--name "Country Nonstop" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
|
||||
# Preset 4 — Radio Piterpan
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://klasse1.fluidstream.eu/piterpan.mp3?FLID=8" \
|
||||
--name "Radio Piterpan" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
|
||||
# Preset 5 — kronehit
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://secureonair.krone.at/kronehit-hp.mp3" \
|
||||
--name "kronehit" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
|
||||
# Preset 6 — Radio Niederösterreich
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/noe-q2a" \
|
||||
--name "Radio Niederoesterreich" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
```
|
||||
|
||||
These are the stations from weissigera's setup (Austrian public and
|
||||
@@ -259,7 +351,7 @@ pattern is the same regardless of station.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Verify presets and final reboot
|
||||
## Step 11 — Verify presets and final reboot
|
||||
|
||||
```bash
|
||||
wget -qO- http://localhost:8090/presets
|
||||
@@ -285,7 +377,7 @@ should start playing the corresponding stream.
|
||||
| SSH "no matching host key type" | Add `-oHostKeyAlgorithms=+ssh-rsa` |
|
||||
| Port 8000 not reachable from LAN | Use the SSH tunnel (Step 5) |
|
||||
| `margeAccountUUID` still empty after reboot | Re-run Health QuickFix, reboot again |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 6 first |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 7 first |
|
||||
| `http://localhost:8000` not responding after install | `logread \| grep aftertouch \| tail -20` |
|
||||
| No space left on device during install | Run the cleanup in Step 2; check `df -h /mnt/nv` |
|
||||
|
||||
@@ -304,14 +396,21 @@ 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
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 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.123.0
|
||||
|
||||
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.123.0
|
||||
```
|
||||
|
||||
The script's own final output already confirms the new version came up and
|
||||
is answering on `:8000`. If you separately check the version yourself
|
||||
(`wget -qO- http://localhost:8000/health`, or the Admin UI), **reboot the
|
||||
speaker first**: an Admin UI tab left open from before the update, or a
|
||||
browser cache of the previous page load, can otherwise still show the old
|
||||
version even though the new binary is already running.
|
||||
|
||||
**Rollback:** the installer keeps a `.backup` file alongside the binary:
|
||||
|
||||
```bash
|
||||
@@ -321,6 +420,39 @@ cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
|
||||
/etc/init.d/aftertouch restart
|
||||
```
|
||||
|
||||
**Testing a pre-release build (from `main`, not yet tagged):** `install.sh`
|
||||
only ever downloads from GitHub Releases, so there's no one-line installer
|
||||
for an unreleased commit. Cross-compile and swap the binary manually
|
||||
instead — this is a direct extension of the rollback procedure above:
|
||||
|
||||
```bash
|
||||
# On your own machine, from a checkout of the branch/commit you want:
|
||||
make build-linux-armv7 # builds build/soundtouch-service-linux-armv7,
|
||||
# build/soundtouch-cli-linux-armv7, and
|
||||
# build/soundtouch-backup-linux-armv7
|
||||
|
||||
scp build/soundtouch-service-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/aftertouch-service.new
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
|
||||
rw
|
||||
/etc/init.d/aftertouch stop
|
||||
cp /mnt/nv/aftertouch/aftertouch-service /mnt/nv/aftertouch/aftertouch-service.pre-test.backup
|
||||
mv /mnt/nv/aftertouch/aftertouch-service.new /mnt/nv/aftertouch/aftertouch-service
|
||||
chmod +x /mnt/nv/aftertouch/aftertouch-service
|
||||
/etc/init.d/aftertouch start
|
||||
```
|
||||
|
||||
If you're testing an unreleased `soundtouch-cli` change (not just the
|
||||
service), swap that binary too — same idea, and it lands in the same
|
||||
`/mnt/nv/aftertouch` directory Step 9 above uses:
|
||||
|
||||
```bash
|
||||
scp build/soundtouch-cli-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/soundtouch-cli
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1 chmod +x /mnt/nv/aftertouch/soundtouch-cli
|
||||
```
|
||||
|
||||
Roll back the same way as above, using the `.pre-test.backup` file.
|
||||
|
||||
---
|
||||
|
||||
## Service management
|
||||
|
||||
@@ -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.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.107.0 \
|
||||
VERSION=v0.123.0 \
|
||||
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.123.0 # 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.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.107.0 \
|
||||
VERSION=v0.123.0 \
|
||||
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.123.0 # 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.
|
||||
|
||||
@@ -156,28 +156,33 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for 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` |
|
||||
| `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)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `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` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for 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` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `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)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -384,6 +456,63 @@ client.SelectAux()
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ **Lifestyle / Console Device Behavior** {#lifestyle-console-devices}
|
||||
|
||||
### ❌ "Console-style device (Lifestyle, CineMate) plays the first test station but every later one reports INVALID_SOURCE"
|
||||
|
||||
On a Bose Lifestyle or CineMate console, the SoundTouch module is one input
|
||||
among several (TV, AUX, Bluetooth, ...). As already established in #160,
|
||||
the console's active input cannot be switched from the SoundTouch side —
|
||||
there is no API call that forces it back onto SoundTouch.
|
||||
|
||||
**Symptoms:**
|
||||
- `/now_playing` reports `source="LOCAL"` with an empty `ContentItem`:
|
||||
```xml
|
||||
<nowPlaying deviceID="..." source="LOCAL">
|
||||
<ContentItem source="LOCAL" isPresetable="true" />
|
||||
</nowPlaying>
|
||||
```
|
||||
- `LOCAL` does not appear in `/sources` at all.
|
||||
- `POST /select` and `POST /key` (e.g. `PRESET_1`) are accepted
|
||||
(`<status>/select</status>`) but have no observable effect.
|
||||
|
||||
This means the console is sitting on its own (non-SoundTouch) input, not
|
||||
that the content/station itself is invalid. The input has to be selected
|
||||
on the console's own remote or front panel; there is no way to do it via
|
||||
the SoundTouch API.
|
||||
|
||||
**The trap:** `POST /key POWER` does not behave like it does on a plain
|
||||
speaker. On a speaker, `POWER` is a harmless way to stop playback between
|
||||
test runs. On a console, it puts the whole unit into standby — and on
|
||||
waking, the console returns to **its own** input, not back to SoundTouch.
|
||||
A test loop that stops playback with `POWER` between trials silently
|
||||
switches the device off SoundTouch after the *first* trial, so every
|
||||
station from the second one onward reports `INVALID_SOURCE` — including
|
||||
stations that would otherwise play perfectly fine. This is easy to
|
||||
misread as a per-station problem (e.g. "this console can't handle TLS/
|
||||
https streams") when it is actually a test-methodology artifact: whichever
|
||||
station happens to run first in the loop is the only one actually tested
|
||||
against SoundTouch input.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Before testing anything, select the SoundTouch input on the console
|
||||
itself (remote or front panel), not via the API.
|
||||
2. Do not use `POST /key POWER` to stop playback between trials on these
|
||||
devices. If you need to interrupt playback, use a different key
|
||||
(e.g. `PAUSE`/`STOP`) or simply move directly to selecting the next
|
||||
station.
|
||||
3. If `/now_playing` shows `source="LOCAL"` with `LOCAL` absent from
|
||||
`/sources`, treat that as "console is on a different input" — re-select
|
||||
SoundTouch on the console and retest before concluding anything about
|
||||
the station or migration itself.
|
||||
|
||||
See #597 for the original report, including a packet capture confirming a
|
||||
station that appeared to fail actually completed a full TLS handshake and
|
||||
streamed normally once the console was back on the SoundTouch input.
|
||||
|
||||
---
|
||||
|
||||
## 🎶 **Music Service & Preset Issues**
|
||||
|
||||
### ❌ Spotify preset fails with "Current content cannot be saved as preset"
|
||||
@@ -465,6 +594,151 @@ 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.
|
||||
|
||||
### ❌ Changing Target Domain in Settings doesn't change what a speaker actually uses {#settings-vs-migrate}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You update **Settings → Target Domain / Server URL** (via the Admin UI, `SERVER_URL`, or `--deployment-mode`), and the Admin UI confirms the new value with no warning.
|
||||
- An already-migrated speaker's own behavior is unchanged: playback/BMX requests still go to the *old* address, and `soundtouch-cli setup inspect --telnet` still shows the old `margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl`.
|
||||
|
||||
**Cause:** Settings only updates the *service's own* record of its address (`s.serverURL`, persisted to `settings.json`) — the save handler never contacts any device. A speaker only learns a new address at migrate time: the telnet method writes it via `sys configuration ...` plus a closing `envswitch boseurls set ...` for the reboot-persisted layer; the XML/SSH method uploads a fresh `SoundTouchSdkPrivateCfg.xml`. Both write **once**, with no mechanism for a speaker to later re-fetch its own config from the service — this is equally true for either migration method. A "Sync" or `sourcesUpdated` notification only refreshes the speaker's source *list*, not its server URL configuration.
|
||||
|
||||
**Fix:** Any Target Domain change that needs to reach an already-migrated speaker requires a fresh Migrate afterward — Settings alone is never enough for a speaker that's been migrated before:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url <new-target-domain>
|
||||
```
|
||||
|
||||
Confirm it took:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup inspect --telnet
|
||||
```
|
||||
|
||||
`margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl` should all match the new value. Repeat per speaker — Settings is one service-wide value, but each speaker keeps its own independently-migrated copy, so a multi-speaker household needs a re-migrate for each one.
|
||||
|
||||
This also applies to a freshly-fixed on-device default (see `DEPLOYMENT_MODE`, #546): the installer now gets the *default* right for new installs automatically, but an install that was already migrated before you updated still needs the explicit re-migrate above — the fix only stops a *new* bad value from being written, it doesn't retroactively correct an already-migrated speaker.
|
||||
|
||||
### ❌ 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 — confirmed
|
||||
identical on the SoundTouch 30 Series III, not just the original ST30).
|
||||
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.
|
||||
|
||||
### ❌ `setup enable-ssh` (or a telnet command) fails right after a power-cycle, but works if you wait
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You power-cycled the speaker — as our own retry guidance suggests after a `setup enable-ssh` timeout — and immediately re-ran the command (or a telnet migration/pairing step).
|
||||
- You get `telnet dial <ip>:17000: connection refused` or the command otherwise fails as if the port were closed.
|
||||
- Running the exact same command again a minute or two later works fine, on the same device.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Confirmed on hardware across five device variants (2026-08-09): different ports on the same speaker become ready at very different times after a cold boot. HTTP `:8090` typically answers first, but the diagnostic telnet shell on `:17000` — and the config subsystem behind it that `getpdo` reads — takes longer: 55–92 seconds observed, median ~70s. "The box answers on one port" is a weaker signal than "the box can answer on the specific port you need." See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md) for the underlying mechanism.
|
||||
|
||||
**Fix:** After a power-cycle, wait at least 90 seconds before retrying any telnet-based command. If it still fails after that, wait a full 2 minutes before assuming the port is genuinely closed on that firmware rather than just slow to come up.
|
||||
|
||||
### ❌ Speaker gets slower/less responsive over time after `setup enable-ssh` with no `--service-url`
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You ran `soundtouch-cli setup enable-ssh` without `--service-url` (or via the Admin UI's equivalent) to bootstrap SSH, and never followed up with a real `setup migrate`.
|
||||
- Over time (hours to days), the speaker becomes progressively less responsive — slow to answer `:8090`, SSH connections time out, the Admin UI shows it as flaky or offline.
|
||||
|
||||
**Cause:**
|
||||
|
||||
`enable-ssh` without `--service-url` writes a deliberately-invalid placeholder (`https://aftertouch.invalid`) into `margeServerUrl`/`swUpdateUrl`/etc — by design, since the SSH-enable injection only needs *a* URL to round-trip through, not a working one. But unless you run `setup migrate` (or the Admin UI's Migrate step) afterward, that placeholder **stays persisted** — the command's own success message says so explicitly. The firmware then retries a failing DNS/curl lookup against it on a background loop (same class of failure as the `mojo`/`taigan` unresolvable-hostname case, #546) — an ongoing resource drain that isn't dramatic on its own, but confirmed on real hardware (2026-08-16) to compound badly if anything else (e.g. a burst of SSH connections — see the `setup revert` entry below) puts the speaker under load at the same time.
|
||||
|
||||
**Fix:** Always follow `enable-ssh` (when run without `--service-url`) with a real `setup migrate` before walking away. If you're recovering a speaker that's already stuck like this: power-cycle it, confirm it's reachable (`ping`, `curl :8090/info`, a single plain `ssh ... echo ok`) before doing anything else, then run `setup migrate` with the real URLs. If you want to point it back at the **original Bose cloud** URLs instead of AfterTouch (e.g. to fully decommission it), use the per-field overrides on `--method=telnet` — see the `setup migrate` section of [CLI-REFERENCE.md](CLI-REFERENCE.md) — which writes over a single telnet connection, no SSH required:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <SPEAKER-IP> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
### ❌ `setup revert` (or the Admin UI's "Revert to Defaults") fails with "backup .original not found" even though the file exists
|
||||
|
||||
**Status: fixed** (branch `docs-ondevice-install-gaps`, not yet in a numbered release as of this writing) — kept below for anyone hitting this on an older build, and because the underlying "don't hammer a struggling speaker" advice is still good practice generally.
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You confirm via a separate SSH session that `/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original` genuinely exists.
|
||||
- `setup revert` (or clicking "Revert to Defaults") still reports `backup .../SoundTouchSdkPrivateCfg.xml.original not found, cannot revert`.
|
||||
- A follow-up plain SSH command to the same speaker fails with `Operation timed out` at the TCP level — not an auth or shell error.
|
||||
|
||||
**Cause:** `RevertMigration`'s full call graph opened **17 separate SSH connections** in rapid succession (`pkg/ssh.Client.Run()` dialed fresh every call, with no connection reuse across `revertXMLConfig`/`revertHosts`/`revertResolvConf`/`revertAftertouchHook`/`removeRcLocalHooks`/`revertCACert`). Hitting a resource-constrained embedded speaker with that many rapid reconnects could overwhelm it — confirmed on real hardware (2026-08-16), where the speaker became unreachable shortly after. On top of that, `revertXMLConfig`'s error handling collapses *any* non-nil error from its file-existence check into "not found," so a dial failure got misreported as a missing backup — the message didn't mean what it said.
|
||||
|
||||
**Fix:** `pkg/ssh.Client` now supports an opt-in persistent connection (`Connect()`/`Close()`) that `RevertMigration` uses to collapse those 17 connections into 1 — confirmed on the same real hardware (2026-08-16): a subsequent `setup revert` completed quickly, and the restored config file diffed byte-identical against `.original`. If you're on a build that predates this fix, don't retry `setup revert` back-to-back — if it fails, wait a minute and confirm the speaker is reachable again (`ping`, a single plain `ssh ... echo ok`) before retrying. If all you actually need is to point the speaker's URLs somewhere else (back to AfterTouch, or back to the original Bose cloud), the lighter-weight `setup migrate --method telnet` with explicit URL overrides (previous entry) uses one telnet connection instead of SSH entirely.
|
||||
|
||||
### ❌ On-device install: AfterTouch answers on the speaker but not from other machines on the LAN
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- On the speaker itself, `curl http://localhost:8000/health` works and `/etc/init.d/aftertouch status` is green.
|
||||
- From any other machine, `http://<speaker-ip>:8000` fails immediately (connection refused/reset, not a timeout).
|
||||
- SSH to the same speaker works fine, so it is clearly reachable in general.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth co-processor, and inbound LAN traffic reaches the main Linux SoC only for a fixed set of Bose's *own* service ports, a list that appears to be compiled into the co-processor's firmware. AfterTouch's `:8000` was never part of that original design, so the connection never arrives at the SoC at all. Confirmed on an ST20 (`spotty`, FW 27.0.6) in 2026-08: `tcpdump -i eth0` on the speaker saw **zero packets** for `:8000` while Bose's `:8090`/`:8091`/`:17000` answered normally from the same client. This is not a firewall (the speaker's `iptables` is empty) and not a binding problem (the service does listen on `0.0.0.0:8000`).
|
||||
|
||||
**Fix:**
|
||||
|
||||
The on-device installer handles this automatically: on an affected speaker it redirects a relayed Bose port to AfterTouch, so use:
|
||||
|
||||
```
|
||||
http://<speaker-ip>:17008
|
||||
```
|
||||
|
||||
To check or change it, on the speaker:
|
||||
|
||||
```bash
|
||||
/etc/init.d/aftertouch status # reports the LAN port when active
|
||||
iptables -t nat -S PREROUTING # shows the redirect rule
|
||||
```
|
||||
|
||||
Set `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf` to a different port, or to `none` to disable the redirect and use an SSH tunnel instead; then `/etc/init.d/aftertouch restart`. Note that **linking music-service accounts still works best through the tunnel** (`http://localhost:8000`), because Spotify only accepts `https://` or loopback OAuth redirect URIs. If you also run the `streborn` project on the same speaker, note it defaults to the same port, so change one of them. Which models are affected is tracked in [MODEL-SUPPORT-MATRIX.md](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
|
||||
## 🔊 **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).
|
||||
|
||||
|
||||
@@ -100,6 +100,20 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
|
||||
|
||||
## Phase 2 — Pairing a New Speaker
|
||||
|
||||
> **Preflight (AfterTouch's `setup pair --mode=full`).** Before opening the
|
||||
> WebSocket, AfterTouch reads `GET /supportedURLs` (must list
|
||||
> `/setMargeAccount`) and `GET /soundTouchConfigurationStatus`, and only
|
||||
> runs the state machine below when the status is exactly
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`. This matters because a speaker can be
|
||||
> reachable, named, and already have a `margeAccountUUID` set, yet still
|
||||
> report `SOUNDTOUCH_NOT_CONFIGURED` — the firmware keeps prompting to
|
||||
> install the Bose app until a full acknowledged pass through this state
|
||||
> machine runs, not just `setMargeAccount` on its own. Already-configured
|
||||
> devices are a no-op; an unsupported route or an unrecognised status value
|
||||
> aborts without writing anything. See
|
||||
> [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615) and
|
||||
> `Manager.PreflightInitPlan` (`pkg/service/setup/marge_pairing.go`).
|
||||
|
||||
### 2.1 Setup State Machine
|
||||
|
||||
The pairing flow uses a setup state machine on the device. States must be sent in order.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "Model Support Matrix"
|
||||
---
|
||||
A living record of how individual SoundTouch models behave with AfterTouch,
|
||||
built up from things actually observed on hardware.
|
||||
|
||||
**This table only claims what someone has verified.** Anything not tested is
|
||||
marked `?` rather than inferred from a similar-looking model. Bose used
|
||||
several different chassis designs across the SoundTouch line, and at least
|
||||
one behaviour (LAN reachability, below) differs between them in a way that is
|
||||
invisible from the outside. If you have a model that isn't filled in yet,
|
||||
[the commands below](#how-to-fill-in-a-row) produce everything a row needs.
|
||||
|
||||
## What the columns mean
|
||||
|
||||
- **variant / moduleType**: the speaker's own identifiers, straight out of
|
||||
`/info`. `variant` is Bose's internal codename for the product; `moduleType`
|
||||
distinguishes chassis generations (`scm` and `sm2` are the two seen so far).
|
||||
- **BCO**: whether the board carries a BCO co-processor (Bose's internal name
|
||||
for the SMSC Wi-Fi/Bluetooth combo chip that also handles AirPlay). Bose's
|
||||
own `has-bco` helper on the device is simply
|
||||
`[ "$(cat /proc/module_type)" = scm ]`.
|
||||
- **`:8000` from LAN**: whether AfterTouch's own port is reachable from
|
||||
another machine on the network *without* any workaround.
|
||||
- **Entry port**: when `:8000` isn't reachable, the port AfterTouch redirects
|
||||
to itself so the admin UI still works. See
|
||||
[LAN access on co-processor chassis](#lan-access-on-co-processor-chassis).
|
||||
|
||||
## Matrix
|
||||
|
||||
| Model | variant | moduleType | BCO | On-device install | `:8000` from LAN | Entry port | Evidence |
|
||||
|---------------------|----------|------------|-----|-------------------|------------------|------------|-----------------------------------------------------------------------|
|
||||
| SoundTouch 20 | `spotty` | `scm` | yes | works | ✗ blocked | `17008` | verified on hardware 2026-08-16 (FW 27.0.6), redirect survives reboot |
|
||||
| SoundTouch 10 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch 30 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch Portable | ? | ? | ? | ? | ? | ? | not tested |
|
||||
| Wave / SA-4 | ? | ? | ? | ? | ? | ? | not tested |
|
||||
|
||||
Not every SoundTouch shares one firmware image, so treat a `?` as genuinely
|
||||
unknown. In particular, do not assume a model is unaffected just because it is
|
||||
newer or older than a model that is.
|
||||
|
||||
## LAN access on co-processor chassis
|
||||
|
||||
On chassis with a BCO co-processor, inbound LAN traffic reaches the speaker's
|
||||
main Linux SoC only for a fixed set of Bose's *own* service ports. That list
|
||||
appears to be compiled into the co-processor's firmware, and AfterTouch's
|
||||
`:8000` is not on it, so a connection attempt never arrives at the SoC at
|
||||
all. On a verified ST20, `tcpdump -i eth0` on the speaker recorded **zero
|
||||
packets** for `:8000` while Bose's `:8090`, `:8091`, `:8200`, `:82`, `:8080`
|
||||
and `:17000` all answered normally from the same client.
|
||||
|
||||
This is not a firewall, and not something AfterTouch can fix by binding
|
||||
differently: the service already listens on `0.0.0.0:8000`, and the speaker's
|
||||
`iptables` is empty (there is no `nft` or `ebtables` at all).
|
||||
|
||||
The on-device installer works around it by redirecting one of the relayed
|
||||
ports to AfterTouch. **Credit for this technique goes to the
|
||||
[STR / SoundTouch Reborn](https://github.com/JRpersonal/streborn) project**,
|
||||
which documented and shipped it first (their agent uses the same entry port
|
||||
for the same reason); finding their prior art is what turned this from an
|
||||
apparent hardware dead end into a one-line fix:
|
||||
|
||||
```
|
||||
iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport 17008 -j REDIRECT --to-ports 8000
|
||||
```
|
||||
|
||||
`17008` is Bose's `SoftwareUpdate` listener. Its cloud service no longer
|
||||
exists, so taking over its inbound traffic costs nothing in practice. Only
|
||||
external traffic is matched (`! -i lo`), so anything running on the speaker
|
||||
still reaches AfterTouch on `:8000` exactly as before.
|
||||
|
||||
The rule is re-applied by the init script on every start, so it survives
|
||||
reboots (confirmed on the ST20) without any background watchdog. It is
|
||||
removed again on `stop` and on uninstall.
|
||||
|
||||
The redirect is applied automatically on chassis that need it, and configured
|
||||
via `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf`:
|
||||
|
||||
| Value | Effect |
|
||||
|------------|-----------------------------------------------------------------|
|
||||
| `auto` | *(default)* redirect only where the co-processor blocks `:8000` |
|
||||
| `none` | never redirect; use an SSH tunnel instead |
|
||||
| *(a port)* | always redirect that inbound port to AfterTouch |
|
||||
|
||||
Two caveats worth knowing:
|
||||
|
||||
- **Account linking still prefers the SSH tunnel.** Spotify only accepts
|
||||
`https://` or *loopback* OAuth redirect URIs, so `http://localhost:8000`
|
||||
through a tunnel works for linking where a plain LAN address does not.
|
||||
- **The `streborn` project defaults to the same port** for the same reason. If
|
||||
you run both on one speaker, change `AFTERTOUCH_LAN_PORT`.
|
||||
|
||||
## How to fill in a row
|
||||
|
||||
Run these from a machine on the same network (replace the address), then open
|
||||
an issue or PR with the output:
|
||||
|
||||
```bash
|
||||
# variant, moduleType, and whether an SCM/SMSC component is listed
|
||||
curl -s http://<speaker-ip>:8090/info
|
||||
|
||||
# is AfterTouch's own port reachable directly? (only meaningful once
|
||||
# AfterTouch is installed on the device)
|
||||
curl -v --max-time 5 http://<speaker-ip>:8000/health
|
||||
|
||||
# which Bose ports the chassis relays at all
|
||||
for p in 82 8080 8090 8091 8200 17000 17008; do
|
||||
printf '%s: ' "$p"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 "http://<speaker-ip>:$p/" || echo unreachable
|
||||
done
|
||||
```
|
||||
|
||||
And on the speaker itself, if you have SSH access:
|
||||
|
||||
```bash
|
||||
has-bco; echo "has-bco exit status: $?" # 0 = BCO co-processor present
|
||||
cat /proc/module_type /proc/variant
|
||||
```
|
||||
@@ -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.6
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.107.0
|
||||
require github.com/gesellix/bose-soundtouch v0.123.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.6
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.107.0
|
||||
require github.com/gesellix/bose-soundtouch v0.123.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.6
|
||||
|
||||
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.55.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/net v0.58.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.45.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.49.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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
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.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
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.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
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.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
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
|
||||
|
||||
@@ -59,8 +59,9 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
|
||||
}
|
||||
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
|
||||
expectedErr := "UNKNOWN_ACTION_ERROR: " + expectedMsg
|
||||
if err.Error() != expectedErr {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedErr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -2,6 +2,8 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -80,7 +82,7 @@ type ErrorsResponse struct {
|
||||
// Error implements the error interface for ErrorsResponse
|
||||
func (e *ErrorsResponse) Error() string {
|
||||
if len(e.Errors) > 0 {
|
||||
return e.Errors[0].Message
|
||||
return e.Errors[0].Error()
|
||||
}
|
||||
|
||||
return "unknown API error"
|
||||
@@ -93,6 +95,27 @@ type DeviceError struct {
|
||||
Message string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// Error implements the error interface for DeviceError. Some speakers
|
||||
// return a Message that just restates Value as text (e.g. a bare "1047"
|
||||
// for an error the firmware has no localized string for) — Name is the
|
||||
// only informative part in that case, so it's always included unless
|
||||
// Message already carries it.
|
||||
func (e DeviceError) Error() string {
|
||||
if e.Name == "" {
|
||||
if e.Message == "" {
|
||||
return fmt.Sprintf("device error %d", e.Value)
|
||||
}
|
||||
|
||||
return e.Message
|
||||
}
|
||||
|
||||
if e.Message == "" || e.Message == e.Name || e.Message == strconv.Itoa(e.Value) {
|
||||
return fmt.Sprintf("%s (%d)", e.Name, e.Value)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %s", e.Name, e.Message)
|
||||
}
|
||||
|
||||
// DiscoveredDevice represents a device found through network discovery
|
||||
type DiscoveredDevice struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeviceError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err DeviceError
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "message repeats the numeric value (real speaker case)",
|
||||
err: DeviceError{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: "1047"},
|
||||
expected: "SOURCE_ALREADY_REMOVED (1047)",
|
||||
},
|
||||
{
|
||||
name: "message is empty",
|
||||
err: DeviceError{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: ""},
|
||||
expected: "SOURCE_ALREADY_REMOVED (1047)",
|
||||
},
|
||||
{
|
||||
name: "message is meaningful and distinct from name",
|
||||
err: DeviceError{Value: 1029, Name: "UNKNOWN_ACTION_ERROR", Message: "This version of SCM does not support spotify create account functionality."},
|
||||
expected: "UNKNOWN_ACTION_ERROR: This version of SCM does not support spotify create account functionality.",
|
||||
},
|
||||
{
|
||||
name: "name is empty, message carries the detail",
|
||||
err: DeviceError{Value: 500, Name: "", Message: "internal error"},
|
||||
expected: "internal error",
|
||||
},
|
||||
{
|
||||
name: "both name and message are empty",
|
||||
err: DeviceError{Value: 500, Name: "", Message: ""},
|
||||
expected: "device error 500",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Error(); got != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsResponse_Error(t *testing.T) {
|
||||
t.Run("delegates to the first DeviceError", func(t *testing.T) {
|
||||
errs := &ErrorsResponse{
|
||||
Errors: []DeviceError{
|
||||
{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: "1047"},
|
||||
},
|
||||
}
|
||||
|
||||
expected := "SOURCE_ALREADY_REMOVED (1047)"
|
||||
if got := errs.Error(); got != expected {
|
||||
t.Errorf("expected %q, got %q", expected, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no errors", func(t *testing.T) {
|
||||
errs := &ErrorsResponse{}
|
||||
|
||||
expected := "unknown API error"
|
||||
if got := errs.Error(); got != expected {
|
||||
t.Errorf("expected %q, got %q", expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
@@ -2573,6 +2594,8 @@ type Settings struct {
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
UpdateCheckInterval string `json:"update_check_interval,omitempty"`
|
||||
UpdateCheckEnabled bool `json:"update_check_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
@@ -2592,17 +2615,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"`
|
||||
@@ -2628,6 +2651,19 @@ type Settings struct {
|
||||
// individual format tokens.
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
|
||||
// AutoResumeOnSourceDisconnect, when true, re-issues a device's last
|
||||
// playing content item if now_playing drops into an error source right
|
||||
// after a healthy one, instead of leaving the speaker silent until a
|
||||
// user manually re-selects it. See #622: some TuneIn streams disconnect
|
||||
// the speaker's own audio pipeline (errorUpdate 1041
|
||||
// SOURCE_DISCONNECTED) on their own, mid-playback, with the SoundTouch
|
||||
// WebSocket control channel staying healthy throughout; the observed
|
||||
// fix is exactly what pressing the preset again does. Opt-in (default
|
||||
// false): this automatically re-triggers content selection without a
|
||||
// user action, which not every operator wants. Hand-edit settings.json
|
||||
// to enable — no admin UI control yet, matching TuneInStreamFormats.
|
||||
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
|
||||
|
||||
// DefaultLanding selects what the root path "/" serves to a browser:
|
||||
// "chooser" (or empty) — the neutral landing page that links to the
|
||||
// player and the admin/setup console;
|
||||
@@ -2636,6 +2672,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.
|
||||
@@ -2682,6 +2735,63 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// UpdateCheckState is the small persisted state for the opt-in periodic
|
||||
// update check (#591, _/i591/design-update-check.md): when it last ran and
|
||||
// what it last saw, so a restart doesn't lose the "already logged this
|
||||
// version" and "don't hammer GitHub on every startup" context. Separate
|
||||
// from Settings, which is operator-editable config, not runtime state.
|
||||
type UpdateCheckState struct {
|
||||
LastCheckedAt string `json:"last_checked_at,omitempty"`
|
||||
LastSeenVersion string `json:"last_seen_version,omitempty"`
|
||||
LastReleaseURL string `json:"last_release_url,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdateCheckState retrieves the persisted update-check state. Same
|
||||
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
|
||||
// one that has never had the check enabled) has no file yet.
|
||||
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
if !ds.rootExists(path) {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
var state UpdateCheckState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SaveUpdateCheckState persists the update-check state.
|
||||
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// SaveUsageStats saves usage statistics to the datastore.
|
||||
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "usage")
|
||||
@@ -2700,6 +2810,85 @@ 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. Not
|
||||
// a security-sensitive use of randomness — only affects filename
|
||||
// uniqueness, not any value that's compared or kept secret.
|
||||
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
|
||||
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")
|
||||
|
||||
@@ -428,10 +428,12 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
settings := Settings{
|
||||
ServerURL: "http://myserver:8000",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
ServerURL: "http://myserver:8000",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
UpdateCheckInterval: "12h",
|
||||
UpdateCheckEnabled: true,
|
||||
}
|
||||
|
||||
err = ds.SaveSettings(settings)
|
||||
@@ -456,6 +458,146 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
|
||||
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
|
||||
}
|
||||
if loaded.UpdateCheckInterval != settings.UpdateCheckInterval {
|
||||
t.Errorf("Expected UpdateCheckInterval %s, got %s", settings.UpdateCheckInterval, loaded.UpdateCheckInterval)
|
||||
}
|
||||
if loaded.UpdateCheckEnabled != settings.UpdateCheckEnabled {
|
||||
t.Errorf("Expected UpdateCheckEnabled %v, got %v", settings.UpdateCheckEnabled, loaded.UpdateCheckEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_MissingFileReturnsZeroValue verifies a fresh install
|
||||
// (or one where the update check has never run) gets a zero-value state,
|
||||
// not an error — same shape as GetSettings on a missing settings.json.
|
||||
func TestUpdateCheckState_MissingFileReturnsZeroValue(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-missing-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState on a fresh install should not error, got: %v", err)
|
||||
}
|
||||
if state != (UpdateCheckState{}) {
|
||||
t.Errorf("Expected zero-value state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_Persistence is the roundtrip test, mirroring
|
||||
// TestSettingsPersistence.
|
||||
func TestUpdateCheckState_Persistence(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-persist-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state := UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-09T12:00:00Z",
|
||||
LastSeenVersion: "v0.122.0",
|
||||
LastReleaseURL: "https://github.com/gesellix/Bose-SoundTouch/releases/tag/v0.122.0",
|
||||
}
|
||||
|
||||
if err := ds.SaveUpdateCheckState(state); err != nil {
|
||||
t.Fatalf("SaveUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded != state {
|
||||
t.Errorf("Expected %+v, got %+v", state, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user