mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -0,0 +1,87 @@
|
||||
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, then open a draft PR for review.
|
||||
|
||||
## Step 1 — Determine lookback window
|
||||
|
||||
Run:
|
||||
```
|
||||
git log --format="%ad" --date=short -- docs/content/blog/ | grep -v '_index' | head -1
|
||||
```
|
||||
|
||||
If a date is returned, use it as SINCE.
|
||||
If the output is empty (no posts yet), compute SINCE = 30 days before today:
|
||||
- macOS: `date -v-30d +%Y-%m-%d`
|
||||
- Linux: `date -d '30 days ago' +%Y-%m-%d`
|
||||
|
||||
## Step 2 — Collect commits since SINCE
|
||||
|
||||
Run:
|
||||
```
|
||||
git log --format="%ad %h %s" --date=short --since="$SINCE" --no-merges
|
||||
```
|
||||
|
||||
Exclude these (they are noise):
|
||||
- Subjects matching: `^(ci|chore|deps|bump|Bump|test|lint|style|code style|debug)`
|
||||
- Dependabot bumps (subject contains "bump" and includes a package name pattern)
|
||||
- Routine doc link/URL fixes
|
||||
|
||||
Group the remaining commits into categories:
|
||||
- **NEW FEATURES** — subjects starting with `feat(` or `feat:`
|
||||
- **BUG FIXES** — subjects starting with `fix(` or `fix:`
|
||||
- **SECURITY** — subjects starting with `sec` or containing "security", "inject", "path expression"
|
||||
- **DOCS** — user-visible doc changes only (new guides, major restructures)
|
||||
- **MAINTENANCE** — everything else that passed the filter
|
||||
|
||||
Omit empty categories entirely.
|
||||
|
||||
## Step 3 — Current version
|
||||
|
||||
Run: `git tag --sort=-version:refname | head -1`
|
||||
|
||||
## Step 4 — Determine the period label
|
||||
|
||||
Use the first and last commit dates from Step 2 to produce a human-readable label,
|
||||
e.g. "May 2026" or "April – May 2026".
|
||||
|
||||
## Step 5 — Write the blog post
|
||||
|
||||
Create the file at: `docs/content/blog/YYYY-MM-slug.md`
|
||||
- YYYY-MM = today's year-month
|
||||
- slug = short kebab-case summary of the biggest theme
|
||||
|
||||
Use this exact frontmatter shape:
|
||||
```yaml
|
||||
---
|
||||
title: "AfterTouch PERIOD: <one-line theme>"
|
||||
date: YYYY-MM-DD
|
||||
description: "<one sentence, ≤200 chars, suitable as a standalone teaser>"
|
||||
tags:
|
||||
- <up to 4 tags from: security, tls, discovery, docs, cli, web, spotify, amazon, health, migration, fixes, ci>
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
Target length: 300–600 words. Never include real IPs, MAC addresses, account IDs, or device names.
|
||||
|
||||
## Step 6 — Create a branch and open a draft PR
|
||||
|
||||
```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"
|
||||
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."
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -27,7 +27,7 @@
|
||||
"pattern": "^https://pkg.go.dev.*badge"
|
||||
},
|
||||
{
|
||||
"pattern": "^/images/[^/]+\\.png$"
|
||||
"pattern": "^/images/"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
|
||||
|
||||
+30
-18
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -143,7 +143,9 @@ jobs:
|
||||
|
||||
mkdir -p build
|
||||
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
|
||||
# soundtouch-web is now a transitional alias of soundtouch-player
|
||||
# (same source); building the player is enough to verify both.
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-player soundtouch-backup; do
|
||||
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
|
||||
echo "Building $OUTPUT"
|
||||
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
|
||||
@@ -163,7 +165,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -190,7 +192,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
@@ -248,7 +250,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -303,11 +305,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Set build date
|
||||
id: build_date
|
||||
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
run: |
|
||||
@@ -350,29 +356,35 @@ jobs:
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
- name: Extract metadata (tags, labels) for soundtouch-player
|
||||
id: meta-player
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
images: ghcr.io/${{ github.repository }}-player
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
- name: Build and push soundtouch-player Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
target: soundtouch-player
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
tags: ${{ steps.meta-player.outputs.tags }}
|
||||
labels: ${{ steps.meta-player.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -380,7 +392,7 @@ jobs:
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
env:
|
||||
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
|
||||
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
|
||||
PLAYER_TAGS: ${{ steps.meta-player.outputs.tags }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
@@ -404,12 +416,12 @@ jobs:
|
||||
done <<< "$SERVICE_TAGS"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### soundtouch-web"
|
||||
echo "### soundtouch-player"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$WEB_TAGS"
|
||||
done <<< "$PLAYER_TAGS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
run: hugo --source docs/ --minify --destination ../_site --baseURL "${{ steps.pages.outputs.base_url }}"
|
||||
env:
|
||||
HUGO_ENVIRONMENT: production
|
||||
HUGO_PARAMS_GITHASH: ${{ github.sha }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -170,8 +170,12 @@ jobs:
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
|
||||
# Build Web
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-web"
|
||||
# Build Player (formerly soundtouch-web)
|
||||
build_binary "soundtouch-player" "./cmd/soundtouch-player"
|
||||
|
||||
# Build 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"
|
||||
@@ -181,6 +185,7 @@ jobs:
|
||||
run: |
|
||||
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 }}"
|
||||
|
||||
@@ -198,6 +203,7 @@ jobs:
|
||||
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$PLAYER_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
generate_checksums "$BCK_NAME"
|
||||
|
||||
@@ -212,6 +218,7 @@ jobs:
|
||||
path: |
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-player-v*
|
||||
build/soundtouch-web-v*
|
||||
build/soundtouch-backup-v*
|
||||
retention-days: 1
|
||||
@@ -240,7 +247,7 @@ jobs:
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -255,14 +262,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-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-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
|
||||
|
||||
echo "📋 Generated combined checksums:"
|
||||
cat checksums.sha256
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
|
||||
EXPECTED_COUNT=35 # 7 platforms * 5 binaries (player + its web alias)
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
@@ -305,7 +312,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -393,11 +400,15 @@ jobs:
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Web
|
||||
### SoundTouch Player (formerly soundtouch-web)
|
||||
\`\`\`bash
|
||||
# Start the web app
|
||||
./soundtouch-web
|
||||
# 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
|
||||
@@ -423,7 +434,7 @@ jobs:
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
`soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included.
|
||||
`soundtouch-cli`, `soundtouch-service`, `soundtouch-player` (with `soundtouch-web` as a transitional alias), and `soundtouch-backup` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
@@ -478,6 +489,7 @@ jobs:
|
||||
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
|
||||
@@ -506,6 +518,7 @@ jobs:
|
||||
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
|
||||
@@ -521,7 +534,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set build date
|
||||
id: build_date
|
||||
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
@@ -552,9 +569,40 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.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
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-player
|
||||
id: meta-player
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-player
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-player Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-player
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
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
|
||||
@@ -574,6 +622,10 @@ jobs:
|
||||
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 }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+3
-1
@@ -15,6 +15,7 @@ dist/
|
||||
/soundtouch-backup
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-player
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
@@ -48,7 +49,8 @@ node_modules/
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/commands/
|
||||
.junie/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ linters:
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
# Carry-over from cmd/soundtouch-web/handlers relocation: same code,
|
||||
# Carry-over from cmd/soundtouch-player/handlers relocation: same code,
|
||||
# same waiver. Tighten in a follow-up if/when the package is reviewed.
|
||||
- path: pkg/service/soundtouchweb/.*\.go
|
||||
text: "Error return value of.*is not checked"
|
||||
|
||||
@@ -18,7 +18,7 @@ Key binaries:
|
||||
(status, play, presets, groups, migration, …).
|
||||
- `soundtouch-service` — replacement for `streaming.bose.com`
|
||||
and the `bmx` services, default port `8000`.
|
||||
- `soundtouch-web` — Web UI for Radio browsing and device control.
|
||||
- `soundtouch-player` — Web UI for Radio browsing and device control.
|
||||
- `soundtouch-backup` — Helper for on-device backup and restore.
|
||||
|
||||
Per-session pickup notes live in two local files at the repo root (they are `.gitignore`d and only exist if created during a session):
|
||||
@@ -105,7 +105,7 @@ retrospective diffing whenever something goes sideways.
|
||||
cmd/
|
||||
soundtouch-cli/ # CLI tool for device control
|
||||
soundtouch-service/ # Local cloud service emulator
|
||||
soundtouch-web/ # Web UI (TuneIn browser, device control)
|
||||
soundtouch-player/ # Web UI (TuneIn browser, device control)
|
||||
soundtouch-backup/ # On-device backup helper
|
||||
example-*/ # Usage examples
|
||||
pkg/
|
||||
@@ -182,6 +182,12 @@ unless the user has already authorised that specific action in this
|
||||
session. Prefer reversible alternatives (`git stash` over
|
||||
`git reset --hard`).
|
||||
|
||||
**Force-flags also require explicit approval.** `git add -f` (force-add
|
||||
a gitignored file), `git push --force`, `git push --force-with-lease`,
|
||||
and any other flag that overrides a git safety mechanism must be
|
||||
proposed and confirmed before running, for the same reason: they
|
||||
bypass protections that exist intentionally.
|
||||
|
||||
## What never goes into this repo
|
||||
|
||||
This repository is public. The following must never be committed:
|
||||
@@ -221,6 +227,16 @@ When working with a human user in this repo:
|
||||
back to whatever you were doing when the user asks something else.
|
||||
- **Don't substitute assumptions for real information.** When something
|
||||
is unclear, ask or check, rather than guessing and proceeding.
|
||||
- **An issue is only "resolved" once the reporter confirms.** Prefer
|
||||
"candidate fix, awaiting reporter confirmation" over "fixed" or
|
||||
"closed" until the person who reported it says it works. A merged PR
|
||||
or a shipped release is not confirmation.
|
||||
- **Mind GitHub's `#<id>` auto-linking.** `#<id>` links to issues and
|
||||
pull requests only — it does **not** resolve to discussions. For a
|
||||
discussion, write the full URL
|
||||
(`https://github.com/gesellix/Bose-SoundTouch/discussions/<id>`). For
|
||||
security alerts, write e.g. "CodeQL alert 280" (no `#`) or the full
|
||||
URL, since `#280` would point at an unrelated issue/PR.
|
||||
|
||||
These principles also apply to other AI assistants pointed at this
|
||||
repo. Tool-specific config dirs (e.g. `.junie/`, `.claude/`) should
|
||||
|
||||
+72
-9
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.4-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
|
||||
@@ -8,6 +8,12 @@ ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
ARG TARGETVARIANT
|
||||
|
||||
# Version info injected at build time; defaults keep local builds working.
|
||||
# The release workflow passes VERSION, COMMIT, and DATE via --build-arg.
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
ARG DATE=unknown
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod and sum files
|
||||
@@ -19,16 +25,24 @@ COPY . .
|
||||
|
||||
# Build the soundtouch-service
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
fi
|
||||
|
||||
# Build the soundtouch-web
|
||||
# Build the soundtouch-player (formerly soundtouch-web)
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-player ./cmd/soundtouch-player; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-player ./cmd/soundtouch-player; \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
@@ -36,6 +50,13 @@ FROM alpine:3.23 AS soundtouch-service
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Non-root prep (dormant). Everything below is set up so the service CAN run
|
||||
# as a fixed non-root user, but the image still runs as root by default
|
||||
# (APP_USER below) so this is not a breaking change yet. The UID/GID is pinned
|
||||
# (65532) so a mounted data volume's ownership stays predictable.
|
||||
RUN addgroup -g 65532 -S aftertouch \
|
||||
&& adduser -u 65532 -S -G aftertouch -H -h /app aftertouch
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
@@ -43,28 +64,70 @@ COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
# Verify the binary works on the target platform
|
||||
RUN /app/soundtouch-service version || echo "Binary verification complete"
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
# Create the data dir and hand /app to the non-root user.
|
||||
RUN mkdir -p /app/data && chown -R aftertouch:aftertouch /app
|
||||
|
||||
# Allow the non-root process to bind the privileged DNS port (:53) when DNS
|
||||
# Discovery is enabled, without granting the whole container extra privileges
|
||||
# at runtime. NET_BIND_SERVICE is in Docker's default capability set, so this
|
||||
# file capability is effective out of the box (no --cap-add needed). Done
|
||||
# after chown, which would otherwise clear it; the setcap tool is removed after.
|
||||
RUN apk add --no-cache --virtual .setcap libcap \
|
||||
&& setcap 'cap_net_bind_service=+ep' /app/soundtouch-service \
|
||||
&& apk del .setcap
|
||||
|
||||
ENV PORT=8000
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LOG_PROXY_BODY=false
|
||||
ENV REDACT_PROXY_LOGS=true
|
||||
|
||||
# The toggle. Defaults to root, so this image behaves exactly as before and
|
||||
# the change is non-breaking today. Enabling non-root is planned for v1.0.0
|
||||
# (BREAKING: a bind-mounted DATA_DIR must then be writable by uid 65532 — the
|
||||
# service logs the exact chown command at startup if it can't write). To
|
||||
# enable, either change this default to "aftertouch" (a one-line commit) or
|
||||
# build with --build-arg APP_USER=aftertouch.
|
||||
ARG APP_USER=root
|
||||
USER ${APP_USER}
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
|
||||
# soundtouch-web image
|
||||
# soundtouch-player image
|
||||
FROM alpine:3.23 AS soundtouch-player
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-player /app/soundtouch-player
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# The player is stateless and binds an unprivileged port, so it has no reason
|
||||
# to run as root. mDNS/SSDP discovery uses unprivileged multicast.
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-player"]
|
||||
|
||||
# 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-web /app/soundtouch-web
|
||||
COPY --from=builder /soundtouch-player /app/soundtouch-web
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
|
||||
@@ -17,8 +17,11 @@ BINARY_NAME=soundtouch-cli
|
||||
BINARY_PATH=./cmd/$(BINARY_NAME)
|
||||
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
|
||||
WEB_PATH=./cmd/$(WEB_NAME)
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
@@ -52,7 +55,7 @@ AUTH_SERVICE_URL ?= $(BACKEND_URL)
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
build: build-cli build-service build-player build-web build-examples build-favicon-gen build-backup
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@@ -64,10 +67,17 @@ build-service:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
build-player:
|
||||
@echo "Building $(PLAYER_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(PLAYER_NAME) $(PLAYER_PATH)
|
||||
|
||||
# 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)..."
|
||||
@@ -164,10 +174,8 @@ test-http-client-rotate:
|
||||
fi
|
||||
|
||||
test-http-client:
|
||||
@echo "Starting services with docker compose..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
|
||||
@echo "Waiting for services to start..."
|
||||
@sleep 10
|
||||
@echo "Starting services with docker compose (waiting for healthchecks)..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build --wait
|
||||
@echo "Running .http tests..."
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
-v "$(PWD)/tests/integration/http-client:/workdir" \
|
||||
@@ -177,11 +185,22 @@ test-http-client:
|
||||
/workdir/spotify_registration.http \
|
||||
/workdir/amazon_registration.http \
|
||||
/workdir/create_account.http \
|
||||
/workdir/get_emailaddress.http \
|
||||
/workdir/get_customer_profile.http \
|
||||
/workdir/post_customer_profile.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/post_scmudc_event.http \
|
||||
/workdir/get_speaker_auth.http \
|
||||
/workdir/get_blacklist.http \
|
||||
/workdir/post_alexa_certificate.http \
|
||||
/workdir/unsupported_routes.http \
|
||||
/workdir/spotify_full_flow.http \
|
||||
/workdir/customer_support.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_bmx_services.http \
|
||||
/workdir/get_bmx_services_availability.http \
|
||||
/workdir/get_bmx_service_descriptors.http \
|
||||
/workdir/get_ced_index.http \
|
||||
/workdir/get_sourceproviders.http \
|
||||
/workdir/get_software_update.http \
|
||||
/workdir/get_soundtouch_updates.http \
|
||||
@@ -190,8 +209,15 @@ test-http-client:
|
||||
/workdir/post_oauth_token_amazon.http \
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/tunein_playback_station.http \
|
||||
/workdir/post_tunein_report.http \
|
||||
/workdir/tunein_favorite.http \
|
||||
/workdir/get_orion_station.http \
|
||||
/workdir/get_custom_playback.http \
|
||||
/workdir/get_media_ding.http \
|
||||
/workdir/get_bmx_icon.http \
|
||||
/workdir/set_preset_6.http \
|
||||
/workdir/get_presets.http \
|
||||
/workdir/get_presets_conditional.http \
|
||||
/workdir/delete_preset_6.http \
|
||||
/workdir/set_preset_5.http \
|
||||
/workdir/post_recent.http \
|
||||
@@ -199,11 +225,14 @@ test-http-client:
|
||||
/workdir/get_account_presets.http \
|
||||
/workdir/get_account_devices.http \
|
||||
/workdir/get_account_sources.http \
|
||||
/workdir/delete_source.http \
|
||||
/workdir/get_api_versions.http \
|
||||
/workdir/post_musicprovider_is_eligible.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/get_full_account_conditional.http \
|
||||
/workdir/create_group.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/delete_group.http \
|
||||
/workdir/rename_device.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
@@ -315,17 +344,17 @@ dev-scan-http: build-examples
|
||||
@echo "Scanning for HTTP mDNS services..."
|
||||
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
|
||||
|
||||
dev-web: build-web
|
||||
@echo "Starting web UI (default port 8080)..."
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
|
||||
dev-player: build-player
|
||||
@echo "Starting web player (default port 8080)..."
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME)
|
||||
|
||||
dev-web-port: build-web
|
||||
@echo "Starting web UI on custom port..."
|
||||
dev-player-port: build-player
|
||||
@echo "Starting web player on custom port..."
|
||||
@if [ -z "$(PORT)" ]; then \
|
||||
echo "Usage: make dev-web-port PORT=8888"; \
|
||||
echo "Usage: make dev-player-port PORT=8888"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -port $(PORT)
|
||||
|
||||
dev-backup: build-backup
|
||||
@echo "Running backup tool..."
|
||||
@@ -339,18 +368,19 @@ dev-backup-local: build-backup
|
||||
@echo "Running local backup (auto-discover)..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) local --discover
|
||||
|
||||
dev-web-host: build-web
|
||||
@echo "Starting web UI with specific host..."
|
||||
dev-player-host: build-player
|
||||
@echo "Starting web player with specific host..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-web-host HOST=192.0.2.10"; \
|
||||
echo "Usage: make dev-player-host HOST=192.0.2.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-web build-backup
|
||||
install: build-cli build-service build-player build-web 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/
|
||||
|
||||
@@ -458,7 +488,7 @@ screenshots:
|
||||
# First run: make dev-docs-tidy (downloads Hextra, writes docs/go.sum)
|
||||
# Then: make dev-docs (http://localhost:1313, live reload)
|
||||
dev-docs:
|
||||
docker compose -f docker-compose.docs.yml up
|
||||
HUGO_PARAMS_GITHASH=$(shell git rev-parse HEAD) docker compose -f docker-compose.docs.yml up
|
||||
|
||||
dev-docs-tidy:
|
||||
docker compose -f docker-compose.docs.yml run --rm hugo mod tidy --source docs/
|
||||
@@ -511,9 +541,9 @@ help:
|
||||
@echo " dev-backup - Build and show backup tool help"
|
||||
@echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)"
|
||||
@echo " dev-backup-local - Build and run local backup (auto-discover speakers)"
|
||||
@echo " dev-web - Build and run web UI (default port 8080)"
|
||||
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
|
||||
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
|
||||
@echo " dev-player - Build and run web player (default port 8080)"
|
||||
@echo " dev-player-port - Build and run web player on custom port (PORT=8888)"
|
||||
@echo " dev-player-host - Build and run web player with specific device (HOST=ip)"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " release - Create release binaries"
|
||||
@@ -538,8 +568,8 @@ help:
|
||||
@echo " make dev-upnp-timeout TIMEOUT=10s"
|
||||
@echo " make dev-scan-all"
|
||||
@echo " make dev-scan-soundtouch"
|
||||
@echo " make dev-web"
|
||||
@echo " make dev-web-port PORT=8888"
|
||||
@echo " make dev-web-host HOST=192.0.2.10"
|
||||
@echo " make dev-player"
|
||||
@echo " make dev-player-port PORT=8888"
|
||||
@echo " make dev-player-host HOST=192.0.2.10"
|
||||
@echo " make test"
|
||||
@echo " make build-all"
|
||||
|
||||
@@ -15,6 +15,8 @@ Bose shut down SoundTouch cloud services on **May 6, 2026**. Presets, music serv
|
||||
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture.
|
||||
|
||||
[](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
@@ -68,11 +70,13 @@ See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/C
|
||||
|
||||
---
|
||||
|
||||
### soundtouch-web
|
||||
### soundtouch-player
|
||||
|
||||
A standalone web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Complements `soundtouch-service` when you want a dedicated device-control interface separate from the setup/admin UI.
|
||||
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are still published as a transitional alias and will be removed in a future release; please switch to `soundtouch-player`.
|
||||
|
||||
See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
|
||||
A standalone, LAN-resident web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Because it reaches speakers directly on your network and can delegate cloud-only features (e.g. TTS) to a remote AfterTouch service via `--service-url`, it stays useful when `soundtouch-service` runs off-LAN (for example in the cloud), where the embedded `/app` player cannot reach your speakers.
|
||||
|
||||
See the [soundtouch-player README](cmd/soundtouch-player/README.md) for usage.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -43,10 +43,10 @@ func main() {
|
||||
log.Fatalf("start fake speaker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
|
||||
log.Printf("fake speaker HTTP listening on http://%s", sanitizeLog(s.HTTPAddr()))
|
||||
|
||||
if addr := s.TelnetAddr(); addr != "" {
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", addr)
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", sanitizeLog(addr))
|
||||
}
|
||||
|
||||
if *register != "" {
|
||||
@@ -58,7 +58,7 @@ func main() {
|
||||
if err := registerWithService(*register, target); err != nil {
|
||||
log.Printf("self-register failed: %v (continuing anyway)", err)
|
||||
} else {
|
||||
log.Printf("registered %s with service at %s", target, *register)
|
||||
log.Printf("registered %s with service at %s", sanitizeLog(target), sanitizeLog(*register))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func main() {
|
||||
defer close(entries)
|
||||
|
||||
if *verbose {
|
||||
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", *service, *timeout)
|
||||
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", sanitizeLog(*service), *timeout)
|
||||
}
|
||||
|
||||
// Query for services
|
||||
@@ -196,7 +196,7 @@ func parseServiceEntry(entry *mdns.ServiceEntry, verbose bool) *ServiceInfo {
|
||||
|
||||
if verbose {
|
||||
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
|
||||
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
|
||||
sanitizeLog(entry.Name), sanitizeLog(entry.Host), entry.Port, entry.AddrV4, entry.AddrV6)
|
||||
}
|
||||
|
||||
service := &ServiceInfo{
|
||||
|
||||
@@ -17,6 +17,9 @@ func main() {
|
||||
|
||||
log.Printf("Starting mock Amazon LWA server on port %d", *port)
|
||||
|
||||
// Plaintext HTTP is intentional: this is a throwaway test mock that only
|
||||
// runs on the loopback / CI compose network, never in production.
|
||||
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ func main() {
|
||||
|
||||
log.Printf("Starting mock Spotify server on port %d", *port)
|
||||
|
||||
// Plaintext HTTP is intentional: this is a throwaway test mock that only
|
||||
// runs on the loopback / CI compose network, never in production.
|
||||
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package main provides a mock TuneIn (radiotime.com) server for testing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/tunein"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock TuneIn server on port %d", *port)
|
||||
|
||||
// Plaintext HTTP is intentional: this is a throwaway test mock that only
|
||||
// runs on the loopback / CI compose network, never in production.
|
||||
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), tunein.NewTuneInHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -568,7 +568,7 @@ type VerboseLogger struct{}
|
||||
|
||||
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, sanitizeLog(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
type SilentLogger struct{}
|
||||
|
||||
@@ -244,7 +244,10 @@ func renameGroup(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair.
|
||||
// removeGroup tears down the device's stereo pair by sending /removeGroup to
|
||||
// every member in parallel. Sending it only to the master (as the old code
|
||||
// did) leaves the slave stuck in GroupSlave state indefinitely — mirrors the
|
||||
// same symmetry as createGroup (see issue #252 comment there).
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
@@ -255,11 +258,76 @@ func removeGroup(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stClient.RemoveGroup(); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
|
||||
// Fetch current group to learn every member's IP before tearing down.
|
||||
group, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair — nothing to remove")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect the unique set of member IPs. The master is always reachable
|
||||
// via clientConfig.Host; the roles carry all members including slaves.
|
||||
type memberResult struct {
|
||||
ip string
|
||||
err error
|
||||
}
|
||||
|
||||
members := make([]string, 0, len(group.Roles.Roles))
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, role := range group.Roles.Roles {
|
||||
if role.IPAddress != "" && !seen[role.IPAddress] {
|
||||
seen[role.IPAddress] = true
|
||||
members = append(members, role.IPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// Always include the addressed host even if the group response omitted IPs.
|
||||
if !seen[clientConfig.Host] {
|
||||
members = append(members, clientConfig.Host)
|
||||
}
|
||||
|
||||
results := make([]memberResult, len(members))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, ip := range members {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, host string) {
|
||||
defer wg.Done()
|
||||
|
||||
mc, mcErr := clientForHost(c, host)
|
||||
if mcErr != nil {
|
||||
results[idx] = memberResult{ip: host, err: mcErr}
|
||||
return
|
||||
}
|
||||
|
||||
results[idx] = memberResult{ip: host, err: mc.RemoveGroup()}
|
||||
}(i, ip)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
anyErr := false
|
||||
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
PrintError(fmt.Sprintf("%s /removeGroup failed: %v", r.ip, r.err))
|
||||
|
||||
anyErr = true
|
||||
}
|
||||
}
|
||||
|
||||
if anyErr {
|
||||
return fmt.Errorf("/removeGroup propagation failed")
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
|
||||
return nil
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
@@ -85,6 +87,7 @@ type presetParams struct {
|
||||
name string
|
||||
itemType string
|
||||
artwork string
|
||||
serviceURL string
|
||||
}
|
||||
|
||||
// extractPresetParams extracts parameters from CLI context
|
||||
@@ -97,9 +100,16 @@ func extractPresetParams(c *cli.Context) *presetParams {
|
||||
name: c.String("name"),
|
||||
itemType: c.String("type"),
|
||||
artwork: c.String("artwork"),
|
||||
serviceURL: strings.TrimRight(c.String("service-url"), "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// isOrionLocation reports whether location is already an Orion station URL so
|
||||
// we don't double-wrap it.
|
||||
func isOrionLocation(location string) bool {
|
||||
return strings.Contains(location, "/core02/svc-bmx-adapter-orion/")
|
||||
}
|
||||
|
||||
// resolveLocationAndMetadata resolves location and fetches metadata if needed
|
||||
func resolveLocationAndMetadata(params *presetParams) error {
|
||||
originalLocation := params.location
|
||||
@@ -108,6 +118,24 @@ func resolveLocationAndMetadata(params *presetParams) error {
|
||||
params.source = resolvedSource
|
||||
params.location = resolvedLocation
|
||||
|
||||
// For LOCAL_INTERNET_RADIO, the speaker's BMX module calls GET on the stored
|
||||
// location expecting a BmxPlaybackResponse JSON (the Orion station format).
|
||||
// A direct stream URL returns raw audio, which BMX cannot parse, so playback
|
||||
// silently stays on the previous source.
|
||||
if params.source == "LOCAL_INTERNET_RADIO" &&
|
||||
!isOrionLocation(params.location) &&
|
||||
(strings.HasPrefix(params.location, "http://") || strings.HasPrefix(params.location, "https://")) {
|
||||
if params.serviceURL != "" {
|
||||
params.location = bmxpkg.BuildOrionLocation(params.serviceURL, params.name, params.artwork, resolvedLocation)
|
||||
|
||||
fmt.Printf(" Wrapped stream URL in Orion location for LOCAL_INTERNET_RADIO\n")
|
||||
} else {
|
||||
fmt.Printf(" ⚠️ --service-url not set: storing raw stream URL as location.\n")
|
||||
fmt.Printf(" The speaker's BMX module expects an Orion station URL, not raw audio.\n")
|
||||
fmt.Printf(" Re-run with --service-url <https://your-aftertouch-host> to fix this.\n")
|
||||
}
|
||||
}
|
||||
|
||||
// If metadata (name or artwork) is missing, try to fetch it
|
||||
if params.name == "" || params.artwork == "" {
|
||||
var (
|
||||
|
||||
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
|
||||
setupWaitAPCmd(),
|
||||
setupWaitOnlineCmd(),
|
||||
setupSSHCheckCmd(),
|
||||
setupEnableSSHCmd(),
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
@@ -537,6 +538,152 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func setupEnableSSHCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enable-ssh",
|
||||
Usage: "Bootstrap SSH on a speaker with no prior access via the port-17000 envswitch trick (#471), " +
|
||||
"then restore clean URLs and persist it",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "AfterTouch service base URL to point the speaker at (e.g. https://192.0.2.10:8443). " +
|
||||
"Optional: enabling SSH does not need a live server (the injection fires when the speaker " +
|
||||
"parses its boseurls), so you can omit this now and set the real URLs later via migration",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "wait",
|
||||
Value: 90 * time.Second,
|
||||
Usage: "How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-reset-urls",
|
||||
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-persist",
|
||||
Usage: "Skip persisting the remote_services marker (SSH would not survive a reboot)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "authorized-key",
|
||||
Usage: "Opt-in hardening: install this SSH public key for root (key auth instead of the empty-password login). Pass the key text, e.g. --authorized-key \"$(cat id_ed25519.pub)\"",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "close-17000",
|
||||
Usage: "Opt-in hardening: block port 17000 from the LAN (firewall rule applied now + persisted); loopback access is kept",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
// The URL is only the vehicle for the command injection; the
|
||||
// SSH-enable fires when the speaker parses its boseurls, whether
|
||||
// or not anything answers there. When the user has no service URL
|
||||
// yet, use a clearly-placeholder value and tell them to set the
|
||||
// real URLs during migration.
|
||||
serviceURL := c.String("service-url")
|
||||
placeholder := serviceURL == ""
|
||||
|
||||
if placeholder {
|
||||
serviceURL = "https://aftertouch.invalid"
|
||||
}
|
||||
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", cfg.Host)
|
||||
|
||||
logs, err := m.EnableSSHViaTelnet(cfg.Host, serviceURL)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
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
|
||||
}
|
||||
|
||||
PrintSuccess("SSH is up on " + cfg.Host)
|
||||
|
||||
if !c.Bool("no-reset-urls") {
|
||||
fmt.Println("Restoring clean boseurls (so the marge URL is usable again)...")
|
||||
|
||||
rlogs, rerr := m.ResetBoseURLs(cfg.Host, serviceURL)
|
||||
if rlogs != "" {
|
||||
fmt.Print(rlogs)
|
||||
}
|
||||
|
||||
if rerr != nil {
|
||||
PrintError(rerr.Error())
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
|
||||
if !c.Bool("no-persist") {
|
||||
fmt.Println("Persisting the remote_services marker (SSH survives reboot)...")
|
||||
|
||||
plogs, perr := m.EnsureRemoteServices(cfg.Host)
|
||||
if plogs != "" {
|
||||
fmt.Print(plogs)
|
||||
}
|
||||
|
||||
if perr != nil {
|
||||
PrintError(perr.Error())
|
||||
return perr
|
||||
}
|
||||
}
|
||||
|
||||
if key := c.String("authorized-key"); key != "" {
|
||||
fmt.Println("Installing authorized_keys for root (key auth)...")
|
||||
|
||||
klogs, kerr := m.InstallAuthorizedKey(cfg.Host, key)
|
||||
if klogs != "" {
|
||||
fmt.Print(klogs)
|
||||
}
|
||||
|
||||
if kerr != nil {
|
||||
PrintError(kerr.Error())
|
||||
return kerr
|
||||
}
|
||||
}
|
||||
|
||||
closed17000 := c.Bool("close-17000")
|
||||
if closed17000 {
|
||||
fmt.Println("Closing port 17000 to the LAN (loopback kept)...")
|
||||
|
||||
clogs, cerr := m.Close17000(cfg.Host)
|
||||
if clogs != "" {
|
||||
fmt.Print(clogs)
|
||||
}
|
||||
|
||||
if cerr != nil {
|
||||
PrintError(cerr.Error())
|
||||
return cerr
|
||||
}
|
||||
}
|
||||
|
||||
PrintSuccess("Done — SSH enabled on " + cfg.Host + ". From here, the usual migration / CA-install / inspect commands work.")
|
||||
|
||||
if placeholder {
|
||||
fmt.Println("No --service-url was given, so the speaker's boseurls now point at a placeholder; run your migration next to set the real service URLs.")
|
||||
}
|
||||
|
||||
if closed17000 {
|
||||
fmt.Println("Port 17000 is now blocked from the LAN (loopback kept).")
|
||||
} else {
|
||||
fmt.Println("Note: port 17000 is left open (opt-in --close-17000 to block it from the LAN).")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupRemoteServicesCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "remote-services",
|
||||
@@ -606,7 +753,7 @@ func setupInstallCACmd() *cli.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Fetched %d bytes of CA PEM from %s/setup/ca.crt\n", len(certPEM), serviceURL)
|
||||
fmt.Printf("Fetched %d bytes of CA PEM from %s/api/setup/ca.crt\n", len(certPEM), serviceURL)
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
@@ -627,11 +774,11 @@ func setupInstallCACmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchCACert pulls AfterTouch's CA bundle from /setup/ca.crt. On HTTP 401
|
||||
// fetchCACert pulls AfterTouch's CA bundle from /api/setup/ca.crt. On HTTP 401
|
||||
// it prompts interactively for basic-auth credentials (or accepts --auth)
|
||||
// and retries once.
|
||||
func fetchCACert(serviceURL, authFlag string) ([]byte, error) {
|
||||
url := serviceURL + "/setup/ca.crt"
|
||||
url := serviceURL + "/api/setup/ca.crt"
|
||||
|
||||
doRequest := func(user, pass string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
|
||||
@@ -2,14 +2,23 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// searchStations handles searching for stations across different sources
|
||||
func searchStations(c *cli.Context) error {
|
||||
PrintDeprecation(
|
||||
"station search",
|
||||
"It asks the speaker to search, which fails when the speaker's cloud is gone.",
|
||||
`soundtouch-cli station find --provider tunein --query "<your search>"`,
|
||||
)
|
||||
|
||||
source := c.String("source")
|
||||
sourceAccount := c.String("source-account")
|
||||
searchTerm := c.String("query")
|
||||
@@ -49,6 +58,12 @@ func searchStations(c *cli.Context) error {
|
||||
|
||||
// searchTuneIn handles searching TuneIn specifically
|
||||
func searchTuneIn(c *cli.Context) error {
|
||||
PrintDeprecation(
|
||||
"station search-tunein",
|
||||
"It asks the speaker to search, which fails when the speaker's cloud is gone.",
|
||||
`soundtouch-cli station find-tunein --query "<your search>"`,
|
||||
)
|
||||
|
||||
searchTerm := c.String("query")
|
||||
|
||||
if searchTerm == "" {
|
||||
@@ -84,6 +99,12 @@ func searchTuneIn(c *cli.Context) error {
|
||||
|
||||
// searchPandora handles searching Pandora specifically
|
||||
func searchPandora(c *cli.Context) error {
|
||||
PrintDeprecation(
|
||||
"station search-pandora",
|
||||
"There is no built-in Pandora search yet (it requires the speaker and your account).",
|
||||
"",
|
||||
)
|
||||
|
||||
sourceAccount := c.String("source-account")
|
||||
searchTerm := c.String("query")
|
||||
|
||||
@@ -125,6 +146,12 @@ func searchPandora(c *cli.Context) error {
|
||||
|
||||
// searchSpotify handles searching Spotify specifically
|
||||
func searchSpotify(c *cli.Context) error {
|
||||
PrintDeprecation(
|
||||
"station search-spotify",
|
||||
"There is no built-in Spotify search yet (it requires the speaker and your account).",
|
||||
"",
|
||||
)
|
||||
|
||||
sourceAccount := c.String("source-account")
|
||||
searchTerm := c.String("query")
|
||||
|
||||
@@ -473,3 +500,182 @@ func printStationList(response *models.NavigateResponse, source string) {
|
||||
fmt.Printf(" • To play a station: Use the location value with 'play content' command\n")
|
||||
fmt.Printf(" • To save as preset: Use 'preset set' command with the location\n")
|
||||
}
|
||||
|
||||
// playbackID returns the bare station/episode id from a playback href,
|
||||
// e.g. "/v1/playback/station/s228737" -> "s228737" and
|
||||
// "/v1/playback/episodes/p1864248?encoded_name=…" -> "p1864248".
|
||||
// Returns "" when href is empty.
|
||||
func playbackID(href string) string {
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if i := strings.IndexByte(href, '?'); i >= 0 {
|
||||
href = href[:i]
|
||||
}
|
||||
|
||||
return path.Base(href)
|
||||
}
|
||||
|
||||
// printBmxNavResults renders a *models.BmxNavResponse to stdout.
|
||||
// For each section it prints the section name as a header, then each item as a
|
||||
// leading id column followed by the name, with subtitle and playback location
|
||||
// indented below. The bare id sits alone in its own column so it is easy to
|
||||
// copy-paste.
|
||||
func printBmxNavResults(resp *models.BmxNavResponse) {
|
||||
if len(resp.BmxSections) == 0 {
|
||||
fmt.Println(" No results found")
|
||||
return
|
||||
}
|
||||
|
||||
for _, section := range resp.BmxSections {
|
||||
if section.Name != "" {
|
||||
fmt.Printf("\n [%s]\n", section.Name)
|
||||
}
|
||||
|
||||
if len(section.Items) == 0 {
|
||||
fmt.Println(" (empty)")
|
||||
continue
|
||||
}
|
||||
|
||||
// Width of the leading id column = widest id in this section.
|
||||
maxID := 0
|
||||
|
||||
for _, item := range section.Items {
|
||||
if item.Links != nil && item.Links.BmxPlayback != nil {
|
||||
maxID = max(maxID, len(playbackID(item.Links.BmxPlayback.Href)))
|
||||
}
|
||||
}
|
||||
|
||||
// Continuation lines align under the name: 4 leading spaces
|
||||
// + id column + 2-space gap.
|
||||
indent := strings.Repeat(" ", 4+maxID+2)
|
||||
|
||||
for _, item := range section.Items {
|
||||
id := ""
|
||||
if item.Links != nil && item.Links.BmxPlayback != nil {
|
||||
id = playbackID(item.Links.BmxPlayback.Href)
|
||||
}
|
||||
|
||||
fmt.Printf(" %-*s %s\n", maxID, id, item.Name)
|
||||
|
||||
if item.Subtitle != "" {
|
||||
fmt.Printf("%s%s\n", indent, item.Subtitle)
|
||||
}
|
||||
|
||||
if item.Links != nil && item.Links.BmxPlayback != nil {
|
||||
fmt.Printf("%sLocation: %s\n", indent, item.Links.BmxPlayback.Href)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bmxNavCursor extracts the opaque cursor value from a section's BmxNext link.
|
||||
// The Href looks like "...?cursor=<value>"; this returns the cursor query param.
|
||||
// Returns "" when no next link is present.
|
||||
func bmxNavCursor(section *models.BmxNavSection) string {
|
||||
if section == nil || section.Links == nil || section.Links.BmxNext == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
href := section.Links.BmxNext.Href
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// The cursor is the query parameter named "cursor".
|
||||
parsed, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parsed.Query().Get("cursor")
|
||||
}
|
||||
|
||||
// runFind performs a built-in station search for the given provider and
|
||||
// prints the results. The search runs inside the CLI itself, querying the
|
||||
// radio provider's public API directly — it needs neither the speaker's
|
||||
// cloud nor a running soundtouch-service. When more is true it follows up
|
||||
// to three additional result pages while a next cursor is available.
|
||||
func runFind(provider stations.Provider, label, query string, more bool) error {
|
||||
if query == "" {
|
||||
PrintError("Search query is required")
|
||||
return fmt.Errorf("search query cannot be empty")
|
||||
}
|
||||
|
||||
fmt.Printf("Searching %s for: %s\n", label, query)
|
||||
|
||||
resp, err := stations.Search(provider, query)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Search failed: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printBmxNavResults(resp)
|
||||
|
||||
if !more {
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxExtraPages = 3
|
||||
for page := 0; page < maxExtraPages; page++ {
|
||||
// Find a cursor from any section that has one.
|
||||
cursor := ""
|
||||
for i := range resp.BmxSections {
|
||||
cursor = bmxNavCursor(&resp.BmxSections[i])
|
||||
if cursor != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cursor == "" {
|
||||
break
|
||||
}
|
||||
|
||||
fmt.Printf("\n -- page %d --\n", page+2)
|
||||
|
||||
resp, err = stations.SearchNext(provider, cursor)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to fetch next page: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printBmxNavResults(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findStations is the action for the unified `station find` with
|
||||
// --provider / --query / --more.
|
||||
func findStations(c *cli.Context) error {
|
||||
providerStr := c.String("provider")
|
||||
|
||||
var (
|
||||
provider stations.Provider
|
||||
label string
|
||||
)
|
||||
|
||||
switch strings.ToLower(providerStr) {
|
||||
case "tunein":
|
||||
provider, label = stations.ProviderTuneIn, "TuneIn"
|
||||
case "radiobrowser":
|
||||
provider, label = stations.ProviderRadioBrowser, "Radio Browser"
|
||||
default:
|
||||
PrintError(fmt.Sprintf("Unknown provider %q: must be 'tunein' or 'radiobrowser'", providerStr))
|
||||
return fmt.Errorf("unknown provider: %s", providerStr)
|
||||
}
|
||||
|
||||
return runFind(provider, label, c.String("query"), c.Bool("more"))
|
||||
}
|
||||
|
||||
// findTuneIn is the action for `station find-tunein` (built-in TuneIn search).
|
||||
func findTuneIn(c *cli.Context) error {
|
||||
return runFind(stations.ProviderTuneIn, "TuneIn", c.String("query"), c.Bool("more"))
|
||||
}
|
||||
|
||||
// findRadioBrowser is the action for `station find-radiobrowser`
|
||||
// (built-in Radio Browser search).
|
||||
func findRadioBrowser(c *cli.Context) error {
|
||||
return runFind(stations.ProviderRadioBrowser, "Radio Browser", c.String("query"), c.Bool("more"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// ttsCloudCmd is the `speaker tts-cloud` subcommand. Unlike `speaker tts`
|
||||
// (which sends a Google Translate URL straight to the speaker), this routes
|
||||
// through the AfterTouch service, which synthesizes the audio with the
|
||||
// configured provider (e.g. Google Cloud TTS), hosts it, and plays it on the
|
||||
// speaker. It therefore needs --service-url. Target the speaker with the global
|
||||
// --host, or with --device (resolved to an IP by the service).
|
||||
func ttsCloudCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "tts-cloud",
|
||||
Usage: "Speak text via the AfterTouch service (Google Cloud TTS), synthesized server-side",
|
||||
Description: "Routes through the AfterTouch service (requires --service-url), which\n" +
|
||||
"synthesizes the audio with the configured provider, hosts it, and plays it\n" +
|
||||
"on the speaker. Target the speaker with the global --host or with --device.\n\n" +
|
||||
"Contrast with 'speaker tts', which sends a Google Translate URL directly to\n" +
|
||||
"the speaker without involving the service.",
|
||||
Flags: append(CloudCommonFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "text",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Text to speak",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "device",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Target device ID (the service resolves it to an IP); alternative to --host",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "language",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Language code (provider-specific; defaults to the service setting)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "voice",
|
||||
Usage: "Voice name (Google Cloud TTS; ignored by the translate provider)",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "volume",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Playback volume (0-100, 0 = service default; only honoured by --method speaker)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "method",
|
||||
Usage: "Playback method: 'speaker' (/speaker notification, ducks+resumes, supports volume) or 'radio' (LOCAL_INTERNET_RADIO, no app_key, replaces source)",
|
||||
Value: "speaker",
|
||||
},
|
||||
),
|
||||
Action: ttsCloud,
|
||||
}
|
||||
}
|
||||
|
||||
func ttsCloud(c *cli.Context) error {
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
device := c.String("device")
|
||||
host := c.String("host") // global flag
|
||||
|
||||
if device == "" && host == "" {
|
||||
return fmt.Errorf("one of --host or --device is required")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{"text": c.String("text")}
|
||||
if device != "" {
|
||||
payload["deviceId"] = device
|
||||
}
|
||||
|
||||
if host != "" {
|
||||
payload["host"] = host
|
||||
}
|
||||
|
||||
if l := c.String("language"); l != "" {
|
||||
payload["language"] = l
|
||||
}
|
||||
|
||||
if v := c.String("voice"); v != "" {
|
||||
payload["voice"] = v
|
||||
}
|
||||
|
||||
if c.IsSet("volume") {
|
||||
payload["volume"] = c.Int("volume")
|
||||
}
|
||||
|
||||
if m := c.String("method"); m != "" {
|
||||
payload["method"] = m
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, serviceURL+"/api/setup/tts/speak", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Spoke %q", c.String("text")))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func PrintSuccess(message string) {
|
||||
|
||||
// PrintError prints a standard error message
|
||||
func PrintError(message string) {
|
||||
fmt.Printf("✗ %s\n", message)
|
||||
fmt.Printf("✗ %s\n", sanitizeLog(message))
|
||||
}
|
||||
|
||||
// PrintWarning prints a standard warning message
|
||||
@@ -340,6 +340,22 @@ func PrintWarning(message string) {
|
||||
fmt.Printf("⚠️ %s\n", message)
|
||||
}
|
||||
|
||||
// PrintDeprecation prints a deprecation notice to stderr (so it does not
|
||||
// pollute piped stdout output). reason explains why the command is going
|
||||
// away; newUsage is an optional replacement example — pass "" when there
|
||||
// is no replacement yet.
|
||||
func PrintDeprecation(command, reason, newUsage string) {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ '%s' is deprecated and will be removed in a future release.\n", command)
|
||||
|
||||
if reason != "" {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", reason)
|
||||
}
|
||||
|
||||
if newUsage != "" {
|
||||
fmt.Fprintf(os.Stderr, " Use instead:\n %s\n", newUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// showVersionInfo displays detailed version information including build details
|
||||
func showVersionInfo(_ *cli.Context) error {
|
||||
fmt.Printf("%s version %s\n", os.Args[0], version)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -385,6 +385,11 @@ func main() {
|
||||
Name: "artwork",
|
||||
Usage: "Artwork URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "AfterTouch service HTTPS URL (e.g. https://soundtouch.local). Required for LOCAL_INTERNET_RADIO: the speaker's BMX module calls GET on the preset location and expects an Orion JSON response, not raw audio. When provided, the stream URL is automatically wrapped in the Orion station endpoint.",
|
||||
EnvVars: []string{"SOUNDTOUCH_SERVICE_URL"},
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
@@ -584,9 +589,72 @@ func main() {
|
||||
Aliases: []string{"st"},
|
||||
Usage: "Search and manage stations",
|
||||
Subcommands: []*cli.Command{
|
||||
// Built-in search ("find" family): runs inside the CLI,
|
||||
// querying the radio provider's public API directly. No
|
||||
// speaker cloud and no soundtouch-service required.
|
||||
{
|
||||
Name: "find",
|
||||
Usage: "Find stations directly (built-in tunein or radiobrowser search; no speaker needed)",
|
||||
Action: findStations,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "provider",
|
||||
Usage: "Station provider: tunein or radiobrowser",
|
||||
Value: "tunein",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "query",
|
||||
Aliases: []string{"q"},
|
||||
Usage: "Search query",
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "more",
|
||||
Usage: "Follow up to 3 additional result pages when available",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find-tunein",
|
||||
Usage: "Find TuneIn stations directly (built-in search; no speaker needed)",
|
||||
Action: findTuneIn,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "query",
|
||||
Aliases: []string{"q"},
|
||||
Usage: "Search query",
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "more",
|
||||
Usage: "Follow up to 3 additional result pages when available",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find-radiobrowser",
|
||||
Usage: "Find Radio Browser stations directly (built-in search; no speaker needed)",
|
||||
Action: findRadioBrowser,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "query",
|
||||
Aliases: []string{"q"},
|
||||
Usage: "Search query",
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "more",
|
||||
Usage: "Follow up to 3 additional result pages when available",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Deprecated speaker-based search commands. They ask the
|
||||
// speaker to search, which fails once its cloud is gone.
|
||||
// Prefer the "find" family above. Kept for now; each emits
|
||||
// a deprecation notice on stderr.
|
||||
{
|
||||
Name: "search",
|
||||
Usage: "Search for stations and content",
|
||||
Usage: "[DEPRECATED] Search via the speaker; use 'station find' instead",
|
||||
Action: searchStations,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@@ -609,7 +677,7 @@ func main() {
|
||||
},
|
||||
{
|
||||
Name: "search-tunein",
|
||||
Usage: "Search TuneIn stations",
|
||||
Usage: "[DEPRECATED] Search TuneIn via the speaker; use 'station find-tunein' instead",
|
||||
Action: searchTuneIn,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@@ -623,7 +691,7 @@ func main() {
|
||||
},
|
||||
{
|
||||
Name: "search-pandora",
|
||||
Usage: "Search Pandora stations",
|
||||
Usage: "[DEPRECATED] Search Pandora via the speaker (no built-in equivalent yet)",
|
||||
Action: searchPandora,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@@ -642,7 +710,7 @@ func main() {
|
||||
},
|
||||
{
|
||||
Name: "search-spotify",
|
||||
Usage: "Search Spotify content",
|
||||
Usage: "[DEPRECATED] Search Spotify via the speaker (no built-in equivalent yet)",
|
||||
Action: searchSpotify,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@@ -1873,6 +1941,7 @@ func main() {
|
||||
Action: playNotificationBeep,
|
||||
Before: RequireHost,
|
||||
},
|
||||
ttsCloudCmd(),
|
||||
{
|
||||
Name: "help",
|
||||
Usage: "Show detailed help about speaker functionality",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
soundtouch-player
|
||||
soundtouch-player-test
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
The `soundtouch-player` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -141,10 +141,10 @@ GET /api/control/{id}/source?name=X # Select source
|
||||
### Build Commands
|
||||
```bash
|
||||
# Build the web application
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
cd cmd/soundtouch-player
|
||||
go build -o soundtouch-player
|
||||
|
||||
# Build all project components (includes soundtouch-web)
|
||||
# Build all project components (includes soundtouch-player)
|
||||
make build
|
||||
|
||||
# Cross-platform builds
|
||||
@@ -154,19 +154,19 @@ make build-all
|
||||
### Testing
|
||||
```bash
|
||||
# Run unit tests
|
||||
go test ./cmd/soundtouch-web/...
|
||||
go test ./cmd/soundtouch-player/...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./cmd/soundtouch-web/...
|
||||
go test -cover ./cmd/soundtouch-player/...
|
||||
|
||||
# Lint checking
|
||||
golangci-lint run cmd/soundtouch-web/...
|
||||
golangci-lint run cmd/soundtouch-player/...
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
# Run development server
|
||||
cd cmd/soundtouch-web
|
||||
cd cmd/soundtouch-player
|
||||
go run main.go -port 8080
|
||||
|
||||
# Access the web interface
|
||||
@@ -177,7 +177,7 @@ open http://localhost:8080
|
||||
|
||||
### Command Line Options
|
||||
```bash
|
||||
soundtouch-web [options]
|
||||
soundtouch-player [options]
|
||||
|
||||
Options:
|
||||
-port string Web server port (default "8080")
|
||||
@@ -186,9 +186,9 @@ Options:
|
||||
|
||||
### File Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
cmd/soundtouch-player/
|
||||
├── main.go # Application entry point
|
||||
├── soundtouch-web # Built binary
|
||||
├── soundtouch-player # Built binary
|
||||
├── handlers/
|
||||
│ ├── handlers.go # HTTP request handlers
|
||||
│ ├── handlers_test.go # Handler tests
|
||||
@@ -75,29 +75,56 @@ Individual device pages provide full control over:
|
||||
make build
|
||||
|
||||
# Or manually
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
cd cmd/soundtouch-player
|
||||
go build -o soundtouch-player
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
# Run with default settings (port 8080)
|
||||
./soundtouch-web
|
||||
./soundtouch-player
|
||||
|
||||
# Specify custom port
|
||||
./soundtouch-web -port 8888
|
||||
./soundtouch-player -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-web -host 192.0.2.100
|
||||
./soundtouch-player -host 192.0.2.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
```
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific SoundTouch device host (optional, enables single-device mode)
|
||||
-help Show help information
|
||||
--port, -p string HTTP port to listen on (default "8080", env PORT)
|
||||
--bind string Address for the HTTP listener: host, IP, or interface name (env BIND_ADDR)
|
||||
--interface string Network interface name for mDNS/UPnP discovery (env DISCOVERY_INTERFACE)
|
||||
--devices strings SoundTouch device IP(s) to add manually, repeatable (env SOUNDTOUCH_DEVICES)
|
||||
--service-url string AfterTouch service base URL, e.g. https://soundtouch.local (env SERVICE_URL)
|
||||
--service-ca string Path to the AfterTouch service CA certificate (PEM) to trust (env SERVICE_CA)
|
||||
--help, -h Show help information
|
||||
```
|
||||
|
||||
### Text-to-Speech (TTS)
|
||||
|
||||
TTS synthesis and the Bose `app_key` live in the AfterTouch service, not in
|
||||
soundtouch-player, so the "Speak" feature proxies to the service's
|
||||
`/setup/tts/speak` endpoint. To use it, point soundtouch-player at the service
|
||||
with `--service-url`.
|
||||
|
||||
When the service is served over HTTPS with its own self-signed certificate
|
||||
(the default), soundtouch-player also needs to trust the service's CA, or the
|
||||
proxied call fails with `x509: certificate signed by unknown authority`. Pass
|
||||
the CA with `--service-ca`; it is the service's `<dataDir>/certs/ca.crt`:
|
||||
|
||||
```bash
|
||||
soundtouch-player \
|
||||
--service-url https://soundtouch.fritz.box \
|
||||
--service-ca /path/to/certs/ca.crt
|
||||
```
|
||||
|
||||
The CA is appended to the system trust store, so a service URL that uses a
|
||||
publicly trusted certificate keeps working without the flag. The target
|
||||
speaker must be known to the service (it resolves the speaker against its own
|
||||
device datastore).
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Interface
|
||||
@@ -191,7 +218,7 @@ ws.onmessage = function(event) {
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
cmd/soundtouch-player/
|
||||
├── main.go # Application entry point and SPA routing
|
||||
├── handlers/ # HTTP and WebSocket handlers
|
||||
│ ├── handlers.go # JSON API endpoints
|
||||
@@ -217,7 +244,7 @@ cmd/soundtouch-web/
|
||||
go test ./...
|
||||
|
||||
# Manual testing with multiple devices
|
||||
./soundtouch-web -port 8080
|
||||
./soundtouch-player -port 8080
|
||||
|
||||
# API testing
|
||||
curl http://localhost:8080/api/devices
|
||||
@@ -296,7 +323,7 @@ This UI is based on extensive analysis of captured SoundTouch WebSocket interact
|
||||
Add verbose logging by setting environment variable:
|
||||
```bash
|
||||
export DEBUG=true
|
||||
./soundtouch-web
|
||||
./soundtouch-player
|
||||
```
|
||||
|
||||
## Contributing
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
// Package main provides soundtouch-player, the LAN-resident web player for
|
||||
// controlling Bose SoundTouch devices. It reaches speakers directly on the
|
||||
// local network and optionally delegates cloud-only features (e.g. TTS) to a
|
||||
// remote AfterTouch service via --service-url, which is why it stays useful
|
||||
// when soundtouch-service runs off-LAN (e.g. in the cloud).
|
||||
//
|
||||
// It was previously named soundtouch-web; that name is still published as a
|
||||
// transitional alias and will be dropped in a future release.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -8,7 +15,9 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
@@ -46,12 +55,30 @@ 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.
|
||||
func warnIfInvokedAsWeb() {
|
||||
if len(os.Args) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
name := filepath.Base(os.Args[0])
|
||||
if name == "soundtouch-web" || name == "soundtouch-web.exe" {
|
||||
log.Println("notice: 'soundtouch-web' has been renamed to 'soundtouch-player'. " +
|
||||
"This name is a transitional alias and will stop being published in a future release; " +
|
||||
"please switch to 'soundtouch-player'.")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
warnIfInvokedAsWeb()
|
||||
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
Usage: "Web UI for controlling Bose SoundTouch devices",
|
||||
Name: "soundtouch-player",
|
||||
Usage: "LAN web player for controlling Bose SoundTouch devices",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
@@ -75,6 +102,16 @@ func main() {
|
||||
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
|
||||
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO",
|
||||
EnvVars: []string{"SERVICE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "service-ca",
|
||||
Usage: "Path to the AfterTouch service CA certificate (PEM) to trust for server-side calls such as TTS. Typically the service's <dataDir>/certs/ca.crt. Appended to the system trust store",
|
||||
EnvVars: []string{"SERVICE_CA"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
port := c.String("port")
|
||||
@@ -86,7 +123,7 @@ func main() {
|
||||
}
|
||||
|
||||
if rawBind != "" && bindAddr != rawBind {
|
||||
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
|
||||
log.Printf("Resolved --bind %q to %s", sanitizeLog(rawBind), sanitizeLog(bindAddr))
|
||||
}
|
||||
|
||||
rawIface := c.String("interface")
|
||||
@@ -94,7 +131,7 @@ func main() {
|
||||
|
||||
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
||||
if rawIface == "" && ifaceName != "" {
|
||||
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
|
||||
log.Printf("Defaulting --interface to %q from --bind", sanitizeLog(ifaceName))
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
@@ -108,6 +145,18 @@ func main() {
|
||||
webApp.Commit = commit
|
||||
webApp.Date = date
|
||||
webApp.RepoURL = repoURL
|
||||
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
|
||||
|
||||
if caPath := c.String("service-ca"); caPath != "" {
|
||||
client, err := soundtouchweb.NewServiceHTTPClient(caPath)
|
||||
if err != nil {
|
||||
log.Fatalf("--service-ca: %v", err)
|
||||
}
|
||||
|
||||
webApp.ServiceClient = client
|
||||
|
||||
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
|
||||
}
|
||||
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
|
||||
|
||||
@@ -131,7 +180,7 @@ func main() {
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r, discoveryService)
|
||||
|
||||
log.Printf("AfterTouch Web UI starting on http://%s", addr)
|
||||
log.Printf("AfterTouch Web UI starting on http://%s", sanitizeLog(addr))
|
||||
|
||||
return http.ListenAndServe(addr, r)
|
||||
},
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// frozenFirstSegments are the top-level path prefixes that belong to the frozen
|
||||
// speaker / app contract (category 1a/1b in
|
||||
// docs/content/docs/architecture/API-ROUTE-LAYOUT.md). Routes under these must
|
||||
// not change shape across the issue #451 refactor, so each should have at least
|
||||
// one .http contract test (the suite under tests/integration/http-client/, run
|
||||
// by `make test-http-client`). Movable surfaces (/setup, /mgmt, /web) and infra
|
||||
// (/, /health, /docs, /favicon.ico) are intentionally excluded.
|
||||
var frozenFirstSegments = map[string]bool{
|
||||
"streaming": true,
|
||||
"accounts": true,
|
||||
"customer": true,
|
||||
"bmx": true,
|
||||
"bmx-icons": true,
|
||||
"core02": true,
|
||||
"oauth": true,
|
||||
"custom": true,
|
||||
"media": true,
|
||||
"updates": true,
|
||||
"v1": true,
|
||||
"alexa": true,
|
||||
"ced": true,
|
||||
}
|
||||
|
||||
func coverageFirstSegment(p string) string {
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if i := strings.IndexByte(p, '/'); i >= 0 {
|
||||
return p[:i]
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// patternToRegexp converts a chi route pattern into an anchored regexp:
|
||||
// `{param}` becomes a single path segment (`[^/]+`) and `*` becomes `.*`.
|
||||
func patternToRegexp(pattern string) *regexp.Regexp {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("^")
|
||||
|
||||
for i, seg := range strings.Split(pattern, "/") {
|
||||
if i > 0 {
|
||||
b.WriteString("/")
|
||||
}
|
||||
|
||||
switch {
|
||||
case seg == "*":
|
||||
b.WriteString(".*")
|
||||
case strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}"):
|
||||
b.WriteString("[^/]+")
|
||||
default:
|
||||
b.WriteString(regexp.QuoteMeta(seg))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("$")
|
||||
|
||||
return regexp.MustCompile(b.String())
|
||||
}
|
||||
|
||||
// loadHTTPClientRequests extracts (method, path) pairs from every .http file in
|
||||
// the integration suite. `{{host}}` is stripped (leaving a leading `/`), query
|
||||
// strings are dropped, and `{{var}}` template segments are left intact (they
|
||||
// contain no slash, so they match a `[^/]+` route segment).
|
||||
func loadHTTPClientRequests(t *testing.T, dir string) [][2]string {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read http-client dir %s: %v", dir, err)
|
||||
}
|
||||
|
||||
reqLine := regexp.MustCompile(`^\s*(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(\S+)`)
|
||||
|
||||
var out [][2]string
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".http") {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
m := reqLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
url := strings.ReplaceAll(m[2], "{{host}}", "")
|
||||
if i := strings.IndexByte(url, '?'); i >= 0 {
|
||||
url = url[:i]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(url, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, [2]string{m[1], url})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFrozenRouteContractCoverage enforces that every frozen-contract route the
|
||||
// service registers is exercised by at least one .http integration test. The
|
||||
// set of *uncovered* frozen routes is golden-filed: adding a new frozen route
|
||||
// without a test (or adding a test that newly covers one) changes the set and
|
||||
// fails this test, forcing a conscious update of the golden file. It is the
|
||||
// machine-checked companion to tests/integration/http-client/COVERAGE.md.
|
||||
func TestFrozenRouteContractCoverage(t *testing.T) {
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server, nil, nil)
|
||||
|
||||
httpRequests := loadHTTPClientRequests(t, filepath.Join("..", "..", "tests", "integration", "http-client"))
|
||||
|
||||
// Only the request methods the contract suite actually exercises. Routes
|
||||
// registered via chi HandleFunc carry every method (CONNECT/TRACE/...); those
|
||||
// extra verbs are noise for coverage purposes.
|
||||
meaningfulMethods := map[string]bool{
|
||||
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true, http.MethodDelete: true,
|
||||
}
|
||||
|
||||
var uncovered []string
|
||||
|
||||
walkFunc := func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if !meaningfulMethods[method] {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !frozenFirstSegments[coverageFirstSegment(route)] {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := patternToRegexp(route)
|
||||
for _, req := range httpRequests {
|
||||
if req[0] == method && re.MatchString(req[1]) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
uncovered = append(uncovered, fmt.Sprintf("%-7s %s", method, route))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(r, walkFunc); err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(uncovered)
|
||||
output := strings.Join(uncovered, "\n") + "\n"
|
||||
|
||||
const goldenPath = "testdata/frozen_routes_uncovered.txt"
|
||||
|
||||
actualPath := "testdata/frozen_routes_uncovered.actual.txt"
|
||||
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("write actual: %v", err)
|
||||
}
|
||||
|
||||
golden, err := os.ReadFile(goldenPath)
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.WriteFile(goldenPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("create golden: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("created golden %s with %d uncovered frozen routes", goldenPath, len(uncovered))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
|
||||
if string(golden) != output {
|
||||
t.Errorf("Frozen-route contract coverage changed.\n"+
|
||||
"A frozen route either lost its .http test or a new one was added without one.\n"+
|
||||
"Review and, if intended, update %s from %s.", goldenPath, actualPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
)
|
||||
|
||||
// TestDeprecatedRouteSignal verifies the legacy admin paths are counted (and the
|
||||
// new /api/* twins are not), so the diagnostic export can show whether the old
|
||||
// paths are still in use before they are removed in a future major release.
|
||||
func TestDeprecatedRouteSignal(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
|
||||
r := setupRouter(server, nil, nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
hit := func(path string) {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
hit("/setup/version") // legacy — counted
|
||||
hit("/setup/version") // legacy again — count increments
|
||||
hit("/api/setup/version") // new canonical — must NOT be counted
|
||||
|
||||
hits := server.DeprecatedRouteHits()
|
||||
|
||||
if got := hits["GET /setup/version"]; got != 2 {
|
||||
t.Errorf("legacy GET /setup/version hits = %d, want 2", got)
|
||||
}
|
||||
|
||||
if _, tracked := hits["GET /api/setup/version"]; tracked {
|
||||
t.Errorf("/api/setup/version must not be tracked as deprecated; hits=%v", hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
)
|
||||
|
||||
// TestDualRouteEquivalence verifies the issue #451 step-1 aliasing invariant:
|
||||
// each admin-tier route served at both its legacy path and the new /api/* path
|
||||
// returns an identical response (same handler, same middleware). It fires the
|
||||
// same request at the old and new path and asserts equal status + body.
|
||||
//
|
||||
// The cases use endpoints whose body does not embed per-request time/random
|
||||
// values, so the only thing that can differ is the routing — which is exactly
|
||||
// what we want to pin while the routes are dual-mounted.
|
||||
func TestDualRouteEquivalence(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
|
||||
r := setupRouter(server, nil, nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
cases := []struct {
|
||||
method string
|
||||
oldPath string
|
||||
newPath string
|
||||
}{
|
||||
{http.MethodGet, "/setup/version", "/api/setup/version"},
|
||||
{http.MethodGet, "/setup/settings", "/api/setup/settings"},
|
||||
{http.MethodGet, "/setup/tts/config", "/api/setup/tts/config"},
|
||||
{http.MethodGet, "/setup/logging-settings", "/api/setup/logging-settings"},
|
||||
{http.MethodGet, "/setup/interaction-stats", "/api/setup/interaction-stats"},
|
||||
{http.MethodGet, "/setup/dns-discoveries", "/api/setup/dns-discoveries"},
|
||||
// /mgmt is Basic-Auth'd; without credentials both paths must reject
|
||||
// identically — that pins the auth gate is mirrored onto /api/mgmt too.
|
||||
{http.MethodGet, "/mgmt/accounts/", "/api/mgmt/accounts/"},
|
||||
{http.MethodGet, "/mgmt/spotify/accounts", "/api/mgmt/spotify/accounts"},
|
||||
{http.MethodGet, "/mgmt/amazon/accounts", "/api/mgmt/amazon/accounts"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.method+" "+c.newPath, func(t *testing.T) {
|
||||
oldStatus, oldBody := doEquivReq(t, ts.URL, c.method, c.oldPath)
|
||||
newStatus, newBody := doEquivReq(t, ts.URL, c.method, c.newPath)
|
||||
|
||||
if oldStatus != newStatus {
|
||||
t.Errorf("status mismatch for %s vs %s: old=%d new=%d", c.oldPath, c.newPath, oldStatus, newStatus)
|
||||
}
|
||||
|
||||
if !bytes.Equal(oldBody, newBody) {
|
||||
t.Errorf("body mismatch for %s vs %s:\n old=%q\n new=%q", c.oldPath, c.newPath, oldBody, newBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func doEquivReq(t *testing.T, base, method, path string) (int, []byte) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(method, base+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build request %s: %v", path, err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body %s: %v", path, err)
|
||||
}
|
||||
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
+437
-81
@@ -22,12 +22,14 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -71,13 +73,13 @@ func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
for i := range allDevices {
|
||||
dev := &allDevices[i]
|
||||
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
|
||||
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
|
||||
log.Printf("Initializing default Sources.xml for existing device %s", sanitizeLog(dev.DeviceID))
|
||||
|
||||
// Find default sources and merge them if missing or outdated tokens.
|
||||
// claimed tracks which stored sources have already been matched by a default,
|
||||
// so two defaults with the same SourceKeyType but different SourceProviderIDs
|
||||
// (e.g. INTERNET_RADIO/2 and INTERNET_RADIO/39) are treated as distinct entries.
|
||||
defaults := ds.GetDefaultSources()
|
||||
defaults := ds.GetInitialSources()
|
||||
modified := false
|
||||
claimed := make(map[int]bool)
|
||||
|
||||
@@ -103,13 +105,13 @@ func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
claimed[foundIdx] = true
|
||||
|
||||
if sources[foundIdx].Secret == "" && def.Secret != "" {
|
||||
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
|
||||
log.Printf("Initializing missing token for source %s on device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(dev.DeviceID))
|
||||
sources[foundIdx].Secret = def.Secret
|
||||
sources[foundIdx].SecretType = def.SecretType
|
||||
modified = true
|
||||
}
|
||||
} else {
|
||||
log.Printf("Adding missing default source %s (providerID=%s) to device %s", def.SourceKeyType, def.SourceProviderID, dev.DeviceID)
|
||||
log.Printf("Adding missing default source %s (providerID=%s) to device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(def.SourceProviderID), sanitizeLog(dev.DeviceID))
|
||||
sources = append(sources, def)
|
||||
modified = true
|
||||
}
|
||||
@@ -117,7 +119,7 @@ func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
|
||||
if modified {
|
||||
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
|
||||
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
|
||||
log.Printf("Failed to save updated sources for %s: %v", sanitizeLog(dev.DeviceID), errSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +149,7 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
|
||||
}
|
||||
|
||||
if config.amazonClientID != "" {
|
||||
@@ -172,10 +174,27 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Amazon Music service initialized (client ID: %s...)", clientIDPrefix)
|
||||
log.Printf("Amazon Music service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
// initTTSService loads the text-to-speech configuration onto the server and
|
||||
// builds the running service. The provider construction and (re)build logic
|
||||
// lives on the server so the settings UI can re-apply changes at runtime; see
|
||||
// handlers.Server.ReinitTTSService.
|
||||
func initTTSService(config serviceConfig, server *handlers.Server) {
|
||||
server.SetTTSConfig(
|
||||
config.ttsProvider,
|
||||
config.ttsGoogleAPIKey,
|
||||
config.ttsGoogleEndpoint,
|
||||
config.ttsAppKey,
|
||||
config.ttsLanguage,
|
||||
config.ttsVoice,
|
||||
config.ttsVolume,
|
||||
)
|
||||
server.ReinitTTSService()
|
||||
}
|
||||
|
||||
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
|
||||
// returns a positive capacity. Invalid or unset values fall back
|
||||
// to the default; a value of 0 or negative is treated as "disable"
|
||||
@@ -188,7 +207,7 @@ func logBufferCapacityFromEnv(defaultCap int) int {
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", raw, defaultCap)
|
||||
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", sanitizeLog(raw), defaultCap)
|
||||
return defaultCap
|
||||
}
|
||||
|
||||
@@ -358,6 +377,52 @@ func main() {
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
EnvVars: []string{"TTS_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-api-key",
|
||||
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
|
||||
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-endpoint",
|
||||
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
|
||||
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-language",
|
||||
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
|
||||
EnvVars: []string{"TTS_LANGUAGE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-voice",
|
||||
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
|
||||
EnvVars: []string{"TTS_VOICE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-app-key",
|
||||
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
|
||||
EnvVars: []string{"TTS_APP_KEY"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "tts-volume",
|
||||
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
|
||||
Value: 0,
|
||||
EnvVars: []string{"TTS_VOLUME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
@@ -415,7 +480,7 @@ func main() {
|
||||
persisted := applyPersistedSettings(ds, &config)
|
||||
|
||||
if persisted.ServerURL == "" {
|
||||
log.Printf("Creating default settings.json in %s", config.dataDir)
|
||||
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
@@ -445,6 +510,13 @@ func main() {
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
|
||||
initMusicServices(config, server)
|
||||
initTTSService(config, server)
|
||||
|
||||
// Redirect TuneIn upstream calls when overridden (e.g. to a local
|
||||
// mock in integration tests); empty values keep the real hosts.
|
||||
if config.tuneInOpmlURL != "" || config.tuneInAPIURL != "" {
|
||||
bmx.SetTuneInEndpoints(config.tuneInOpmlURL, config.tuneInAPIURL)
|
||||
}
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
@@ -468,7 +540,7 @@ func main() {
|
||||
server.SetShortcuts(persisted.Shortcuts)
|
||||
|
||||
for path, status := range persisted.Shortcuts {
|
||||
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
|
||||
log.Printf("Warning: configured shortcut: %s -> %d", sanitizeLog(path), status)
|
||||
}
|
||||
|
||||
recorder := proxy.NewRecorder(config.dataDir)
|
||||
@@ -477,11 +549,11 @@ func main() {
|
||||
|
||||
patterns, err := proxy.LoadPatterns(patternsPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
|
||||
log.Printf("Warning: Failed to load patterns from %s: %v", sanitizeLog(patternsPath), err)
|
||||
}
|
||||
|
||||
if len(patterns) == 0 {
|
||||
log.Printf("Creating default patterns at %s", patternsPath)
|
||||
log.Printf("Creating default patterns at %s", sanitizeLog(patternsPath))
|
||||
|
||||
patterns = proxy.DefaultPatterns()
|
||||
|
||||
@@ -512,17 +584,38 @@ func main() {
|
||||
} else {
|
||||
stockholmHandler = sh
|
||||
|
||||
log.Printf("Stockholm frontend enabled from %s", config.stockholmDir)
|
||||
log.Printf("Stockholm frontend enabled from %s", sanitizeLog(config.stockholmDir))
|
||||
}
|
||||
}
|
||||
|
||||
r := setupRouter(server, stockholmHandler)
|
||||
// Embedded web UI (soundtouch-player): LAN control UI under /app, control
|
||||
// API under /api/control. Same LAN-trust tier as /setup, no auth.
|
||||
// Server-side self-calls (TTS proxy) use the service's own loopback
|
||||
// HTTP listener so they never depend on TLS / the service CA.
|
||||
loopbackHost := config.bindAddr
|
||||
if loopbackHost == "" {
|
||||
loopbackHost = "127.0.0.1"
|
||||
}
|
||||
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
|
||||
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
|
||||
|
||||
r := setupRouter(server, stockholmHandler, webApp)
|
||||
|
||||
// Bind the listener before logging so we print the true
|
||||
// effective port (handles :0 and catches "address already
|
||||
// in use" before the TLS goroutine launches).
|
||||
ln, err := net.Listen("tcp", config.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %w", config.addr, err)
|
||||
}
|
||||
|
||||
log.Printf("Go service listening on %s (configured: %s, server URL: %s)",
|
||||
ln.Addr().String(), sanitizeLog(config.addr), sanitizeLog(config.serverURL))
|
||||
|
||||
// TLS cert generation can be slow on constrained hardware; run it in the
|
||||
// background so the HTTP server is available immediately.
|
||||
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
|
||||
log.Printf("HTTPS setup running in background; %s will be available shortly", sanitizeLog(config.httpsServerURL))
|
||||
|
||||
go func() {
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
@@ -536,7 +629,7 @@ func main() {
|
||||
runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight)
|
||||
}()
|
||||
|
||||
return http.ListenAndServe(config.addr, r)
|
||||
return http.Serve(ln, r)
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
@@ -593,8 +686,17 @@ type serviceConfig struct {
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
tuneInOpmlURL string
|
||||
tuneInAPIURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
ttsProvider string
|
||||
ttsGoogleAPIKey string
|
||||
ttsGoogleEndpoint string
|
||||
ttsLanguage string
|
||||
ttsVoice string
|
||||
ttsAppKey string
|
||||
ttsVolume int
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
stockholmDir string
|
||||
@@ -623,6 +725,9 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
if serverURL == "" {
|
||||
serverURL = "http://" + hostname + ":" + port
|
||||
}
|
||||
// Strip a trailing slash so it cannot leak into the BMX registry base or the
|
||||
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
|
||||
serverURL = handlers.NormalizeServerURL(serverURL)
|
||||
|
||||
httpsPort := c.String("https-port")
|
||||
|
||||
@@ -652,7 +757,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", discoveryIntervalStr, err)
|
||||
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", sanitizeLog(discoveryIntervalStr), err)
|
||||
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
@@ -667,8 +772,17 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
amazonRedirectURI := c.String("amazon-redirect-uri")
|
||||
amazonTokenURL := c.String("amazon-token-url")
|
||||
amazonProfileURL := c.String("amazon-profile-url")
|
||||
tuneInOpmlURL := c.String("tunein-opml-url")
|
||||
tuneInAPIURL := c.String("tunein-api-url")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
ttsProvider := c.String("tts-provider")
|
||||
ttsGoogleAPIKey := c.String("tts-google-api-key")
|
||||
ttsGoogleEndpoint := c.String("tts-google-endpoint")
|
||||
ttsLanguage := c.String("tts-language")
|
||||
ttsVoice := c.String("tts-voice")
|
||||
ttsAppKey := c.String("tts-app-key")
|
||||
ttsVolume := c.Int("tts-volume")
|
||||
internalPaths := c.StringSlice("internal-paths")
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
@@ -705,8 +819,17 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
tuneInOpmlURL: tuneInOpmlURL,
|
||||
tuneInAPIURL: tuneInAPIURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
ttsProvider: ttsProvider,
|
||||
ttsGoogleAPIKey: ttsGoogleAPIKey,
|
||||
ttsGoogleEndpoint: ttsGoogleEndpoint,
|
||||
ttsLanguage: ttsLanguage,
|
||||
ttsVoice: ttsVoice,
|
||||
ttsAppKey: ttsAppKey,
|
||||
ttsVolume: ttsVolume,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
@@ -794,7 +917,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
}
|
||||
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
config.serverURL = handlers.NormalizeServerURL(persisted.ServerURL)
|
||||
}
|
||||
|
||||
if persisted.HTTPServerURL != "" {
|
||||
@@ -890,6 +1013,30 @@ func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted data
|
||||
if config.amazonRedirectURI == "" {
|
||||
config.amazonRedirectURI = persisted.AmazonRedirectURI
|
||||
}
|
||||
|
||||
if config.ttsProvider == "" {
|
||||
config.ttsProvider = persisted.TTSProvider
|
||||
}
|
||||
|
||||
if config.ttsGoogleAPIKey == "" {
|
||||
config.ttsGoogleAPIKey = persisted.TTSGoogleAPIKey
|
||||
}
|
||||
|
||||
if config.ttsAppKey == "" {
|
||||
config.ttsAppKey = persisted.TTSAppKey
|
||||
}
|
||||
|
||||
if config.ttsLanguage == "" {
|
||||
config.ttsLanguage = persisted.TTSLanguage
|
||||
}
|
||||
|
||||
if config.ttsVoice == "" {
|
||||
config.ttsVoice = persisted.TTSVoice
|
||||
}
|
||||
|
||||
if config.ttsVolume == 0 {
|
||||
config.ttsVolume = persisted.TTSVolume
|
||||
}
|
||||
}
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
@@ -917,6 +1064,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
}
|
||||
|
||||
func initDataStore(dataDir string) *datastore.DataStore {
|
||||
warnIfDataDirNotWritable(dataDir)
|
||||
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize datastore: %v", err)
|
||||
@@ -925,6 +1074,48 @@ func initDataStore(dataDir string) *datastore.DataStore {
|
||||
return ds
|
||||
}
|
||||
|
||||
// warnIfDataDirNotWritable probes the data dir and logs an actionable message
|
||||
// when the process can't write to it. The common cause is running the
|
||||
// container as non-root (uid 65532) while a bind-mounted host directory is
|
||||
// owned by someone else; without this the failure would surface later as a
|
||||
// cryptic permission error deep in a save. It only warns: the datastore's own
|
||||
// resilience handles the degraded state.
|
||||
func warnIfDataDirNotWritable(dataDir string) {
|
||||
if dataDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
log.Printf("WARNING: data dir %s cannot be created: %v", sanitizeLog(dataDir), err)
|
||||
logDataDirChownHint(dataDir)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
probe := filepath.Join(dataDir, ".write-probe")
|
||||
if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil {
|
||||
log.Printf("WARNING: data dir %s is not writable: %v", sanitizeLog(dataDir), err)
|
||||
logDataDirChownHint(dataDir)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(probe)
|
||||
}
|
||||
|
||||
// logDataDirChownHint prints the one-time fix for a non-writable bind-mounted
|
||||
// data dir, using the process's own uid. Skipped where uid is unavailable
|
||||
// (e.g. Windows), where the hint wouldn't apply.
|
||||
func logDataDirChownHint(dataDir string) {
|
||||
uid := os.Getuid()
|
||||
if uid < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(" The service runs as uid %d. If you bind-mounted a host directory as the data dir, "+
|
||||
"make it writable once: chown -R %d:%d %s", uid, uid, uid, sanitizeLog(dataDir))
|
||||
}
|
||||
|
||||
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
|
||||
|
||||
@@ -949,9 +1140,88 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
|
||||
// newEmbeddedWebApp builds the soundtouch-player application for embedding in the
|
||||
// service router: release metadata from the build vars, the service's public
|
||||
// ServiceURL (used by Play URL for speaker-fetched stream URLs and shown in the
|
||||
// UI), a loopback InternalServiceURL for the player's own server-side self-calls
|
||||
// (the TTS proxy) so they never depend on TLS or the service CA, and device
|
||||
// state sourced entirely from the service.
|
||||
//
|
||||
// The web UI shares the service's discovery rather than running its own (the
|
||||
// datastore is the single source of truth): ExtraDeviceHosts reads it,
|
||||
// TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the
|
||||
// devices-changed hook re-syncs the UI registry whenever the service's
|
||||
// discovery or a manual add changes the set.
|
||||
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
|
||||
webApp := soundtouchweb.NewWebApp()
|
||||
webApp.Version = version
|
||||
webApp.Commit = commit
|
||||
webApp.Date = date
|
||||
webApp.RepoURL = repoURL
|
||||
webApp.ServiceURL = strings.TrimRight(serverURL, "/")
|
||||
|
||||
// The player's own server-side calls (the TTS proxy hits
|
||||
// /api/setup/tts/speak) go to the service's loopback HTTP listener, not the
|
||||
// public ServiceURL. That avoids the "service doesn't trust its own CA"
|
||||
// x509 failure entirely: loopback is plain HTTP, so it needs no CA and
|
||||
// works on HTTP and HTTPS deployments alike — and before the CA is even
|
||||
// generated. ServiceURL stays the public URL because Play URL bakes it into
|
||||
// stream URLs the speaker fetches and the UI displays it.
|
||||
webApp.InternalServiceURL = internalURL
|
||||
|
||||
webApp.ExtraDeviceHosts = func() []string {
|
||||
devices, listErr := ds.ListAllDevices()
|
||||
if listErr != nil {
|
||||
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(devices))
|
||||
for i := range devices {
|
||||
if devices[i].IPAddress != "" {
|
||||
hosts = append(hosts, devices[i].IPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
// UI "discover" runs the service's sweep, not a second mDNS stack.
|
||||
webApp.TriggerDiscovery = server.DiscoverDevices
|
||||
|
||||
// A removal from the player UI cascades to the datastore (the single
|
||||
// source of truth), so the device does not reappear on the next re-sync.
|
||||
webApp.RemoveDeviceHook = func(deviceID string) error {
|
||||
_, err := server.RemoveDeviceByID(deviceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep the UI registry live as the service discovers or devices are added.
|
||||
server.SetDevicesChangedHook(func() {
|
||||
webApp.SeedExtraDevices()
|
||||
webApp.BroadcastDeviceList()
|
||||
})
|
||||
|
||||
go func() {
|
||||
// Project the current device set into the UI; the devices-changed hook
|
||||
// and the service's periodic discovery keep it current from here on.
|
||||
webApp.SeedExtraDevices()
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
return webApp
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, webApp *soundtouchweb.WebApp) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// CleanPath collapses duplicate slashes ("//bmx/..." -> "/bmx/...") and
|
||||
// resolves . / .. before routing. Defensive net for the double-slash
|
||||
// playback bug: even if a misconfigured base URL hands a speaker a "//bmx"
|
||||
// path, it still reaches the right handler instead of 404ing. Runs first so
|
||||
// every downstream middleware and the recorder see the cleaned path.
|
||||
r.Use(middleware.CleanPath)
|
||||
|
||||
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
|
||||
// SnapshotMiddleware captures the request, and several handlers
|
||||
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
|
||||
@@ -969,13 +1239,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.Get("/admin", server.HandleAdmin)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
// Passive peer-reachability probe. Registers a device IP with the
|
||||
// in-process observer, nudges :8090/swUpdateCheck, and waits for
|
||||
// any inbound from that IP. Used post-migration where the daemon
|
||||
// caches its swUpdateUrl at boot and the active round-trip can't
|
||||
// reach it without a reboot.
|
||||
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
// The favicon lives in the embedded web/img bundle, not under
|
||||
// static/media — HandleMedia would 404. HandleWeb serves from
|
||||
@@ -985,6 +1250,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
})
|
||||
|
||||
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
|
||||
// Synthesized TTS clips (Google Cloud provider). Served before the
|
||||
// /media/* wildcard so the {id} param route takes precedence.
|
||||
r.Get("/media/tts/{id}", server.HandleTTSMedia)
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/bmx-icons/*", server.HandleBmxIcons())
|
||||
r.Get("/ced/*", server.HandleCedStatic())
|
||||
@@ -997,6 +1265,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
|
||||
|
||||
r.Route("/tunein", func(r chi.Router) {
|
||||
// Bare service descriptor (the registry's `self` link for TuneIn).
|
||||
r.Get("/", server.HandleTuneInService)
|
||||
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
@@ -1017,6 +1287,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
|
||||
// reach the token + station endpoints at exactly these paths under
|
||||
// either DNS-interception or URL-flip migration.
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion", server.HandleOrionService)
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
@@ -1037,6 +1308,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/account", server.HandleMargeCreateAccount)
|
||||
r.Post("/account/login", server.HandleMargeLogin)
|
||||
r.Post("/account/{account}/source", server.HandleMargeAddSource)
|
||||
r.Delete("/account/{account}/source/{sourceID}", server.HandleMargeDeleteSource)
|
||||
|
||||
r.Route("/account/{account}", func(r chi.Router) {
|
||||
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
@@ -1081,6 +1353,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
// the addGroup payload to Marge during stereo-pair formation --
|
||||
// see issue #252. Register both forms so chi accepts either.
|
||||
// Speakers POST to /group/ (with trailing slash) when forwarding
|
||||
// the addGroup payload to Marge during stereo-pair formation --
|
||||
// see issue #252. Register both forms so chi accepts either.
|
||||
@@ -1088,6 +1363,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
// Speakers send DELETE /group/ (no group ID, trailing slash) during
|
||||
// stereo-pair teardown; master and slave use their own account IDs
|
||||
// so each deletes its own copy.
|
||||
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
|
||||
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
@@ -1117,29 +1397,38 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
|
||||
})
|
||||
|
||||
// The /accounts/* group mirrored /streaming/account/* for compatibility, but
|
||||
// no speaker or app was ever observed using this prefix in the recording
|
||||
// corpus (the integration tests that exercised it were migrated onto the
|
||||
// /streaming equivalents). The whole mirror is therefore treated as unused
|
||||
// and stubbed (HandleUnsupported): it logs + 501s so any real-world use
|
||||
// surfaces instead of being silently dropped, leaving the prefix a clean
|
||||
// removal candidate for the #451 refactor.
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Route("/{account}", func(r chi.Router) {
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
r.Get("/full", server.HandleUnsupported)
|
||||
r.Get("/sources", server.HandleUnsupported)
|
||||
r.Get("/devices", server.HandleUnsupported)
|
||||
|
||||
r.Post("/devices", server.HandleMargeAddDevice)
|
||||
r.Post("/devices", server.HandleUnsupported)
|
||||
|
||||
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Delete("/devices/{device}", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group/", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group/server", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/group/member", server.HandleUnsupported)
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/group", server.HandleUnsupported)
|
||||
r.Post("/group/", server.HandleUnsupported)
|
||||
r.Post("/group/{groupId}", server.HandleUnsupported)
|
||||
r.Delete("/group/{groupId}", server.HandleUnsupported)
|
||||
r.Delete("/group", server.HandleUnsupported)
|
||||
r.Delete("/group/", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/presets", server.HandleUnsupported)
|
||||
r.Get("/devices/{device}/recents", server.HandleUnsupported)
|
||||
|
||||
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleUnsupported)
|
||||
r.Post("/devices/{device}/recents", server.HandleUnsupported)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1165,49 +1454,79 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
})
|
||||
// app_key validation for the /speaker notification endpoint. Real Bose
|
||||
// validated the app_key against its cloud; as the cloud replacement we
|
||||
// accept it (200). A 404 here makes the speaker report "invalid app key"
|
||||
// (HandleInvalidAppKeyCb) and refuse TTS/URL notifications.
|
||||
// When an active DNS-path probe is running (POST /setup/health/dns-path-probe),
|
||||
// a matching probe nonce returns 403 instead so no audio plays.
|
||||
r.Get("/auth", server.HandleSpeakerAuth)
|
||||
})
|
||||
|
||||
// Management API (admin tier). Registered under both /mgmt (legacy) and
|
||||
// /api/mgmt (new canonical — issue #451 route-transition step 1) from one
|
||||
// shared registration so the two paths stay byte-identical; both carry the
|
||||
// same Basic Auth. The browser OAuth callbacks are externally-pinned
|
||||
// (provider redirect URIs) and therefore stay at /mgmt only, not aliased.
|
||||
mountMgmtAuthed := func(r chi.Router) {
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Get("/", server.HandleMgmtListAccounts)
|
||||
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
|
||||
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
})
|
||||
|
||||
r.Route("/spotify", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
|
||||
r.Route("/amazon", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtAmazonInit)
|
||||
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
|
||||
r.Get("/token", server.HandleMgmtAmazonToken)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
|
||||
})
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
}
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callbacks — no auth required (provider redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
// short-lived, and useless without the client_secret.
|
||||
// short-lived, and useless without the client_secret. Not aliased under
|
||||
// /api/mgmt (externally-pinned redirect URIs).
|
||||
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
|
||||
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
|
||||
|
||||
// All other management endpoints require Basic Auth.
|
||||
// All other management endpoints require Basic Auth. On the legacy mount
|
||||
// they also carry the deprecation signal (counts + one-time warning); the
|
||||
// callbacks above are excluded (externally-pinned, not deprecated).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Get("/", server.HandleMgmtListAccounts)
|
||||
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
|
||||
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
})
|
||||
|
||||
r.Route("/spotify", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
|
||||
r.Route("/amazon", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtAmazonInit)
|
||||
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
|
||||
r.Get("/token", server.HandleMgmtAmazonToken)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
|
||||
})
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
mountMgmtAuthed(r)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Route("/api/mgmt", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
mountMgmtAuthed(r)
|
||||
})
|
||||
})
|
||||
|
||||
// Setup / admin API (admin tier). Registered under both /setup (legacy) and
|
||||
// /api/setup (new canonical) from one shared registration. The Stockholm
|
||||
// setup-wizard static catch-all is a frontend concern and stays under /setup
|
||||
// only — /api/setup serves data only.
|
||||
mountSetupAPI := func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
@@ -1215,6 +1534,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
// TTS lives under /setup (LAN-trust, like the rest of the integration
|
||||
// surface and Play URL), not /mgmt: the API key is already configured
|
||||
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
|
||||
r.Post("/tts/speak", server.HandleTTSSpeak)
|
||||
r.Get("/tts/config", server.HandleTTSConfig)
|
||||
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
@@ -1222,6 +1546,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
|
||||
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
|
||||
// Passive peer-reachability probe. Registers a device IP with the
|
||||
// in-process observer, nudges :8090/swUpdateCheck, and waits for any
|
||||
// inbound from that IP. Used post-migration where the daemon caches its
|
||||
// swUpdateUrl at boot and the active round-trip can't reach it without a
|
||||
// reboot.
|
||||
r.Post("/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
@@ -1251,17 +1581,42 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
|
||||
r.Get("/health", server.HandleHealthChecks)
|
||||
r.Post("/health/fix", server.HandleHealthFix)
|
||||
r.Post("/health/dns-path-probe", server.HandleDNSPathProbe)
|
||||
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
|
||||
r.Get("/logs", server.HandleGetLogs)
|
||||
}
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the management API.
|
||||
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
// Legacy admin API: same handlers as /api/setup, plus the deprecation
|
||||
// signal (counts + one-time warning). Scoped to the API routes only — the
|
||||
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
mountSetupAPI(r)
|
||||
})
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the
|
||||
// management API. The Stockholm frontend has a setup/ directory that must
|
||||
// be accessible at /setup/*. Frontend-only — not mirrored under /api/setup.
|
||||
if stockholmHandler != nil {
|
||||
r.Get("/*", stockholmHandler.HandleStatic)
|
||||
r.Get("/", stockholmHandler.HandleStatic)
|
||||
}
|
||||
})
|
||||
|
||||
r.Route("/api/setup", func(r chi.Router) {
|
||||
mountSetupAPI(r)
|
||||
})
|
||||
|
||||
// Embedded web UI: control API under /api/control and the SPA under /app
|
||||
// (LAN-trust, like /setup). Additive — nothing here collides with the
|
||||
// service's own /, /health, or /static. The web app shares the service's
|
||||
// discovery (nil discovery service here), so it runs no mDNS of its own.
|
||||
// Skipped when nil, e.g. unit tests that only exercise the service surface.
|
||||
if webApp != nil {
|
||||
webApp.MountWeb(r, nil)
|
||||
}
|
||||
|
||||
if stockholmHandler != nil {
|
||||
stockholmHandler.Mount(r)
|
||||
}
|
||||
@@ -1294,7 +1649,7 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
return &tlsConfig.Certificates[0], nil
|
||||
}
|
||||
|
||||
log.Printf("[TLS] ❌ No certificate available for %s", clientHello.ServerName)
|
||||
log.Printf("[TLS] ❌ No certificate available for %s", sanitizeLog(clientHello.ServerName))
|
||||
|
||||
return nil, fmt.Errorf("no certificate available for %s", clientHello.ServerName)
|
||||
}
|
||||
@@ -1306,8 +1661,6 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
ErrorLog: log.Default(), // Ensure error logging is enabled
|
||||
}
|
||||
|
||||
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
|
||||
|
||||
go func() {
|
||||
listener, err := net.Listen("tcp", httpsAddr)
|
||||
if err != nil {
|
||||
@@ -1315,6 +1668,9 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Go service listening HTTPS on %s (server URL: %s)",
|
||||
listener.Addr().String(), sanitizeLog(httpsServerURL))
|
||||
|
||||
tlsListener := tls.NewListener(listener, tlsConfig)
|
||||
|
||||
// Wrap listener to log connection attempts
|
||||
@@ -1361,9 +1717,9 @@ func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolv
|
||||
case res.Skipped:
|
||||
// Listener already on :443 — nothing to say.
|
||||
case res.NotApplicable:
|
||||
log.Printf("HTTPS pre-flight: :443 check skipped — %s", res.Reason)
|
||||
log.Printf("HTTPS pre-flight: :443 check skipped — %s", sanitizeLog(res.Reason))
|
||||
default:
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", sanitizeLog(res.LANHost))
|
||||
}
|
||||
|
||||
return
|
||||
@@ -1438,7 +1794,7 @@ func (c *loggingTLSConn) Read(b []byte) (n int, err error) {
|
||||
if strings.Contains(err.Error(), "tls:") ||
|
||||
strings.Contains(err.Error(), "handshake") ||
|
||||
strings.Contains(err.Error(), "certificate") {
|
||||
log.Printf("[TLS] ❌ Handshake failed from %s: %v", c.addr, err)
|
||||
log.Printf("[TLS] ❌ Handshake failed from %s: %v", sanitizeLog(c.addr.String()), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,16 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
// Initialize a minimal server to get the router. Pass a web app so the
|
||||
// snapshot also captures the embedded soundtouch-player surface
|
||||
// (/api/control + /app); discovery is nil since we only register routes.
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server, nil)
|
||||
r := setupRouter(server, nil, soundtouchweb.NewWebApp())
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
@@ -128,7 +131,7 @@ func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server, nil)
|
||||
r := setupRouter(server, nil, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
DELETE /streaming/account/{account}/group
|
||||
GET /bmx-icons/*
|
||||
GET /bmx/tunein/v1/navigate
|
||||
GET /bmx/tunein/v1/navigate/*
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID}
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID}
|
||||
GET /bmx/tunein/v1/search
|
||||
GET /bmx/tunein/v1/search/next
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
GET /media/tts/{id}
|
||||
GET /streaming/account/{account}/device/{device}/group
|
||||
GET /streaming/account/{account}/device/{device}/group/member
|
||||
GET /streaming/account/{account}/device/{device}/group/server
|
||||
GET /streaming/account/{account}/device/{device}/recent
|
||||
GET /streaming/account/{account}/presets
|
||||
GET /streaming/device_setting/account/{account}/device/{device}/device_settings
|
||||
POST /core02/svc-bmx-adapter-orion/prod/orion/token
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token
|
||||
POST /streaming/account/{account}/device/{device}
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber}
|
||||
POST /streaming/account/{account}/group
|
||||
POST /streaming/account/{account}/group/{groupId}
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings
|
||||
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible
|
||||
POST /streaming/stats/error
|
||||
POST /streaming/stats/usage
|
||||
POST /v1/stapp/{deviceId}
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
|
||||
+130
-17
@@ -1,7 +1,15 @@
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleDeleteDevice-fm
|
||||
DELETE /api/setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /api/setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /api/setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /api/setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /api/setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
@@ -12,20 +20,76 @@ DELETE /setup/interactions/sessions/{session} handlers.(
|
||||
DELETE /setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /streaming/account/{account}/source/{sourceID} handlers.(*Server).HandleMargeDeleteSource-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
|
||||
GET /admin handlers.(*Server).HandleAdmin-fm
|
||||
GET /api/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}/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/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm
|
||||
GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
|
||||
GET /api/control/providers/tunein/navigate/* soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
|
||||
GET /api/control/providers/tunein/search soundtouchweb.(*WebApp).HandleTuneInSearch-fm
|
||||
GET /api/control/providers/tunein/search/next soundtouchweb.(*WebApp).HandleTuneInSearchNext-fm
|
||||
GET /api/control/version soundtouchweb.(*WebApp).HandleAPIVersion-fm
|
||||
GET /api/control/ws soundtouchweb.(*WebApp).HandleWebSocket-fm
|
||||
GET /api/mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /api/mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /api/mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
GET /api/mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
|
||||
GET /api/mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
|
||||
GET /api/mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
|
||||
GET /api/mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
|
||||
GET /api/mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /api/setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /api/setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /api/setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
|
||||
GET /api/setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /api/setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
GET /api/setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /api/setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /api/setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /api/setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
|
||||
GET /api/setup/health handlers.(*Server).HandleHealthChecks-fm
|
||||
GET /api/setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /api/setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /api/setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
GET /api/setup/interactions handlers.(*Server).HandleListInteractions-fm
|
||||
GET /api/setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
|
||||
GET /api/setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
|
||||
GET /api/setup/logs handlers.(*Server).HandleGetLogs-fm
|
||||
GET /api/setup/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /api/setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /api/setup/tts/config handlers.(*Server).HandleTTSConfig-fm
|
||||
GET /api/setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
GET /app soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/device/* soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/devices soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/playurl soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/radiobrowser soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/static/* http.Handler.ServeHTTP-fm
|
||||
GET /app/tts soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /app/tunein soundtouchweb.(*WebApp).serveIndex-fm
|
||||
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
|
||||
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
|
||||
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
|
||||
GET /bmx/tunein/ handlers.(*Server).HandleTuneInService-fm
|
||||
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
|
||||
@@ -34,6 +98,7 @@ GET /bmx/tunein/v1/playback/station/{stationID} handlers.(
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion handlers.(*Server).HandleOrionService-fm
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
@@ -44,6 +109,7 @@ GET /favicon.ico setupRoute
|
||||
GET /health handlers.(*Server).HandleHealth-fm
|
||||
GET /media/* handlers.(*Server).HandleMedia
|
||||
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
|
||||
GET /media/tts/{id} handlers.(*Server).HandleTTSMedia-fm
|
||||
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
@@ -73,6 +139,7 @@ GET /setup/logging-settings handlers.(
|
||||
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
|
||||
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /setup/tts/config handlers.(*Server).HandleTTSConfig-fm
|
||||
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
@@ -94,6 +161,7 @@ GET /streaming/resources/api_versions.xml handlers.(
|
||||
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
@@ -102,13 +170,56 @@ OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /api/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}/play soundtouchweb.(*WebApp).HandleDevicePlay-fm
|
||||
POST /api/control/devices/{id}/power soundtouchweb.(*WebApp).HandleDevicePower-fm
|
||||
POST /api/control/devices/{id}/providers/radiobrowser/play soundtouchweb.(*WebApp).HandlePlayRadioBrowser-fm
|
||||
POST /api/control/devices/{id}/providers/tts/play soundtouchweb.(*WebApp).HandleAPISpeakText-fm
|
||||
POST /api/control/devices/{id}/providers/tunein/play soundtouchweb.(*WebApp).HandlePlayTuneIn-fm
|
||||
POST /api/control/devices/{id}/providers/url/play soundtouchweb.(*WebApp).HandlePlayURL-fm
|
||||
POST /api/control/devices/{id}/volume/{volume} soundtouchweb.(*WebApp).HandleDirectVolumeControl-fm
|
||||
POST /api/control/devices/{id}/zone/add/{slaveId} soundtouchweb.(*WebApp).HandleZoneAdd-fm
|
||||
POST /api/control/devices/{id}/zone/dissolve soundtouchweb.(*WebApp).HandleZoneDissolve-fm
|
||||
POST /api/control/devices/{id}/zone/leave soundtouchweb.(*WebApp).HandleZoneLeave-fm
|
||||
POST /api/control/devices/{id}/zone/remove/{slaveId} soundtouchweb.(*WebApp).HandleZoneRemove-fm
|
||||
POST /api/control/discover soundtouchweb.(*WebApp).MountWeb
|
||||
POST /api/mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
POST /api/mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
|
||||
POST /api/mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
|
||||
POST /api/mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
|
||||
POST /api/mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
|
||||
POST /api/mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
|
||||
POST /api/mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
|
||||
POST /api/mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
|
||||
POST /api/mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
|
||||
POST /api/setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
|
||||
POST /api/setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /api/setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /api/setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /api/setup/health/dns-path-probe handlers.(*Server).HandleDNSPathProbe-fm
|
||||
POST /api/setup/health/fix handlers.(*Server).HandleHealthFix-fm
|
||||
POST /api/setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
|
||||
POST /api/setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /api/setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
POST /api/setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
|
||||
POST /api/setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /api/setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
POST /api/setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
|
||||
POST /api/setup/settings handlers.(*Server).HandleUpdateSettings-fm
|
||||
POST /api/setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
|
||||
POST /api/setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
|
||||
POST /api/setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
|
||||
POST /api/setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
|
||||
POST /api/setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
|
||||
POST /api/setup/tts/speak handlers.(*Server).HandleTTSSpeak-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
@@ -134,6 +245,7 @@ POST /setup/backup/{deviceId} handlers.(
|
||||
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/health/dns-path-probe handlers.(*Server).HandleDNSPathProbe-fm
|
||||
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
|
||||
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
@@ -148,6 +260,7 @@ POST /setup/test-connection/{deviceId} handlers.(
|
||||
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
|
||||
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
|
||||
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
|
||||
POST /setup/tts/speak handlers.(*Server).HandleTTSSpeak-fm
|
||||
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
|
||||
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
|
||||
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -573,7 +573,7 @@ type VerboseLogger struct{}
|
||||
|
||||
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, sanitizeLog(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// SilentLogger provides no-op WebSocket logging
|
||||
|
||||
+49
-2
@@ -16,9 +16,26 @@ services:
|
||||
- AMAZON_CLIENT_SECRET=mock-amazon-secret
|
||||
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
- TUNEIN_OPML_URL=http://tunein-mock:8080
|
||||
- TUNEIN_API_URL=http://tunein-mock:8080
|
||||
# Start only once every mock is actually listening (the mocks are `go run`,
|
||||
# so cold compilation can take a while); see depends_on below.
|
||||
depends_on:
|
||||
spotify-mock:
|
||||
condition: service_healthy
|
||||
amazon-mock:
|
||||
condition: service_healthy
|
||||
tunein-mock:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/health"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
image: golang:1.26.4-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -28,9 +45,15 @@ services:
|
||||
- "8081:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
image: golang:1.26.4-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -40,6 +63,30 @@ services:
|
||||
- "8082:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.4-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-tunein/main.go -port 8080
|
||||
ports:
|
||||
- "8083:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 3s
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
# downloads Hextra once.
|
||||
- hugo-mod-cache:/root/.cache/hugo_cache
|
||||
working_dir: /src
|
||||
environment:
|
||||
- HUGO_PARAMS_GITHASH
|
||||
|
||||
volumes:
|
||||
hugo-mod-cache:
|
||||
|
||||
@@ -665,7 +665,7 @@ soundtouch --device 192.0.2.100 preset 1
|
||||
soundtouch interactive
|
||||
|
||||
# Web interface
|
||||
soundtouch-webapp --port 8080
|
||||
soundtouch-playerapp --port 8080
|
||||
```
|
||||
|
||||
### JavaScript/WASM Usage
|
||||
@@ -727,10 +727,10 @@ client.startEventStream((event) => {
|
||||
./soundtouch-linux-amd64 --device IP play
|
||||
|
||||
# Web Application (embedded assets)
|
||||
./soundtouch-webapp-linux-amd64 --port 8080
|
||||
./soundtouch-playerapp-linux-amd64 --port 8080
|
||||
|
||||
# Docker
|
||||
docker run -p 8080:8080 soundtouch-webapp
|
||||
docker run -p 8080:8080 soundtouch-playerapp
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Font paths use ../../fonts/ (two levels up) so the URL resolves correctly
|
||||
regardless of where the CSS is served from:
|
||||
- dev: /css/custom.css → ../../fonts/ → /fonts/
|
||||
- production: /css/compiled/main.css → ../../fonts/ → /fonts/
|
||||
- GH Pages: /Bose-SoundTouch/css/compiled/main.css
|
||||
→ ../../fonts/ → /Bose-SoundTouch/fonts/ */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../../fonts/noto-sans-v42-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../../fonts/noto-sans-v42-latin-italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../../fonts/noto-sans-v42-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../../fonts/noto-sans-v42-latin-700italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--hx-default-font-family: "Noto Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
.content {
|
||||
font-family: var(--hx-default-font-family);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ layout: hextra-home
|
||||
</div>
|
||||
|
||||
<div class="hx-mb-6">
|
||||
{{< hextra/hero-button text="Get Started" link="docs/guides/GETTING-STARTED" >}}
|
||||
{{< hextra/hero-button text="Get Started" link="docs/guides/MIGRATION-GUIDE" >}}
|
||||
{{< hextra/hero-button text="Survival Guide" link="docs/guides/SURVIVAL-GUIDE" style="outline" >}}
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ layout: hextra-home
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="Music Browsing"
|
||||
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-web and soundtouch-cli."
|
||||
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-player and soundtouch-cli."
|
||||
icon="speakerphone"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: "Welcome to AfterTouch: Your SoundTouch Speakers, Still Alive"
|
||||
date: 2026-05-24
|
||||
description: "Bose shut down SoundTouch cloud services in May 2026. AfterTouch replaces everything your speakers relied on — migration, radio, Spotify, presets, and more."
|
||||
tags:
|
||||
- migration
|
||||
- web
|
||||
- spotify
|
||||
- cli
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
On May 6, 2026, Bose shut down the SoundTouch cloud services that millions of speakers
|
||||
depended on for account sync, presets, internet radio, and streaming. Speakers kept
|
||||
working locally, but remote features stopped and first-time setup became impossible.
|
||||
|
||||
AfterTouch was built to change that. It is a self-hosted replacement for the Bose
|
||||
cloud infrastructure — a drop-in local service that your speakers talk to instead of
|
||||
`streaming.bose.com`. This post covers what works today and how to get started.
|
||||
|
||||
## What works right now
|
||||
|
||||
### Migration and first-time setup
|
||||
|
||||
If your speaker was registered with Bose before the shutdown, AfterTouch can **migrate
|
||||
your existing account and presets** in a single step — no reconfiguration on the
|
||||
speaker side. If you are setting up a factory-reset or brand-new speaker, AfterTouch
|
||||
handles that path too, guiding you through Wi-Fi pairing and account creation locally.
|
||||
See the [Migration Guide](../docs/guides/MIGRATION-GUIDE.md) for step-by-step instructions.
|
||||
|
||||
### Internet radio — TuneIn and RadioBrowser
|
||||
|
||||
Both **TuneIn** and **RadioBrowser** are fully supported for browsing and playback.
|
||||
Navigate categories and search for stations exactly as you did with the original Bose
|
||||
app. TuneIn delivers the same station catalogue; RadioBrowser provides an open,
|
||||
community-maintained alternative.
|
||||
|
||||
### Spotify
|
||||
|
||||
**Spotify** works via both OAuth (account linking) and Spotify Connect (the ZeroConf
|
||||
"connect to device" flow from the Spotify app). Once linked, playback and device
|
||||
selection behave the same as before.
|
||||
|
||||
### Presets
|
||||
|
||||
Your six preset buttons work. AfterTouch stores preset bindings locally and serves them
|
||||
back to the speaker on request. You can also **save new presets** — via the API,
|
||||
via `soundtouch-cli`, or through the soundtouch-player UI.
|
||||
|
||||
### ST-10 stereo pairing
|
||||
|
||||
**SoundTouch 10 stereo pairs** (and other ST pairing configurations) are supported
|
||||
end-to-end: creation, management, and playback routing all go through AfterTouch.
|
||||
|
||||
### soundtouch-player — browser UI
|
||||
|
||||
**soundtouch-player** is an early-stage but functional browser UI bundled with AfterTouch.
|
||||
It gives you:
|
||||
|
||||
- TuneIn and RadioBrowser browsing and playback
|
||||
- Speaker management and device discovery
|
||||
- Recent tracks panel
|
||||
- Multi-room zone management
|
||||
|
||||
It runs as part of the AfterTouch service — no separate install needed.
|
||||
|
||||

|
||||
|
||||
### Automation with soundtouch-cli
|
||||
|
||||
The **`soundtouch-cli`** command-line tool covers every speaker control: play, pause,
|
||||
volume, source selection, preset recall, group management, migration, and more.
|
||||
It is well-suited for home-automation scripts, cron jobs, and shell one-liners.
|
||||
|
||||
## Three ways to install
|
||||
|
||||
AfterTouch runs on any machine your speakers can reach:
|
||||
|
||||
1. **On the speaker itself** — install directly on supported SoundTouch hardware via
|
||||
the on-device installer. The speaker hosts its own replacement cloud, with no
|
||||
additional hardware required.
|
||||
|
||||
2. **On a local network host** — run AfterTouch on any machine on your LAN. A
|
||||
**Raspberry Pi Zero 2W** handles the load without breaking a sweat, making this
|
||||
path remarkably low-cost and low-power.
|
||||
|
||||
3. **On a cloud or VPS host** — deploy to a remote server for access outside your
|
||||
home network. AfterTouch handles TLS certificate generation and DNS configuration
|
||||
for this scenario.
|
||||
|
||||
All three paths are documented in the [Deployment Overview](../docs/guides/DEPLOYMENT-OVERVIEW.md).
|
||||
|
||||
## Current release
|
||||
|
||||
**v0.93.1** — released May 24, 2026
|
||||
|
||||
## Community
|
||||
|
||||
AfterTouch would not be where it is without the people who opened issues, tested
|
||||
pre-release builds, reported edge cases, and contributed code. A significant share of
|
||||
the fixes and features shipped in the lead-up to the cloud shutdown were driven by
|
||||
real-world feedback from the community — from migration quirks to stereo-pair
|
||||
specifics to Spotify Connect timing issues. Thank you to everyone who helped.
|
||||
|
||||
If you run into something or have an idea, the
|
||||
[GitHub issue tracker](https://github.com/gesellix/Bose-SoundTouch/issues) and
|
||||
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) are the
|
||||
right places to start.
|
||||
|
||||
## What's next
|
||||
|
||||
The soundtouch-player UI will gain richer preset management — browsing, editing, and
|
||||
reordering presets directly from the browser. Longer term, merging
|
||||
`soundtouch-service` and `soundtouch-player` into a single binary is on the table,
|
||||
which would simplify deployment to a single process with no extra flags.
|
||||
|
||||
This blog will be updated monthly — or whenever something significant ships.
|
||||
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
|
||||
for individual version notes.
|
||||
@@ -1,3 +1,5 @@
|
||||
---
|
||||
title: News & Updates
|
||||
---
|
||||
|
||||
Project updates, release notes, and development notes for AfterTouch — the local replacement for the Bose SoundTouch cloud.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Placeholder values for examples"
|
||||
---
|
||||
|
||||
# Placeholder values for examples
|
||||
|
||||
This repo is public. Documentation, READMEs, example configs, and test
|
||||
fixtures must never carry real LAN IPs, real device MACs, real Bose
|
||||
account IDs, or personal device names from any maintainer or
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
---
|
||||
title: "Bose SoundTouch API Coverage Analysis"
|
||||
---
|
||||
|
||||
# Bose SoundTouch API Coverage Analysis
|
||||
|
||||
**Last Updated:** February 2026
|
||||
**Last Updated:** June 2026 (reconciled against `pkg/client`)
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Implementation Status:** 100% Official Coverage + Extended Features
|
||||
**Implementation Status:** Official coverage 20/21 + extended features
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This Go implementation provides **complete coverage** of the Bose SoundTouch Web API with **100% of official endpoints implemented** (18/19) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
|
||||
This Go implementation provides near-complete coverage of the Bose SoundTouch Web API with **20 of 21 official endpoints implemented** (the one exception, `/trackInfo`, is documented but non-functional on real hardware) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
|
||||
|
||||
### Key Findings
|
||||
- ✅ **All essential user functionality implemented**
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Bose SoundTouch Traffic Interception Runbook"
|
||||
---
|
||||
|
||||
# Bose SoundTouch Traffic Interception Runbook
|
||||
|
||||
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
|
||||
|
||||
## Automated Setup
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Bose SoundTouch – Traffic Analysis Runbook"
|
||||
---
|
||||
|
||||
# Bose SoundTouch – Traffic Analysis Runbook
|
||||
|
||||
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app – specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
|
||||
|
||||
---
|
||||
@@ -87,7 +84,7 @@ sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
|
||||
Name=wlan0
|
||||
|
||||
[Network]
|
||||
Address=192.168.10.1/24
|
||||
Address=198.51.100.1/24
|
||||
IPForward=yes
|
||||
ConfigureWithoutCarrier=yes
|
||||
DHCP=no
|
||||
@@ -107,7 +104,7 @@ sudo systemctl mask wpa_supplicant@wlan0
|
||||
**Verify:**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
|
||||
# Expected: ONLY inet 198.51.100.1/24 (NO second DHCP IP)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -154,9 +151,9 @@ sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
|
||||
|
||||
sudo tee /etc/dnsmasq.conf << 'EOF'
|
||||
interface=wlan0
|
||||
dhcp-range=192.168.10.100,192.168.10.200,24h
|
||||
dhcp-option=3,192.168.10.1
|
||||
dhcp-option=6,192.168.10.1
|
||||
dhcp-range=198.51.100.100,198.51.100.200,24h
|
||||
dhcp-option=3,198.51.100.1
|
||||
dhcp-option=6,198.51.100.1
|
||||
|
||||
# DNS Upstream: custom server on localhost (adjust port if necessary)
|
||||
server=127.0.0.1#5353 # Example: custom server on port 5353
|
||||
@@ -240,7 +237,7 @@ If you cannot see the `Bose-Lab` SSID on your phone:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `198.51.100.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
# If the ghost IP is still there, remove it manually:
|
||||
@@ -262,13 +259,13 @@ If you haven't created a CA yet, follow **Appendix A** first.
|
||||
# Temporarily make reachable via HTTP for easy download:
|
||||
cd /etc/my-dns-ca/
|
||||
python3 -m http.server 8080
|
||||
# → Reachable at http://192.168.10.1:8080/ca.crt
|
||||
# → Reachable at http://198.51.100.1:8080/ca.crt
|
||||
```
|
||||
|
||||
### Install on Android
|
||||
|
||||
1. Connect phone to `Bose-Lab`
|
||||
2. Open browser → `http://192.168.10.1:8080/ca.crt`
|
||||
2. Open browser → `http://198.51.100.1:8080/ca.crt`
|
||||
3. Download certificate
|
||||
4. **Settings → Security → Credentials → Install CA Certificate**
|
||||
5. Select certificate and confirm
|
||||
@@ -325,7 +322,7 @@ sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
|
||||
|
||||
# Traffic of a specific host only (filter by phone IP)
|
||||
# Read phone IP from dnsmasq.leases beforehand (see below)
|
||||
sudo tcpdump -i wlan0 -n host 192.168.10.101
|
||||
sudo tcpdump -i wlan0 -n host 198.51.100.101
|
||||
```
|
||||
|
||||
### Read SNI from TLS Traffic (without decryption)
|
||||
@@ -354,7 +351,7 @@ Transfer `.pcap` files from the Pi to the PC:
|
||||
|
||||
```bash
|
||||
# From the PC (scp)
|
||||
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
scp pi@198.51.100.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
```
|
||||
|
||||
**Important Wireshark Filters:**
|
||||
@@ -610,7 +607,7 @@ You can either configure the macOS system proxy manually or use `mitmproxy`'s au
|
||||
**Method 1: System Proxy (Manual)**
|
||||
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
|
||||
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
|
||||
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
|
||||
3. Set Server to your Pi's IP (`198.51.100.1`) and Port to `8080`.
|
||||
4. Click **OK** and **Apply**.
|
||||
|
||||
**Method 2: mitmproxy Local Redirect (Automatic)**
|
||||
@@ -670,7 +667,7 @@ If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA t
|
||||
If the **Transparent AP** setup (Steps 1–6) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
|
||||
|
||||
### 1. How it works
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `198.51.100.1:8080`.
|
||||
|
||||
* **Pros:** No complex `nftables` or NAT rules required.
|
||||
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
|
||||
@@ -686,7 +683,7 @@ mitmproxy --listen-port 8080
|
||||
1. Go to **Settings → Wi-Fi → Bose-Lab**.
|
||||
2. Select **Modify Network** (or the "i" icon).
|
||||
3. Set **Proxy** to **Manual**.
|
||||
4. **Proxy hostname:** `192.168.10.1`
|
||||
4. **Proxy hostname:** `198.51.100.1`
|
||||
5. **Proxy port:** `8080`
|
||||
6. Save and try to browse a site.
|
||||
|
||||
@@ -709,7 +706,7 @@ go get github.com/google/gopacket
|
||||
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
|
||||
|
||||
# Example: Filter for a specific speaker's IP in WebSocket messages
|
||||
go run scripts/extract-ws.go capture.pcap 192.168.100.1
|
||||
go run scripts/extract-ws.go capture.pcap 203.0.113.1
|
||||
```
|
||||
|
||||
### 2. Manual Extraction with tshark
|
||||
@@ -892,5 +889,5 @@ pgrep -a tcpdump
|
||||
dig @127.0.0.1 -p 5353 global.api.bose.io
|
||||
|
||||
# Check network connectivity from the phone (from the Pi)
|
||||
ping 192.168.10.101 # Phone IP from dnsmasq.leases
|
||||
ping 198.51.100.101 # Phone IP from dnsmasq.leases
|
||||
```
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Device Redirect Methods & Custom Service Setup"
|
||||
---
|
||||
|
||||
# Device Redirect Methods & Custom Service Setup
|
||||
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "What a SoundTouch speaker does during factory reset"
|
||||
---
|
||||
|
||||
# What a SoundTouch speaker does during factory reset
|
||||
|
||||
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
|
||||
|
||||
## Sequence
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "IoT Configuration Quick Reference"
|
||||
---
|
||||
|
||||
# IoT Configuration Quick Reference
|
||||
|
||||
## Key Files and Locations
|
||||
|
||||
| File/Location | Purpose | Notes |
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "IoT Configuration Analysis"
|
||||
---
|
||||
|
||||
# IoT Configuration Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a detailed analysis of the AWS IoT configuration system used by Bose SoundTouch devices, based on firmware backup analysis from ST10 and ST20 models.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Spotify Account Addition Implementation Status"
|
||||
---
|
||||
|
||||
# Spotify Account Addition Implementation Status
|
||||
|
||||
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
|
||||
|
||||
## 1. OAuth Token Exchange (Bose Cloud)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?"
|
||||
---
|
||||
|
||||
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
|
||||
|
||||
## Why we are doing this
|
||||
|
||||
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START` → `SETUP_ENTER` → `SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
---
|
||||
title: "SoundTouch supportedURLs Endpoint Analysis"
|
||||
---
|
||||
|
||||
# SoundTouch supportedURLs Endpoint Analysis
|
||||
|
||||
This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation.
|
||||
|
||||
> **Reconciliation note (June 2026).** The categorised lists below had drifted
|
||||
> from `pkg/client`. Verified against the code, these are **implemented** and have
|
||||
> been re-marked (some were wrongly under "Not Yet Implemented", and a few were
|
||||
> listed twice): the music-service set (`setMusicServiceAccount`,
|
||||
> `setMusicServiceOAuthAccount`, `removeMusicServiceAccount`, `serviceAvailability`),
|
||||
> presets (`storePreset`, `removePreset`), stations (`searchStation`, `addStation`,
|
||||
> `removeStation`), `navigate`, the native stereo-pair group set (`getGroup`,
|
||||
> `addGroup`, `removeGroup`, `updateGroup`), `speaker`, `playNotification`,
|
||||
> `requestToken`, `notification`. Still **not** implemented (confirmed absent from
|
||||
> `pkg/client`): `search`, `standby`, `powerManagement`, `lowPowerStandby`,
|
||||
> `language`, `listMediaServers`, `bluetoothInfo`, `userPlayControl`, and the
|
||||
> wireless / bluetooth-pairing / software-update / source-shortcut families.
|
||||
|
||||
## Discovery Summary
|
||||
|
||||
**Test Devices:**
|
||||
- Device 1: `192.0.2.11:8090` (deviceID: `08DF1F0BA325`)
|
||||
- Device 1: `192.0.2.11:8090` (deviceID: `AABBCCDDEE01`)
|
||||
- Device 2: `192.0.2.10:8090` (deviceID: `AABBCCDDEEFF`)
|
||||
|
||||
**Key Findings:**
|
||||
@@ -64,18 +74,18 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/audioproducttonecontrols` - Advanced tone controls (capability-dependent)
|
||||
- `/audioproductlevelcontrols` - Speaker level controls (capability-dependent)
|
||||
|
||||
**System Info (3/3):**
|
||||
- `/trackInfo` - Track information
|
||||
- `/bluetoothInfo` - Bluetooth information
|
||||
- `/recents` - Recently played content
|
||||
**System Info (1/3):**
|
||||
- `/recents` - Recently played content ✅
|
||||
- `/trackInfo` - Track information ❌ non-functional on real devices (use `/now_playing`)
|
||||
- `/bluetoothInfo` - Bluetooth information ❌ not implemented in `pkg/client`
|
||||
|
||||
### 🔶 Partially Implemented/Different Approach
|
||||
### ✅ Stereo-Pair Group Management (native)
|
||||
|
||||
**Zone Management:**
|
||||
- `/addGroup` ⚠️ - We use `/setZone` for group management
|
||||
- `/removeGroup` ⚠️ - We use `/setZone` for group management
|
||||
- `/getGroup` ⚠️ - We use `/getZone` for group information
|
||||
- `/updateGroup` ⚠️ - We use `/setZone` for group updates
|
||||
Implemented natively in `pkg/client` (in addition to the `/setZone` multiroom path):
|
||||
- `/addGroup` ✅ - `AddGroup()`
|
||||
- `/removeGroup` ✅ - `RemoveGroup()`
|
||||
- `/getGroup` ✅ - `GetGroup()`
|
||||
- `/updateGroup` ✅ - `UpdateGroup()`
|
||||
|
||||
### ❌ Not Yet Implemented (High Priority)
|
||||
|
||||
@@ -94,22 +104,22 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/selectLastSoundTouchSource` - Select last SoundTouch source
|
||||
- `/selectLocalSource` - Select local source
|
||||
|
||||
**Music Services Integration:**
|
||||
- `/setMusicServiceAccount` - Configure music service account
|
||||
- `/setMusicServiceOAuthAccount` - OAuth account setup
|
||||
- `/removeMusicServiceAccount` - Remove music service account
|
||||
- `/serviceAvailability` - Check service availability
|
||||
**Music Services Integration:** ✅ implemented (moved out of this list)
|
||||
- ~~`/setMusicServiceAccount`~~ ✅ `SetMusicServiceAccount()`
|
||||
- ~~`/setMusicServiceOAuthAccount`~~ ✅ `SetMusicServiceOAuthAccount()`
|
||||
- ~~`/removeMusicServiceAccount`~~ ✅ `RemoveMusicServiceAccount()`
|
||||
- ~~`/serviceAvailability`~~ ✅ `GetServiceAvailability()`
|
||||
|
||||
**Enhanced Presets:**
|
||||
- `/storePreset` - Store new preset
|
||||
- `/removePreset` - Remove existing preset
|
||||
- ~~`/storePreset`~~ ✅ `StorePreset()` (also listed under Fully Implemented)
|
||||
- ~~`/removePreset`~~ ✅ `RemovePreset()`
|
||||
- `/bookmark` - Bookmark current content
|
||||
- `/userRating` - User rating for content
|
||||
|
||||
**Station/Radio Management:**
|
||||
- `/searchStation` - Search for stations
|
||||
- `/addStation` - Add station to favorites
|
||||
- `/removeStation` - Remove station from favorites
|
||||
- ~~`/searchStation`~~ ✅ `SearchStation()`
|
||||
- ~~`/addStation`~~ ✅ `AddStation()`
|
||||
- ~~`/removeStation`~~ ✅ `RemoveStation()`
|
||||
- `/genreStations` - Browse stations by genre
|
||||
- `/stationInfo` - Station information
|
||||
|
||||
@@ -122,7 +132,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/systemtimeout` - System timeout settings
|
||||
- `/powersaving` - Power saving configuration
|
||||
- `/language` - Language settings
|
||||
- `/speaker` - Speaker configuration
|
||||
- ~~`/speaker`~~ ✅ `PlayTTS()` / `PlayURL()` (TTS & URL notifications; not "speaker configuration")
|
||||
|
||||
**Network & Connectivity:**
|
||||
- `/performWirelessSiteSurvey` - WiFi site survey
|
||||
@@ -136,7 +146,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
|
||||
**Content Discovery:**
|
||||
- `/search` - Content search
|
||||
- `/navigate` - Content navigation
|
||||
- ~~`/navigate`~~ ✅ `Navigate()`
|
||||
- `/listMediaServers` - List available media servers
|
||||
|
||||
### ❌ Not Yet Implemented (Low Priority)
|
||||
@@ -159,9 +169,9 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
|
||||
**System Utilities:**
|
||||
- `/userActivity` - User activity tracking
|
||||
- `/requestToken` - Token management
|
||||
- `/notification` - Notification management
|
||||
- `/playNotification` - Play notification sound
|
||||
- ~~`/requestToken`~~ ✅ `RequestToken()`
|
||||
- ~~`/notification`~~ ✅ `NotifySourcesUpdated()`
|
||||
- ~~`/playNotification`~~ ✅ `PlayNotification()`
|
||||
- `/introspect` - System introspection
|
||||
- `/test` - System test interface
|
||||
|
||||
@@ -232,7 +242,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
**Example Response Structure:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<supportedURLs deviceID="08DF1F0BA325">
|
||||
<supportedURLs deviceID="AABBCCDDEE01">
|
||||
<URL location="/info" />
|
||||
<URL location="/capabilities" />
|
||||
<!-- ... 101 additional endpoints ... -->
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Bose SoundTouch Telnet (Port 17000) Command Reference"
|
||||
---
|
||||
|
||||
# Bose SoundTouch Telnet (Port 17000) Command Reference
|
||||
|
||||
A consolidated reference for the diagnostic shell that listens on TCP port
|
||||
17000 across the SoundTouch line. Compiled from multiple community sources
|
||||
to give a single map of what's been observed in the wild — useful both for
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Telnet (Port 17000) Migration Method — Analysis"
|
||||
---
|
||||
|
||||
# Telnet (Port 17000) Migration Method — Analysis
|
||||
|
||||
This document captures the use cases, community findings, and feasibility analysis
|
||||
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
|
||||
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
|
||||
@@ -12,7 +9,7 @@ deprecated and is intentionally kept off the visible UI options.
|
||||
> **Sources** — community discussion synthesised from
|
||||
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
|
||||
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
|
||||
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
|
||||
> [scheilch/opencloudtouch#167](https://github.com/opencloudtouch/opencloudtouch/issues/167),
|
||||
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
|
||||
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
|
||||
> the post-EOS walkthrough PDF in `docs/`,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Upstream URLs & Domains Analysis"
|
||||
---
|
||||
|
||||
# Upstream URLs & Domains Analysis
|
||||
|
||||
This document provides a comprehensive overview of the upstream Bose cloud services and domains that SoundTouch devices communicate with. These details were gathered from firmware analysis of ST10/ST20 devices, binary string extraction, and community research from the **SoundCork** project (Issue #128).
|
||||
|
||||
## Core Service Domains
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "SoundTouch API Comparison: Community Wiki vs Current Implementation"
|
||||
---
|
||||
|
||||
# SoundTouch API Comparison: Community Wiki vs Current Implementation
|
||||
|
||||
**Date:** January 2026
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Our Implementation:** Bose-SoundTouch Go Library v1.0
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
title: "Bose SoundTouch — Community Tools for Post-EOL Preservation"
|
||||
---
|
||||
|
||||
# Bose SoundTouch — Community Tools for Post-EOL Preservation
|
||||
|
||||
> **Context:** Bose announced the shutdown of SoundTouch cloud services, extended to **May 6, 2026**. On that date the official SoundTouch app will update to a local-only version. Bose has released the [SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf) as open-source to enable community-driven development. This document surveys the active community projects, their feature coverage, and open development opportunities.
|
||||
|
||||
---
|
||||
@@ -201,7 +198,7 @@ The web UI is already fully responsive — it has Bootstrap grid columns, `@medi
|
||||
### Priority 2 — RadioBrowser as a first-class provider
|
||||
|
||||
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-player` web UI as a browsable/searchable source.
|
||||
- Make discovered stations directly presetable to hardware buttons.
|
||||
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
|
||||
|
||||
@@ -245,7 +242,7 @@ These exist in soundcork but are deliberate architectural choices in AfterTouch,
|
||||
|
||||
| Area | soundcork | AfterTouch |
|
||||
|--------------------------|---------------------------------------|-----------------------------------------------------------|
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-web` component (Go + plain HTML/JS) |
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-player` component (Go + plain HTML/JS) |
|
||||
| Direct device management | SSH/SCP access into speakers | HTTP API only; no SSH |
|
||||
| Device discovery client | Python `upnpclient` library | mDNS + UPnP in Go, with dedicated DNS interception server |
|
||||
| Token delivery | Push (ZeroConf priming to port 8200) | Pull (device calls back to fetch) |
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Navigation API Reference"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Navigation API Reference
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a complete API reference for the Bose SoundTouch navigation and station management functionality. For usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "CLAUDE.md - Development Guidelines for Bose SoundTouch Project"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# CLAUDE.md - Development Guidelines for Bose SoundTouch Project
|
||||
|
||||
## Documentation Overview
|
||||
|
||||
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Content Selection Implementation Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Content Selection Implementation Summary
|
||||
|
||||
This document summarizes the implementation of advanced content selection features for the Bose SoundTouch Go client, including full support for the LOCAL_INTERNET_RADIO streamUrl format and LOCAL_MUSIC/STORED_MUSIC content selection.
|
||||
|
||||
## ✅ Implementation Status: COMPLETE
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Device Customization Setup Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Device Customization Setup Guide
|
||||
|
||||
This guide documents the manual steps required to configure your Bose SoundTouch device for customization using the SoundCork approach.
|
||||
|
||||
Based on: https://github.com/deborahgu/soundcork
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Device Logging & Troubleshooting"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Device Logging & Troubleshooting
|
||||
|
||||
Accessing logs from SoundTouch devices is critical for debugging custom service integrations and understanding internal device behavior. This document outlines the methods for collecting logs, as discovered by the **SoundCork** and **ÜberBöse API** communities.
|
||||
|
||||
## Log Types
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Bose SoundTouch Device Setup Flow"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Bose SoundTouch Device Setup Flow
|
||||
|
||||
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
|
||||
|
||||
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Encrypted Diagnostic Export"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Encrypted Diagnostic Export
|
||||
|
||||
AfterTouch can produce an encrypted diagnostic report that users can download and
|
||||
send to the project maintainer without exposing sensitive data to third parties.
|
||||
The report is encrypted with an SSH public key using
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Technical Proposal: External Service Provider Abstraction"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Technical Proposal: External Service Provider Abstraction
|
||||
|
||||
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Feature Development History"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Feature Development History
|
||||
|
||||
This document tracks the detailed evolution of features and capabilities in the Bose SoundTouch API client library.
|
||||
|
||||
## Development Timeline
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Host:Port Parsing Feature"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Host:Port Parsing Feature
|
||||
|
||||
This document describes the automatic host:port parsing functionality added to the SoundTouch CLI, which allows users to specify both host and port in a single `-host` flag.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Manual Network Discovery on macOS"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Manual Network Discovery on macOS
|
||||
|
||||
This document provides comprehensive guidance for manually discovering network services and devices using built-in macOS tools and command-line utilities. This is particularly useful for troubleshooting network discovery issues or understanding what services are available on your local network.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Navigation and Station Management Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Navigation and Station Management Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Bose SoundTouch Go client provides comprehensive navigation and station management functionality that allows you to:
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Official SoundTouch Web API Verification"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Official SoundTouch Web API Verification
|
||||
|
||||
**Source**: Official Bose SoundTouch Web API v1.0 Documentation (January 7, 2026)
|
||||
**Verification Date**: January 9, 2026
|
||||
**Project Status**: Complete API coverage verification
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
|
||||
@@ -3,14 +3,31 @@ title: "Preset Management Quick Start Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Preset Management Quick Start Guide
|
||||
|
||||
**Save your favorite music, radio stations, and playlists as 1-6 presets for instant access.**
|
||||
|
||||
## Overview
|
||||
|
||||
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using both the CLI and Go library.
|
||||
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-player UI, the CLI, or the Go library.
|
||||
|
||||
## Via soundtouch-player (browser UI)
|
||||
|
||||
**soundtouch-player** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
|
||||
|
||||
### ★ Star button — save from Now Playing
|
||||
|
||||
1. Navigate to your speaker's detail page.
|
||||
2. Play any station or track (via Radio Browser, TuneIn, or the speaker's own sources).
|
||||
3. A semi-transparent **★** appears in the top-right corner of the **Now Playing** card.
|
||||
4. Click the star — a slot picker **1 · 2 · 3 · 4 · 5 · 6** opens.
|
||||
5. Click the target slot number. The star turns gold once the current content is saved to at least one slot.
|
||||
|
||||
### + button — save directly to a preset tile
|
||||
|
||||
While something is playing, hover over any of the six **Preset** tiles. A small **+** button appears in the tile's corner; clicking it saves the current content to that slot immediately (no picker needed).
|
||||
|
||||
Use the **+** when you already know which slot you want; use the **★** when you want to pick the slot after you've decided to save.
|
||||
|
||||
---
|
||||
|
||||
## Quick CLI Usage
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ title: "Project Structure Patterns: Bose SoundTouch API Client"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Project Structure Patterns: Bose SoundTouch API Client
|
||||
## Summary for Reuse in API Client Projects
|
||||
|
||||
This document describes the most important patterns for the Bose SoundTouch API client, especially for XML-based API clients with Web UI, CLI tool, and WASM support.
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Request Recording Concept"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Request Recording Concept
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current request recording system has fundamental issues when dealing with request cloning, body consumption, and multiple response scenarios. Specifically:
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "SCMUDC Enrichment Implementation Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# SCMUDC Enrichment Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the implementation of SCMUDC (Sound Control Management Usage Data Collection) event enrichment in the AfterTouch toolkit. The enhancement provides human-readable analysis of device telemetry data to improve usability and debugging capabilities.
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Service Availability Implementation Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Service Availability Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the implementation of the `/serviceAvailability` endpoint support in the Bose SoundTouch Go client library. This feature enables applications to query which music services and input sources are available on a SoundTouch device, providing better user feedback about supported stations and sources.
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "🎉 Introducing SoundTouch Service: Local Cloud Service Emulation"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# 🎉 Introducing SoundTouch Service: Local Cloud Service Emulation
|
||||
|
||||
**Date**: February 2026
|
||||
**Version**: v2.0.0+
|
||||
**Status**: Production Ready
|
||||
|
||||
@@ -3,8 +3,6 @@ title: "Undocumented Community Features & API Discoveries"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Undocumented Community Features & API Discoveries
|
||||
This document captures advanced API endpoints and device behaviors discovered by the SoundTouch community through reverse engineering projects like **SoundCork** and **ÜberBöse API**. These features are not documented in the official Bose SoundTouch Web API v1.0 but are crucial for full device emulation and offline operation.
|
||||
## Cloud Emulation (Marge/BMX) Discoveries
|
||||
While the local `/8090` API is well-documented, the cloud-side service emulation reveals deeper device integration points.
|
||||
|
||||
@@ -3,16 +3,30 @@ title: "Unimplemented SoundTouch API Endpoints"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Unimplemented SoundTouch API Endpoints
|
||||
|
||||
**Last Updated:** January 2026
|
||||
**Last Updated:** June 2026 (reconciled against `pkg/client`)
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Current Implementation:** 35 endpoints (including preset & navigation management discovered via SoundTouch Plus Wiki)
|
||||
**Current Implementation:** ~41 endpoints in `pkg/client` (see reconciliation note)
|
||||
**Wiki Documentation:** 87 endpoints
|
||||
**Implementation Gap:** 52 endpoints
|
||||
**Implementation Gap:** ~46 endpoints
|
||||
|
||||
This document provides comprehensive information about SoundTouch API endpoints documented in the community wiki but not yet implemented in this Go library. All examples are based on real device responses and extensive community testing.
|
||||
This document covers SoundTouch **device** WebServices API endpoints (the
|
||||
speaker's local `:8090` API consumed by `pkg/client`) documented in the community
|
||||
wiki but not yet implemented. It is **not** about the cloud-service router
|
||||
(`cmd/soundtouch-service`); for that surface see the contract checklist
|
||||
`tests/integration/http-client/COVERAGE.md`. Examples are based on real device
|
||||
responses and community testing.
|
||||
|
||||
> **Reconciliation note (June 2026).** Verified against `pkg/client`. Since the
|
||||
> last update these are **now implemented** and have been re-marked below:
|
||||
> `setMusicServiceAccount` / `removeMusicServiceAccount` (`SetMusicServiceAccount`,
|
||||
> `RemoveMusicServiceAccount`) and 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -72,12 +86,15 @@ Specialized hardware-specific features.
|
||||
- CLI command: `preset select --slot <1-6>`
|
||||
- Alternative: Direct key commands (`SendKey("PRESET_1")` etc.)
|
||||
|
||||
### Music Service Management
|
||||
### ~~Music Service Management~~ ✅ **IMPLEMENTED**
|
||||
Critical for streaming service integration.
|
||||
|
||||
#### POST /setMusicServiceAccount 🔥 **CRITICAL**
|
||||
#### ~~POST /setMusicServiceAccount~~ ✅ **IMPLEMENTED**
|
||||
Adds a music service account to the sources list.
|
||||
|
||||
**Status:** **COMPLETE** - `pkg/client` exposes `SetMusicServiceAccount(...)`
|
||||
(and `SetMusicServiceOAuthAccount(...)` for OAuth sources like Spotify/Amazon).
|
||||
|
||||
**Request Examples:**
|
||||
|
||||
Pandora Service:
|
||||
@@ -114,9 +131,11 @@ NAS Music Library:
|
||||
- Note the `/0` suffix for STORED_MUSIC user names
|
||||
- Spotify requires PREMIUM account for most operations
|
||||
|
||||
#### POST /removeMusicServiceAccount 🔥 **CRITICAL**
|
||||
#### ~~POST /removeMusicServiceAccount~~ ✅ **IMPLEMENTED**
|
||||
Removes an existing music service account.
|
||||
|
||||
**Status:** **COMPLETE** - `pkg/client` exposes `RemoveMusicServiceAccount(...)`.
|
||||
|
||||
**Request Examples:**
|
||||
|
||||
Remove Pandora:
|
||||
@@ -633,9 +652,12 @@ Selects LOCAL source (only way to select LOCAL on some devices).
|
||||
<status>/selectLocalSource</status>
|
||||
```
|
||||
|
||||
### Group Management (ST-10 Stereo Pairs Only)
|
||||
### ~~Group Management (ST-10 Stereo Pairs Only)~~ ✅ **IMPLEMENTED**
|
||||
|
||||
#### GET /getGroup 📊 **MEDIUM**
|
||||
**Status:** **COMPLETE** - the full stereo-pair set is implemented in `pkg/client`:
|
||||
`GetGroup()`, `AddGroup()`, `RemoveGroup()`, `UpdateGroup()`.
|
||||
|
||||
#### ~~GET /getGroup~~ ✅ **IMPLEMENTED**
|
||||
Gets current stereo pair configuration.
|
||||
|
||||
**Response Example (paired):**
|
||||
@@ -665,7 +687,7 @@ Gets current stereo pair configuration.
|
||||
<group />
|
||||
```
|
||||
|
||||
#### POST /addGroup 📊 **MEDIUM**
|
||||
#### ~~POST /addGroup~~ ✅ **IMPLEMENTED**
|
||||
Creates new stereo pair group.
|
||||
|
||||
**Request Example:**
|
||||
@@ -691,7 +713,7 @@ Creates new stereo pair group.
|
||||
**Response:** Same as GET /getGroup
|
||||
**WebSocket Event:** `groupUpdated` sent to both devices
|
||||
|
||||
#### GET /removeGroup 📊 **MEDIUM**
|
||||
#### ~~GET /removeGroup~~ ✅ **IMPLEMENTED**
|
||||
Removes existing stereo pair group.
|
||||
|
||||
**Response:**
|
||||
@@ -701,7 +723,7 @@ Removes existing stereo pair group.
|
||||
|
||||
**WebSocket Event:** `groupUpdated` sent to both devices
|
||||
|
||||
#### POST /updateGroup 📊 **MEDIUM**
|
||||
#### ~~POST /updateGroup~~ ✅ **IMPLEMENTED**
|
||||
Updates stereo pair group name.
|
||||
|
||||
**Request Example:**
|
||||
@@ -985,8 +1007,8 @@ func TestDeviceCompatibility(t *testing.T) {
|
||||
|
||||
### Phase 1: Essential Features (4 weeks)
|
||||
1. ✅ **Preset Management**: ~~`storePreset`, `removePreset`, `selectPreset`~~ (IMPLEMENTED)
|
||||
2. **Music Services**: `setMusicServiceAccount`, `removeMusicServiceAccount`
|
||||
3. ✅ **Content Discovery**: ~~`navigate`, `search`~~ (IMPLEMENTED), `recents`
|
||||
2. ✅ **Music Services**: ~~`setMusicServiceAccount`, `removeMusicServiceAccount`~~ (IMPLEMENTED)
|
||||
3. ✅ **Content Discovery**: ~~`navigate`~~ (IMPLEMENTED), `search`, `recents`
|
||||
4. ✅ **Station Management**: ~~`searchStation`, `addStation`, `removeStation`~~ (IMPLEMENTED)
|
||||
5. **Enhanced Controls**: `userPlayControl`, `userRating`
|
||||
|
||||
@@ -999,7 +1021,7 @@ func TestDeviceCompatibility(t *testing.T) {
|
||||
### Phase 3: Advanced Features (3 weeks)
|
||||
1. **Bluetooth**: `enterBluetoothPairing`, `clearBluetoothPaired`
|
||||
2. **Software Updates**: `swUpdateCheck`, `swUpdateQuery`
|
||||
3. **Stereo Pairs**: `getGroup`, `addGroup`, `removeGroup`, `updateGroup`
|
||||
3. ✅ **Stereo Pairs**: ~~`getGroup`, `addGroup`, `removeGroup`, `updateGroup`~~ (IMPLEMENTED)
|
||||
4. **Source Shortcuts**: `selectLastSource`, `selectLastSoundTouchSource`
|
||||
|
||||
### Phase 4: Specialized Features (2 weeks)
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Device Lifecycle and /power_on Enhancement"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Device Lifecycle and /power_on Enhancement
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive analysis of the current SoundTouch device registration and lifecycle management implementation, and proposes enhancements using the `/power_on` endpoint to reduce dependency on local network connectivity.
|
||||
@@ -25,7 +22,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
Name string // From UPnP friendlyName
|
||||
Host string // IP address
|
||||
Port int // Usually 8090
|
||||
ModelID string // From UPnP modelName
|
||||
ModelID string // From UPnP modelName
|
||||
SerialNo string // MAC address from UPnP
|
||||
UPnPLocation string // Device description URL
|
||||
UPnPUSN string // Unique service name
|
||||
@@ -69,7 +66,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Service as SoundTouch Service
|
||||
participant UPnP as UPnP Discovery
|
||||
participant UPnP as UPnP Discovery
|
||||
participant mDNS as mDNS Discovery
|
||||
participant Device as SoundTouch Device
|
||||
participant DataStore as Data Store
|
||||
@@ -79,28 +76,28 @@ sequenceDiagram
|
||||
|
||||
Service->>UPnP: Start SSDP Discovery
|
||||
Service->>mDNS: Start mDNS Discovery
|
||||
|
||||
|
||||
UPnP->>UPnP: Send M-SEARCH multicast
|
||||
Device->>UPnP: Respond with location URL
|
||||
UPnP->>Device: Fetch device description XML
|
||||
Device->>UPnP: Return basic device info
|
||||
|
||||
|
||||
mDNS->>mDNS: Query _soundtouch._tcp
|
||||
Device->>mDNS: Respond with service info
|
||||
|
||||
|
||||
Service->>Service: Merge discovery results
|
||||
Service->>Device: GET /info (enrich data)
|
||||
Device->>Service: Return detailed device info
|
||||
Service->>DataStore: Store discovered device
|
||||
|
||||
|
||||
Note over User,DataStore: User Registration
|
||||
User->>Service: POST /account/{id}/devices
|
||||
Note right of User: deviceId + user-friendly name
|
||||
Service->>DataStore: Link device to account
|
||||
|
||||
|
||||
Note over Service,DataStore: Migration Process
|
||||
Service->>Device: GET /info (device identification)
|
||||
Device->>Service: Return device details
|
||||
Device->>Service: Return device details
|
||||
Service->>Service: Build migration summary
|
||||
Service->>Device: Apply configuration changes
|
||||
```
|
||||
@@ -119,7 +116,7 @@ The system has distinct phases where device information is collected and enhance
|
||||
**Endpoint**: `POST /streaming/account/{accountId}/devices`
|
||||
**Request Format**:
|
||||
```xml
|
||||
<device deviceid="08DF1F0BA325">
|
||||
<device deviceid="AABBCCDDEE0A">
|
||||
<name>Living Room Speaker</name>
|
||||
</device>
|
||||
```
|
||||
@@ -202,25 +199,25 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
|
||||
|
||||
### Data Completeness Comparison
|
||||
|
||||
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|
||||
|------------|----------------|-------------|----------------|
|
||||
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
|
||||
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
|
||||
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
|
||||
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
|
||||
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
|
||||
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
|
||||
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
|
||||
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
|
||||
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
|
||||
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
|
||||
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
|
||||
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|
||||
|-----------------------|----------------------|--------------------|-------------------|
|
||||
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
|
||||
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
|
||||
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
|
||||
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
|
||||
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
|
||||
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
|
||||
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
|
||||
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
|
||||
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
|
||||
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
|
||||
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
|
||||
|
||||
### Enhancement Benefits
|
||||
|
||||
#### 1. Network Independence
|
||||
- ✅ Works across internet/WAN connections
|
||||
- ✅ No multicast/broadcast requirements
|
||||
- ✅ No multicast/broadcast requirements
|
||||
- ✅ Firewall/NAT friendly
|
||||
- ✅ Supports remote device management
|
||||
|
||||
@@ -249,21 +246,21 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
// Fallback to existing discovery
|
||||
return s.fallbackToDiscovery(r.RemoteAddr)
|
||||
}
|
||||
|
||||
|
||||
// Extract device information
|
||||
deviceMAC := powerOnData.Device.ID
|
||||
deviceIP := powerOnData.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
|
||||
// Lookup existing device data
|
||||
deviceInfo := s.lookupDeviceByMAC(deviceMAC)
|
||||
if deviceInfo == nil {
|
||||
// New device - trigger registration flow
|
||||
deviceInfo = s.createDeviceFromPowerOn(powerOnData)
|
||||
}
|
||||
|
||||
|
||||
// Update with power_on data
|
||||
s.updateDeviceFromPowerOn(deviceInfo, powerOnData)
|
||||
|
||||
|
||||
// Determine response actions
|
||||
response := s.buildPowerOnResponse(deviceInfo)
|
||||
s.sendResponse(w, response)
|
||||
@@ -283,7 +280,7 @@ Address missing data through complementary mechanisms:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Device as SoundTouch Device
|
||||
participant Service as SoundTouch Service
|
||||
participant Service as SoundTouch Service
|
||||
participant DataStore as Data Store
|
||||
participant User as User/App
|
||||
|
||||
@@ -296,7 +293,7 @@ sequenceDiagram
|
||||
alt Device Unknown
|
||||
Service->>DataStore: Create device record
|
||||
Service->>User: Notify new device found
|
||||
else Device Known
|
||||
else Device Known
|
||||
Service->>DataStore: Update device status
|
||||
end
|
||||
Service->>Device: Configuration response
|
||||
@@ -363,11 +360,11 @@ type Migration struct {
|
||||
|
||||
### Immediate Actions (Phase 1)
|
||||
1. **Enhance `/power_on` handler** to extract and store comprehensive device data
|
||||
2. **Implement device lookup by MAC address** as primary identification method
|
||||
2. **Implement device lookup by MAC address** as primary identification method
|
||||
3. **Create hybrid discovery system** using both `/power_on` and existing methods
|
||||
4. **Add network-independent device management** capabilities
|
||||
|
||||
### Medium-term Improvements (Phase 2)
|
||||
### Medium-term Improvements (Phase 2)
|
||||
1. **Implement account-device MAC mapping** for automatic association
|
||||
2. **Add IP geolocation** for regional settings inference
|
||||
3. **Create device registration UI** optimized for `/power_on` discovered devices
|
||||
@@ -375,7 +372,7 @@ type Migration struct {
|
||||
|
||||
### Long-term Enhancements (Phase 3)
|
||||
1. **Request firmware enhancement** to include missing data in `/power_on`
|
||||
2. **Implement real-time device monitoring** via `/power_on` events
|
||||
2. **Implement real-time device monitoring** via `/power_on` events
|
||||
3. **Create centralized device management** independent of network topology
|
||||
4. **Add predictive migration** based on device status patterns
|
||||
|
||||
@@ -390,8 +387,8 @@ type Migration struct {
|
||||
The `/power_on` endpoint provides a significant opportunity to reduce network dependencies while enhancing device management capabilities. By implementing a hybrid approach that leverages `/power_on` data for primary device identification and status updates while maintaining existing registration workflows for user-controlled metadata, the system can achieve:
|
||||
|
||||
- **Network independence** for core device management
|
||||
- **Enhanced real-time capabilities** through device-initiated communication
|
||||
- **Enhanced real-time capabilities** through device-initiated communication
|
||||
- **Improved scalability** across diverse network topologies
|
||||
- **Better user experience** with automatic device discovery and status updates
|
||||
|
||||
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
|
||||
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "Device Lifecycle Analysis - Executive Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# Device Lifecycle Analysis - Executive Summary
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
The SoundTouch service currently relies heavily on local network connectivity for device discovery and management:
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "/power_on Implementation Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# /power_on Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides detailed technical specifications for implementing `/power_on` endpoint enhancements to reduce network dependency and improve device lifecycle management in the SoundTouch service.
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "SoundTouch `/storePreset` Implementation Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# SoundTouch `/storePreset` Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document analyzes the feasibility and implementation approach for adding `/storePreset` functionality to the Bose SoundTouch API client, based on [GitHub Issue #14](https://github.com/gesellix/Bose-SoundTouch/issues/14) and endpoints discovered through the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
|
||||
@@ -3,9 +3,6 @@ title: "SCMUDC Events Analysis"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# SCMUDC Events Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
SCMUDC (Sound Control Management Usage Data Collection) events are telemetry data sent from SoundTouch devices to `events.api.bosecm.com` via `/v1/scmudc/{deviceId}` endpoints. These events track user interactions and device behaviors for analytics and monitoring.
|
||||
|
||||
+19
-10
@@ -1,16 +1,19 @@
|
||||
---
|
||||
title: "soundtouch-web: remaining features"
|
||||
title: "soundtouch-player: remaining features"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
# soundtouch-web: remaining features
|
||||
|
||||
Four features complete the parity gap between soundtouch-web and the Stockholm
|
||||
Four features complete the parity gap between soundtouch-player and the Stockholm
|
||||
app's local-control functionality. Everything else in Stockholm (OAuth flows,
|
||||
setup wizard, service account linking, onboarding, analytics) is cloud
|
||||
infrastructure that is either shut down or already handled by soundtouch-service.
|
||||
|
||||
> **Shipped:** Saving the current content to a preset slot (slots 1–6) is
|
||||
> already implemented — a ★ star button in the top-right corner of the Now
|
||||
> Playing card opens a slot picker, and a **+** button on each preset tile
|
||||
> saves to that slot directly. See [PRESET-QUICKSTART.md](PRESET-QUICKSTART.md)
|
||||
> for usage details.
|
||||
|
||||
---
|
||||
|
||||
## 1. Seek / scrub
|
||||
@@ -40,10 +43,16 @@ func (c *Client) Seek(positionSeconds int) error {
|
||||
|
||||
---
|
||||
|
||||
## 2. Favorites
|
||||
## 2. Favorites (device-native, distinct from presets)
|
||||
|
||||
Mark or unmark the currently playing track as a favourite directly from the
|
||||
Now Playing card.
|
||||
> **Note:** This section is about the speaker's **built-in** `/favorites` API —
|
||||
> a separate concept from the 6 preset slots. Preset-slot saving (★ star /
|
||||
> **+** button) is already shipped; the native Favorites API is not yet
|
||||
> surfaced in soundtouch-player.
|
||||
|
||||
Mark or unmark the currently playing track as a device favourite directly from
|
||||
the Now Playing card. Unlike presets (maximum 6, numbered slots), the device
|
||||
can hold a larger favourites list; support varies by source.
|
||||
|
||||
**Device API:**
|
||||
- `GET /favorites` — returns `<favorites>` list
|
||||
@@ -94,7 +103,7 @@ rename and network/firmware info.
|
||||
|
||||
## 4. Render stereo pairs as a single device
|
||||
|
||||
Today soundtouch-web shows the two halves of a stereo pair (formed via
|
||||
Today soundtouch-player shows the two halves of a stereo pair (formed via
|
||||
`/addGroup` — see issue #252) as independent entries in the device list. The
|
||||
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
|
||||
presentation closes the perception gap BirdyBA flagged at
|
||||
@@ -130,7 +139,7 @@ end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
|
||||
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
|
||||
against the fake speaker's group routes
|
||||
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
|
||||
presentation in soundtouch-web's device list — no protocol work required.
|
||||
presentation in soundtouch-player's device list — no protocol work required.
|
||||
|
||||
---
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user