Compare commits
@@ -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,7 +305,7 @@ 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
|
||||
@@ -360,26 +362,26 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
- 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 }}
|
||||
@@ -390,7 +392,7 @@ jobs:
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
env:
|
||||
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
|
||||
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
|
||||
PLAYER_TAGS: ${{ steps.meta-player.outputs.tags }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
@@ -414,12 +416,12 @@ jobs:
|
||||
done <<< "$SERVICE_TAGS"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### soundtouch-web"
|
||||
echo "### soundtouch-player"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$WEB_TAGS"
|
||||
done <<< "$PLAYER_TAGS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@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
|
||||
|
||||
@@ -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,7 @@ 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
|
||||
@@ -563,6 +576,33 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -15,6 +15,7 @@ dist/
|
||||
/soundtouch-backup
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-player
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
|
||||
@@ -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/
|
||||
@@ -227,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
|
||||
|
||||
@@ -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
|
||||
@@ -34,15 +34,15 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
-o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
fi
|
||||
|
||||
# Build the soundtouch-web
|
||||
# Build the soundtouch-player (formerly soundtouch-web)
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
-o /soundtouch-player ./cmd/soundtouch-player; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||
-o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
-o /soundtouch-player ./cmd/soundtouch-player; \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
@@ -50,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
|
||||
@@ -57,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/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -70,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -176,6 +178,23 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
@@ -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",
|
||||
@@ -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()
|
||||
@@ -516,7 +588,19 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
r := setupRouter(server, stockholmHandler)
|
||||
// Embedded web UI (soundtouch-player): LAN control UI under /app, control
|
||||
// API under /api/control. Same LAN-trust tier as /setup, no auth.
|
||||
// Server-side self-calls (TTS proxy) use the service's own loopback
|
||||
// HTTP listener so they never depend on TLS / the service CA.
|
||||
loopbackHost := config.bindAddr
|
||||
if loopbackHost == "" {
|
||||
loopbackHost = "127.0.0.1"
|
||||
}
|
||||
|
||||
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
|
||||
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
|
||||
|
||||
r := setupRouter(server, stockholmHandler, webApp)
|
||||
|
||||
// Bind the listener before logging so we print the true
|
||||
// effective port (handles :0 and catches "address already
|
||||
@@ -602,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
|
||||
@@ -632,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")
|
||||
|
||||
@@ -676,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")
|
||||
@@ -714,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,
|
||||
@@ -803,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 != "" {
|
||||
@@ -899,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 {
|
||||
@@ -926,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)
|
||||
@@ -934,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"))
|
||||
|
||||
@@ -958,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
|
||||
@@ -978,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
|
||||
@@ -994,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())
|
||||
@@ -1006,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)
|
||||
@@ -1026,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)
|
||||
|
||||
@@ -1046,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)
|
||||
@@ -1090,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.
|
||||
@@ -1097,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)
|
||||
@@ -1126,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1174,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)
|
||||
@@ -1224,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)
|
||||
@@ -1231,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)
|
||||
@@ -1260,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)
|
||||
}
|
||||
|
||||
@@ -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/*
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -665,7 +665,7 @@ soundtouch --device 192.0.2.100 preset 1
|
||||
soundtouch interactive
|
||||
|
||||
# Web interface
|
||||
soundtouch-webapp --port 8080
|
||||
soundtouch-playerapp --port 8080
|
||||
```
|
||||
|
||||
### JavaScript/WASM Usage
|
||||
@@ -727,10 +727,10 @@ client.startEventStream((event) => {
|
||||
./soundtouch-linux-amd64 --device IP play
|
||||
|
||||
# Web Application (embedded assets)
|
||||
./soundtouch-webapp-linux-amd64 --port 8080
|
||||
./soundtouch-playerapp-linux-amd64 --port 8080
|
||||
|
||||
# Docker
|
||||
docker run -p 8080:8080 soundtouch-webapp
|
||||
docker run -p 8080:8080 soundtouch-playerapp
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
|
||||
@@ -35,7 +35,7 @@ layout: hextra-home
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="Music Browsing"
|
||||
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-web and soundtouch-cli."
|
||||
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-player and soundtouch-cli."
|
||||
icon="speakerphone"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
|
||||
@@ -46,16 +46,16 @@ selection behave the same as before.
|
||||
|
||||
Your six preset buttons work. AfterTouch stores preset bindings locally and serves them
|
||||
back to the speaker on request. You can also **save new presets** — via the API,
|
||||
via `soundtouch-cli`, or through the soundtouch-web UI.
|
||||
via `soundtouch-cli`, or through the soundtouch-player UI.
|
||||
|
||||
### ST-10 stereo pairing
|
||||
|
||||
**SoundTouch 10 stereo pairs** (and other ST pairing configurations) are supported
|
||||
end-to-end: creation, management, and playback routing all go through AfterTouch.
|
||||
|
||||
### soundtouch-web — browser UI
|
||||
### soundtouch-player — browser UI
|
||||
|
||||
**soundtouch-web** is an early-stage but functional browser UI bundled with AfterTouch.
|
||||
**soundtouch-player** is an early-stage but functional browser UI bundled with AfterTouch.
|
||||
It gives you:
|
||||
|
||||
- TuneIn and RadioBrowser browsing and playback
|
||||
@@ -65,7 +65,7 @@ It gives you:
|
||||
|
||||
It runs as part of the AfterTouch service — no separate install needed.
|
||||
|
||||

|
||||

|
||||
|
||||
### Automation with soundtouch-cli
|
||||
|
||||
@@ -110,9 +110,9 @@ right places to start.
|
||||
|
||||
## What's next
|
||||
|
||||
The soundtouch-web UI will gain richer preset management — browsing, editing, and
|
||||
The soundtouch-player UI will gain richer preset management — browsing, editing, and
|
||||
reordering presets directly from the browser. Longer term, merging
|
||||
`soundtouch-service` and `soundtouch-web` into a single binary is on the table,
|
||||
`soundtouch-service` and `soundtouch-player` into a single binary is on the table,
|
||||
which would simplify deployment to a single process with no extra flags.
|
||||
|
||||
This blog will be updated monthly — or whenever something significant ships.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Bose SoundTouch API Coverage Analysis"
|
||||
---
|
||||
**Last Updated:** February 2026
|
||||
**Last Updated:** June 2026 (reconciled against `pkg/client`)
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Implementation Status:** 100% Official Coverage + Extended Features
|
||||
**Implementation Status:** Official coverage 20/21 + extended features
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This Go implementation provides **complete coverage** of the Bose SoundTouch Web API with **100% of official endpoints implemented** (18/19) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
|
||||
This Go implementation provides near-complete coverage of the Bose SoundTouch Web API with **20 of 21 official endpoints implemented** (the one exception, `/trackInfo`, is documented but non-functional on real hardware) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
|
||||
|
||||
### Key Findings
|
||||
- ✅ **All essential user functionality implemented**
|
||||
|
||||
@@ -84,7 +84,7 @@ sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
|
||||
Name=wlan0
|
||||
|
||||
[Network]
|
||||
Address=192.168.10.1/24
|
||||
Address=198.51.100.1/24
|
||||
IPForward=yes
|
||||
ConfigureWithoutCarrier=yes
|
||||
DHCP=no
|
||||
@@ -104,7 +104,7 @@ sudo systemctl mask wpa_supplicant@wlan0
|
||||
**Verify:**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
|
||||
# Expected: ONLY inet 198.51.100.1/24 (NO second DHCP IP)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -151,9 +151,9 @@ sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
|
||||
|
||||
sudo tee /etc/dnsmasq.conf << 'EOF'
|
||||
interface=wlan0
|
||||
dhcp-range=192.168.10.100,192.168.10.200,24h
|
||||
dhcp-option=3,192.168.10.1
|
||||
dhcp-option=6,192.168.10.1
|
||||
dhcp-range=198.51.100.100,198.51.100.200,24h
|
||||
dhcp-option=3,198.51.100.1
|
||||
dhcp-option=6,198.51.100.1
|
||||
|
||||
# DNS Upstream: custom server on localhost (adjust port if necessary)
|
||||
server=127.0.0.1#5353 # Example: custom server on port 5353
|
||||
@@ -237,7 +237,7 @@ If you cannot see the `Bose-Lab` SSID on your phone:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `198.51.100.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
# If the ghost IP is still there, remove it manually:
|
||||
@@ -259,13 +259,13 @@ If you haven't created a CA yet, follow **Appendix A** first.
|
||||
# Temporarily make reachable via HTTP for easy download:
|
||||
cd /etc/my-dns-ca/
|
||||
python3 -m http.server 8080
|
||||
# → Reachable at http://192.168.10.1:8080/ca.crt
|
||||
# → Reachable at http://198.51.100.1:8080/ca.crt
|
||||
```
|
||||
|
||||
### Install on Android
|
||||
|
||||
1. Connect phone to `Bose-Lab`
|
||||
2. Open browser → `http://192.168.10.1:8080/ca.crt`
|
||||
2. Open browser → `http://198.51.100.1:8080/ca.crt`
|
||||
3. Download certificate
|
||||
4. **Settings → Security → Credentials → Install CA Certificate**
|
||||
5. Select certificate and confirm
|
||||
@@ -322,7 +322,7 @@ sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
|
||||
|
||||
# Traffic of a specific host only (filter by phone IP)
|
||||
# Read phone IP from dnsmasq.leases beforehand (see below)
|
||||
sudo tcpdump -i wlan0 -n host 192.168.10.101
|
||||
sudo tcpdump -i wlan0 -n host 198.51.100.101
|
||||
```
|
||||
|
||||
### Read SNI from TLS Traffic (without decryption)
|
||||
@@ -351,7 +351,7 @@ Transfer `.pcap` files from the Pi to the PC:
|
||||
|
||||
```bash
|
||||
# From the PC (scp)
|
||||
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
scp pi@198.51.100.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
```
|
||||
|
||||
**Important Wireshark Filters:**
|
||||
@@ -607,7 +607,7 @@ You can either configure the macOS system proxy manually or use `mitmproxy`'s au
|
||||
**Method 1: System Proxy (Manual)**
|
||||
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
|
||||
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
|
||||
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
|
||||
3. Set Server to your Pi's IP (`198.51.100.1`) and Port to `8080`.
|
||||
4. Click **OK** and **Apply**.
|
||||
|
||||
**Method 2: mitmproxy Local Redirect (Automatic)**
|
||||
@@ -667,7 +667,7 @@ If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA t
|
||||
If the **Transparent AP** setup (Steps 1–6) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
|
||||
|
||||
### 1. How it works
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `198.51.100.1:8080`.
|
||||
|
||||
* **Pros:** No complex `nftables` or NAT rules required.
|
||||
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
|
||||
@@ -683,7 +683,7 @@ mitmproxy --listen-port 8080
|
||||
1. Go to **Settings → Wi-Fi → Bose-Lab**.
|
||||
2. Select **Modify Network** (or the "i" icon).
|
||||
3. Set **Proxy** to **Manual**.
|
||||
4. **Proxy hostname:** `192.168.10.1`
|
||||
4. **Proxy hostname:** `198.51.100.1`
|
||||
5. **Proxy port:** `8080`
|
||||
6. Save and try to browse a site.
|
||||
|
||||
@@ -706,7 +706,7 @@ go get github.com/google/gopacket
|
||||
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
|
||||
|
||||
# Example: Filter for a specific speaker's IP in WebSocket messages
|
||||
go run scripts/extract-ws.go capture.pcap 192.168.100.1
|
||||
go run scripts/extract-ws.go capture.pcap 203.0.113.1
|
||||
```
|
||||
|
||||
### 2. Manual Extraction with tshark
|
||||
@@ -889,5 +889,5 @@ pgrep -a tcpdump
|
||||
dig @127.0.0.1 -p 5353 global.api.bose.io
|
||||
|
||||
# Check network connectivity from the phone (from the Pi)
|
||||
ping 192.168.10.101 # Phone IP from dnsmasq.leases
|
||||
ping 198.51.100.101 # Phone IP from dnsmasq.leases
|
||||
```
|
||||
|
||||
@@ -3,10 +3,23 @@ title: "SoundTouch supportedURLs Endpoint Analysis"
|
||||
---
|
||||
This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation.
|
||||
|
||||
> **Reconciliation note (June 2026).** The categorised lists below had drifted
|
||||
> from `pkg/client`. Verified against the code, these are **implemented** and have
|
||||
> been re-marked (some were wrongly under "Not Yet Implemented", and a few were
|
||||
> listed twice): the music-service set (`setMusicServiceAccount`,
|
||||
> `setMusicServiceOAuthAccount`, `removeMusicServiceAccount`, `serviceAvailability`),
|
||||
> presets (`storePreset`, `removePreset`), stations (`searchStation`, `addStation`,
|
||||
> `removeStation`), `navigate`, the native stereo-pair group set (`getGroup`,
|
||||
> `addGroup`, `removeGroup`, `updateGroup`), `speaker`, `playNotification`,
|
||||
> `requestToken`, `notification`. Still **not** implemented (confirmed absent from
|
||||
> `pkg/client`): `search`, `standby`, `powerManagement`, `lowPowerStandby`,
|
||||
> `language`, `listMediaServers`, `bluetoothInfo`, `userPlayControl`, and the
|
||||
> wireless / bluetooth-pairing / software-update / source-shortcut families.
|
||||
|
||||
## Discovery Summary
|
||||
|
||||
**Test Devices:**
|
||||
- Device 1: `192.0.2.11:8090` (deviceID: `08DF1F0BA325`)
|
||||
- Device 1: `192.0.2.11:8090` (deviceID: `AABBCCDDEE01`)
|
||||
- Device 2: `192.0.2.10:8090` (deviceID: `AABBCCDDEEFF`)
|
||||
|
||||
**Key Findings:**
|
||||
@@ -61,18 +74,18 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/audioproducttonecontrols` - Advanced tone controls (capability-dependent)
|
||||
- `/audioproductlevelcontrols` - Speaker level controls (capability-dependent)
|
||||
|
||||
**System Info (3/3):**
|
||||
- `/trackInfo` - Track information
|
||||
- `/bluetoothInfo` - Bluetooth information
|
||||
- `/recents` - Recently played content
|
||||
**System Info (1/3):**
|
||||
- `/recents` - Recently played content ✅
|
||||
- `/trackInfo` - Track information ❌ non-functional on real devices (use `/now_playing`)
|
||||
- `/bluetoothInfo` - Bluetooth information ❌ not implemented in `pkg/client`
|
||||
|
||||
### 🔶 Partially Implemented/Different Approach
|
||||
### ✅ Stereo-Pair Group Management (native)
|
||||
|
||||
**Zone Management:**
|
||||
- `/addGroup` ⚠️ - We use `/setZone` for group management
|
||||
- `/removeGroup` ⚠️ - We use `/setZone` for group management
|
||||
- `/getGroup` ⚠️ - We use `/getZone` for group information
|
||||
- `/updateGroup` ⚠️ - We use `/setZone` for group updates
|
||||
Implemented natively in `pkg/client` (in addition to the `/setZone` multiroom path):
|
||||
- `/addGroup` ✅ - `AddGroup()`
|
||||
- `/removeGroup` ✅ - `RemoveGroup()`
|
||||
- `/getGroup` ✅ - `GetGroup()`
|
||||
- `/updateGroup` ✅ - `UpdateGroup()`
|
||||
|
||||
### ❌ Not Yet Implemented (High Priority)
|
||||
|
||||
@@ -91,22 +104,22 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/selectLastSoundTouchSource` - Select last SoundTouch source
|
||||
- `/selectLocalSource` - Select local source
|
||||
|
||||
**Music Services Integration:**
|
||||
- `/setMusicServiceAccount` - Configure music service account
|
||||
- `/setMusicServiceOAuthAccount` - OAuth account setup
|
||||
- `/removeMusicServiceAccount` - Remove music service account
|
||||
- `/serviceAvailability` - Check service availability
|
||||
**Music Services Integration:** ✅ implemented (moved out of this list)
|
||||
- ~~`/setMusicServiceAccount`~~ ✅ `SetMusicServiceAccount()`
|
||||
- ~~`/setMusicServiceOAuthAccount`~~ ✅ `SetMusicServiceOAuthAccount()`
|
||||
- ~~`/removeMusicServiceAccount`~~ ✅ `RemoveMusicServiceAccount()`
|
||||
- ~~`/serviceAvailability`~~ ✅ `GetServiceAvailability()`
|
||||
|
||||
**Enhanced Presets:**
|
||||
- `/storePreset` - Store new preset
|
||||
- `/removePreset` - Remove existing preset
|
||||
- ~~`/storePreset`~~ ✅ `StorePreset()` (also listed under Fully Implemented)
|
||||
- ~~`/removePreset`~~ ✅ `RemovePreset()`
|
||||
- `/bookmark` - Bookmark current content
|
||||
- `/userRating` - User rating for content
|
||||
|
||||
**Station/Radio Management:**
|
||||
- `/searchStation` - Search for stations
|
||||
- `/addStation` - Add station to favorites
|
||||
- `/removeStation` - Remove station from favorites
|
||||
- ~~`/searchStation`~~ ✅ `SearchStation()`
|
||||
- ~~`/addStation`~~ ✅ `AddStation()`
|
||||
- ~~`/removeStation`~~ ✅ `RemoveStation()`
|
||||
- `/genreStations` - Browse stations by genre
|
||||
- `/stationInfo` - Station information
|
||||
|
||||
@@ -119,7 +132,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
- `/systemtimeout` - System timeout settings
|
||||
- `/powersaving` - Power saving configuration
|
||||
- `/language` - Language settings
|
||||
- `/speaker` - Speaker configuration
|
||||
- ~~`/speaker`~~ ✅ `PlayTTS()` / `PlayURL()` (TTS & URL notifications; not "speaker configuration")
|
||||
|
||||
**Network & Connectivity:**
|
||||
- `/performWirelessSiteSurvey` - WiFi site survey
|
||||
@@ -133,7 +146,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
|
||||
**Content Discovery:**
|
||||
- `/search` - Content search
|
||||
- `/navigate` - Content navigation
|
||||
- ~~`/navigate`~~ ✅ `Navigate()`
|
||||
- `/listMediaServers` - List available media servers
|
||||
|
||||
### ❌ Not Yet Implemented (Low Priority)
|
||||
@@ -156,9 +169,9 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
|
||||
**System Utilities:**
|
||||
- `/userActivity` - User activity tracking
|
||||
- `/requestToken` - Token management
|
||||
- `/notification` - Notification management
|
||||
- `/playNotification` - Play notification sound
|
||||
- ~~`/requestToken`~~ ✅ `RequestToken()`
|
||||
- ~~`/notification`~~ ✅ `NotifySourcesUpdated()`
|
||||
- ~~`/playNotification`~~ ✅ `PlayNotification()`
|
||||
- `/introspect` - System introspection
|
||||
- `/test` - System test interface
|
||||
|
||||
@@ -229,7 +242,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
|
||||
**Example Response Structure:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<supportedURLs deviceID="08DF1F0BA325">
|
||||
<supportedURLs deviceID="AABBCCDDEE01">
|
||||
<URL location="/info" />
|
||||
<URL location="/capabilities" />
|
||||
<!-- ... 101 additional endpoints ... -->
|
||||
|
||||
@@ -198,7 +198,7 @@ The web UI is already fully responsive — it has Bootstrap grid columns, `@medi
|
||||
### Priority 2 — RadioBrowser as a first-class provider
|
||||
|
||||
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-player` web UI as a browsable/searchable source.
|
||||
- Make discovered stations directly presetable to hardware buttons.
|
||||
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
|
||||
|
||||
@@ -242,7 +242,7 @@ These exist in soundcork but are deliberate architectural choices in AfterTouch,
|
||||
|
||||
| Area | soundcork | AfterTouch |
|
||||
|--------------------------|---------------------------------------|-----------------------------------------------------------|
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-web` component (Go + plain HTML/JS) |
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-player` component (Go + plain HTML/JS) |
|
||||
| Direct device management | SSH/SCP access into speakers | HTTP API only; no SSH |
|
||||
| Device discovery client | Python `upnpclient` library | mDNS + UPnP in Go, with dedicated DNS interception server |
|
||||
| Token delivery | Push (ZeroConf priming to port 8200) | Pull (device calls back to fetch) |
|
||||
|
||||
@@ -7,11 +7,11 @@ sidebar:
|
||||
|
||||
## Overview
|
||||
|
||||
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-web UI, the CLI, or the Go library.
|
||||
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-player UI, the CLI, or the Go library.
|
||||
|
||||
## Via soundtouch-web (browser UI)
|
||||
## Via soundtouch-player (browser UI)
|
||||
|
||||
**soundtouch-web** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
|
||||
**soundtouch-player** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
|
||||
|
||||
### ★ Star button — save from Now Playing
|
||||
|
||||
|
||||
@@ -3,13 +3,30 @@ title: "Unimplemented SoundTouch API Endpoints"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
**Last Updated:** January 2026
|
||||
**Last Updated:** June 2026 (reconciled against `pkg/client`)
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Current Implementation:** 35 endpoints (including preset & navigation management discovered via SoundTouch Plus Wiki)
|
||||
**Current Implementation:** ~41 endpoints in `pkg/client` (see reconciliation note)
|
||||
**Wiki Documentation:** 87 endpoints
|
||||
**Implementation Gap:** 52 endpoints
|
||||
**Implementation Gap:** ~46 endpoints
|
||||
|
||||
This document provides comprehensive information about SoundTouch API endpoints documented in the community wiki but not yet implemented in this Go library. All examples are based on real device responses and extensive community testing.
|
||||
This document covers SoundTouch **device** WebServices API endpoints (the
|
||||
speaker's local `:8090` API consumed by `pkg/client`) documented in the community
|
||||
wiki but not yet implemented. It is **not** about the cloud-service router
|
||||
(`cmd/soundtouch-service`); for that surface see the contract checklist
|
||||
`tests/integration/http-client/COVERAGE.md`. Examples are based on real device
|
||||
responses and community testing.
|
||||
|
||||
> **Reconciliation note (June 2026).** Verified against `pkg/client`. Since the
|
||||
> last update these are **now implemented** and have been re-marked below:
|
||||
> `setMusicServiceAccount` / `removeMusicServiceAccount` (`SetMusicServiceAccount`,
|
||||
> `RemoveMusicServiceAccount`) 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -69,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:
|
||||
@@ -111,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:
|
||||
@@ -630,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):**
|
||||
@@ -662,7 +687,7 @@ Gets current stereo pair configuration.
|
||||
<group />
|
||||
```
|
||||
|
||||
#### POST /addGroup 📊 **MEDIUM**
|
||||
#### ~~POST /addGroup~~ ✅ **IMPLEMENTED**
|
||||
Creates new stereo pair group.
|
||||
|
||||
**Request Example:**
|
||||
@@ -688,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:**
|
||||
@@ -698,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:**
|
||||
@@ -982,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`
|
||||
|
||||
@@ -996,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)
|
||||
|
||||
@@ -22,7 +22,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
Name string // From UPnP friendlyName
|
||||
Host string // IP address
|
||||
Port int // Usually 8090
|
||||
ModelID string // From UPnP modelName
|
||||
ModelID string // From UPnP modelName
|
||||
SerialNo string // MAC address from UPnP
|
||||
UPnPLocation string // Device description URL
|
||||
UPnPUSN string // Unique service name
|
||||
@@ -66,7 +66,7 @@ The current system uses multiple data collection methods to build a complete dev
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Service as SoundTouch Service
|
||||
participant UPnP as UPnP Discovery
|
||||
participant UPnP as UPnP Discovery
|
||||
participant mDNS as mDNS Discovery
|
||||
participant Device as SoundTouch Device
|
||||
participant DataStore as Data Store
|
||||
@@ -76,28 +76,28 @@ sequenceDiagram
|
||||
|
||||
Service->>UPnP: Start SSDP Discovery
|
||||
Service->>mDNS: Start mDNS Discovery
|
||||
|
||||
|
||||
UPnP->>UPnP: Send M-SEARCH multicast
|
||||
Device->>UPnP: Respond with location URL
|
||||
UPnP->>Device: Fetch device description XML
|
||||
Device->>UPnP: Return basic device info
|
||||
|
||||
|
||||
mDNS->>mDNS: Query _soundtouch._tcp
|
||||
Device->>mDNS: Respond with service info
|
||||
|
||||
|
||||
Service->>Service: Merge discovery results
|
||||
Service->>Device: GET /info (enrich data)
|
||||
Device->>Service: Return detailed device info
|
||||
Service->>DataStore: Store discovered device
|
||||
|
||||
|
||||
Note over User,DataStore: User Registration
|
||||
User->>Service: POST /account/{id}/devices
|
||||
Note right of User: deviceId + user-friendly name
|
||||
Service->>DataStore: Link device to account
|
||||
|
||||
|
||||
Note over Service,DataStore: Migration Process
|
||||
Service->>Device: GET /info (device identification)
|
||||
Device->>Service: Return device details
|
||||
Device->>Service: Return device details
|
||||
Service->>Service: Build migration summary
|
||||
Service->>Device: Apply configuration changes
|
||||
```
|
||||
@@ -116,7 +116,7 @@ The system has distinct phases where device information is collected and enhance
|
||||
**Endpoint**: `POST /streaming/account/{accountId}/devices`
|
||||
**Request Format**:
|
||||
```xml
|
||||
<device deviceid="08DF1F0BA325">
|
||||
<device deviceid="AABBCCDDEE0A">
|
||||
<name>Living Room Speaker</name>
|
||||
</device>
|
||||
```
|
||||
@@ -199,25 +199,25 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
|
||||
|
||||
### Data Completeness Comparison
|
||||
|
||||
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|
||||
|------------|----------------|-------------|----------------|
|
||||
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
|
||||
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
|
||||
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
|
||||
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
|
||||
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
|
||||
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
|
||||
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
|
||||
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
|
||||
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
|
||||
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
|
||||
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
|
||||
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|
||||
|-----------------------|----------------------|--------------------|-------------------|
|
||||
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
|
||||
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
|
||||
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
|
||||
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
|
||||
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
|
||||
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
|
||||
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
|
||||
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
|
||||
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
|
||||
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
|
||||
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
|
||||
|
||||
### Enhancement Benefits
|
||||
|
||||
#### 1. Network Independence
|
||||
- ✅ Works across internet/WAN connections
|
||||
- ✅ No multicast/broadcast requirements
|
||||
- ✅ No multicast/broadcast requirements
|
||||
- ✅ Firewall/NAT friendly
|
||||
- ✅ Supports remote device management
|
||||
|
||||
@@ -246,21 +246,21 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
// Fallback to existing discovery
|
||||
return s.fallbackToDiscovery(r.RemoteAddr)
|
||||
}
|
||||
|
||||
|
||||
// Extract device information
|
||||
deviceMAC := powerOnData.Device.ID
|
||||
deviceIP := powerOnData.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
|
||||
// Lookup existing device data
|
||||
deviceInfo := s.lookupDeviceByMAC(deviceMAC)
|
||||
if deviceInfo == nil {
|
||||
// New device - trigger registration flow
|
||||
deviceInfo = s.createDeviceFromPowerOn(powerOnData)
|
||||
}
|
||||
|
||||
|
||||
// Update with power_on data
|
||||
s.updateDeviceFromPowerOn(deviceInfo, powerOnData)
|
||||
|
||||
|
||||
// Determine response actions
|
||||
response := s.buildPowerOnResponse(deviceInfo)
|
||||
s.sendResponse(w, response)
|
||||
@@ -280,7 +280,7 @@ Address missing data through complementary mechanisms:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Device as SoundTouch Device
|
||||
participant Service as SoundTouch Service
|
||||
participant Service as SoundTouch Service
|
||||
participant DataStore as Data Store
|
||||
participant User as User/App
|
||||
|
||||
@@ -293,7 +293,7 @@ sequenceDiagram
|
||||
alt Device Unknown
|
||||
Service->>DataStore: Create device record
|
||||
Service->>User: Notify new device found
|
||||
else Device Known
|
||||
else Device Known
|
||||
Service->>DataStore: Update device status
|
||||
end
|
||||
Service->>Device: Configuration response
|
||||
@@ -360,11 +360,11 @@ type Migration struct {
|
||||
|
||||
### Immediate Actions (Phase 1)
|
||||
1. **Enhance `/power_on` handler** to extract and store comprehensive device data
|
||||
2. **Implement device lookup by MAC address** as primary identification method
|
||||
2. **Implement device lookup by MAC address** as primary identification method
|
||||
3. **Create hybrid discovery system** using both `/power_on` and existing methods
|
||||
4. **Add network-independent device management** capabilities
|
||||
|
||||
### Medium-term Improvements (Phase 2)
|
||||
### Medium-term Improvements (Phase 2)
|
||||
1. **Implement account-device MAC mapping** for automatic association
|
||||
2. **Add IP geolocation** for regional settings inference
|
||||
3. **Create device registration UI** optimized for `/power_on` discovered devices
|
||||
@@ -372,7 +372,7 @@ type Migration struct {
|
||||
|
||||
### Long-term Enhancements (Phase 3)
|
||||
1. **Request firmware enhancement** to include missing data in `/power_on`
|
||||
2. **Implement real-time device monitoring** via `/power_on` events
|
||||
2. **Implement real-time device monitoring** via `/power_on` events
|
||||
3. **Create centralized device management** independent of network topology
|
||||
4. **Add predictive migration** based on device status patterns
|
||||
|
||||
@@ -387,8 +387,8 @@ type Migration struct {
|
||||
The `/power_on` endpoint provides a significant opportunity to reduce network dependencies while enhancing device management capabilities. By implementing a hybrid approach that leverages `/power_on` data for primary device identification and status updates while maintaining existing registration workflows for user-controlled metadata, the system can achieve:
|
||||
|
||||
- **Network independence** for core device management
|
||||
- **Enhanced real-time capabilities** through device-initiated communication
|
||||
- **Enhanced real-time capabilities** through device-initiated communication
|
||||
- **Improved scalability** across diverse network topologies
|
||||
- **Better user experience** with automatic device discovery and status updates
|
||||
|
||||
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
|
||||
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: "soundtouch-web: remaining features"
|
||||
title: "soundtouch-player: remaining features"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
Four features complete the parity gap between soundtouch-web and the Stockholm
|
||||
Four features complete the parity gap between soundtouch-player and the Stockholm
|
||||
app's local-control functionality. Everything else in Stockholm (OAuth flows,
|
||||
setup wizard, service account linking, onboarding, analytics) is cloud
|
||||
infrastructure that is either shut down or already handled by soundtouch-service.
|
||||
@@ -48,7 +48,7 @@ func (c *Client) Seek(positionSeconds int) error {
|
||||
> **Note:** This section is about the speaker's **built-in** `/favorites` API —
|
||||
> a separate concept from the 6 preset slots. Preset-slot saving (★ star /
|
||||
> **+** button) is already shipped; the native Favorites API is not yet
|
||||
> surfaced in soundtouch-web.
|
||||
> surfaced in soundtouch-player.
|
||||
|
||||
Mark or unmark the currently playing track as a device favourite directly from
|
||||
the Now Playing card. Unlike presets (maximum 6, numbered slots), the device
|
||||
@@ -103,7 +103,7 @@ rename and network/firmware info.
|
||||
|
||||
## 4. Render stereo pairs as a single device
|
||||
|
||||
Today soundtouch-web shows the two halves of a stereo pair (formed via
|
||||
Today soundtouch-player shows the two halves of a stereo pair (formed via
|
||||
`/addGroup` — see issue #252) as independent entries in the device list. The
|
||||
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
|
||||
presentation closes the perception gap BirdyBA flagged at
|
||||
@@ -139,7 +139,7 @@ end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
|
||||
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
|
||||
against the fake speaker's group routes
|
||||
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
|
||||
presentation in soundtouch-web's device list — no protocol work required.
|
||||
presentation in soundtouch-player's device list — no protocol work required.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
---
|
||||
title: "API Route Layout and Refactoring Plan"
|
||||
---
|
||||
|
||||
> **Tracking issue:** [#451 "Merge soundtouch-player into soundtouch-service"](https://github.com/gesellix/Bose-SoundTouch/issues/451).
|
||||
> This document is the architectural reference for the staged API refactoring
|
||||
> that precedes (and enables) that merge.
|
||||
|
||||
## Why this exists
|
||||
|
||||
`soundtouch-service` and `soundtouch-player` are two binaries with two routers.
|
||||
We want to:
|
||||
|
||||
1. Restructure our own routes into a layout that can stay stable.
|
||||
2. Eventually fold `soundtouch-player` into `soundtouch-service` (one binary).
|
||||
3. Stop leaking frontend (SPA) routes into the backend API.
|
||||
4. Make **cloud / remote-host a first-class, clean deployment**, not just LAN /
|
||||
on-device. This is a primary motivation: we consolidate the API *in a way
|
||||
that* closes the trust and auth gaps a public deployment exposes, rather than
|
||||
just merging two binaries. Enforced auth is therefore a real requirement, not
|
||||
an afterthought.
|
||||
|
||||
Before moving anything, every route has to be classified by **whether we are
|
||||
free to move it**, and that depends on **who the client is**. A route the
|
||||
speaker firmware calls is frozen forever; an internal admin route is ours to
|
||||
reshape.
|
||||
|
||||
## Classification criteria
|
||||
|
||||
Classify by client audience, then by what pins the path:
|
||||
|
||||
| Category | Client | Free to move? |
|
||||
|------------------------------------|-----------------------------------|----------------------------------------------------------------------------------------------------------------|
|
||||
| **(1a) Frozen, firmware-pinned** | Speaker firmware | No, ever. The path is hardcoded in the speaker (or relative to a base it fetches from us). |
|
||||
| **(1b) Frozen, externally-pinned** | OAuth providers (Spotify/Amazon) | Only with provider re-registration + device re-priming. Treat as frozen unless that cost is paid deliberately. |
|
||||
| **(2) Service-internal** | The admin/setup UI | Yes, freely. These are ours. |
|
||||
| **(3) Web/control** | The control UI (soundtouch-player) | Yes, freely. |
|
||||
| **(4) Frontend (SPA)** | Browser, client-side routing | Should not be enumerated in the backend at all (see `/app/*` below). |
|
||||
| **(Infra)** | Humans, monitoring, the SPA shell | Conventionally stable; collision-prone at merge time. |
|
||||
|
||||
Two refinements that matter in practice:
|
||||
|
||||
- **"Must stay" is not one thing.** (1a) is immovable; (1b) is movable but
|
||||
coordinated. Do not lump OAuth callbacks in with firmware paths.
|
||||
- **The merge-overlap bucket is smaller than it looks.** Verified against the
|
||||
two routers, only **`/` is a true collision** (service `HandleRoot` vs the web
|
||||
app's `serveIndex`); resolve it with a small **landing page** at `/` that lets
|
||||
the user pick Admin/Setup (service) or the App (web). **`/health` is a merge,
|
||||
not a clash** (both define it; standardise on the service's richer body, which
|
||||
carries version + timestamp, and confirm nothing depends on the web's
|
||||
`{"status":"ok","version"}` shape). **`/ws` and `/static/*` do not collide at
|
||||
all** — the service registers neither, so bringing the web's in is purely
|
||||
additive. TuneIn is **not** in this bucket either: `/bmx/tunein/*` (speaker <->
|
||||
BMX integration, frozen) and `/api/tunein/*` (the player's generalized radio
|
||||
search/play, ours to change) are two different layers.
|
||||
|
||||
**Resolve overlaps structurally, before merging, not behind a flag.** A
|
||||
conditional "only register the web routes when opt-in is on" does not fix a
|
||||
collision — it just hides it while the flag is off, and the double-registration
|
||||
returns when it's on. Do not rely on chi to detect or warn about it. Clean up
|
||||
`/` (and the `/health` merge) up front so the merged router is unambiguous
|
||||
regardless of the flag. The opt-in (below) exists only to let people optionally
|
||||
run the merged variant and give feedback, not as a collision guard.
|
||||
|
||||
## What pins the frozen routes (evidence)
|
||||
|
||||
- The speaker fetches BMX content, marge/streaming data, software updates, and
|
||||
CED config from hostnames it has hardcoded (or from a base URL we hand it).
|
||||
`/ced/*` mirrors `downloads.bose.com/ced/soundtouch/...`; `/bmx`, `/core02`,
|
||||
`/streaming`, `/accounts`, `/customer`, `/oauth`, `/v1` mirror the Bose cloud
|
||||
contract.
|
||||
- Persisted device data embeds absolute service URLs. Presets store
|
||||
`LOCAL_INTERNET_RADIO`/Orion locations like
|
||||
`https://.../core02/svc-bmx-adapter-orion/prod/orion/station?data=...`, and
|
||||
the BMX registry advertises `{MEDIA_SERVER}/media` and `/bmx-icons`. So
|
||||
`/media`, `/bmx-icons`, `/custom`, and `/core02` are effectively part of the
|
||||
firmware-facing contract: a speaker that stored a preset will replay that
|
||||
exact URL later. They cannot move without rewriting persisted state on every
|
||||
device.
|
||||
|
||||
## Service routes (`soundtouch-service`)
|
||||
|
||||
Grouped by prefix. The authoritative enumerated list is the router golden file
|
||||
`cmd/soundtouch-service/testdata/router_routes.txt`.
|
||||
|
||||
| Prefix | Category | Client | Movable? |
|
||||
|----------------------------------------------------------------------------------------------------|-----------------------|---------------------------------------|------------------------------------------|
|
||||
| `/streaming/*` | (1a) frozen | Speaker (marge / streaming.bose.com) | No |
|
||||
| `/accounts/*` | (1a) frozen | Speaker (marge, alternate paths) | No |
|
||||
| `/customer/account/*` | (1a) frozen | Speaker | No |
|
||||
| `/bmx/*` (registry + tunein) | (1a) frozen | Speaker (BMX) | No |
|
||||
| `/core02/svc-bmx-adapter-*` (Orion, SiriusXM) | (1a) frozen | Speaker (BMX adapters) | No |
|
||||
| `/oauth/*/token`<br>`/oauth/*/token/cs`<br>`/oauth/*/token/cs1`<br>`/oauth/*/token/cs3` | (1a) frozen | Speaker (music tokens) | No |
|
||||
| `/custom/v1/playback/*` | (1a) frozen | Speaker (LOCAL_INTERNET_RADIO / ding) | No |
|
||||
| `/bmx-icons/*`<br>`/media/*`<br>`/media/aftertouch-ding.wav`<br>`/media/tts/*` | (1a) frozen | Speaker (advertised base) | No |
|
||||
| `/streaming/resources/api_versions.xml`<br>`/streaming/software/update/*`<br>`/updates/soundtouch` | (1a) frozen | Speaker (SW update) | No |
|
||||
| `/v1/auth`<br>`/v1/blacklist/*`<br>`/v1/scmudc/*`<br>`/v1/stapp/*` | (1a) frozen | Speaker | No |
|
||||
| `/alexa/certificate` | (1a) frozen | Speaker / AWS | No |
|
||||
| `/ced/*` | (1a) frozen | Speaker (mirrors downloads.bose.com) | No |
|
||||
| `/mgmt/amazon/callback`<br>`/mgmt/spotify/callback` | (1b) frozen, external | OAuth providers | Only with re-registration |
|
||||
| `/setup/*` (~40 routes) | (2) service-internal | Admin UI | Yes |
|
||||
| `/mgmt/*` (except the callbacks above) | (2) service-internal | Admin UI | Yes |
|
||||
| `/web/*` (`HandleWeb`) | (4) frontend | Browser (admin SPA) | Yes; already the clean catch-all pattern |
|
||||
| `/`<br>`/docs/*`<br>`/favicon.ico`<br>`/health` | (Infra) | Humans / monitoring | Keep stable by convention |
|
||||
|
||||
## Web routes (`soundtouch-player`)
|
||||
|
||||
Defined in `pkg/service/soundtouchweb/mount.go`. Not currently mounted inside
|
||||
the service; it is a separate binary.
|
||||
|
||||
| Group | Category | Note |
|
||||
|------------------------------------------------------------------------------------------|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `/api/*` (devices, control, tunein, zone, radiobrowser, play-url, device-speak) | (3) web/control | Freely restructurable |
|
||||
| `/health`<br>`/static/*`<br>`/ws` | (Infra) | `/health` is a merge (standardise on the service's body); `/static/*` and `/ws` are additive (the service registers neither) |
|
||||
| `/`<br>`/device/*`<br>`/devices`<br>`/playurl`<br>`/radiobrowser`<br>`/tts`<br>`/tunein` | (4) frontend | `/` is the one true collision (-> landing page); the rest move under `/app/*`. The anti-pattern: each SPA route enumerated in the backend, all serving `index.html` |
|
||||
|
||||
## Deployment scenarios, reachability, and trust boundaries
|
||||
|
||||
The client-audience axis tells you *who* calls a route. The deployment tells you
|
||||
whether that caller can actually reach it and whether the surrounding network
|
||||
can be trusted. AfterTouch runs in materially different places, and that decides
|
||||
which routes are even *meaningful* and what the trust boundary is.
|
||||
|
||||
### Actors (the original Bose model)
|
||||
|
||||
The original Bose architecture had three actors, and our route surface still
|
||||
reflects all three:
|
||||
|
||||
| Actor | Where | Role |
|
||||
|---------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Speaker | Local (the device) | Calls the cloud for its data-plane (`/full`, presets, sources, software update, tokens) and is provisioned by the app. |
|
||||
| App | Local (phone / desktop), **in-between** | Creates the account, adds a speaker to an account, and teaches the speaker its cloud/marge credentials. **Authenticates itself** to the cloud. |
|
||||
| Cloud | External / public (what AfterTouch replaces) | Serves the speaker data-plane and the app's account/provisioning calls. |
|
||||
|
||||
Two things matter for our design:
|
||||
|
||||
- **The app is deployment-agnostic.** It does not care whether the cloud (our
|
||||
service) runs locally or in a datacenter; it talks to whatever cloud endpoint
|
||||
it is pointed at. So the **deployment modes below are about where the *cloud*
|
||||
role runs**, orthogonal to the app actor.
|
||||
- **AfterTouch's own tooling currently plays the app's role.** Account creation
|
||||
and "teach the speaker its marge account" are done by our migration tooling
|
||||
(today via the speaker's local WebSocket `setMargeAccount`), i.e. we are the
|
||||
provisioning agent. But the app-facing *cloud* endpoints still exist in the
|
||||
surface (account create/login, add device, profile, password, groups), and a
|
||||
real app pointed at us would use them. They are part of the frozen contract,
|
||||
but their caller and trust story differ from the speaker's data-plane (see
|
||||
below).
|
||||
|
||||
### Deployment topologies (where the cloud role runs)
|
||||
|
||||
This is descriptive (where it runs), distinct from the `deployment-mode`
|
||||
*parameter* below (the security posture). They correlate but are kept separate so
|
||||
an operator is not locked into one because of the other.
|
||||
|
||||
| Topology | Where | Reaches speakers directly? | Speaker reaches it? |
|
||||
|---------------------|-------------------------------------------|----------------------------|-------------------------------------------|
|
||||
| On-device | On the speaker itself | Itself only | Yes (loopback / LAN) |
|
||||
| LAN host | Raspberry Pi / Docker on the home network | Yes (same LAN) | Yes |
|
||||
| Cloud / remote host | External host, not on the speaker LAN | No | Yes (speaker calls out over the internet) |
|
||||
|
||||
### Two planes: speaker-direct vs data-plane
|
||||
|
||||
Routes fall into two reachability planes that behave very differently across
|
||||
deployments:
|
||||
|
||||
- **Speaker-direct (control plane):** the service opens a connection *to* the
|
||||
speaker's local API (`:8090`) right now. Discovery, migration, reboot,
|
||||
test-connection, peer-probe, and the entire `soundtouch-player`
|
||||
control/zone/volume/key/TTS-to-speaker surface. These only work where the host
|
||||
shares the LAN with the speaker. **In a cloud deployment they are dead weight**,
|
||||
and any UI that shows them is misleading.
|
||||
- **Data-plane (cloud replacement):** something calls the *service*, which works
|
||||
in every deployment because the caller reaches in. Two callers live here:
|
||||
- **Speaker-polled:** the speaker fetches its own data (`/full`, sources,
|
||||
presets, recents, provider/device settings, software update, streaming
|
||||
token, stats). No user auth; the speaker is identified by account/device.
|
||||
- **App / provisioning-called:** the app (or, today, our own tooling acting as
|
||||
the app) creates accounts, logs in, adds/updates/removes devices, edits the
|
||||
profile/password, and manages groups. In the original model the app
|
||||
**authenticates itself** here, so these endpoints carry an auth dimension the
|
||||
speaker's polling does not. They are deployment-agnostic: the app reaches the
|
||||
cloud wherever it runs.
|
||||
|
||||
So a cloud deployment is essentially the data-plane (both callers) plus
|
||||
server-side state management (accounts, presets, provider credentials,
|
||||
diagnostics of stored data). The interactive "do something to a speaker now"
|
||||
features (both the player and migration) need LAN proximity.
|
||||
|
||||
Consequence for the migration tooling (ref the #451 discussion): migration is
|
||||
**recurring**, not one-shot (you add a speaker later too), and it is
|
||||
**LAN-bound**. That argues for migration as a local mode/tool you run on the LAN
|
||||
when needed, rather than always-on code in a cloud binary that could never use
|
||||
it.
|
||||
|
||||
### Trust zones and the current state
|
||||
|
||||
The trust zones, mapped to the actors above, and today barely any is guarded:
|
||||
|
||||
| Zone | Routes | Client auth today | Should be |
|
||||
|--------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
|
||||
| Speaker contract | frozen (1a), speaker-polled | None (no user login; the app_key is validated but is not user auth) | None, but network-segmentable; in cloud these are necessarily public so the speaker can reach them |
|
||||
| App / provisioning | account create/login, add/update/remove device, profile, password, groups (`/streaming/account*`, `/customer/account*`) | None enforced (we accept; the app's self-auth from the original model is not required) | Authenticated in cloud: an open provisioning surface lets anyone create accounts or attach devices |
|
||||
| Admin / setup | `/mgmt/*` (non-callback)<br>`/setup/*`<br>`/web/*` | `/mgmt/*` has single-credential HTTP Basic Auth; **`/setup/*` has none** (explicit "LAN-trust" premise); the Basic Auth even leaks behind a proxy (#419) | Authenticated always; mandatory in cloud |
|
||||
| Control / player | `/api/control/*` (post-merge) | None | Optional auth; low blast radius |
|
||||
|
||||
The "LAN-trust" premise is defensible on a home LAN but **invalid in the cloud**:
|
||||
`/setup/*` (migration, DNS redirect, trust roots / cert state, account data,
|
||||
diagnostics, recovery) is wide open, and so is the app/provisioning surface
|
||||
(anyone could create an account or attach a device). On a public host both are a
|
||||
real exposure. Closing these gaps is a prerequisite for treating cloud as a
|
||||
supported deployment.
|
||||
|
||||
### Requirements this drives
|
||||
|
||||
- **Authentication** on everything user-facing, actually enforced (not
|
||||
bypassable behind a proxy, see #419). Mandatory for cloud; offered and
|
||||
recommended for LAN.
|
||||
- **Authorization tiers** by blast radius (the "authority boundary" from the
|
||||
#451 landing-page note): a low-privilege user may open the player, while
|
||||
setup/mgmt (trust roots, migration, accounts) require admin. The landing page
|
||||
is where that boundary is made explicit.
|
||||
- **Deployment-aware surface:** in cloud mode, hide/disable the speaker-direct
|
||||
features (they cannot work) and require auth on the rest; in LAN/on-device
|
||||
mode, expose the full surface.
|
||||
|
||||
These requirements are why the `/api/*` split below is grouped by trust tier:
|
||||
applying an auth (and later authz) middleware to a whole group is a one-liner,
|
||||
whereas per-route auth is what produced today's patchy coverage.
|
||||
|
||||
### The `deployment-mode` parameter (private / shared / public)
|
||||
|
||||
The security posture is an explicit parameter, **default `private`**. It is a
|
||||
preset over the per-tier auth machinery, not separate architecture: each value
|
||||
just sets which trust tiers require auth.
|
||||
|
||||
| Tier (caller) | private | shared | public |
|
||||
|------------------------------|---------------|-----------------------|-----------------|
|
||||
| Speaker contract | none (frozen) | none | none |
|
||||
| Control / player | open | open | **auth** |
|
||||
| Admin / setup + provisioning | open (opt-in) | **auth (min. Basic)** | **auth** |
|
||||
| Speaker-direct features | on | on | hidden/disabled |
|
||||
|
||||
- **private** (default): free-for-all, maximum insecurity, security is opt-in.
|
||||
Matches a trusted single-owner LAN or on-device.
|
||||
- **public**: opt-out of security. Everything user-facing requires auth; the
|
||||
surrounding network is untrusted (cloud / internet). Speaker-direct features
|
||||
are hidden (they cannot work off-LAN anyway).
|
||||
- **shared**: at least Basic Auth on the structural / admin routes, while the
|
||||
player stays open. The multi-user trusted-LAN case (guests, kids, roommates):
|
||||
daily playback without a login, but infrastructure is protected.
|
||||
|
||||
The **Speaker-direct features** row is UI gating, not auth. "Speaker-direct"
|
||||
means actions that reach a speaker on the LAN (discover, migrate, reboot,
|
||||
volume/play, zone). In `public` the UI hides or disables them because they cannot
|
||||
work off-LAN, so we do not show buttons that only fail; in `private` / `shared`
|
||||
they are shown. The UI derives this from the mode.
|
||||
|
||||
**Provisioning is treated like admin**, not like the player: creating accounts
|
||||
and attaching devices is structural / high-blast-radius, so it shares the admin
|
||||
trust tier (protected in `shared` and `public`). The mechanism may still be Marge
|
||||
self-auth, but the *requirement* matches admin.
|
||||
|
||||
It is a **monotone ladder**: private -> shared adds admin (+ provisioning) auth;
|
||||
shared -> public additionally locks the player.
|
||||
|
||||
**Is `shared` necessary, or just `public`?** It is necessary and distinct. The
|
||||
only difference between shared and public is the **player tier**: shared keeps it
|
||||
open (trusted network, frictionless household use), public locks it (untrusted
|
||||
network). Collapsing them forces either a password on the daily-use player at
|
||||
home, or an open player on the internet. The cost of keeping `shared` is near
|
||||
zero once tier-based auth exists (it is just "admin required, player optional"),
|
||||
so it earns its place as the trusted-LAN-with-privilege-split preset.
|
||||
|
||||
Note this 3-value enum compresses two orthogonal axes, network trust (private /
|
||||
shared = trusted; public = untrusted) and the player/admin privilege split. That
|
||||
is a deliberate usability simplification over a toggle matrix; if the presets
|
||||
ever feel too coarse, the underlying per-tier toggles are the escape hatch.
|
||||
|
||||
**Configuration and lockout-safety.** `deployment-mode` is set like the other
|
||||
config: CLI flag, env var, and persisted setting, with the same precedence as
|
||||
the rest (e.g. like `server-url`). Because of that, the host operator always has
|
||||
an out-of-band path: even if a mode change in the UI would lock them out, they
|
||||
can reset it via env / flag / the settings file on the host. The service must
|
||||
also not let a setting strand its owner: if a mode requires auth but no
|
||||
credential / provider is configured yet, warn and keep a way in (refuse to apply,
|
||||
fall back, or allow a loopback/on-host admin bypass) rather than hard-locking the
|
||||
admin surface.
|
||||
|
||||
### Auth posture: opt-none -> opt-in -> opt-out?
|
||||
|
||||
The maturity path over releases, which the `deployment-mode` parameter then
|
||||
expresses per posture:
|
||||
|
||||
- **Today: "opt-none".** Auth is not even opt-in. `/mgmt` has a single Basic-Auth
|
||||
credential (and it leaks behind a proxy, #419); `/setup` and the
|
||||
app/provisioning surface have nothing. There is effectively no usable way to
|
||||
turn real auth on. This is `private` before `private` is even a choice.
|
||||
- **0.x: prepare opt-in.** Make auth something an operator *can* enable
|
||||
(enforced, not proxy-bypassable; covering the whole admin tier, ideally the
|
||||
provisioning surface too) and introduce the `deployment-mode` parameter so
|
||||
`shared` / `public` become selectable. The default stays `private` (security
|
||||
off) so existing LAN setups are undisturbed.
|
||||
- **1.x: default still `private`?** The parameter exists, but whether the
|
||||
shipped default should ever move off `private` is the open call. A cloud-first
|
||||
stance argues for stricter defaults; the home-LAN majority argues for keeping
|
||||
`private`. Because the posture is now an explicit parameter, the default can
|
||||
stay `private` while operators opt into `shared` / `public`, so there is no
|
||||
need for a hard global flip.
|
||||
|
||||
### Auth mechanisms
|
||||
|
||||
Three identities, three mechanisms, only the last two are ours to shape:
|
||||
|
||||
- **Speaker -> data-plane: fixed, not ours to change.** The speaker authenticates
|
||||
with a long-lived **Marge account token**, provisioned as an account ID + auth
|
||||
token (`SetMargeAccount(accountID, authToken)`,
|
||||
`pkg/service/setup/init_plan.go`); it is *not* given an email/password. This is
|
||||
part of the frozen contract, so no new auth mechanism can be imposed on the
|
||||
speaker.
|
||||
- **Admin -> admin UI: HTTP Basic Auth to start, pluggable later.** We begin with
|
||||
Basic Auth as the single admin mechanism, but structure it behind one boundary
|
||||
so additional providers (OIDC, etc.) are easy to add. None of this ever reaches
|
||||
a speaker; it is purely our app's auth.
|
||||
- **User -> web app: Marge auth, delegated.** A human (not a speaker) signs into
|
||||
the player/control UI with their Marge account, and that authentication
|
||||
**delegates to the existing Marge routes** (`/streaming/account/login` and the
|
||||
app/provisioning surface). "User auth" thus reuses the same account the speaker
|
||||
belongs to, rather than a separate user store.
|
||||
- **Native / non-browser clients (CLI, desktop or mobile app, automation) ->
|
||||
service.** A whole client class, not just the CLI. Talking to a *speaker's*
|
||||
local API needs no service auth; talking to *our service*
|
||||
(cloud/admin/provisioning routes) makes them authenticated clients. Interactive
|
||||
native clients do OIDC the standard way (RFC 8252, "OAuth 2.0 for Native
|
||||
Apps"): a loopback `localhost:<port>` redirect (CLI / desktop) or a private-use
|
||||
URI-scheme redirect (`app://callback`, mobile); the system browser runs the
|
||||
flow and the client exchanges the code for a token. The case that still needs a
|
||||
**non-interactive** credential (issued token / API key, or a device-code /
|
||||
client-credentials grant) is **headless** automation: CI, scripts, no browser.
|
||||
Requirements on the provider abstraction: (a) support both an interactive path
|
||||
(browser, including native loopback / custom-scheme redirects) and a headless
|
||||
token path, and (b) allow registering those redirect URIs (the same
|
||||
externally-pinned concern as the Spotify/Amazon callbacks).
|
||||
|
||||
**Mental model: Marge is an auth provider, like EntraID would be.** The UI auth
|
||||
sits behind one provider abstraction, and Marge is simply one provider
|
||||
implementation (the built-in / legacy one) alongside Basic Auth and future OIDC
|
||||
providers (EntraID, Google, ...). "Sign in with your Marge account" is the same
|
||||
pattern as "Sign in with EntraID": the app delegates to the provider. Basic Auth,
|
||||
Marge, and any OIDC provider all implement the same interface, so they are
|
||||
interchangeable and additive.
|
||||
|
||||
Design rule: keep the UI auth pluggable behind that single provider boundary so
|
||||
new providers slot in without touching the speaker contract (which is not a
|
||||
provider and never changes) or the Marge delegation.
|
||||
|
||||
### Identity in logs
|
||||
|
||||
Request logs should carry the resolved caller identity as context, **but only
|
||||
where the request actually exposes one** (do not fabricate an id the protocol did
|
||||
not send):
|
||||
|
||||
- **Authenticated UI / native / headless clients:** once auth lands, log the
|
||||
principal (provider subject / username / client id).
|
||||
- **Speakers:** there is no single speaker login, so it depends on the route.
|
||||
Many marge/streaming routes embed `{account}` / `{device}` in the path (also
|
||||
`/v1/scmudc/{deviceId}`, `/v1/stapp/{deviceId}`), so the device/account is
|
||||
available and worth logging. Others (BMX content like `/bmx/tunein/...`,
|
||||
`/v1/auth`) carry only a token / app_key or nothing identifying; log what is
|
||||
present and otherwise leave it blank rather than guessing.
|
||||
- **Unauthenticated:** mark as anonymous.
|
||||
|
||||
Caveats: sanitise the value before logging (the existing log-injection guard,
|
||||
`sanitizeLog` / `sanitizeErr`), and remember these ids (account / device /
|
||||
principal) are sensitive, so they must follow the existing log redaction on
|
||||
diagnostic export, not leak into shared bundles.
|
||||
|
||||
## Target layout
|
||||
|
||||
```
|
||||
# Frozen compat layer (top-level, never reshape):
|
||||
/streaming /accounts /customer /bmx /core02 /oauth /custom
|
||||
/media /bmx-icons /updates /v1 /alexa /ced
|
||||
|
||||
# Our JSON API (everything movable lives here, grouped BY TRUST TIER so
|
||||
# auth/authz middleware applies per group, not per route):
|
||||
/api/setup/* (today: /setup/*) -> admin tier: auth required
|
||||
/api/mgmt/* (today: /mgmt/*, no callbacks) -> admin tier: auth required
|
||||
/api/control/* (today: soundtouch-player /api/*) -> player tier: auth optional
|
||||
/api/devices ...
|
||||
|
||||
# OAuth provider callbacks (externally-pinned; freeze in place,
|
||||
# or move only with provider re-registration):
|
||||
/mgmt/spotify/callback, /mgmt/amazon/callback
|
||||
|
||||
# Frontend (one role-gated app, single catch-all, no per-route registration):
|
||||
/app/* (the unified app; role/auth decides Player vs Setup visibility)
|
||||
/web/* (legacy admin UI; retired once /app/* subsumes it)
|
||||
|
||||
# Infra:
|
||||
/health /metrics /ws
|
||||
```
|
||||
|
||||
### The `/app/*` pattern
|
||||
|
||||
The service's admin UI already does the right thing: `/web/*` is one catch-all
|
||||
(`HandleWeb`), not one route per page. The `soundtouch-player` SPA routes
|
||||
(`mount.go`, the `/`, `/devices`, `/tunein`, ... block) are the legacy
|
||||
anti-pattern. The target:
|
||||
|
||||
- **`/app/*`** is a single catch-all that returns `index.html`. The browser does
|
||||
client-side routing within `/app/`. No frontend path appears in the backend
|
||||
router.
|
||||
- **`/api/*`** serves data only.
|
||||
- Static assets live under a fixed prefix (e.g. `/app/static/*`).
|
||||
|
||||
This keeps the backend API free of frontend routes while still avoiding any
|
||||
need for server-side SPA routing config.
|
||||
|
||||
### One app, role-gated (not two apps)
|
||||
|
||||
Decision: converge to a **single app** under `/app/*`; role/auth decides what a
|
||||
user sees (Player vs Setup are views of one app, not separate apps). This is the
|
||||
natural expression of the trust tiers, removes the duplicated shell / device
|
||||
handling the two frontends carry today, and lets them share device list and
|
||||
state (the data-sharing win from the #451 discussion). `/web/*` is retired once
|
||||
`/app/*` subsumes it.
|
||||
|
||||
Two things make this safe:
|
||||
|
||||
- **Size (the on-device concern): "one app" is not "one eager bundle."**
|
||||
Code-split the heavy Setup/Admin surface (migration, certs, DNS, diagnostics,
|
||||
the ~4.8k-line `script.js`) into a **lazily loaded chunk** that loads only when
|
||||
an admin navigates there, so the Player path stays light. If size ever gets
|
||||
tight on-device, a **build tag / flag** can produce a player-only variant that
|
||||
does not embed the Setup chunk at all. The combined *embedded* size is likely
|
||||
to *drop*, not grow, since two separate apps duplicate more than one modular
|
||||
app does; the only real risk is naive eager bundling. Guard it with a
|
||||
bundle-size / route-count acceptance check (per the #451 discussion): measure
|
||||
first.
|
||||
- **Role-gating is UX, not security.** Hiding the Setup views from non-admins is
|
||||
convenience only. The real boundary stays the **server-side auth middleware**
|
||||
on the admin / provisioning tiers, otherwise someone just loads the chunk and
|
||||
calls the routes directly.
|
||||
|
||||
## Regression safety: contract tests from the frozen recordings
|
||||
|
||||
Build the regression net **before** touching routes. We already record
|
||||
interactions (`RECORD_INTERACTIONS`) and have a large collection; frozen and
|
||||
sanitised, that collection becomes a contract suite that proves the refactor
|
||||
preserves behavior. It is stronger than the router golden file
|
||||
(`router_routes.txt`), which only checks that routes are registered, not what
|
||||
they return.
|
||||
|
||||
Two directions, matching the two consumers:
|
||||
|
||||
- **Speaker contract (highest value): provider-side replay.** The speaker is a
|
||||
consumer we do *not* control (it is Bose firmware), so this is not classic
|
||||
consumer-driven Pact: the speaker's real recorded traffic *is* the contract.
|
||||
Replay each recorded request against the service and assert the response still
|
||||
matches (body and headers). This pins category-1 byte-for-byte, exactly the
|
||||
invariant the refactor must not break, and it catches subtle wire details a
|
||||
route reshuffle could disturb (for example the case-sensitive `ETag` header).
|
||||
It aligns with the existing parity tests (local vs official Bose recordings).
|
||||
- **CLI / `/api/*` contract (optional): consumer-driven Pact.** The CLI is a
|
||||
consumer we *do* control, so real Pact fits: the CLI declares expectations and
|
||||
the service verifies them. Most useful once the new `/api/*` shape exists, and
|
||||
to assert **dual-routing equivalence** (old and new path satisfy the same
|
||||
contract). Lower priority, since this surface is intentionally changing in 0.x.
|
||||
|
||||
We are not starting from zero: the existing `tests/integration/http-client/*.http`
|
||||
suite (run in CI via `make test-http-client` against the service plus the
|
||||
spotify/amazon mocks) is already a near-consumer-driven contract from the
|
||||
speaker's perspective. The requests carry the firmware user-agent
|
||||
(`Bose_Lisa/27.0.6`) and assert status, content-type, and XML structure of the
|
||||
marge/streaming/BMX routes. It is not literally Pact (no consumer/provider broker
|
||||
or generated pacts), but it is functionally the speaker contract, and it already
|
||||
asserts structure and invariants rather than raw bytes, which is exactly the
|
||||
matcher approach that keeps contracts non-flaky. The natural path is to treat
|
||||
this suite as the seed and broaden it with the frozen recordings, rather than
|
||||
inventing a new harness.
|
||||
|
||||
How it de-risks the rebuild:
|
||||
|
||||
- Pins the frozen speaker contract so a route reshuffle cannot silently alter the
|
||||
wire.
|
||||
- During dual-routing, runs the same contract against both old and new paths to
|
||||
prove the alias is faithful.
|
||||
- Becomes the gate: the refactor lands only when the contract suite is green.
|
||||
|
||||
Caveats:
|
||||
|
||||
- **Sanitise before freezing.** Recordings carry real IPs, MACs, account /
|
||||
device ids, and tokens; per the repo rules they must be anonymised (the
|
||||
existing testdata anonymisation / rotation) before they become committed
|
||||
fixtures.
|
||||
- **Match, do not byte-compare blindly.** Legitimately dynamic fields
|
||||
(timestamps, tokens, generated ids, ETag *values*) need normalisation /
|
||||
matchers, or the contracts go flaky. Freeze structure and invariants, not the
|
||||
volatile bits.
|
||||
|
||||
## Staged migration
|
||||
|
||||
Everything below happens **within 0.x**. 1.x is only the cutover (removal). The
|
||||
frozen speaker/app contract routes (category 1) are out of scope throughout: they
|
||||
never move, so none of the aliasing / redirect / deprecation machinery touches
|
||||
them.
|
||||
|
||||
### Route-transition track (0.x)
|
||||
|
||||
1. **Add the new routes, switch the service admin UI to them, alias the old
|
||||
paths.** Mount `/setup/*` and `/mgmt/*` under the new `/api/*` grouping (chi
|
||||
`Route`/`Mount`; carve it so `/api/control/*` fits later) and point
|
||||
`script.js` at the new paths.
|
||||
- **Use aliasing (dual-mount), not HTTP redirects, for our own routes:**
|
||||
register the same handler at both the old and new path. It avoids the
|
||||
client-following and method/body pitfalls of redirects (a redirect would
|
||||
have to be 307/308 to keep a POST body) and is a no-break upgrade for any
|
||||
lagging client.
|
||||
- **Does this work for speaker/legacy routes? No, and it is not needed.** We
|
||||
never move frozen routes, and a fixed speaker firmware cannot be assumed to
|
||||
follow a redirect on its marge/BMX calls (untested; do not rely on it). This
|
||||
step is about our movable routes only.
|
||||
- **Exclude** `/mgmt/spotify/callback` and `/mgmt/amazon/callback` (1b):
|
||||
freeze, or move only with a deliberate provider re-registration.
|
||||
2. **First, migrate `soundtouch-player` in place to the target API shape.** Before
|
||||
touching the service, restructure the standalone `-web` binary's own routes to
|
||||
what they should be *after* the merge: the control API under `/api/control/*`
|
||||
and the SPA under `/app/*` (with `/ws` as e.g. `/api/control/ws`). Unlike the
|
||||
service, this is a **direct migration, not a dual-mount, and with no
|
||||
deprecation signal**: `-web`'s only client is its own bundled frontend, served
|
||||
and reloaded from the same binary, so there are no out-of-band callers to keep
|
||||
compatible — restructure the routes and update the frontend in lockstep, in
|
||||
small commits, and a stale tab is fixed by a reload. (The careful
|
||||
add-alias-then-deprecate dance is reserved for `-service`, which is central and
|
||||
serves callers we do not control.) The payoff: by the time we merge, `-web`'s
|
||||
routes already match the target and don't overlap the service's namespaces, so
|
||||
the merge below is a near-additive mount.
|
||||
|
||||
3. **Fold `soundtouch-player` into the service.** Bring the (already target-shaped)
|
||||
control API in as `/api/control/*` and the UI under `/app/*` (one role-gated
|
||||
app, see above).
|
||||
The actual overlap to clean up (verified) is small: only **`/`** truly
|
||||
collides, so replace the two competing root handlers with a **landing page**
|
||||
that routes the user to Admin/Setup or the App; **`/health`** is a merge
|
||||
(keep the service's richer body); **`/ws`** and **`/static/*`** are additive
|
||||
(the service registers neither, so no collision). Do this cleanup
|
||||
structurally and verify it (a test that builds the merged router and asserts
|
||||
no double-registration) rather than hiding overlaps behind the opt-in flag.
|
||||
Keep the two TuneIn layers separate (frozen `/bmx/tunein/*` vs the player's
|
||||
`/api/control/*` radio feature).
|
||||
- **Ship the merged variant behind an opt-in flag (default off).** Its sole
|
||||
purpose is to let people optionally run the combined binary and give
|
||||
feedback; it is **not** a collision guard and **not** a security boundary on
|
||||
its own. Until the auth track lands, default-off keeps the merged app/control
|
||||
surface from being exposed unless an operator deliberately enables it. The
|
||||
flag follows the same CLI/env/persisted precedence as `server-url`, and is
|
||||
the seam the `deployment-mode` parameter later subsumes.
|
||||
4. **Deprecate the `soundtouch-player` binary.** It keeps working in 0.x but prints
|
||||
a startup deprecation warning (along the lines of "this binary is removed in
|
||||
1.x, use soundtouch-service") so its removal is no surprise.
|
||||
5. **Warn on old-route hits in the service, observably.** When a deprecated path
|
||||
is called, log a deprecation warning **and** count it (a metric / signal), so
|
||||
the 1.x removal is data-driven: a route is only cut once it has gone quiet
|
||||
across real deployments, not on a guess. *(Done for the `/setup` and `/mgmt`
|
||||
legacy paths via `DeprecatedRouteMiddleware`; extends to any future aliased
|
||||
route.)*
|
||||
|
||||
### Auth track (0.x, parallel)
|
||||
|
||||
- Group `/setup/*` + `/mgmt/*` (+ provisioning) into one admin tier and apply a
|
||||
single auth middleware, replacing today's per-route gap (`/mgmt` has Basic
|
||||
Auth, `/setup` has none).
|
||||
- Make auth enforceable behind a reverse proxy (close #419), not dependent on a
|
||||
header a proxy can strip.
|
||||
- Land the `deployment-mode` parameter (private / shared / public) with its
|
||||
lockout-safety, and the speaker-direct UI gating.
|
||||
- Authorization (player vs admin tiers) can follow authentication; design the
|
||||
groups now so it slots in without another reshuffle.
|
||||
|
||||
### Before 1.x: definition of done
|
||||
|
||||
1.x removes the old routes and the deprecated binary, so all of this must be true
|
||||
in a 0.x release first:
|
||||
|
||||
- **Auth / `deployment-mode` actually shipped** and opt-in works. This is the
|
||||
cloud-first motivation; without it 1.x has no payoff.
|
||||
- **Every client we ship moved off the old paths:** the admin UI, the merged
|
||||
app, the **CLI**, the **HTTP-client integration tests**, **docs and examples**,
|
||||
any reverse-proxy guide. The 0.x dual-routing is their migration window, but
|
||||
someone has to actually move them.
|
||||
- **Old-route usage has gone quiet** in the step-4 signal (do not remove blind).
|
||||
- **A deprecation window of at least one release** where the warnings were live.
|
||||
- **A user-facing migration note / changelog entry.**
|
||||
- The router golden file (`router_routes.txt`) and the contract suite (above)
|
||||
kept green throughout; they are the regression guards.
|
||||
|
||||
### 1.x cutover
|
||||
|
||||
Remove the obsolete routes and retire `soundtouch-player`. Per the versioning
|
||||
section, this is the only point where anything is removed; the frozen
|
||||
speaker/app routes stay.
|
||||
|
||||
## Versioning and the 1.x cutover
|
||||
|
||||
We do **not** version our own API in the path (`/api/v1/...`). In practice path
|
||||
versioning buys little; its one real benefit is explicitness, and it can be
|
||||
retrofitted later if a hard break ever forces it. Either way, a `/v1` -> `/v2`
|
||||
bump does not remove the need to be careful when changing or breaking a route.
|
||||
|
||||
(The frozen `/v1/*` routes in the tables above are Bose's firmware contract, not
|
||||
our versioning. They are unrelated.)
|
||||
|
||||
Versioning lives at the **release level (semver)** instead:
|
||||
|
||||
- **0.x (now):** the API may evolve. When a route moves, the **old and new paths
|
||||
stay live at the same time** (the alias/redirect layer from step 1). Every
|
||||
release stays a no-break upgrade, which gives users time to follow.
|
||||
- **1.x (the cutover):** the release where we settle on the better API. At 1.x we
|
||||
**remove the obsolete routes**. That is the only point where an old route
|
||||
disappears.
|
||||
|
||||
Why this is low-risk: the service and the frontend(s) it serves ship in **one
|
||||
binary**. A user updates the service and reloads the browser tab; the reloaded
|
||||
SPA is the client for the new API, so the two always match, with no window where
|
||||
an old frontend talks to a new backend.
|
||||
|
||||
Caveat: this holds for the clients we ship (the bundled UIs). Out-of-band callers
|
||||
that hardcode paths (the CLI, user scripts, reverse-proxy rules, the HTTP-client
|
||||
integration tests) must follow by 1.x as well; the 0.x dual-routing is precisely
|
||||
the window that lets them. The frozen speaker/app contract routes are never
|
||||
removed, 1.x included.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Lockout-safety mechanism: which of refuse-to-apply / fall-back / loopback-on-
|
||||
host bypass we use when a mode requires auth but none is configured yet.
|
||||
- The form of the non-interactive credential for native / headless clients:
|
||||
issued token, API key, device-code, or client-credentials grant.
|
||||
- The shipped default at 1.x: stay `private`, or move to a stricter default
|
||||
(the parameter lets operators opt in regardless, so no hard flip is forced).
|
||||
@@ -156,7 +156,7 @@ Where today's surfaces fall short for this user:
|
||||
|
||||
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
|
||||
|
||||
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
|
||||
**Surfaces.** Physical preset buttons (always there), `soundtouch-player` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
|
||||
|
||||
### What this layer needs to be good at
|
||||
|
||||
@@ -168,14 +168,14 @@ Where today's surfaces fall short for this user:
|
||||
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
|
||||
- `soundtouch-player`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
|
||||
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
|
||||
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
|
||||
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
|
||||
- Do we keep `soundtouch-player` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
|
||||
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
|
||||
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
|
||||
|
||||
@@ -199,7 +199,7 @@ Where today's surfaces fall short for this user:
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
|
||||
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
|
||||
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-player` and by third-party automation.
|
||||
- Home Assistant: external integration; track but do not own.
|
||||
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
|
||||
|
||||
@@ -217,7 +217,7 @@ Where today's surfaces fall short for this user:
|
||||
|------------------------------------|---------------------|-------------------|-------------------|------------------------|
|
||||
| `soundtouch-cli` | partial (today) | partial (today) | no | primary |
|
||||
| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) |
|
||||
| `soundtouch-web` | no | no | primary | no |
|
||||
| `soundtouch-player` | no | no | primary | no |
|
||||
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
|
||||
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
|
||||
| Physical preset buttons | no | no | primary | no |
|
||||
@@ -229,7 +229,7 @@ The diagonal isn't full because some journeys lack a polished surface today (Jou
|
||||
|
||||
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
|
||||
|
||||
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
|
||||
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-player`.
|
||||
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
|
||||
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
|
||||
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
|
||||
|
||||
@@ -2,3 +2,12 @@
|
||||
title: "Architecture"
|
||||
weight: 5
|
||||
---
|
||||
|
||||
Architecture notes and analyses:
|
||||
|
||||
- [API Route Layout and Refactoring Plan](API-ROUTE-LAYOUT.md) - route
|
||||
classification (frozen speaker contract vs our movable surface), the
|
||||
actor / deployment / trust model, auth, and the staged plan toward the
|
||||
`soundtouch-player` / `soundtouch-service` merge (issue #451).
|
||||
- [Device-Local Install: Four User Journeys](DEVICE-LOCAL-INSTALL.md) - install
|
||||
patterns and user journeys for on-device deployment.
|
||||
|
||||
@@ -91,7 +91,7 @@ that delegates to AfterTouch for these names). The implementation lives in
|
||||
|
||||
> **IP-based `--server-url` is incompatible with OAuth (both Spotify and Amazon
|
||||
> Music).** The speaker's hostname construction appends `oauth` to the first
|
||||
> label only, so `192.168.0.30` would produce `192oauth.168.0.30` — malformed,
|
||||
> label only, so `192.0.2.30` would produce `192oauth.0.2.30` — malformed,
|
||||
> no DNS resolver will answer for it, and there is no clean workaround on the
|
||||
> AfterTouch side. **Use a real LAN hostname** before configuring Spotify or
|
||||
> Amazon Music. The Health-tab `oauth_target_reachable` check warns when this
|
||||
|
||||
@@ -904,39 +904,71 @@ Search for and manage radio stations and streaming content.
|
||||
|
||||
Search and manage stations.
|
||||
|
||||
##### Built-in search: the `find` family (recommended)
|
||||
|
||||
The `find` commands run the search **inside the CLI itself**, querying the
|
||||
radio provider's public API directly. They need **neither the speaker's
|
||||
cloud nor a running `soundtouch-service`**, and they don't require a
|
||||
reachable speaker (`--host`) to search — so they keep working even after
|
||||
the speaker's original cloud is gone. This is the recommended way to
|
||||
search.
|
||||
|
||||
- `station find --provider tunein|radiobrowser --query <term>` — unified
|
||||
built-in search. `--provider` defaults to `tunein`.
|
||||
- `station find-tunein --query <term>` — TuneIn sibling
|
||||
(= `find --provider tunein`).
|
||||
- `station find-radiobrowser --query <term>` — Radio Browser sibling
|
||||
(= `find --provider radiobrowser`).
|
||||
- `… --more` — follow up to three additional result pages when available
|
||||
(both TuneIn and Radio Browser paginate).
|
||||
|
||||
Results include each station's playback `Location`, which you can feed to
|
||||
`source tunein` (TuneIn) or a preset/play flow.
|
||||
|
||||
```bash
|
||||
# Search across any source
|
||||
# Unified built-in search (no speaker required)
|
||||
soundtouch-cli station find --provider tunein --query "jazz"
|
||||
|
||||
# TuneIn sibling
|
||||
soundtouch-cli station find-tunein --query "jazz"
|
||||
|
||||
# Radio Browser, walking extra result pages
|
||||
soundtouch-cli station find-radiobrowser --query "jazz" --more
|
||||
```
|
||||
|
||||
##### Deprecated: speaker-based search
|
||||
|
||||
These commands ask the **speaker** to search, which only works while the
|
||||
speaker's cloud source is reachable. They are **deprecated** — each prints
|
||||
a deprecation notice — and will be removed in a future release. Prefer the
|
||||
`find` family above. There is no built-in equivalent for Pandora or
|
||||
Spotify *yet*; those still require the speaker and your account.
|
||||
|
||||
```bash
|
||||
# [DEPRECATED] Search across any source via the speaker → use `station find`
|
||||
soundtouch-cli --host <device> station search --source <SOURCE> --query <SEARCH_TERM>
|
||||
|
||||
# Search TuneIn specifically
|
||||
# [DEPRECATED] Search TuneIn via the speaker → use `station find-tunein`
|
||||
soundtouch-cli --host <device> station search-tunein --query <SEARCH_TERM>
|
||||
|
||||
# Search Pandora specifically (requires account)
|
||||
# [DEPRECATED] Search Pandora via the speaker (no built-in equivalent yet)
|
||||
soundtouch-cli --host <device> station search-pandora --source-account <ACCOUNT> --query <SEARCH_TERM>
|
||||
|
||||
# Search Spotify specifically (requires account)
|
||||
# [DEPRECATED] Search Spotify via the speaker (no built-in equivalent yet)
|
||||
soundtouch-cli --host <device> station search-spotify --source-account <ACCOUNT> --query <SEARCH_TERM>
|
||||
```
|
||||
|
||||
##### Manage stations
|
||||
|
||||
```bash
|
||||
# Add station and play immediately
|
||||
soundtouch-cli --host <device> station add --source <SOURCE> --token <TOKEN> --name <NAME>
|
||||
|
||||
# Remove station from collection
|
||||
soundtouch-cli --host <device> station remove --source <SOURCE> --location <LOCATION>
|
||||
```
|
||||
|
||||
**Search Examples:**
|
||||
```bash
|
||||
# Search TuneIn for jazz stations
|
||||
soundtouch-cli --host 192.0.2.10 station search-tunein --query "jazz"
|
||||
|
||||
# Search Pandora for Taylor Swift
|
||||
soundtouch-cli --host 192.0.2.10 station search-pandora --source-account myuser123 --query "Taylor Swift"
|
||||
|
||||
# Search Spotify for workout playlists
|
||||
soundtouch-cli --host 192.0.2.10 station search-spotify --source-account spotify_user --query "workout playlist"
|
||||
|
||||
# General search across any source
|
||||
soundtouch-cli --host 192.0.2.10 station search --source TUNEIN --query "classic rock"
|
||||
# List saved stations for a source
|
||||
soundtouch-cli --host <device> station list --source <SOURCE> [--source-account <ACCOUNT>]
|
||||
```
|
||||
|
||||
**Station Management Examples:**
|
||||
@@ -962,8 +994,8 @@ soundtouch-cli --host 192.0.2.10 station remove \
|
||||
|
||||
**Workflow Example - Discover and Play New Content:**
|
||||
```bash
|
||||
# 1. Search for content
|
||||
soundtouch-cli --host 192.0.2.10 station search-tunein --query "smooth jazz"
|
||||
# 1. Search for content (built-in, no speaker needed)
|
||||
soundtouch-cli station find-tunein --query "smooth jazz"
|
||||
|
||||
# 2. Add interesting station from results (copy token from output)
|
||||
soundtouch-cli --host 192.0.2.10 station add \
|
||||
|
||||
@@ -40,7 +40,7 @@ starts on boot.
|
||||
To install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.93.1
|
||||
sudo bash install.sh v0.107.0
|
||||
```
|
||||
|
||||
Check that the service is running:
|
||||
@@ -167,19 +167,40 @@ curl -s http://192.0.2.1:8090/sources
|
||||
|
||||
## Step 7 — Set up preset buttons (optional)
|
||||
|
||||
### Via soundtouch-web
|
||||
### Via soundtouch-player
|
||||
|
||||
The Radio Browser, TuneIn tabs, and preset saving live in
|
||||
**soundtouch-web**, a separate binary from the service. Run it on your
|
||||
host and open **`http://<host-ip>:8080`** in your browser (default port
|
||||
8080).
|
||||
**soundtouch-player**, a separate binary from the service. Once running,
|
||||
open **`http://<host-ip>:8080`** in your browser (default port 8080).
|
||||
|
||||
> **Raspberry Pi note:** The Raspberry Pi installer (`install.sh`) only
|
||||
> installs `soundtouch-service`. Download `soundtouch-web` separately from
|
||||
> the [Releases page](https://github.com/gesellix/Bose-SoundTouch/releases)
|
||||
> and start it alongside the service.
|
||||
### Installing soundtouch-player on a Raspberry Pi
|
||||
|
||||
soundtouch-web provides two ways to save what's currently playing to a
|
||||
`install.sh` only installs `soundtouch-service`. Use the dedicated
|
||||
`install-web.sh` script to add soundtouch-player:
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
|
||||
sudo bash install-web.sh
|
||||
```
|
||||
|
||||
For configuration, service management, updates, and removal see the
|
||||
[Raspberry Pi guide → soundtouch-player](RASPBERRY-PI.md#soundtouch-player).
|
||||
|
||||
### Installing soundtouch-player on other hosts
|
||||
|
||||
Download the binary for your OS and architecture from the
|
||||
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases)
|
||||
and run it directly:
|
||||
|
||||
```bash
|
||||
./soundtouch-player --port 8080
|
||||
```
|
||||
|
||||
Or install it as a systemd service following the same unit-file pattern
|
||||
described in [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
soundtouch-player provides two ways to save what's currently playing to a
|
||||
preset slot:
|
||||
|
||||
**★ Star button in the Now Playing card**
|
||||
@@ -242,7 +263,7 @@ curl -s http://192.0.2.1:8090/presets
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # updates to latest release
|
||||
sudo bash install.sh v0.93.1 # updates to a specific version
|
||||
sudo bash install.sh v0.107.0 # updates to a specific version
|
||||
```
|
||||
|
||||
The installer stops the service, downloads the new binary, and restarts
|
||||
|
||||
@@ -10,9 +10,6 @@ by [weissigera](https://github.com/weissigera) in
|
||||
[issue #329](https://github.com/gesellix/Bose-SoundTouch/issues/329#issuecomment-4521280831),
|
||||
documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
For the installer reference and troubleshooting tips see
|
||||
[scripts/on-device-install/README.md](https://github.com/gesellix/Bose-SoundTouch/blob/main/scripts/on-device-install/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
@@ -88,10 +85,10 @@ To target a specific version instead of the default:
|
||||
|
||||
```bash
|
||||
# Via environment variable (works with pipe-to-sh)
|
||||
VERSION=0.92.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Via command-line flag (pass args after sh -s --)
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.92.0
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
|
||||
```
|
||||
|
||||
Verify the installed version:
|
||||
@@ -100,7 +97,7 @@ Verify the installed version:
|
||||
wget -qO- http://localhost:8000/health
|
||||
```
|
||||
|
||||
The JSON response should include `"version":"v0.93.1"` (or whichever
|
||||
The JSON response should include `"version":"v0.107.0"` (or whichever
|
||||
version you installed).
|
||||
|
||||
---
|
||||
@@ -188,13 +185,13 @@ next reboot — which is fine for a one-time setup run):
|
||||
cd /tmp
|
||||
|
||||
curl -L --fail -o soundtouch-cli \
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.93.1/soundtouch-cli-v0.93.1-linux-armv7
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.107.0/soundtouch-cli-v0.107.0-linux-armv7
|
||||
chmod +x soundtouch-cli
|
||||
|
||||
/tmp/soundtouch-cli --version
|
||||
```
|
||||
|
||||
Replace `v0.93.1` with the version you installed.
|
||||
Replace `v0.107.0` with the version you installed.
|
||||
|
||||
---
|
||||
|
||||
@@ -292,6 +289,72 @@ should start playing the corresponding stream.
|
||||
| `http://localhost:8000` not responding after install | `logread \| grep aftertouch \| tail -20` |
|
||||
| No space left on device during install | Run the cleanup in Step 2; check `df -h /mnt/nv` |
|
||||
|
||||
For more detail on any of these, see
|
||||
[TROUBLESHOOTING.md](./TROUBLESHOOTING.md) and the
|
||||
[on-device installer README](https://github.com/gesellix/Bose-SoundTouch/blob/main/scripts/on-device-install/README.md).
|
||||
For more detail see [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Updating AfterTouch
|
||||
|
||||
Re-run the installer with the version you want. The script backs up the
|
||||
running binary (named after its version), installs the new one, and prunes
|
||||
older artefacts to keep `/mnt/nv` free:
|
||||
|
||||
```bash
|
||||
# Update to latest release
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Update to a specific version — three equivalent forms
|
||||
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
|
||||
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.107.0
|
||||
```
|
||||
|
||||
**Rollback:** the installer keeps a `.backup` file alongside the binary:
|
||||
|
||||
```bash
|
||||
ls /mnt/nv/aftertouch/aftertouch-service*.backup
|
||||
cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
|
||||
/mnt/nv/aftertouch/aftertouch-service
|
||||
/etc/init.d/aftertouch restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service management
|
||||
|
||||
```bash
|
||||
/etc/init.d/aftertouch start
|
||||
/etc/init.d/aftertouch stop
|
||||
/etc/init.d/aftertouch restart
|
||||
/etc/init.d/aftertouch status # distinguishes "running + listener up" from "PID alive but listener down"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
|
||||
The daemon writes to BusyBox syslog (tagged `aftertouch`). Disk usage stays
|
||||
bounded — the syslog ring buffer is in memory:
|
||||
|
||||
```bash
|
||||
logread | grep aftertouch | tail -20 # recent entries
|
||||
logread -f | grep aftertouch # live tail
|
||||
```
|
||||
|
||||
If the service is running but port 8000 isn't responding, check the syslog
|
||||
tail first — panics and startup errors appear there.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Before uninstalling, consider reverting the speaker migration from the
|
||||
AfterTouch Admin UI so the speaker URL is set back to the Bose cloud (though
|
||||
neither Bose nor AfterTouch will be reachable once both are removed).
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/uninstall.sh | sh
|
||||
```
|
||||
|
||||
@@ -1,75 +1,270 @@
|
||||
---
|
||||
title: "Raspberry Pi Installation Guide"
|
||||
---
|
||||
This guide explains how to install the `soundtouch-service` as a persistent systemd service on a Raspberry Pi (tested on Raspberry Pi Zero 2W, 3, and 4).
|
||||
How to install and manage AfterTouch on a Raspberry Pi (or any always-on Linux
|
||||
host) using the provided installer scripts.
|
||||
|
||||
For a complete walkthrough — from install through speaker migration and preset setup — see
|
||||
Two scripts are available, one per binary:
|
||||
|
||||
| Script | Binary | Role | Default port |
|
||||
|------------------|----------------------|-------------------------------------|--------------|
|
||||
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
|
||||
| `install-web.sh` | `soundtouch-player` | Browser control panel | 8080 |
|
||||
|
||||
Both auto-detect CPU architecture (armv7 / arm64 / amd64), create a `soundtouch`
|
||||
system user, and install a systemd unit. They are safe to re-run for updates.
|
||||
|
||||
For a complete install-through-migration walkthrough see
|
||||
[EXTERNAL-HOST-WALKTHROUGH.md](EXTERNAL-HOST-WALKTHROUGH.md).
|
||||
Not sure whether to use a Pi or run AfterTouch on the speaker itself? See
|
||||
[DEPLOYMENT-OVERVIEW.md](DEPLOYMENT-OVERVIEW.md).
|
||||
|
||||
## Automated Installer
|
||||
---
|
||||
|
||||
We provide a specialized installer script located in the `scripts/raspberry-pi/` directory of the repository.
|
||||
## soundtouch-service
|
||||
|
||||
### Features
|
||||
* **Automatic start on boot**: Installs a systemd unit.
|
||||
* **Non-root operation**: Uses `AmbientCapabilities` to bind to ports 80/443 without root privileges.
|
||||
* **Arch Detection**: Automatically selects the correct binary for `armv7`, `arm64`, or `amd64`.
|
||||
* **Easy Updates**: Re-running the script updates the binary to the latest version.
|
||||
### Installation
|
||||
|
||||
### Installation Steps
|
||||
```bash
|
||||
curl -fsSL -o install.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install.sh
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
1. **Download the installer**:
|
||||
```bash
|
||||
curl -fsSL -o install.sh https://raw.githubusercontent.com/gesellix/bose-soundtouch/main/scripts/raspberry-pi/install.sh
|
||||
```
|
||||
Install a specific version:
|
||||
|
||||
2. **Run with sudo**:
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
```
|
||||
```bash
|
||||
sudo bash install.sh v0.107.0
|
||||
```
|
||||
|
||||
### Overriding Defaults
|
||||
|
||||
You can customize the installation using environment variables:
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.93.1 \
|
||||
VERSION=v0.107.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
### Updating the Service
|
||||
### Configuration
|
||||
|
||||
To update the service to a specific version, run the installer with the version as an argument:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.93.1
|
||||
```
|
||||
/etc/soundtouch-service/soundtouch-service.env
|
||||
```
|
||||
|
||||
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
|
||||
|
||||
## Management
|
||||
|
||||
Once installed, use standard `systemctl` commands to manage the service:
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
systemctl status soundtouch-service
|
||||
PORT=80
|
||||
HTTPS_PORT=443
|
||||
DATA_DIR=/var/lib/soundtouch-service
|
||||
|
||||
# Follow logs
|
||||
journalctl -u soundtouch-service -f
|
||||
LOG_PROXY_BODY=false
|
||||
REDACT_PROXY_LOGS=true
|
||||
RECORD_INTERACTIONS=true
|
||||
DISCOVERY_INTERVAL=5m
|
||||
|
||||
# Restart
|
||||
SERVER_URL=http://soundtouch.local
|
||||
HTTPS_SERVER_URL=https://soundtouch.local
|
||||
```
|
||||
|
||||
After editing the env file:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
## Configuration
|
||||
### Service management
|
||||
|
||||
Configuration is stored in `/etc/soundtouch-service/soundtouch-service.env`. Note that settings saved via the Web UI (in `settings.json`) will take precedence over these environment variables once the service is running.
|
||||
```bash
|
||||
systemctl status soundtouch-service
|
||||
sudo systemctl enable soundtouch-service # start on boot
|
||||
sudo systemctl disable soundtouch-service
|
||||
sudo systemctl stop soundtouch-service
|
||||
sudo systemctl start soundtouch-service
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
For more details, see the [scripts/raspberry-pi/README.md](https://github.com/gesellix/Bose-SoundTouch/blob/main/scripts/raspberry-pi/README.md) in the repository.
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
journalctl -u soundtouch-service -e --no-pager # recent
|
||||
journalctl -u soundtouch-service -f # follow live
|
||||
journalctl -u soundtouch-service -b # this boot only
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # update to latest release
|
||||
sudo bash install.sh v0.107.0 # update to a specific version
|
||||
```
|
||||
|
||||
The script stops the service, downloads the new binary (backs up the old one to
|
||||
`.old`), and restarts automatically. Your env file and data directory are preserved.
|
||||
|
||||
### Removal
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-service
|
||||
sudo rm /etc/systemd/system/soundtouch-service.service
|
||||
sudo rm -rf /etc/soundtouch-service
|
||||
sudo rm -rf /var/lib/soundtouch-service
|
||||
sudo rm /usr/local/bin/soundtouch-service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## soundtouch-player
|
||||
|
||||
`soundtouch-player` is a stateless browser control panel — it holds no persistent
|
||||
data and can be stopped or restarted at any time without data loss.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
|
||||
sudo bash install-web.sh
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install-web.sh v0.107.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.107.0 \
|
||||
HTTP_PORT=8081 \
|
||||
bash install-web.sh
|
||||
```
|
||||
|
||||
Once running, open **`http://<pi-ip>:8080`** in a browser.
|
||||
|
||||
### Configuration
|
||||
|
||||
```
|
||||
/etc/soundtouch-player/soundtouch-player.env
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
PORT=8080
|
||||
BIND_ADDR=
|
||||
DISCOVERY_INTERFACE=
|
||||
SOUNDTOUCH_DEVICES=
|
||||
SERVICE_URL=
|
||||
SERVICE_CA=
|
||||
```
|
||||
|
||||
`SOUNDTOUCH_DEVICES` accepts a comma-separated list of IP addresses for manual
|
||||
device registration — useful when mDNS auto-discovery is unreliable on your
|
||||
network:
|
||||
|
||||
```bash
|
||||
SOUNDTOUCH_DEVICES=192.0.2.1,192.0.2.2
|
||||
```
|
||||
|
||||
`SERVICE_URL` links `soundtouch-player` to your `soundtouch-service` instance,
|
||||
which is required for Text-to-Speech ("Speak"). When the service is served
|
||||
over HTTPS with its own self-signed certificate (the default), also set
|
||||
`SERVICE_CA` to that CA certificate, or the proxied TTS call fails with
|
||||
`x509: certificate signed by unknown authority`. The CA is the service's
|
||||
`<dataDir>/certs/ca.crt` (also downloadable from `GET /setup/ca.crt`). For
|
||||
example:
|
||||
|
||||
```bash
|
||||
SERVICE_URL=https://soundtouch.local
|
||||
SERVICE_CA=/var/lib/soundtouch-service/certs/ca.crt
|
||||
```
|
||||
|
||||
With a plain `http://` `SERVICE_URL`, `SERVICE_CA` is unused (no TLS) and can
|
||||
be left empty.
|
||||
|
||||
After editing the env file:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart soundtouch-player
|
||||
```
|
||||
|
||||
### Port conflicts
|
||||
|
||||
Port 8080 is a common default for other services. To check what is already
|
||||
using it:
|
||||
|
||||
```bash
|
||||
sudo ss -tulpn | grep :8080
|
||||
```
|
||||
|
||||
To use a different port, pass `HTTP_PORT=<port>` to the installer, or edit
|
||||
the env file after installation and restart the service.
|
||||
|
||||
### Service management
|
||||
|
||||
```bash
|
||||
systemctl status soundtouch-player
|
||||
sudo systemctl enable soundtouch-player # start on boot
|
||||
sudo systemctl disable soundtouch-player
|
||||
sudo systemctl stop soundtouch-player
|
||||
sudo systemctl start soundtouch-player
|
||||
sudo systemctl restart soundtouch-player
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
journalctl -u soundtouch-player -e --no-pager
|
||||
journalctl -u soundtouch-player -f
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
sudo bash install-web.sh # update to latest release
|
||||
sudo bash install-web.sh v0.107.0 # update to a specific version
|
||||
```
|
||||
|
||||
### Removal
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-player
|
||||
sudo rm /etc/systemd/system/soundtouch-player.service
|
||||
sudo rm -rf /etc/soundtouch-player
|
||||
sudo rm /usr/local/bin/soundtouch-player
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture auto-detection
|
||||
|
||||
Both installers detect the CPU and pick the matching release asset automatically:
|
||||
|
||||
| `uname -m` | asset suffix |
|
||||
|---------------------|---------------|
|
||||
| `aarch64` | `linux-arm64` |
|
||||
| `armv7l` / `armv6l` | `linux-armv7` |
|
||||
| `x86_64` | `linux-amd64` |
|
||||
|
||||
Override if needed:
|
||||
|
||||
```bash
|
||||
sudo ARCH_ASSET=linux-arm64 bash install.sh
|
||||
sudo ARCH_ASSET=linux-arm64 bash install-web.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
Both services run as the `soundtouch` system user (no login shell, no home
|
||||
directory). `soundtouch-service` additionally uses
|
||||
`AmbientCapabilities=CAP_NET_BIND_SERVICE` to bind ports 80 / 443 without root.
|
||||
|
||||
@@ -30,7 +30,7 @@ The service consists of several key components:
|
||||
- **Service Registry**: Media service discovery and configuration
|
||||
- **Playback Control**: Stream URL resolution and audio metadata
|
||||
|
||||

|
||||

|
||||
|
||||
### Marge Services (Account & Device Management)
|
||||
- **Account Management**: User account simulation and device association
|
||||
@@ -390,7 +390,7 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
```json
|
||||
[
|
||||
{
|
||||
"device_id": "08DF1F0BA325",
|
||||
"device_id": "AABBCCDDEE0A",
|
||||
"name": "Living Room Speaker",
|
||||
"ip_address": "192.0.2.100",
|
||||
"product_code": "SoundTouch 20",
|
||||
|
||||
@@ -11,14 +11,14 @@ Bose shut down SoundTouch cloud services on **May 6, 2026**. Per the [official e
|
||||
|
||||
What **continues to work** regardless:
|
||||
- The official SoundTouch app for local control (play/pause/volume/source selection)
|
||||
- Local playback controls via `soundtouch-cli`, `soundtouch-web`, or any app that uses the local Web API
|
||||
- Local playback controls via `soundtouch-cli`, `soundtouch-player`, or any app that uses the local Web API
|
||||
- Bluetooth, AUX, and AirPlay inputs
|
||||
- Multiroom zones (local, peer-to-peer)
|
||||
|
||||
**AfterTouch** — the `soundtouch-service` — restores the first three:
|
||||
|
||||
- **Presets** — full preset management including long-press assignment and recently-played sync; music service presets (Spotify, TuneIn, etc.) work once the service is linked (see [Connecting Music Services](MUSIC-SERVICES.md))
|
||||
- **Music browsing and playback** — TuneIn, Internet Radio, and RadioBrowser via `soundtouch-web`; direct station/URL playback via `soundtouch-cli`; Spotify via Spotify Connect (speaker-native) or AfterTouch's OAuth integration; Amazon Music OAuth infrastructure is in place but streaming is not yet verified
|
||||
- **Music browsing and playback** — TuneIn, Internet Radio, and RadioBrowser via `soundtouch-player`; direct station/URL playback via `soundtouch-cli`; Spotify via Spotify Connect (speaker-native) or AfterTouch's OAuth integration; Amazon Music OAuth infrastructure is in place but streaming is not yet verified
|
||||
- **Stereo pairing** — via `soundtouch-cli`
|
||||
|
||||
Alexa voice commands are not currently supported.
|
||||
|
||||
@@ -316,7 +316,7 @@ avahi-resolve -n soundtouch.local
|
||||
```go
|
||||
nowPlaying, err := client.GetNowPlaying()
|
||||
if err == nil {
|
||||
fmt.Printf("Status: %s, Source: %s\n",
|
||||
fmt.Printf("Status: %s, Source: %s\n",
|
||||
nowPlaying.PlayStatus, nowPlaying.Source)
|
||||
}
|
||||
```
|
||||
@@ -326,7 +326,7 @@ if err == nil {
|
||||
sources, err := client.GetSources()
|
||||
if err == nil {
|
||||
for _, source := range sources.Sources {
|
||||
fmt.Printf("Source: %s, Status: %s\n",
|
||||
fmt.Printf("Source: %s, Status: %s\n",
|
||||
source.Source, source.Status)
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,7 @@ if err == nil {
|
||||
// Only zone master can control volume
|
||||
if zoneStatus == "MEMBER" {
|
||||
fmt.Println("Device is zone member - only master controls volume")
|
||||
|
||||
|
||||
// Find and use master device
|
||||
zone, _ := client.GetZone()
|
||||
// Connect to master device using zone.Master ID
|
||||
@@ -507,7 +507,7 @@ client.DecreaseVolume(5)
|
||||
3. **Check Current Volume:**
|
||||
```go
|
||||
volume, _ := client.GetVolume()
|
||||
fmt.Printf("Target: %d, Actual: %d, Muted: %t\n",
|
||||
fmt.Printf("Target: %d, Actual: %d, Muted: %t\n",
|
||||
volume.TargetVolume, volume.ActualVolume, volume.Muted)
|
||||
```
|
||||
|
||||
@@ -586,7 +586,7 @@ curl http://192.0.2.10:8090/playNotification
|
||||
**Causes & Solutions:**
|
||||
|
||||
#### 1. **Device Model Compatibility**
|
||||
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
|
||||
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
|
||||
- ❌ **Not Supported**: SoundTouch 300 (ST-300), older models
|
||||
|
||||
**Solution:** Verify device model with:
|
||||
@@ -619,10 +619,56 @@ Only one notification can play at a time. Wait a few seconds and retry.
|
||||
#### 2. **Check Current Playback Status**
|
||||
```go
|
||||
nowPlaying, _ := client.GetNowPlaying()
|
||||
fmt.Printf("Current source: %s, status: %s\n",
|
||||
fmt.Printf("Current source: %s, status: %s\n",
|
||||
nowPlaying.Source, nowPlaying.PlayStatus)
|
||||
```
|
||||
|
||||
### ❌ soundtouch-player TTS fails with `certificate signed by unknown authority`
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
TTS service request failed: Post "https://soundtouch.fritz.box/setup/tts/speak":
|
||||
tls: failed to verify certificate: x509: certificate signed by unknown authority
|
||||
```
|
||||
|
||||
**Cause:** TTS synthesis and the Bose app key live in `soundtouch-service`,
|
||||
so `soundtouch-player` proxies the "Speak" action to the service. When the
|
||||
service is served over HTTPS with its own self-signed certificate (the
|
||||
default — see `GET /setup/ca.crt`), `soundtouch-player` doesn't trust that CA out
|
||||
of the box, so the proxied call fails verification.
|
||||
|
||||
**Solution:** start `soundtouch-player` with `--service-ca` pointing at the
|
||||
service's CA certificate (its `<dataDir>/certs/ca.crt`, or the file served at
|
||||
`/setup/ca.crt`):
|
||||
|
||||
```bash
|
||||
soundtouch-player \
|
||||
--service-url https://soundtouch.fritz.box \
|
||||
--service-ca /path/to/certs/ca.crt
|
||||
```
|
||||
|
||||
`SERVICE_CA` is the equivalent environment variable. The CA is appended to the
|
||||
system trust store, so a service URL that uses a publicly trusted certificate
|
||||
needs no flag.
|
||||
|
||||
### ❌ soundtouch-player TTS returns `host ... is not a known device`
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
TTS service returned 400: {"error":"host http://192.0.2.10:8090 is not a known device"}
|
||||
```
|
||||
|
||||
**Cause:** the service only plays TTS on speakers it knows (an SSRF guard:
|
||||
the target is matched against the service's device datastore, never taken
|
||||
verbatim from the request).
|
||||
|
||||
**Solution:** make sure the target speaker is known to `soundtouch-service`
|
||||
(discovered or manually added, and migrated to AfterTouch), not only to
|
||||
`soundtouch-player`'s own discovery. Check with `GET /setup/devices` on the
|
||||
service. (Recent `soundtouch-player` versions identify the speaker by its device
|
||||
ID and a bare IP, so this error otherwise indicates the speaker simply isn't
|
||||
registered with the service.)
|
||||
|
||||
---
|
||||
|
||||
## 📡 **WebSocket Issues**
|
||||
@@ -834,7 +880,7 @@ config.Logger = &client.DefaultLogger{} // Or custom logger
|
||||
# Capture SoundTouch traffic
|
||||
sudo tcpdump -i any host 192.0.2.100 and port 8090
|
||||
|
||||
# Monitor WebSocket traffic
|
||||
# Monitor WebSocket traffic
|
||||
sudo tcpdump -i any host 192.0.2.100 and port 8080
|
||||
|
||||
# HTTP debugging with curl
|
||||
@@ -978,7 +1024,7 @@ Use this checklist to systematically troubleshoot issues:
|
||||
|
||||
### Network Connectivity
|
||||
- [ ] Device power LED is solid white
|
||||
- [ ] Both devices on same network subnet
|
||||
- [ ] Both devices on same network subnet
|
||||
- [ ] Firewall allows ports 8090 (HTTP) and 8080 (WebSocket)
|
||||
- [ ] Can ping device IP address
|
||||
- [ ] Can telnet to ports 8090 and 8080
|
||||
@@ -995,7 +1041,7 @@ Use this checklist to systematically troubleshoot issues:
|
||||
- [ ] Proper error handling
|
||||
- [ ] Resource cleanup (defer statements)
|
||||
|
||||
### Multiroom Specific
|
||||
### Multiroom Specific
|
||||
- [ ] All devices support multiroom
|
||||
- [ ] Device IDs are correct (from GetDeviceInfo)
|
||||
- [ ] Devices on same network subnet
|
||||
@@ -1039,6 +1085,83 @@ cat data/accounts/1000001/devices/*/DeviceInfo.xml | grep macAddress
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Cross-subnet / VLAN isolation** {#cross-subnet}
|
||||
|
||||
### ❌ AfterTouch unreachable when speaker and server are on different subnets
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- AfterTouch is running and reachable from your computer, but the speaker cannot
|
||||
connect to it after migration.
|
||||
- `logread | grep aftertouch` on the speaker shows no outgoing requests, or shows
|
||||
`Curl 7, http 0` for the AfterTouch host.
|
||||
- Everything works when speaker and server are on the same `/24` subnet, but fails
|
||||
when they are on different VLANs (e.g. IoT VLAN `192.168.20.x` vs server VLAN
|
||||
`192.168.10.x`).
|
||||
|
||||
**Cause:**
|
||||
|
||||
Since a 2018 firmware update, SoundTouch devices apply an iptables policy that
|
||||
blocks incoming traffic from subnets other than their own. The policy lives in
|
||||
`/etc/init.d/Firewalls/update_iptables` inside the `block_remote_traffic()`
|
||||
function.
|
||||
|
||||
**Fix A — Add an explicit ACCEPT rule for your AfterTouch subnet (targeted):**
|
||||
|
||||
SSH into the speaker and edit the file:
|
||||
|
||||
```bash
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@<speaker-ip>
|
||||
rw
|
||||
cd /etc/init.d/Firewalls/
|
||||
vi update_iptables
|
||||
```
|
||||
|
||||
In `vi`, find `block_remote_traffic()`. Press `i` to enter insert mode. After the
|
||||
first `done` line in that function, add:
|
||||
|
||||
```
|
||||
echo -A INPUT -i $IFACE -s 192.0.2.0/24 -j ACCEPT
|
||||
```
|
||||
|
||||
Replace `192.0.2.0/24` with the subnet your AfterTouch host is on. Press `Esc`,
|
||||
type `:wq`, press `Enter`, then reboot:
|
||||
|
||||
```bash
|
||||
reboot
|
||||
```
|
||||
|
||||
To allow **all** subnets, use a wider CIDR range such as `192.168.0.0/16` or
|
||||
`0.0.0.0/0`.
|
||||
|
||||
**Fix B — Comment out the DROP rule (simpler, allows all inbound traffic):**
|
||||
|
||||
Instead of adding an ACCEPT rule, find the `DROP` line inside
|
||||
`block_remote_traffic()` and comment it out by prepending `#`:
|
||||
|
||||
```bash
|
||||
# Before:
|
||||
echo -A INPUT -i $IFACE ! -s $ADDR/$CIDR -j DROP
|
||||
# After:
|
||||
# echo -A INPUT -i $IFACE ! -s $ADDR/$CIDR -j DROP
|
||||
```
|
||||
|
||||
This is less targeted than Fix A but simpler if you run the speaker in an already
|
||||
firewalled network.
|
||||
|
||||
**Related: ST20 Series I also blocks outbound connections to non-standard ports**
|
||||
|
||||
Some older firmware images (observed on SoundTouch 20 Series I) also apply an
|
||||
outbound iptables policy that blocks connections to ports other than 80 and 443.
|
||||
If AfterTouch is running on a non-standard port (e.g. 8000, 8080) and the speaker
|
||||
simply never reaches it, this policy may be the cause. Fix A and Fix B apply
|
||||
equally — inspect `update_iptables` for matching DROP rules on the OUTPUT chain.
|
||||
|
||||
*This behaviour was first documented in
|
||||
[Discussion #354](https://github.com/gesellix/Bose-SoundTouch/discussions/354).*
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Hostname Resolution** {#hostname-resolution}
|
||||
|
||||
### Why the service resolves the hostname from the device
|
||||
@@ -1163,4 +1286,4 @@ go run ./cmd/soundtouch-cli -host <ip> -network-info
|
||||
- **Examples**: Review `/examples` for working code patterns
|
||||
- **CLI Tool**: Use built-in CLI for testing and debugging
|
||||
|
||||
Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues.
|
||||
Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues.
|
||||
|
||||
@@ -3,6 +3,8 @@ title: "Bose SoundTouch Web API - Endpoints Overview"
|
||||
---
|
||||
This document provides a comprehensive overview of the available API endpoints verified against the official Bose SoundTouch Web API v1.0 specification (January 7, 2026).
|
||||
|
||||
> **Note:** This documents the *speaker device* Web API (port 8090). For the AfterTouch *service's* own route layout (cloud emulation vs admin/control surface) and the planned refactoring, see [API Route Layout and Refactoring Plan](../architecture/API-ROUTE-LAYOUT.md).
|
||||
|
||||
**Acknowledgment**: Additional endpoints beyond the official API were discovered through the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) maintained by the SoundTouch Plus community. Special thanks to @thlucas1 and contributors for documenting these working endpoints that enable full preset management and content navigation functionality.
|
||||
|
||||
## Implementation Status Legend
|
||||
|
||||
@@ -3,6 +3,8 @@ title: "Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)"
|
||||
---
|
||||
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
|
||||
|
||||
> **See also:** [API Route Layout and Refactoring Plan](../architecture/API-ROUTE-LAYOUT.md) - how these cloud-emulation routes are classified (frozen speaker contract vs our own movable surface) and the planned API consolidation toward a single binary.
|
||||
|
||||
## Marge API (Account & Configuration)
|
||||
|
||||
Base path: `/marge`
|
||||
|
||||
@@ -71,7 +71,7 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
|
||||
```
|
||||
|
||||
`requestID` is a monotonically increasing integer per connection (client-side sequence).
|
||||
`{device_id}` is the speaker's MAC address with colons removed (e.g. `08DF1F0BA325`).
|
||||
`{device_id}` is the speaker's MAC address with colons removed (e.g. `AABBCCDDEE0A`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ Device system settings:
|
||||
$ soundtouch-cli --host 192.0.2.100 analyze
|
||||
|
||||
🔍 Device Capability Analysis:
|
||||
Device ID: 08DF1F0BA325
|
||||
Device ID: AABBCCDDEE0A
|
||||
Feature Coverage: 87% (13/15 features)
|
||||
Device Type: Premium SoundTouch Speaker (Full Feature Set)
|
||||
|
||||
@@ -161,27 +161,27 @@ import (
|
||||
func analyzeDevice(host string) {
|
||||
// Create client
|
||||
c := client.NewClient(&client.Config{Host: host})
|
||||
|
||||
|
||||
// Get supported URLs with feature mapping
|
||||
supportedURLs, err := c.GetSupportedURLs()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Get device capabilities overview
|
||||
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
|
||||
fmt.Printf("Device supports %d%% of features (%d/%d)\n",
|
||||
fmt.Printf("Device supports %d%% of features (%d/%d)\n",
|
||||
completeness, supported, total)
|
||||
|
||||
|
||||
// Check specific capabilities
|
||||
if supportedURLs.HasMultiroomSupport() {
|
||||
fmt.Println("✅ Device can create multiroom zones")
|
||||
}
|
||||
|
||||
|
||||
if supportedURLs.HasAdvancedAudioSupport() {
|
||||
fmt.Println("✅ Device has advanced audio controls")
|
||||
}
|
||||
|
||||
|
||||
// Get missing essential features
|
||||
missing := supportedURLs.GetMissingEssentialFeatures()
|
||||
if len(missing) > 0 {
|
||||
@@ -190,13 +190,13 @@ func analyzeDevice(host string) {
|
||||
fmt.Printf(" • %s\n", feature.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get features by category
|
||||
featuresByCategory := supportedURLs.GetFeaturesByCategory()
|
||||
for category, features := range featuresByCategory {
|
||||
fmt.Printf("%s: %d features available\n", category, len(features))
|
||||
}
|
||||
|
||||
|
||||
// Check for partial implementations
|
||||
partial := supportedURLs.GetPartiallyImplementedFeatures()
|
||||
for _, feature := range partial {
|
||||
@@ -211,10 +211,10 @@ func analyzeDevice(host string) {
|
||||
func canDoAdvancedAudio(supportedURLs *models.SupportedURLsResponse) bool {
|
||||
requiredEndpoints := []string{
|
||||
"/audiodspcontrols",
|
||||
"/audioproducttonecontrols",
|
||||
"/audioproducttonecontrols",
|
||||
"/audioproductlevelcontrols",
|
||||
}
|
||||
|
||||
|
||||
for _, endpoint := range requiredEndpoints {
|
||||
if !supportedURLs.HasURL(endpoint) {
|
||||
return false
|
||||
@@ -226,19 +226,19 @@ func canDoAdvancedAudio(supportedURLs *models.SupportedURLsResponse) bool {
|
||||
// Get device-specific recommendations
|
||||
func getPersonalizedTips(supportedURLs *models.SupportedURLsResponse) []string {
|
||||
var tips []string
|
||||
|
||||
|
||||
if supportedURLs.HasURL("/presets") {
|
||||
tips = append(tips, "Set up presets for your favorite stations")
|
||||
}
|
||||
|
||||
|
||||
if supportedURLs.HasURL("/setZone") {
|
||||
tips = append(tips, "Create multiroom zones for whole-home audio")
|
||||
}
|
||||
|
||||
|
||||
if supportedURLs.HasURL("/search") && supportedURLs.HasURL("/addStation") {
|
||||
tips = append(tips, "Search and save new radio stations")
|
||||
}
|
||||
|
||||
|
||||
return tips
|
||||
}
|
||||
```
|
||||
@@ -289,7 +289,7 @@ soundtouch-cli audio level get # Get level controls
|
||||
```bash
|
||||
# Basic Playback (Essential)
|
||||
soundtouch-cli play start # Start playback
|
||||
soundtouch-cli play stop # Stop playback
|
||||
soundtouch-cli play stop # Stop playback
|
||||
soundtouch-cli play pause # Pause playback
|
||||
soundtouch-cli play now # Get now playing info
|
||||
|
||||
@@ -306,7 +306,7 @@ soundtouch-cli key mute # Mute toggle
|
||||
# Audio Sources
|
||||
soundtouch-cli source list # List available sources
|
||||
soundtouch-cli source select --source SPOTIFY # Select Spotify
|
||||
soundtouch-cli source bluetooth # Select Bluetooth
|
||||
soundtouch-cli source bluetooth # Select Bluetooth
|
||||
soundtouch-cli source aux # Select AUX input
|
||||
|
||||
# Service Availability
|
||||
@@ -321,7 +321,7 @@ soundtouch-cli browse tunein # Browse TuneIn content
|
||||
soundtouch-cli browse pandora --source-account <account> # Browse Pandora
|
||||
soundtouch-cli browse spotify --source-account <account> # Browse Spotify
|
||||
|
||||
# Station Management
|
||||
# Station Management
|
||||
soundtouch-cli station search-tunein --query "jazz" # Search TuneIn
|
||||
soundtouch-cli station search-pandora --query "rock" --source-account <account>
|
||||
soundtouch-cli station add --source TUNEIN --token <token> --name "Jazz FM"
|
||||
@@ -354,7 +354,7 @@ soundtouch-cli zone remove --member 192.0.2.103 # Remove from zone
|
||||
# Quick capability check
|
||||
soundtouch-cli supported-urls | grep "Feature Coverage"
|
||||
|
||||
# Essential features verification
|
||||
# Essential features verification
|
||||
soundtouch-cli analyze | grep -A 5 "Missing Essential Features"
|
||||
|
||||
# Advanced features check
|
||||
@@ -368,7 +368,7 @@ soundtouch-cli supported-urls --features | grep "Multiroom"
|
||||
Based on feature support, devices are automatically classified:
|
||||
|
||||
- **Premium SoundTouch Speaker**: Multiroom + Advanced Audio + Full Feature Set
|
||||
- **Standard SoundTouch Speaker**: Multiroom Capable + Core Features
|
||||
- **Standard SoundTouch Speaker**: Multiroom Capable + Core Features
|
||||
- **Basic SoundTouch Speaker**: Streaming + Presets + Core Features
|
||||
- **Essential SoundTouch Device**: Core Playback Features Only
|
||||
- **Limited SoundTouch Device**: Minimal Feature Set
|
||||
@@ -384,7 +384,7 @@ soundtouch-cli supported-urls --features | grep -i "bass control"
|
||||
# If not listed, device doesn't support bass control
|
||||
```
|
||||
|
||||
**Issue**: "Multiroom not available"
|
||||
**Issue**: "Multiroom not available"
|
||||
```bash
|
||||
# Verify multiroom support
|
||||
soundtouch-cli analyze | grep "Multiroom"
|
||||
@@ -405,7 +405,7 @@ soundtouch-cli supported-urls --features | grep "Content Navigation"
|
||||
The feature mapping system provides personalized recommendations:
|
||||
|
||||
- **Missing Balance Control**: "No balance control available on this device"
|
||||
- **Multiroom Available**: "Create speaker groups with other devices"
|
||||
- **Multiroom Available**: "Create speaker groups with other devices"
|
||||
- **Advanced Audio**: "Fine-tune sound with DSP controls"
|
||||
- **Limited Features**: "Consider upgrading for full functionality"
|
||||
|
||||
@@ -417,4 +417,4 @@ The feature mapping system provides personalized recommendations:
|
||||
4. **Review recommendations** for optimal device usage
|
||||
5. **Monitor feature completeness** to understand device limitations
|
||||
|
||||
This comprehensive feature mapping system ensures you get the most out of your SoundTouch device by understanding exactly what it can do and how to use it effectively.
|
||||
This comprehensive feature mapping system ensures you get the most out of your SoundTouch device by understanding exactly what it can do and how to use it effectively.
|
||||
|
||||
@@ -66,15 +66,15 @@ func main() {
|
||||
Host: "192.0.2.100",
|
||||
Port: 8090,
|
||||
}
|
||||
|
||||
|
||||
client := client.NewClient(config)
|
||||
|
||||
|
||||
// Play TTS at current volume (language code "EN", "DE", etc.)
|
||||
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Play TTS at specific volume (70)
|
||||
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
|
||||
if err != nil {
|
||||
@@ -91,9 +91,9 @@ func main() {
|
||||
Host: "192.0.2.100",
|
||||
Port: 8090,
|
||||
}
|
||||
|
||||
|
||||
client := client.NewClient(config)
|
||||
|
||||
|
||||
// Play audio from URL
|
||||
err := client.PlayURL(
|
||||
"https://example.com/audio.mp3",
|
||||
@@ -114,7 +114,7 @@ func main() {
|
||||
```go
|
||||
func main() {
|
||||
client := client.NewClient(config)
|
||||
|
||||
|
||||
// Create custom play info
|
||||
playInfo := models.NewPlayInfo(
|
||||
"https://example.com/audio.mp3",
|
||||
@@ -123,7 +123,7 @@ func main() {
|
||||
"Custom Message",
|
||||
"Custom Reason",
|
||||
).SetVolume(60)
|
||||
|
||||
|
||||
err := client.PlayCustom(playInfo)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -136,7 +136,7 @@ func main() {
|
||||
```go
|
||||
func main() {
|
||||
client := client.NewClient(config)
|
||||
|
||||
|
||||
// Uses GET request (fixed in v2025.02+)
|
||||
err := client.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
@@ -206,22 +206,22 @@ soundtouch-cli speaker url --help
|
||||
|
||||
The following language codes are supported for Google TTS:
|
||||
|
||||
| Code | Language |
|
||||
|------|----------|
|
||||
| EN | English |
|
||||
| DE | German |
|
||||
| ES | Spanish |
|
||||
| FR | French |
|
||||
| IT | Italian |
|
||||
| NL | Dutch |
|
||||
| PT | Portuguese |
|
||||
| RU | Russian |
|
||||
| ZH | Chinese |
|
||||
| JA | Japanese |
|
||||
| KO | Korean |
|
||||
| AR | Arabic |
|
||||
| HI | Hindi |
|
||||
| TH | Thai |
|
||||
| Code | Language |
|
||||
|------|------------|
|
||||
| EN | English |
|
||||
| DE | German |
|
||||
| ES | Spanish |
|
||||
| FR | French |
|
||||
| IT | Italian |
|
||||
| NL | Dutch |
|
||||
| PT | Portuguese |
|
||||
| RU | Russian |
|
||||
| ZH | Chinese |
|
||||
| JA | Japanese |
|
||||
| KO | Korean |
|
||||
| AR | Arabic |
|
||||
| HI | Hindi |
|
||||
| TH | Thai |
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
@@ -230,7 +230,7 @@ The following language codes are supported for Google TTS:
|
||||
- Automatically restore the previous volume after playback completes
|
||||
- If volume is 0 or omitted, content plays at current volume
|
||||
|
||||
2. **Content Interruption**:
|
||||
2. **Content Interruption**:
|
||||
- Currently playing content is paused during notification playback
|
||||
- Original content resumes automatically after notification ends
|
||||
- If currently playing content is already a notification, you may get an error
|
||||
@@ -241,7 +241,7 @@ The following language codes are supported for Google TTS:
|
||||
|
||||
4. **Now Playing Display**:
|
||||
- Service name appears in the "artist" field
|
||||
- Message appears in the "album" field
|
||||
- Message appears in the "album" field
|
||||
- Reason appears in the "track" field
|
||||
- Custom artwork can be included in URL-based content
|
||||
|
||||
@@ -264,6 +264,95 @@ Both TTS and URL playback require an `app_key` parameter. This appears to be use
|
||||
|
||||
You'll need to provide your own application key. The format and generation method for valid app keys is not documented in the official API.
|
||||
|
||||
## Google Cloud Text-to-Speech (via the AfterTouch service)
|
||||
|
||||
The direct `speaker tts` path above hands the speaker an (undocumented) Google
|
||||
Translate URL to fetch. That endpoint is fine for short notifications but is
|
||||
low quality, length-limited, and can change without notice.
|
||||
|
||||
For higher-quality speech (real voices, SSML, many languages) the AfterTouch
|
||||
service can synthesize audio with **Google Cloud Text-to-Speech** and host it
|
||||
locally for the speaker to play. Because Cloud TTS returns audio bytes from an
|
||||
authenticated request (not a fetchable URL), the service caches the clip and
|
||||
serves it at `GET /media/tts/{id}`, then tells the speaker to play that local
|
||||
URL via `/speaker`. The same `app_key` constraint applies.
|
||||
|
||||
### Provider selection
|
||||
|
||||
The service picks a TTS provider via `--tts-provider` (env `TTS_PROVIDER`):
|
||||
|
||||
- `translate` (default): the Google Translate URL path. No credentials.
|
||||
- `google-cloud`: Google Cloud TTS via a REST API key. No OAuth, no SDK.
|
||||
|
||||
### Configuration (soundtouch-service)
|
||||
|
||||
| Flag | Env | Purpose |
|
||||
|------------------------|----------------------|---------------------------------------------------------------------------------|
|
||||
| `--tts-provider` | `TTS_PROVIDER` | `translate` or `google-cloud` |
|
||||
| `--tts-google-api-key` | `TTS_GOOGLE_API_KEY` | Google Cloud TTS API key (required for `google-cloud`) |
|
||||
| `--tts-language` | `TTS_LANGUAGE` | Default language. `EN`/`DE` for translate, BCP-47 like `en-US` for google-cloud |
|
||||
| `--tts-voice` | `TTS_VOICE` | Default Cloud TTS voice (e.g. `en-US-Neural2-C`); ignored by translate |
|
||||
| `--tts-app-key` | `TTS_APP_KEY` | Bose `/speaker` app_key used to play the clip |
|
||||
| `--tts-volume` | `TTS_VOLUME` | Default playback volume (0-100, 0 = keep current) |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
TTS_PROVIDER=google-cloud \
|
||||
TTS_GOOGLE_API_KEY=YOUR_GOOGLE_API_KEY \
|
||||
TTS_LANGUAGE=en-US \
|
||||
TTS_VOICE=en-US-Neural2-C \
|
||||
TTS_APP_KEY=YOUR_APP_KEY \
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
### Triggering speech
|
||||
|
||||
Service HTTP API (under `/setup`, LAN-trust like the rest of the setup surface,
|
||||
no auth):
|
||||
|
||||
```bash
|
||||
curl -X POST http://soundtouch.local:8000/setup/tts/speak \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"host":"192.0.2.100","text":"Dinner is ready"}'
|
||||
```
|
||||
|
||||
`deviceId` may be used instead of `host` (the service resolves it to an IP from its datastore). Optional fields: `language`, `voice`, `volume`, and `method`
|
||||
(`speaker`, the default /speaker notification path that ducks and resumes
|
||||
playback, or `radio`, the LOCAL_INTERNET_RADIO path that needs no app_key but
|
||||
replaces the current source).
|
||||
|
||||
CLI (`speaker tts-cloud` routes through the service for Cloud TTS, in contrast
|
||||
to `speaker tts` which sends a Google Translate URL straight to the speaker):
|
||||
|
||||
```bash
|
||||
soundtouch-cli speaker tts-cloud \
|
||||
--service-url http://soundtouch.local:8000 \
|
||||
--host 192.0.2.100 \
|
||||
--text "Dinner is ready" \
|
||||
--method speaker
|
||||
```
|
||||
|
||||
Web UI: the TTS source view has a "Say something…" box. soundtouch-player proxies
|
||||
it to the service, so it must be started with `--service-url` (the target is
|
||||
server-configured, not entered in the browser, to avoid an SSRF proxy).
|
||||
|
||||
### Notes and limitations
|
||||
|
||||
- **`app_key` validation is handled automatically (speaker method).** The
|
||||
speaker validates the key by calling `GET /v1/auth` on Bose's audio
|
||||
notification host (`audionotification.api.bosecm.com`, and a `…dev…` variant),
|
||||
which AfterTouch intercepts (DNS substring match on `bosecm.com`, plus
|
||||
`/etc/hosts` seeding during migration) and answers `200`. So any non-empty
|
||||
`app_key` works; you don't need a real Bose-issued key. The `radio` method
|
||||
needs no `app_key` at all.
|
||||
- **Model support** for the `speaker` method is the same as the direct `/speaker`
|
||||
path (primarily ST-10 Series III). Use `--method radio` on models without it.
|
||||
- **Reachability:** the speaker must be able to reach the service's
|
||||
`/media/tts/{id}` URL. The service builds it from its configured `server-url`.
|
||||
- Synthesized clips are cached in memory for a short time and identical requests
|
||||
reuse the same clip.
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Device Support**: Limited to specific SoundTouch models (primarily ST-10 Series III)
|
||||
@@ -283,7 +372,7 @@ client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
|
||||
// Security alert
|
||||
client.PlayURL(
|
||||
"https://myserver.com/alerts/security-breach.mp3",
|
||||
"security-system-key",
|
||||
"security-system-key",
|
||||
"Security System",
|
||||
"Alert",
|
||||
"Motion detected in restricted area",
|
||||
@@ -297,7 +386,7 @@ client.PlayURL(
|
||||
# Test connectivity
|
||||
soundtouch-cli speaker beep --host 192.0.2.100
|
||||
|
||||
# Test TTS functionality
|
||||
# Test TTS functionality
|
||||
soundtouch-cli speaker tts --text "Testing TTS functionality" --app-key test-key --host 192.0.2.100
|
||||
|
||||
# Test URL playback
|
||||
|
||||
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 514 KiB After Width: | Height: | Size: 517 KiB |
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.4
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.93.1
|
||||
require github.com/gesellix/bose-soundtouch v0.107.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package main demonstrates content navigation and station management with SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -41,12 +42,14 @@ func main() {
|
||||
func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
// 1. Browse TuneIn content
|
||||
fmt.Println("📻 Step 1: Browsing TuneIn stations...")
|
||||
|
||||
if err := browseTuneInStations(c); err != nil {
|
||||
return fmt.Errorf("failed to browse TuneIn: %w", err)
|
||||
}
|
||||
|
||||
// 2. Search for specific content
|
||||
fmt.Println("\n🔍 Step 2: Searching for jazz stations...")
|
||||
|
||||
searchResults, err := searchForJazzStations(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to search stations: %w", err)
|
||||
@@ -54,6 +57,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 3. Add and play a station
|
||||
fmt.Println("\n➕ Step 3: Adding and playing a station...")
|
||||
|
||||
if err := addAndPlayStation(c, searchResults); err != nil {
|
||||
fmt.Printf("⚠️ Could not add station: %v\n", err)
|
||||
// Continue with demo even if this fails
|
||||
@@ -61,6 +65,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 4. Demonstrate Pandora search (if account available)
|
||||
fmt.Println("\n🎵 Step 4: Demonstrating Pandora search...")
|
||||
|
||||
if err := demonstratePandoraSearch(c); err != nil {
|
||||
fmt.Printf("⚠️ Pandora search not available: %v\n", err)
|
||||
// Continue with demo
|
||||
@@ -68,6 +73,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 5. Browse stored music (if available)
|
||||
fmt.Println("\n💿 Step 5: Browsing stored music...")
|
||||
|
||||
if err := browseStoredMusic(c); err != nil {
|
||||
fmt.Printf("⚠️ Stored music not available: %v\n", err)
|
||||
// Continue with demo
|
||||
@@ -75,6 +81,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 6. Search Spotify content (if account available)
|
||||
fmt.Println("\n🎧 Step 6: Demonstrating Spotify search...")
|
||||
|
||||
if err := demonstrateSpotifySearch(c); err != nil {
|
||||
fmt.Printf("⚠️ Spotify search not available: %v\n", err)
|
||||
// Continue with demo
|
||||
@@ -95,8 +102,10 @@ func browseTuneInStations(c *client.Client) error {
|
||||
|
||||
if len(response.Items) > 0 {
|
||||
fmt.Printf(" 🎵 Sample stations:\n")
|
||||
|
||||
for i, item := range response.Items[:min(5, len(response.Items))] {
|
||||
fmt.Printf(" %d. %s\n", i+1, item.GetDisplayName())
|
||||
|
||||
if item.IsPlayable() {
|
||||
fmt.Printf(" ▶️ Playable\n")
|
||||
} else if item.IsDirectory() {
|
||||
@@ -125,13 +134,16 @@ func searchForJazzStations(c *client.Client) (*models.SearchStationResponse, err
|
||||
if len(songs) > 0 {
|
||||
fmt.Printf(" 🎵 Songs (%d): %s\n", len(songs), songs[0].GetDisplayName())
|
||||
}
|
||||
|
||||
if len(artists) > 0 {
|
||||
fmt.Printf(" 🎤 Artists (%d): %s\n", len(artists), artists[0].GetDisplayName())
|
||||
}
|
||||
|
||||
if len(stations) > 0 {
|
||||
fmt.Printf(" 📻 Stations (%d):\n", len(stations))
|
||||
for i, station := range stations[:min(3, len(stations))] {
|
||||
fmt.Printf(" %d. %s (Token: %s)\n", i+1, station.GetDisplayName(), station.Token)
|
||||
|
||||
for i := range stations[:min(3, len(stations))] {
|
||||
fmt.Printf(" %d. %s (Token: %s)\n", i+1, stations[i].GetDisplayName(), stations[i].Token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +188,7 @@ func addAndPlayStation(c *client.Client, searchResults *models.SearchStationResp
|
||||
return nil
|
||||
}
|
||||
|
||||
func demonstratePandoraSearch(c *client.Client) error {
|
||||
func demonstratePandoraSearch(_ *client.Client) error {
|
||||
// Note: This would require a valid Pandora account
|
||||
// For demo purposes, we'll show how it would work
|
||||
fmt.Printf(" 🎵 Pandora search requires a valid source account\n")
|
||||
@@ -190,7 +202,7 @@ func demonstratePandoraSearch(c *client.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func browseStoredMusic(c *client.Client) error {
|
||||
func browseStoredMusic(_ *client.Client) error {
|
||||
// Note: This would require a valid device ID for stored music
|
||||
fmt.Printf(" 💿 Stored music browsing requires device ID\n")
|
||||
fmt.Printf(" 💡 Example usage:\n")
|
||||
@@ -204,7 +216,7 @@ func browseStoredMusic(c *client.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func demonstrateSpotifySearch(c *client.Client) error {
|
||||
func demonstrateSpotifySearch(_ *client.Client) error {
|
||||
// Note: This would require a valid Spotify account
|
||||
fmt.Printf(" 🎧 Spotify search requires a valid source account\n")
|
||||
fmt.Printf(" 💡 Example usage:\n")
|
||||
@@ -218,14 +230,6 @@ func demonstrateSpotifySearch(c *client.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to get minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("🎵 SoundTouch Navigation & Station Management Demo")
|
||||
fmt.Println()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.4
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.93.1
|
||||
require github.com/gesellix/bose-soundtouch v0.107.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
// Mock speaker error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="08DF1F0BA325">
|
||||
<errors deviceID="AABBCCDDEE0A">
|
||||
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
|
||||
</errors>`
|
||||
|
||||
@@ -38,8 +38,8 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if errs.DeviceID != "08DF1F0BA325" {
|
||||
t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID)
|
||||
if errs.DeviceID != "AABBCCDDEE0A" {
|
||||
t.Errorf("expected DeviceID AABBCCDDEE0A, got %s", errs.DeviceID)
|
||||
}
|
||||
|
||||
if len(errs.Errors) != 1 {
|
||||
@@ -67,7 +67,7 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) {
|
||||
// Mock speaker error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="08DF1F0BA325">
|
||||
<errors deviceID="AABBCCDDEE0A">
|
||||
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
|
||||
</errors>`
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ func ExampleClient_GetSupportedURLs_concept() {
|
||||
}
|
||||
|
||||
// Expected output with a real device:
|
||||
// Device 08DF1F0BA325 supports 103 endpoints
|
||||
// Device AABBCCDDEE0A supports 103 endpoints
|
||||
// Core functionality: true
|
||||
// Multiroom support: true
|
||||
// Streaming support: true
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestClient_GetSupportedURLs(t *testing.T) {
|
||||
{
|
||||
name: "successful_supported_urls_retrieval",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<supportedURLs deviceID="08DF1F0BA325">
|
||||
<supportedURLs deviceID="AABBCCDDEE0A">
|
||||
<URL location="/info" />
|
||||
<URL location="/capabilities" />
|
||||
<URL location="/supportedURLs" />
|
||||
@@ -50,7 +50,7 @@ func TestClient_GetSupportedURLs(t *testing.T) {
|
||||
<URL location="/bassCapabilities" />
|
||||
</supportedURLs>`,
|
||||
expectedError: false,
|
||||
expectedDeviceID: "08DF1F0BA325",
|
||||
expectedDeviceID: "AABBCCDDEE0A",
|
||||
expectedURLCount: 25,
|
||||
expectedURLs: []string{
|
||||
"/info", "/capabilities", "/supportedURLs", "/volume", "/bass",
|
||||
|
||||
@@ -241,6 +241,15 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
|
||||
ws.conn = conn
|
||||
ws.connected = true
|
||||
|
||||
// Extend the read deadline on every pong so the connection survives
|
||||
// quiet periods between speaker events. Without this, the 60-second
|
||||
// read deadline in readLoop fires reliably after one ping cycle (30 s
|
||||
// ping interval + 5 s reconnect = ~65 s disconnect loop).
|
||||
conn.SetPongHandler(func(string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Start background goroutines for connection management
|
||||
go ws.readLoop(config)
|
||||
go ws.pingLoop(config)
|
||||
@@ -554,7 +563,16 @@ func (ws *WebSocketClient) handleEvent(event *models.WebSocketEvent) {
|
||||
if !hasKnownEvent && handlers.OnUnknownEvent != nil {
|
||||
handlers.OnUnknownEvent(event)
|
||||
} else if !hasKnownEvent {
|
||||
ws.logger.Printf("Received unknown event types: %v", eventTypes)
|
||||
// Log the actual unmodeled element names (e.g. nowSelectionUpdated)
|
||||
// rather than an empty list; skip frames that carry no child events.
|
||||
if names := event.UnknownEventNames(); len(names) > 0 {
|
||||
sanitizedNames := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
sanitizedNames = append(sanitizedNames, sanitizeLog(name))
|
||||
}
|
||||
|
||||
ws.logger.Printf("Received unhandled event types: %v", sanitizedNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ type DNSDiscovery struct {
|
||||
derivedHosts []string
|
||||
|
||||
// State
|
||||
discovered map[string]*DiscoveredHost
|
||||
mu sync.RWMutex
|
||||
discovered map[string]*DiscoveredHost
|
||||
interceptClients map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
|
||||
// Callbacks
|
||||
onNewDiscovery func(hostname string)
|
||||
@@ -73,12 +74,13 @@ func NewDNSDiscovery(upstreamDNS []string, serviceIP, serverURL string) *DNSDisc
|
||||
}
|
||||
|
||||
return &DNSDiscovery{
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
derivedHosts: derived,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
timeout: 2 * time.Second,
|
||||
lastLog: make(map[string]time.Time),
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
derivedHosts: derived,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
interceptClients: make(map[string]time.Time),
|
||||
timeout: 2 * time.Second,
|
||||
lastLog: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +223,21 @@ func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAd
|
||||
host.RemoteAddr = remoteAddr
|
||||
}
|
||||
}
|
||||
|
||||
// Track distinct non-loopback clients that queried an intercepted hostname.
|
||||
// Used by the dns_speaker_usage health check to detect whether any speaker
|
||||
// actually resolves Bose hostnames through AfterTouch's DNS interceptor.
|
||||
if isIntercepted && remoteAddr != "" {
|
||||
clientHost, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
// remoteAddr doesn't parse as host:port; use as-is.
|
||||
clientHost = remoteAddr
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(clientHost); ip != nil && !ip.IsLoopback() {
|
||||
d.interceptClients[clientHost] = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InterceptedBoseHosts is the canonical list of Bose cloud service
|
||||
@@ -434,6 +451,24 @@ func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
|
||||
return result
|
||||
}
|
||||
|
||||
// InterceptClientIPs returns a copy of the set of non-loopback client IPs
|
||||
// that have queried an intercepted Bose hostname. The value for each key is
|
||||
// the timestamp of the most recent intercepted query from that client.
|
||||
// Empty map means no non-loopback speaker has ever used AfterTouch's DNS
|
||||
// interceptor. Callers receive a snapshot; the map is safe to read after
|
||||
// this method returns.
|
||||
func (d *DNSDiscovery) InterceptClientIPs() map[string]time.Time {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
result := make(map[string]time.Time, len(d.interceptClients))
|
||||
for k, v := range d.interceptClients {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
|
||||
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
|
||||
d.mu.RLock()
|
||||
|
||||
@@ -545,3 +545,84 @@ func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
|
||||
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
// mockResponseWriterWithAddr is like mockResponseWriter but returns a
|
||||
// configurable remote address; used to simulate queries from speaker IPs.
|
||||
type mockResponseWriterWithAddr struct {
|
||||
mockResponseWriter
|
||||
remote net.Addr
|
||||
}
|
||||
|
||||
func (m *mockResponseWriterWithAddr) RemoteAddr() net.Addr { return m.remote }
|
||||
|
||||
// mockUDPAddr implements net.Addr for test purposes.
|
||||
type mockUDPAddr struct{ addr string }
|
||||
|
||||
func (a *mockUDPAddr) Network() string { return "udp" }
|
||||
func (a *mockUDPAddr) String() string { return a.addr }
|
||||
|
||||
func TestDNSDiscovery_InterceptClientTracking_NonLoopback(t *testing.T) {
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100", "")
|
||||
|
||||
// Query for an intercepted Bose hostname from a non-loopback address.
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("content.api.bose.io.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriterWithAddr{
|
||||
remote: &mockUDPAddr{addr: "10.1.103.209:54321"},
|
||||
}
|
||||
d.ServeDNS(rw, msg)
|
||||
|
||||
clients := d.InterceptClientIPs()
|
||||
if _, ok := clients["10.1.103.209"]; !ok {
|
||||
t.Errorf("expected 10.1.103.209 in interceptClients, got %v", clients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_InterceptClientTracking_LoopbackExcluded(t *testing.T) {
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100", "")
|
||||
|
||||
// Query for an intercepted hostname from loopback (health-probe scenario).
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriterWithAddr{
|
||||
remote: &mockUDPAddr{addr: "127.0.0.1:55069"},
|
||||
}
|
||||
d.ServeDNS(rw, msg)
|
||||
|
||||
clients := d.InterceptClientIPs()
|
||||
if len(clients) != 0 {
|
||||
t.Errorf("expected no clients (loopback should be excluded), got %v", clients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_InterceptClientTracking_NonInterceptedNotRecorded(t *testing.T) {
|
||||
// Forwarded (non-intercepted) queries should not populate interceptClients.
|
||||
upstreamMux := dns.NewServeMux()
|
||||
upstreamMux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5360", Net: "udp", Handler: upstreamMux,
|
||||
ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() { _ = ts.ListenAndServe() }()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
d := NewDNSDiscovery([]string{"127.0.0.1:5360"}, "192.0.2.100", "")
|
||||
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("google.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriterWithAddr{
|
||||
remote: &mockUDPAddr{addr: "10.1.103.209:54321"},
|
||||
}
|
||||
d.ServeDNS(rw, msg)
|
||||
|
||||
clients := d.InterceptClientIPs()
|
||||
if len(clients) != 0 {
|
||||
t.Errorf("expected no intercept clients for non-intercepted query, got %v", clients)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,24 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
|
||||
return p
|
||||
}
|
||||
|
||||
// BuildTranslateTTSURL builds the (undocumented) Google Translate TTS URL the
|
||||
// speaker fetches directly for a /speaker notification. language is a short code
|
||||
// such as "EN" or "DE". Shared by NewTTSPlayInfo and the TTS service's Translate
|
||||
// provider so the query-string format lives in exactly one place.
|
||||
//
|
||||
// https://translate.google.com/translate_tts?ie=UTF-8&tl=de&client=aftertouch&q=Hallo+Wie+Geht%27s
|
||||
func BuildTranslateTTSURL(text, language string) string {
|
||||
return fmt.Sprintf("https://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
|
||||
}
|
||||
|
||||
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
|
||||
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
|
||||
// URL encode the text for Google TTS
|
||||
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
|
||||
ttsURL := BuildTranslateTTSURL(text, language)
|
||||
|
||||
playInfo := &PlayInfo{
|
||||
XMLName: xml.Name{Local: "play_info"},
|
||||
URL: url,
|
||||
URL: ttsURL,
|
||||
AppKey: appKey,
|
||||
Service: "TTS Notification",
|
||||
Message: "Google TTS",
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
|
||||
// Test without volume
|
||||
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
|
||||
|
||||
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
|
||||
expectedURL := "https://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
|
||||
if playInfo.URL != expectedURL {
|
||||
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,28 @@ type WebSocketEvent struct {
|
||||
ErrorUpdated *ErrorUpdatedEvent `xml:"errorUpdated,omitempty"`
|
||||
RecentsUpdated *RecentsUpdatedEvent `xml:"recentsUpdated,omitempty"`
|
||||
LanguageUpdated *LanguageUpdatedEvent `xml:"languageUpdated,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"` // Added by client for tracking
|
||||
// UnknownElements captures <updates> children we don't model yet (e.g.
|
||||
// nowSelectionUpdated), so callers can log them by name instead of an
|
||||
// empty list when no known event matched.
|
||||
UnknownElements []UnknownElement `xml:",any"`
|
||||
Timestamp time.Time `json:"timestamp"` // Added by client for tracking
|
||||
}
|
||||
|
||||
// UnknownElement records the tag name of an <updates> child element that the
|
||||
// WebSocketEvent struct does not (yet) model.
|
||||
type UnknownElement struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
|
||||
// UnknownEventNames returns the tag names of any unmodeled <updates> children,
|
||||
// for diagnostic logging.
|
||||
func (e *WebSocketEvent) UnknownEventNames() []string {
|
||||
names := make([]string, 0, len(e.UnknownElements))
|
||||
for _, u := range e.UnknownElements {
|
||||
names = append(names, u.XMLName.Local)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// GetEvents returns all events present in this WebSocket event
|
||||
|
||||
@@ -540,3 +540,41 @@ func TestCreateMockWebSocketEvent(t *testing.T) {
|
||||
t.Errorf("Event types don't match expected values")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseWebSocketEvent_UnknownElements verifies that an <updates> envelope
|
||||
// whose only child is an unmodeled element (e.g. nowSelectionUpdated, observed
|
||||
// on real SoundTouch 10 firmware) parses with no known event types but with
|
||||
// the element captured by name, so callers can log something useful instead
|
||||
// of an empty list.
|
||||
func TestParseWebSocketEvent_UnknownElements(t *testing.T) {
|
||||
raw := []byte(`<updates deviceID="A81B6A536A98"><nowSelectionUpdated><preset id="0"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s308770" sourceAccount="" isPresetable="true"><itemName>Willy</itemName></ContentItem></preset></nowSelectionUpdated></updates>`)
|
||||
|
||||
event, err := ParseWebSocketEvent(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWebSocketEvent: %v", err)
|
||||
}
|
||||
|
||||
if got := event.GetEventTypes(); len(got) != 0 {
|
||||
t.Errorf("expected no known event types, got %v", got)
|
||||
}
|
||||
|
||||
names := event.UnknownEventNames()
|
||||
if len(names) != 1 || names[0] != "nowSelectionUpdated" {
|
||||
t.Errorf("expected [nowSelectionUpdated], got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseWebSocketEvent_KnownEventNoUnknowns confirms a modeled event is not
|
||||
// also captured as an unknown element.
|
||||
func TestParseWebSocketEvent_KnownEventNoUnknowns(t *testing.T) {
|
||||
raw := []byte(`<updates deviceID="A81B6A536A98"><nowPlayingUpdated><nowPlaying deviceID="A81B6A536A98" source="TUNEIN"></nowPlaying></nowPlayingUpdated></updates>`)
|
||||
|
||||
event, err := ParseWebSocketEvent(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWebSocketEvent: %v", err)
|
||||
}
|
||||
|
||||
if names := event.UnknownEventNames(); len(names) != 0 {
|
||||
t.Errorf("expected no unknown elements for a modeled event, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,61 @@
|
||||
package bmx
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
|
||||
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
|
||||
streamList := []models.Stream{
|
||||
{
|
||||
// BuildOrionLocation wraps a raw stream URL in the AfterTouch Orion station
|
||||
// endpoint that the speaker's BMX module expects when playing LOCAL_INTERNET_RADIO
|
||||
// content. The speaker calls GET on the stored location expecting a
|
||||
// BmxPlaybackResponse JSON — not raw audio bytes.
|
||||
func BuildOrionLocation(serviceURL, name, imageURL, streamURL string) string {
|
||||
payload := struct {
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
StreamURL string `json:"streamUrl"`
|
||||
}{
|
||||
Name: name,
|
||||
ImageURL: imageURL,
|
||||
StreamURL: streamURL,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
encoded := url.QueryEscape(base64.StdEncoding.EncodeToString(data))
|
||||
|
||||
return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded
|
||||
}
|
||||
|
||||
// BuildCustomStreamResponseFromURLs wraps one or more candidate stream URLs
|
||||
// in a playback response. The speaker fails over between entries in the
|
||||
// Streams array, so order matters — pass them as the provider listed them.
|
||||
// The top-level StreamUrl mirrors urls[0] for compatibility.
|
||||
func BuildCustomStreamResponseFromURLs(urls []string, imageURL, name string) (*models.BmxPlaybackResponse, error) {
|
||||
if len(urls) == 0 {
|
||||
return nil, fmt.Errorf("no stream URLs provided")
|
||||
}
|
||||
|
||||
streamList := make([]models.Stream, 0, len(urls))
|
||||
for _, u := range urls {
|
||||
streamList = append(streamList, models.Stream{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: streamURL,
|
||||
},
|
||||
StreamUrl: u,
|
||||
})
|
||||
}
|
||||
|
||||
audio := models.Audio{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: streamURL,
|
||||
StreamUrl: urls[0],
|
||||
Streams: streamList,
|
||||
}
|
||||
|
||||
@@ -36,6 +72,11 @@ func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPla
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
|
||||
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
|
||||
return BuildCustomStreamResponseFromURLs([]string{streamURL}, imageURL, name)
|
||||
}
|
||||
|
||||
// PlayCustomStream builds a playback response from a base64-encoded JSON blob
|
||||
// with fields streamUrl, imageUrl, and name.
|
||||
func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
|
||||
|
||||
@@ -29,3 +29,46 @@ func TestPlayCustomStream(t *testing.T) {
|
||||
t.Errorf("Expected name Stream Name, got %s", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCustomStreamResponseFromURLs(t *testing.T) {
|
||||
// Multiple candidates must all reach the speaker, in order, so it can
|
||||
// fail over from a dead variant to a working one (see s56857 / NDR 2).
|
||||
urls := []string{
|
||||
"https://example.com/aac/low",
|
||||
"https://example.com/mp3/128/stream.mp3",
|
||||
}
|
||||
|
||||
resp, err := BuildCustomStreamResponseFromURLs(urls, "image.png", "NDR 2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCustomStreamResponseFromURLs failed: %v", err)
|
||||
}
|
||||
|
||||
if got := len(resp.Audio.Streams); got != len(urls) {
|
||||
t.Fatalf("expected %d streams, got %d", len(urls), got)
|
||||
}
|
||||
|
||||
for i, want := range urls {
|
||||
if got := resp.Audio.Streams[i].StreamUrl; got != want {
|
||||
t.Errorf("stream %d: expected %q, got %q", i, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Audio.StreamUrl != urls[0] {
|
||||
t.Errorf("top-level StreamUrl: expected %q, got %q", urls[0], resp.Audio.StreamUrl)
|
||||
}
|
||||
|
||||
// Empty input is an error, not a panic.
|
||||
if _, err := BuildCustomStreamResponseFromURLs(nil, "", ""); err == nil {
|
||||
t.Error("expected error for empty URL list, got nil")
|
||||
}
|
||||
|
||||
// The single-URL wrapper still yields exactly one stream.
|
||||
single, err := BuildCustomStreamResponse("https://example.com/only", "", "Solo")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCustomStreamResponse failed: %v", err)
|
||||
}
|
||||
|
||||
if got := len(single.Audio.Streams); got != 1 {
|
||||
t.Errorf("expected 1 stream from single-URL builder, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bmx
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -11,12 +12,25 @@ import (
|
||||
|
||||
var radioBrowserBaseURL = "https://all.api.radio-browser.info"
|
||||
|
||||
// RadioBrowserSearch searches for radio stations using the RadioBrowser API.
|
||||
func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
|
||||
searchURL := fmt.Sprintf("%s/json/stations/search?name=%s&limit=20&order=clickcount&reverse=true",
|
||||
radioBrowserBaseURL, url.QueryEscape(query))
|
||||
const radioBrowserPageSize = 20
|
||||
|
||||
resp, err := http.Get(searchURL)
|
||||
// radioBrowserCursor is the opaque pagination cursor for RadioBrowser search results.
|
||||
type radioBrowserCursor struct {
|
||||
Query string `json:"q"`
|
||||
NextOffset int `json:"o"`
|
||||
}
|
||||
|
||||
// RadioBrowserSearch searches for radio stations using the RadioBrowser API (first page).
|
||||
func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
|
||||
return RadioBrowserSearchPage(query, 0)
|
||||
}
|
||||
|
||||
// RadioBrowserSearchPage searches for radio stations at a specific offset.
|
||||
func RadioBrowserSearchPage(query string, offset int) (*models.BmxNavResponse, error) {
|
||||
searchURL := fmt.Sprintf("%s/json/stations/search?name=%s&limit=%d&offset=%d&hidebroken=true&order=clickcount&reverse=true",
|
||||
radioBrowserBaseURL, url.QueryEscape(query), radioBrowserPageSize, offset)
|
||||
|
||||
resp, err := http.Get(searchURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -32,13 +46,9 @@ func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
navResp := &models.BmxNavResponse{
|
||||
BmxSections: []models.BmxNavSection{
|
||||
{
|
||||
Name: "Stations",
|
||||
Items: make([]models.BmxNavItem, 0, len(stations)),
|
||||
},
|
||||
},
|
||||
section := models.BmxNavSection{
|
||||
Name: "Stations",
|
||||
Items: make([]models.BmxNavItem, 0, len(stations)),
|
||||
}
|
||||
|
||||
for _, station := range stations {
|
||||
@@ -71,8 +81,50 @@ func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
|
||||
},
|
||||
},
|
||||
}
|
||||
navResp.BmxSections[0].Items = append(navResp.BmxSections[0].Items, item)
|
||||
section.Items = append(section.Items, item)
|
||||
}
|
||||
|
||||
// Attach a BmxNext link only when the page is full (more results likely exist).
|
||||
if len(stations) == radioBrowserPageSize {
|
||||
cursorData := radioBrowserCursor{Query: query, NextOffset: offset + radioBrowserPageSize}
|
||||
|
||||
cursorJSON, err := json.Marshal(cursorData)
|
||||
if err == nil {
|
||||
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
|
||||
|
||||
section.Links = &models.Links{
|
||||
BmxNext: &models.Link{Href: "/v1/radiobrowser/search/next?cursor=" + encoded},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navResp := &models.BmxNavResponse{
|
||||
BmxSections: []models.BmxNavSection{section},
|
||||
}
|
||||
|
||||
return navResp, nil
|
||||
}
|
||||
|
||||
// RadioBrowserSearchNext fetches the next page of RadioBrowser search results using the
|
||||
// opaque cursor produced by RadioBrowserSearchPage.
|
||||
func RadioBrowserSearchNext(encodedCursor string) (*models.BmxNavResponse, error) {
|
||||
cursorBytes, err := base64.RawURLEncoding.DecodeString(encodedCursor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor: %w", err)
|
||||
}
|
||||
|
||||
var cursor radioBrowserCursor
|
||||
if err := json.Unmarshal(cursorBytes, &cursor); err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor: %w", err)
|
||||
}
|
||||
|
||||
if cursor.Query == "" {
|
||||
return nil, fmt.Errorf("invalid cursor: missing query")
|
||||
}
|
||||
|
||||
if cursor.NextOffset < 0 {
|
||||
return nil, fmt.Errorf("invalid cursor: negative offset")
|
||||
}
|
||||
|
||||
return RadioBrowserSearchPage(cursor.Query, cursor.NextOffset)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package bmx
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -44,6 +47,140 @@ func TestRadioBrowserSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// makeStationsJSON returns a JSON array of n station objects.
|
||||
func makeStationsJSON(n int) string {
|
||||
stations := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
stations[i] = fmt.Sprintf(`{"name":"Station %d","stationuuid":"uuid-%d","favicon":"","country":"DE","tags":"pop"}`, i, i)
|
||||
}
|
||||
|
||||
return "[" + strings.Join(stations, ",") + "]"
|
||||
}
|
||||
|
||||
func TestRadioBrowserSearchPage_FullPage_HasNext(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, makeStationsJSON(radioBrowserPageSize))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
originalBaseURL := radioBrowserBaseURL
|
||||
radioBrowserBaseURL = ts.URL
|
||||
defer func() { radioBrowserBaseURL = originalBaseURL }()
|
||||
|
||||
resp, err := RadioBrowserSearchPage("test", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RadioBrowserSearchPage failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.BmxSections) == 0 {
|
||||
t.Fatal("expected sections")
|
||||
}
|
||||
|
||||
section := resp.BmxSections[0]
|
||||
|
||||
if len(section.Items) != radioBrowserPageSize {
|
||||
t.Errorf("expected %d items, got %d", radioBrowserPageSize, len(section.Items))
|
||||
}
|
||||
|
||||
if section.Links == nil || section.Links.BmxNext == nil {
|
||||
t.Fatal("expected BmxNext link on full page")
|
||||
}
|
||||
|
||||
if !strings.Contains(section.Links.BmxNext.Href, "cursor=") {
|
||||
t.Errorf("expected cursor in BmxNext href, got %q", section.Links.BmxNext.Href)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadioBrowserSearchPage_ShortPage_NoNext(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, makeStationsJSON(5)) // fewer than radioBrowserPageSize
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
originalBaseURL := radioBrowserBaseURL
|
||||
radioBrowserBaseURL = ts.URL
|
||||
defer func() { radioBrowserBaseURL = originalBaseURL }()
|
||||
|
||||
resp, err := RadioBrowserSearchPage("test", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RadioBrowserSearchPage failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.BmxSections) == 0 {
|
||||
t.Fatal("expected sections")
|
||||
}
|
||||
|
||||
section := resp.BmxSections[0]
|
||||
|
||||
if section.Links != nil && section.Links.BmxNext != nil {
|
||||
t.Errorf("expected no BmxNext link on short page, got %q", section.Links.BmxNext.Href)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadioBrowserSearchNext_CursorRoundTrip(t *testing.T) {
|
||||
// Build a cursor manually to verify RadioBrowserSearchNext decodes it correctly.
|
||||
cursorData := radioBrowserCursor{Query: "jazz", NextOffset: 20}
|
||||
cursorJSON, err := json.Marshal(cursorData)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cursor: %v", err)
|
||||
}
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify the offset was forwarded in the URL.
|
||||
if !strings.Contains(r.URL.RawQuery, "offset=20") {
|
||||
t.Errorf("expected offset=20 in query, got %q", r.URL.RawQuery)
|
||||
}
|
||||
|
||||
if !strings.Contains(r.URL.RawQuery, "name=jazz") {
|
||||
t.Errorf("expected name=jazz in query, got %q", r.URL.RawQuery)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, makeStationsJSON(3))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
originalBaseURL := radioBrowserBaseURL
|
||||
radioBrowserBaseURL = ts.URL
|
||||
defer func() { radioBrowserBaseURL = originalBaseURL }()
|
||||
|
||||
resp, err := RadioBrowserSearchNext(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("RadioBrowserSearchNext failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.BmxSections) == 0 || len(resp.BmxSections[0].Items) != 3 {
|
||||
t.Errorf("expected 3 items, got response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadioBrowserSearchNext_InvalidCursor(t *testing.T) {
|
||||
_, err := RadioBrowserSearchNext("not-valid-base64!!!")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadioBrowserSearchNext_EmptyQueryCursor(t *testing.T) {
|
||||
// A cursor with an empty query should be rejected.
|
||||
cursorData := radioBrowserCursor{Query: "", NextOffset: 20}
|
||||
cursorJSON, err := json.Marshal(cursorData)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cursor: %v", err)
|
||||
}
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
|
||||
|
||||
_, err = RadioBrowserSearchNext(encoded)
|
||||
if err == nil {
|
||||
t.Error("expected error for cursor with empty query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadioBrowserSearch_Real(t *testing.T) {
|
||||
if os.Getenv("RADIOBROWSER_INTEGRATION") == "" {
|
||||
t.Skip("skipping live network test; set RADIOBROWSER_INTEGRATION=1 to run")
|
||||
|
||||
@@ -13,23 +13,10 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TuneIn endpoint templates used to resolve station and stream URLs.
|
||||
// TuneIn endpoint constants. The base URLs themselves are configurable vars
|
||||
// (see tuneInOpmlTuneBase and friends below, set via SetTuneInEndpoints); only
|
||||
// the format-list default is a fixed constant.
|
||||
const (
|
||||
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
|
||||
// TuneInProfileContents is the modern JSON API that lists a
|
||||
// program's (`p<N>`) episodes. The legacy OPML endpoints can't —
|
||||
// `Tune.ashx?id=p<N>` returns `#STATUS: 400`, `Browse.ashx?id=p<N>`
|
||||
// only surfaces related genres + networks. Same payload is served
|
||||
// from api.tunein.com and api.radiotime.com; we use radiotime
|
||||
// because TuneInNavigateProfile already navigates there via
|
||||
// Pivots.Contents.Url, so all program-related traffic stays on the
|
||||
// same host that's already in allowedTuneInHosts. See
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint map.
|
||||
TuneInProfileContents = "https://api.radiotime.com/profiles/%s/contents?version=1.3"
|
||||
|
||||
// DefaultTuneInStreamFormats is the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
|
||||
// pre-2026-05-10 behaviour from before PR #249 added "hls"
|
||||
@@ -53,7 +40,7 @@ func TuneInStream(stationID, formats string) string {
|
||||
formats = DefaultTuneInStreamFormats
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats)
|
||||
return fmt.Sprintf("%s/Tune.ashx?id=%s&formats=%s", tuneInOpmlTuneBase, stationID, formats)
|
||||
}
|
||||
|
||||
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
|
||||
@@ -62,6 +49,45 @@ var allowedTuneInHosts = map[string]bool{
|
||||
"api.radiotime.com": true,
|
||||
}
|
||||
|
||||
// TuneIn endpoint base URLs. They default to the real TuneIn hosts (matching the
|
||||
// constants above) but can be redirected with SetTuneInEndpoints, e.g. to point
|
||||
// the playback / describe / search calls at a local mock so an integration suite
|
||||
// does not depend on the live TuneIn service. opmlBase covers the
|
||||
// opml.radiotime.com endpoints (Tune.ashx, describe.ashx, navigate); apiBase
|
||||
// covers the api.radiotime.com endpoints (search, profile contents).
|
||||
var (
|
||||
tuneInOpmlTuneBase = "http://opml.radiotime.com"
|
||||
tuneInOpmlDescribeBase = "https://opml.radiotime.com"
|
||||
tuneInOpmlNavigateBase = "http://opml.radiotime.com"
|
||||
tuneInAPIBase = "https://api.radiotime.com"
|
||||
)
|
||||
|
||||
// SetTuneInEndpoints overrides the TuneIn upstream base URLs and registers their
|
||||
// hosts in the outbound allowlist. Empty arguments leave the corresponding
|
||||
// default in place. Intended for tests and local mocks; production leaves the
|
||||
// real TuneIn hosts.
|
||||
func SetTuneInEndpoints(opmlBase, apiBase string) {
|
||||
if opmlBase != "" {
|
||||
b := strings.TrimRight(opmlBase, "/")
|
||||
tuneInOpmlTuneBase = b
|
||||
tuneInOpmlDescribeBase = b
|
||||
tuneInOpmlNavigateBase = b
|
||||
|
||||
if u, err := url.Parse(b); err == nil && u.Hostname() != "" {
|
||||
allowedTuneInHosts[u.Hostname()] = true
|
||||
}
|
||||
}
|
||||
|
||||
if apiBase != "" {
|
||||
b := strings.TrimRight(apiBase, "/")
|
||||
tuneInAPIBase = b
|
||||
|
||||
if u, err := url.Parse(b); err == nil && u.Hostname() != "" {
|
||||
allowedTuneInHosts[u.Hostname()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isTuneInOpmlURI returns true when the URL's host is opml.radiotime.com,
|
||||
// used to select the OPML/ashx parser over the JSON API parser.
|
||||
func isTuneInOpmlURI(rawURL string) bool {
|
||||
@@ -94,7 +120,7 @@ func tuneInRenderJSONURI(rawURL string) string {
|
||||
|
||||
// tuneInSearchURI returns the TuneIn search API URL with the query properly URL-encoded.
|
||||
func tuneInSearchURI(query string) string {
|
||||
return TuneInSearchAPI + url.QueryEscape(query)
|
||||
return tuneInAPIBase + "/profiles?fulltextsearch=true&version=1.3&query=" + url.QueryEscape(query)
|
||||
}
|
||||
|
||||
func fetchJSON(fetchURL string) (map[string]interface{}, error) {
|
||||
@@ -117,7 +143,7 @@ func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse,
|
||||
|
||||
tuneInURI = decoded
|
||||
} else {
|
||||
tuneInURI = TuneInNavigateAshx
|
||||
tuneInURI = tuneInOpmlNavigateBase + "/?render=json"
|
||||
templated := true
|
||||
bmxSearchLink = &models.Link{
|
||||
Filters: []interface{}{},
|
||||
@@ -725,7 +751,11 @@ func parseTuneInProgramContents(body []byte, programID string) (episodeID string
|
||||
}
|
||||
|
||||
func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
|
||||
fetchURL := fmt.Sprintf(TuneInProfileContents, programID)
|
||||
// The modern JSON API lists a program's (`p<N>`) episodes; the legacy OPML
|
||||
// endpoints can't (`Tune.ashx?id=p<N>` returns `#STATUS: 400`). We use the
|
||||
// api.radiotime.com host (tuneInAPIBase) because TuneInNavigateProfile already
|
||||
// navigates there. See `_/i226/tunein-api-findings.md` for the endpoint map.
|
||||
fetchURL := fmt.Sprintf("%s/profiles/%s/contents?version=1.3", tuneInAPIBase, programID)
|
||||
|
||||
resp, err := defaultClient.Get(fetchURL)
|
||||
if err != nil {
|
||||
@@ -748,7 +778,7 @@ func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err
|
||||
|
||||
// TuneInDescribeMeta fetches the name and logo for a TuneIn guide ID.
|
||||
func TuneInDescribeMeta(id string) (name, logo string, err error) {
|
||||
fetchURL := fmt.Sprintf(TuneInDescribe, id)
|
||||
fetchURL := fmt.Sprintf("%s/describe.ashx?id=%s", tuneInOpmlDescribeBase, id)
|
||||
|
||||
resp, err := defaultClient.Get(fetchURL)
|
||||
if err != nil {
|
||||
@@ -808,7 +838,7 @@ func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, err
|
||||
|
||||
name, logo, _ := TuneInDescribeMeta(stationID)
|
||||
|
||||
return BuildCustomStreamResponse(urls[0], logo, name)
|
||||
return BuildCustomStreamResponseFromURLs(urls, logo, name)
|
||||
}
|
||||
|
||||
// TuneInPodcastInfo returns info for a TuneIn podcast.
|
||||
|
||||
@@ -91,7 +91,9 @@ func TestTuneInSearchURI(t *testing.T) {
|
||||
{
|
||||
name: "plain query is appended to base URL",
|
||||
query: "jazz",
|
||||
check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
|
||||
check: func(u string) bool {
|
||||
return u == "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query=jazz"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAtomicWriteFile_DurableRoundTrip guards the durable write path added for
|
||||
// #458: content must round-trip, an overwrite must truncate cleanly, and no
|
||||
// `.tmp` sidecar may be left behind. (The fsync durability itself isn't
|
||||
// unit-testable without power-loss fault injection; this is the functional
|
||||
// regression guard so the fsync rework doesn't break writes.)
|
||||
func TestAtomicWriteFile_DurableRoundTrip(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-atomic-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
dir := ds.AccountDeviceDir("1234567", "001122334455")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "Sources.xml")
|
||||
|
||||
if err := ds.atomicWriteFile(path, []byte("first")); err != nil {
|
||||
t.Fatalf("atomicWriteFile (create) failed: %v", err)
|
||||
}
|
||||
|
||||
if got, _ := os.ReadFile(path); string(got) != "first" {
|
||||
t.Errorf("content after create = %q, want %q", got, "first")
|
||||
}
|
||||
|
||||
// Overwrite must truncate the previous (longer) content, not leave a tail.
|
||||
if err := ds.atomicWriteFile(path, []byte("hi")); err != nil {
|
||||
t.Fatalf("atomicWriteFile (overwrite) failed: %v", err)
|
||||
}
|
||||
|
||||
if got, _ := os.ReadFile(path); string(got) != "hi" {
|
||||
t.Errorf("content after overwrite = %q, want %q", got, "hi")
|
||||
}
|
||||
|
||||
// No leftover temp sidecar.
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Errorf("expected no %s.tmp leftover, stat err = %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -962,6 +962,16 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// An empty / 0-byte Presets.xml (e.g. truncated by an unclean power-cut on
|
||||
// the speaker's NAND) is treated as "no presets" rather than a hard parse
|
||||
// error, so the device-level /presets endpoint returns an empty list
|
||||
// instead of HTTP 500. See #458.
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
|
||||
var presetsWrap struct {
|
||||
Presets []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
@@ -989,7 +999,9 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
needsRewrite := !bytes.Equal(normalized, data)
|
||||
|
||||
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
|
||||
return nil, false, fmt.Errorf("malformed presets XML at %s: %w", path, err)
|
||||
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
|
||||
presets := []models.ServicePreset{}
|
||||
@@ -1198,15 +1210,83 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
return ds.atomicWriteFile(path, append(header, data...))
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to filename atomically AND durably: it writes a
|
||||
// temp file, fsyncs it, renames it into place, then fsyncs the parent
|
||||
// directory. Without the fsyncs an unclean power-cut on a journaling NAND
|
||||
// filesystem (UBIFS, the speaker's /mnt/nv) can leave the renamed file present
|
||||
// but 0 bytes — the rename was journalled but the data blocks were never
|
||||
// flushed. See #458.
|
||||
func (ds *DataStore) atomicWriteFile(filename string, data []byte) error {
|
||||
perm := os.FileMode(0644)
|
||||
|
||||
tempFile := filename + ".tmp"
|
||||
if err := ds.rootWriteFile(tempFile, data, perm); err != nil {
|
||||
if err := ds.rootWriteFileSync(tempFile, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ds.rootRename(tempFile, filename)
|
||||
if err := ds.rootRename(tempFile, filename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fsync the parent directory so the rename itself survives a power-cut.
|
||||
// Best-effort: not every filesystem permits directory fsync, and the data +
|
||||
// rename have already succeeded by this point.
|
||||
ds.rootSyncDir(filepath.Dir(filename))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// rootWriteFileSync writes data to absPath (truncating any existing file) and
|
||||
// fsyncs the file before returning, so the contents are on stable storage. This
|
||||
// is the durable equivalent of rootWriteFile.
|
||||
func (ds *DataStore) rootWriteFileSync(absPath string, data []byte, perm os.FileMode) error {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := r.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, werr := f.Write(data); werr != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return werr
|
||||
}
|
||||
|
||||
if serr := f.Sync(); serr != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return serr
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// rootSyncDir fsyncs the directory at absDir so a preceding create/rename is
|
||||
// durable. Best-effort: directory fsync isn't supported on every filesystem, so
|
||||
// failures are logged and swallowed rather than failing an already-successful
|
||||
// write.
|
||||
func (ds *DataStore) rootSyncDir(absDir string) {
|
||||
d, err := ds.rootOpen(absDir)
|
||||
if err != nil {
|
||||
log.Printf("[Datastore] rootSyncDir: open %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if serr := d.Sync(); serr != nil {
|
||||
log.Printf("[Datastore] rootSyncDir: fsync %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(serr))
|
||||
}
|
||||
|
||||
_ = d.Close()
|
||||
}
|
||||
|
||||
// GetRecents returns the list of recently played items for the specified account and device.
|
||||
@@ -1225,6 +1305,16 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// An empty / 0-byte Recents.xml (e.g. truncated by an unclean power-cut) is
|
||||
// treated as "no recents" rather than a hard parse error, so the
|
||||
// device-level /recents endpoint returns an empty list instead of HTTP 500.
|
||||
// See #458.
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Printf("[Datastore] GetRecents: empty/0-byte Recents.xml at %s — treating as no recents (#458)", sanitizeLog(path))
|
||||
|
||||
return []models.ServiceRecent{}, nil
|
||||
}
|
||||
|
||||
type RecentXML struct {
|
||||
DeviceID string `xml:"deviceID,attr,omitempty"`
|
||||
UtcTime string `xml:"utcTime,attr,omitempty"`
|
||||
@@ -1252,7 +1342,9 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
|
||||
var wrap RecentsXML
|
||||
if err := xml.Unmarshal(data, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
|
||||
log.Printf("[Datastore] GetRecents: malformed Recents.xml at %s (%s) — treating as no recents (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
|
||||
return []models.ServiceRecent{}, nil
|
||||
}
|
||||
|
||||
recents := make([]models.ServiceRecent, 0, len(wrap.Recents))
|
||||
@@ -1794,18 +1886,34 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
// defaultSources is the fallback used whenever there is no usable
|
||||
// Sources.xml: file missing (normal for a fresh device) or present but
|
||||
// empty / 0-byte / unparseable. The latter happens when an unclean
|
||||
// power-cut truncates a not-yet-flushed datastore write on the speaker's
|
||||
// NAND; treating it like "missing" lets /full re-serve the managed defaults
|
||||
// so the speaker self-heals instead of dropping all its sources. See #458.
|
||||
defaultSources := func() []models.ConfiguredSource {
|
||||
sources := ds.getInitialSources()
|
||||
ds.DeduceSourceIDs(account, device, sources)
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
sources := ds.getInitialSources()
|
||||
ds.DeduceSourceIDs(account, device, sources)
|
||||
|
||||
return sources, nil
|
||||
return defaultSources(), nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Printf("[Datastore] GetConfiguredSources: empty/0-byte Sources.xml at %s — treating as missing, serving defaults (#458)", sanitizeLog(path))
|
||||
|
||||
return defaultSources(), nil
|
||||
}
|
||||
|
||||
type persistentSource struct {
|
||||
DisplayName string `xml:"displayName,attr,omitempty"`
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
@@ -1830,7 +1938,9 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
|
||||
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
|
||||
log.Printf("[Datastore] GetConfiguredSources: malformed Sources.xml at %s (%s) — treating as missing, serving defaults (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
|
||||
return defaultSources(), nil
|
||||
}
|
||||
|
||||
sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources))
|
||||
@@ -2359,12 +2469,22 @@ func (ds *DataStore) GetETagForPresets(account, device string) int64 {
|
||||
return info.ModTime().UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
|
||||
// HasConfiguredSources reports whether a Sources.xml file exists for the given account and device.
|
||||
// HasConfiguredSources reports whether a non-empty Sources.xml file exists for
|
||||
// the given account and device. A present-but-0-byte file (truncated by an
|
||||
// unclean power-cut) counts as absent. See #458.
|
||||
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
_, err := ds.rootStat(path)
|
||||
|
||||
return err == nil
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// An existing but empty / 0-byte Sources.xml (e.g. truncated by an unclean
|
||||
// power-cut on the speaker's NAND) must not count as "present": otherwise it
|
||||
// hides the sources_xml_present health check and its create_default_sources
|
||||
// quick fix, leaving the device with no managed sources. See #458.
|
||||
return len(bytes.TrimSpace(data)) > 0
|
||||
}
|
||||
|
||||
// GetETagForSources returns the ETag (modification time) for the sources file for a specific device.
|
||||
@@ -2464,6 +2584,12 @@ type Settings struct {
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TTSProvider string `json:"tts_provider,omitempty"`
|
||||
TTSGoogleAPIKey string `json:"tts_google_api_key,omitempty"`
|
||||
TTSAppKey string `json:"tts_app_key,omitempty"`
|
||||
TTSLanguage string `json:"tts_language,omitempty"`
|
||||
TTSVoice string `json:"tts_voice,omitempty"`
|
||||
TTSVolume int `json:"tts_volume,omitempty"`
|
||||
|
||||
// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
|
||||
// immediate TCP peer is one of the TrustedProxyCIDRs, the X-Real-IP /
|
||||
@@ -2501,6 +2627,15 @@ type Settings struct {
|
||||
// is passed through verbatim; AfterTouch does not validate the
|
||||
// individual format tokens.
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
|
||||
// DefaultLanding selects what the root path "/" serves to a browser:
|
||||
// "chooser" (or empty) — the neutral landing page that links to the
|
||||
// player and the admin/setup console;
|
||||
// "app" — redirect straight to the player UI (/app);
|
||||
// "admin" — redirect straight to the admin console (/admin).
|
||||
// API/speaker clients (non-HTML Accept) always get the version JSON
|
||||
// regardless of this setting.
|
||||
DefaultLanding string `json:"default_landing,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
@@ -2816,6 +2951,37 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllGroupsForAccount removes every Group_*.xml file stored under
|
||||
// account. Speakers send DELETE /streaming/account/{id}/group/ (no group
|
||||
// ID) during stereo-pair teardown; since master and slave may live in
|
||||
// different accounts each speaker deletes its own copy. Returns nil if no
|
||||
// group files are found — idempotent by design.
|
||||
func (ds *DataStore) DeleteAllGroupsForAccount(account string) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := ds.rootReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // nothing to delete
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = ds.rootRemove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file.
|
||||
// File presence indicates the station is a favorite; no content is stored.
|
||||
func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestListAllDevices(t *testing.T) {
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "default"
|
||||
deviceID := "BO5EBO5E-F00D-F00D-FEED-08DF1F0BA325"
|
||||
deviceID := "BO5EBO5E-F00D-F00D-FEED-AABBCCDDEE0A"
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
// These tests cover #458: an unclean power-cut on the speaker's NAND can leave
|
||||
// a datastore file present but 0-byte (truncated, not-yet-flushed write). The
|
||||
// read paths must treat empty / 0-byte / unparseable files the same as
|
||||
// "missing" — serve defaults for sources, return empty lists for presets/recents
|
||||
// — instead of advertising nothing on /full (which wipes the speaker) or
|
||||
// returning HTTP 500 on the device-level endpoints.
|
||||
|
||||
func newTestStore(t *testing.T) (*DataStore, string, string) {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "st-empty-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return ds, account, device
|
||||
}
|
||||
|
||||
func writeDeviceFile(t *testing.T, ds *DataStore, account, device, name string, content []byte) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), name)
|
||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfiguredSources_EmptyFile_ServesDefaults(t *testing.T) {
|
||||
ds, account, device := newTestStore(t)
|
||||
writeDeviceFile(t, ds, account, device, constants.SourcesFile, []byte{})
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources returned error for 0-byte file: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) == 0 {
|
||||
t.Fatal("expected default sources for a 0-byte Sources.xml, got none")
|
||||
}
|
||||
|
||||
types := map[string]bool{}
|
||||
for i := range sources {
|
||||
types[sources[i].SourceKeyType] = true
|
||||
}
|
||||
|
||||
for _, want := range []string{constants.ProviderTunein, constants.ProviderLocalInternetRadio} {
|
||||
if !types[want] {
|
||||
t.Errorf("expected default sources to include %q (ding/radio need it); got %v", want, types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfiguredSources_MalformedFile_ServesDefaults(t *testing.T) {
|
||||
ds, account, device := newTestStore(t)
|
||||
writeDeviceFile(t, ds, account, device, constants.SourcesFile, []byte("<sources><not-closed"))
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources returned error for malformed file: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) == 0 {
|
||||
t.Fatal("expected default sources for a malformed Sources.xml, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPresets_EmptyFile_NoError(t *testing.T) {
|
||||
ds, account, device := newTestStore(t)
|
||||
writeDeviceFile(t, ds, account, device, constants.PresetsFile, []byte{})
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets returned error for 0-byte file (would surface as HTTP 500): %v", err)
|
||||
}
|
||||
|
||||
if len(presets) != 0 {
|
||||
t.Errorf("expected no presets for a 0-byte Presets.xml, got %d", len(presets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecents_EmptyFile_NoError(t *testing.T) {
|
||||
ds, account, device := newTestStore(t)
|
||||
writeDeviceFile(t, ds, account, device, constants.RecentsFile, []byte{})
|
||||
|
||||
recents, err := ds.GetRecents(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecents returned error for 0-byte file (would surface as HTTP 500): %v", err)
|
||||
}
|
||||
|
||||
if len(recents) != 0 {
|
||||
t.Errorf("expected no recents for a 0-byte Recents.xml, got %d", len(recents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasConfiguredSources_EmptyFile_False(t *testing.T) {
|
||||
ds, account, device := newTestStore(t)
|
||||
|
||||
// 0-byte file present must NOT count as "has sources" — otherwise the
|
||||
// sources_xml_present health check stays green and hides the
|
||||
// create_default_sources quick fix.
|
||||
writeDeviceFile(t, ds, account, device, constants.SourcesFile, []byte{})
|
||||
|
||||
if ds.HasConfiguredSources(account, device) {
|
||||
t.Error("HasConfiguredSources returned true for a 0-byte Sources.xml; want false")
|
||||
}
|
||||
|
||||
// A populated file must still count as present.
|
||||
writeDeviceFile(t, ds, account, device, constants.SourcesFile,
|
||||
[]byte(`<?xml version="1.0"?><sources><source><sourceKey type="TUNEIN" account=""/></source></sources>`))
|
||||
|
||||
if !ds.HasConfiguredSources(account, device) {
|
||||
t.Error("HasConfiguredSources returned false for a populated Sources.xml; want true")
|
||||
}
|
||||
}
|
||||
@@ -52,21 +52,33 @@ type Options struct {
|
||||
ReleaseDuration float64 // seconds of fade-out per chirp. Default 0.060.
|
||||
|
||||
Peak float64 // final-mix headroom; 0 < Peak <= 1.0. Default 0.85.
|
||||
|
||||
// Repeat is the total number of times the complete ding is played.
|
||||
// Speakers need a moment to start buffering after receiving a
|
||||
// ContentItem, so the first repetition may be missed; later ones
|
||||
// will be heard. Default 3.
|
||||
Repeat int
|
||||
|
||||
// RepeatGapDuration is the silence inserted between successive
|
||||
// repetitions, in seconds. Default 0.40.
|
||||
RepeatGapDuration float64
|
||||
}
|
||||
|
||||
// DefaultOptions returns the canonical option set used by the
|
||||
// runtime handler when no overrides are supplied.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SampleRate: 22050,
|
||||
PitchHigh: 880.00,
|
||||
PitchMid: 659.2551,
|
||||
PitchLow: 440.00,
|
||||
ChirpDuration: 0.25,
|
||||
GapDuration: 0.10,
|
||||
AttackDuration: 0.020,
|
||||
ReleaseDuration: 0.060,
|
||||
Peak: 0.85,
|
||||
SampleRate: 22050,
|
||||
PitchHigh: 880.00,
|
||||
PitchMid: 659.2551,
|
||||
PitchLow: 440.00,
|
||||
ChirpDuration: 0.25,
|
||||
GapDuration: 0.10,
|
||||
AttackDuration: 0.020,
|
||||
ReleaseDuration: 0.060,
|
||||
Peak: 0.85,
|
||||
Repeat: 3,
|
||||
RepeatGapDuration: 0.40,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +127,14 @@ func (o Options) WithDefaults() Options {
|
||||
o.Peak = d.Peak
|
||||
}
|
||||
|
||||
if o.Repeat <= 0 {
|
||||
o.Repeat = d.Repeat
|
||||
}
|
||||
|
||||
if o.RepeatGapDuration <= 0 {
|
||||
o.RepeatGapDuration = d.RepeatGapDuration
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -147,6 +167,25 @@ func Render(opts Options) []byte {
|
||||
renderChirp(left, right, 0, chirpN, attackN, releaseN, voicesS, opts.SampleRate)
|
||||
renderChirp(left, right, chirpN+gapN, chirpN, attackN, releaseN, voicesT, opts.SampleRate)
|
||||
|
||||
// Repeat: append silence + a copy of the base audio for each
|
||||
// additional repetition. Speakers need a moment to start buffering
|
||||
// after receiving a ContentItem; repeating ensures at least one
|
||||
// instance is audible even if the first is missed.
|
||||
if opts.Repeat > 1 {
|
||||
repeatGapN := int(math.Round(float64(opts.SampleRate) * opts.RepeatGapDuration))
|
||||
|
||||
baseLeft := append([]float64{}, left...)
|
||||
baseRight := append([]float64{}, right...)
|
||||
silence := make([]float64, repeatGapN)
|
||||
|
||||
for i := 1; i < opts.Repeat; i++ {
|
||||
left = append(left, silence...)
|
||||
right = append(right, silence...)
|
||||
left = append(left, baseLeft...)
|
||||
right = append(right, baseRight...)
|
||||
}
|
||||
}
|
||||
|
||||
normalise(left, right, opts.Peak)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -45,13 +45,13 @@ func TestRender_ProducesWAVHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_DefaultSizeApproximately52KB(t *testing.T) {
|
||||
func TestRender_DefaultSizeApproximately229KB(t *testing.T) {
|
||||
data := Render(DefaultOptions())
|
||||
|
||||
// Default: 22050 Hz * 2 channels * 2 bytes * 0.6 s = 52920 data
|
||||
// + ~44 byte header.
|
||||
const wantData = 22050 * 2 * 2 * 60 / 100 // 0.6 seconds, integer math
|
||||
if got := len(data); got < wantData || got > wantData+200 {
|
||||
// Default: 3 repetitions of 0.6 s + 2 gaps of 0.4 s = 2.6 s total.
|
||||
// 22050 Hz * 2 ch * 2 bytes * 2.6 s ≈ 229320 data bytes + 44 byte header.
|
||||
const wantData = 22050 * 2 * 2 * 260 / 100 // 2.6 seconds, integer math
|
||||
if got := len(data); got < wantData || got > wantData+500 {
|
||||
t.Errorf("expected ~%d bytes, got %d", wantData, got)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,15 @@ func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_RepeatProducesLongerAudio(t *testing.T) {
|
||||
once := Render(Options{Repeat: 1}.WithDefaults())
|
||||
thrice := Render(Options{Repeat: 3}.WithDefaults())
|
||||
|
||||
if len(thrice) <= len(once) {
|
||||
t.Errorf("expected Repeat:3 to produce more bytes than Repeat:1: %d vs %d", len(thrice), len(once))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefaults_FillsZeroFields(t *testing.T) {
|
||||
got := Options{PitchHigh: 1000}.WithDefaults()
|
||||
if got.PitchHigh != 1000 {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAuthProbeTTL = 30 * time.Second
|
||||
defaultAuthProbeTimeout = 8 * time.Second
|
||||
authProbePollInterval = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// authProbe holds the state for a single active DNS-path probe.
|
||||
type authProbe struct {
|
||||
Nonce string
|
||||
DeviceID string
|
||||
TargetIP string // datastore IP the notification was sent to
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ObservedFrom string // source IP of the /v1/auth callback (set on hit)
|
||||
ObservedAt time.Time // zero until observed
|
||||
}
|
||||
|
||||
// authProbeRegistry is an in-memory, mutex-guarded registry of active probes.
|
||||
// Expired entries are pruned opportunistically on each write operation.
|
||||
type authProbeRegistry struct {
|
||||
mu sync.Mutex
|
||||
active map[string]*authProbe // nonce -> probe
|
||||
ttl time.Duration // default defaultAuthProbeTTL; injectable for tests
|
||||
}
|
||||
|
||||
// newAuthProbeRegistry creates a new registry with the given ttl.
|
||||
func newAuthProbeRegistry(ttl time.Duration) *authProbeRegistry {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultAuthProbeTTL
|
||||
}
|
||||
|
||||
return &authProbeRegistry{
|
||||
active: make(map[string]*authProbe),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// pruneExpired removes expired entries. Caller must hold r.mu.
|
||||
func (r *authProbeRegistry) pruneExpired() {
|
||||
now := time.Now()
|
||||
|
||||
for nonce, p := range r.active {
|
||||
if now.After(p.ExpiresAt) {
|
||||
delete(r.active, nonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// register stores a new probe. Prunes expired entries first.
|
||||
func (r *authProbeRegistry) register(nonce, deviceID, targetIP string) {
|
||||
now := time.Now()
|
||||
p := &authProbe{
|
||||
Nonce: nonce,
|
||||
DeviceID: deviceID,
|
||||
TargetIP: targetIP,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(r.ttl),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.pruneExpired()
|
||||
r.active[nonce] = p
|
||||
}
|
||||
|
||||
// observe marks the probe as observed if the nonce matches an active,
|
||||
// non-expired entry. Returns true if the probe was found and recorded.
|
||||
// No loopback filtering: every matching callback is a genuine probe
|
||||
// response by construction, and tests drive it from loopback.
|
||||
func (r *authProbeRegistry) observe(nonce, sourceIP string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
p, ok := r.active[nonce]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if time.Now().After(p.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
p.ObservedFrom = sourceIP
|
||||
p.ObservedAt = time.Now()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// get returns a snapshot of the probe for the given nonce, or false if not present.
|
||||
func (r *authProbeRegistry) get(nonce string) (*authProbe, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
p, ok := r.active[nonce]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Return a copy so the caller never races on the struct fields.
|
||||
snapshot := *p
|
||||
|
||||
return &snapshot, true
|
||||
}
|
||||
|
||||
// deregister removes the probe after it completes.
|
||||
func (r *authProbeRegistry) deregister(nonce string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
delete(r.active, nonce)
|
||||
}
|
||||
|
||||
// generateNonce returns a 32-char hex-encoded random nonce prefixed with "atp_".
|
||||
func generateNonce() (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
return "atp_" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// clientHostFromRemoteAddr extracts the host part from a "host:port" RemoteAddr.
|
||||
// If parsing fails it returns the raw value unchanged.
|
||||
func clientHostFromRemoteAddr(remoteAddr string) string {
|
||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// dnsProbeSpeakerRequest is the JSON body for POST /setup/health/dns-path-probe.
|
||||
type dnsProbeSpeakerRequest struct {
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
// dnsProbeSpeakerResponse is the JSON response body for the dns-path probe.
|
||||
type dnsProbeSpeakerResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ObservedFrom string `json:"observedFrom,omitempty"`
|
||||
LatencyMs float64 `json:"latencyMs,omitempty"`
|
||||
NATOrProxy bool `json:"natOrProxy,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
}
|
||||
|
||||
// runDNSPathProbe contains the core probe logic: resolve the target IP,
|
||||
// register the nonce, send the PlayURL notification, poll for the /v1/auth
|
||||
// callback up to the configured timeout, deregister the nonce, and return the
|
||||
// result. It is the shared implementation used by both HandleDNSPathProbe and
|
||||
// the health QuickFix registered in server.go.
|
||||
func (s *Server) runDNSPathProbe(deviceID, host string) (dnsProbeSpeakerResponse, error) {
|
||||
// Reuse resolveTTSHost for SSRF-safe target resolution (always a datastore IP).
|
||||
targetIP, err := s.resolveTTSHost(ttsSpeakRequest{
|
||||
DeviceID: deviceID,
|
||||
Host: host,
|
||||
})
|
||||
if err != nil {
|
||||
return dnsProbeSpeakerResponse{}, err
|
||||
}
|
||||
|
||||
nonce, err := generateNonce()
|
||||
if err != nil {
|
||||
return dnsProbeSpeakerResponse{}, fmt.Errorf("failed to generate probe nonce: %w", err)
|
||||
}
|
||||
|
||||
// Register BEFORE sending the notification so a fast callback matches.
|
||||
s.authProbes.register(nonce, deviceID, targetIP)
|
||||
|
||||
defer s.authProbes.deregister(nonce)
|
||||
|
||||
// Build the probe URL using the service's own base URL. The speaker
|
||||
// refuses the notification on 403 before fetching this URL.
|
||||
probeURL := s.serverURL + "/media/tts/dns-probe"
|
||||
|
||||
c := client.NewClientFromHost(targetIP)
|
||||
|
||||
if err := c.PlayURL(probeURL, nonce, "AfterTouch DNS probe", "DNS path probe", ""); err != nil {
|
||||
return dnsProbeSpeakerResponse{
|
||||
Success: false,
|
||||
Reason: "speaker unreachable or notification not accepted: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Determine the polling timeout: use injected value or default.
|
||||
timeout := s.authProbeTimeout()
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for {
|
||||
p, ok := s.authProbes.get(nonce)
|
||||
if ok && !p.ObservedAt.IsZero() {
|
||||
latency := p.ObservedAt.Sub(p.CreatedAt).Seconds() * 1000
|
||||
|
||||
return dnsProbeSpeakerResponse{
|
||||
Success: true,
|
||||
ObservedFrom: p.ObservedFrom,
|
||||
LatencyMs: latency,
|
||||
NATOrProxy: p.ObservedFrom != targetIP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(authProbePollInterval)
|
||||
}
|
||||
|
||||
// Timeout path: notification was accepted but no /v1/auth callback arrived.
|
||||
return dnsProbeSpeakerResponse{
|
||||
Success: false,
|
||||
Reason: "notification accepted but no /v1/auth callback received within timeout",
|
||||
Remediation: "The speaker likely resolves Bose hostnames via a different DNS resolver, " +
|
||||
"not AfterTouch's DNS server. " +
|
||||
"To fix: point speakers at AfterTouch's DNS server via DHCP option 6, " +
|
||||
"configure the upstream resolvers to forward Bose zones to AfterTouch, " +
|
||||
"or re-run migration with the resolv method.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleDNSPathProbe actively tests whether a specific speaker resolves Bose
|
||||
// hostnames through AfterTouch's DNS by sending a /speaker notification with
|
||||
// a per-probe nonce as the app_key and waiting for the speaker to call back
|
||||
// at GET /v1/auth with that nonce in the Apikeyheader header.
|
||||
//
|
||||
// POST /setup/health/dns-path-probe
|
||||
// Body: {"deviceId":"...","host":"..."}
|
||||
func (s *Server) HandleDNSPathProbe(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var req dnsProbeSpeakerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := s.runDNSPathProbe(req.DeviceID, req.Host)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// authProbeTimeout returns the configured probe timeout for polling. It reads
|
||||
// the unexported field so tests can inject a short value without a setter.
|
||||
// Falls back to the default when the field is zero.
|
||||
func (s *Server) authProbeTimeout() time.Duration {
|
||||
if s.authProbeTimeoutOverride > 0 {
|
||||
return s.authProbeTimeoutOverride
|
||||
}
|
||||
|
||||
return defaultAuthProbeTimeout
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// --- Registry unit tests ---
|
||||
|
||||
func TestAuthProbeRegistry_RegisterAndGet(t *testing.T) {
|
||||
reg := newAuthProbeRegistry(5 * time.Second)
|
||||
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
p, ok := reg.get("nonce1")
|
||||
if !ok {
|
||||
t.Fatal("expected probe to be found")
|
||||
}
|
||||
|
||||
if p.Nonce != "nonce1" {
|
||||
t.Errorf("nonce = %q, want nonce1", p.Nonce)
|
||||
}
|
||||
|
||||
if p.DeviceID != "DEVICEID01" {
|
||||
t.Errorf("deviceID = %q, want DEVICEID01", p.DeviceID)
|
||||
}
|
||||
|
||||
if p.TargetIP != "192.0.2.10" {
|
||||
t.Errorf("targetIP = %q, want 192.0.2.10", p.TargetIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProbeRegistry_ObserveMatch(t *testing.T) {
|
||||
reg := newAuthProbeRegistry(5 * time.Second)
|
||||
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
matched := reg.observe("nonce1", "192.0.2.10")
|
||||
if !matched {
|
||||
t.Fatal("expected observe to match")
|
||||
}
|
||||
|
||||
p, ok := reg.get("nonce1")
|
||||
if !ok {
|
||||
t.Fatal("expected probe to still be present")
|
||||
}
|
||||
|
||||
if p.ObservedFrom != "192.0.2.10" {
|
||||
t.Errorf("observedFrom = %q, want 192.0.2.10", p.ObservedFrom)
|
||||
}
|
||||
|
||||
if p.ObservedAt.IsZero() {
|
||||
t.Error("observedAt is zero, expected a timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProbeRegistry_ObserveNoMatch(t *testing.T) {
|
||||
reg := newAuthProbeRegistry(5 * time.Second)
|
||||
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
matched := reg.observe("wrong-nonce", "192.0.2.10")
|
||||
if matched {
|
||||
t.Fatal("expected observe to not match for unknown nonce")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProbeRegistry_ObserveExpired(t *testing.T) {
|
||||
reg := newAuthProbeRegistry(1 * time.Millisecond)
|
||||
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
matched := reg.observe("nonce1", "192.0.2.10")
|
||||
if matched {
|
||||
t.Fatal("expected observe to not match for expired nonce")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProbeRegistry_Deregister(t *testing.T) {
|
||||
reg := newAuthProbeRegistry(5 * time.Second)
|
||||
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
|
||||
reg.deregister("nonce1")
|
||||
|
||||
_, ok := reg.get("nonce1")
|
||||
if ok {
|
||||
t.Fatal("expected probe to be gone after deregister")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProbeRegistry_PrunesExpired(t *testing.T) {
|
||||
reg := newAuthProbeRegistry(1 * time.Millisecond)
|
||||
reg.register("old", "DEV01", "192.0.2.10")
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Registering a new entry triggers pruning of expired ones.
|
||||
reg.register("new", "DEV02", "192.0.2.11")
|
||||
|
||||
reg.mu.Lock()
|
||||
defer reg.mu.Unlock()
|
||||
|
||||
if _, found := reg.active["old"]; found {
|
||||
t.Error("expected expired entry 'old' to be pruned")
|
||||
}
|
||||
|
||||
if _, found := reg.active["new"]; !found {
|
||||
t.Error("expected new entry to be present")
|
||||
}
|
||||
}
|
||||
|
||||
// --- HandleSpeakerAuth tests ---
|
||||
|
||||
func probeAuthRouter(t *testing.T) (*Server, *http.ServeMux) {
|
||||
t.Helper()
|
||||
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/auth", server.HandleSpeakerAuth)
|
||||
|
||||
return server, mux
|
||||
}
|
||||
|
||||
func TestHandleSpeakerAuth_EmptyHeader(t *testing.T) {
|
||||
_, mux := probeAuthRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSpeakerAuth_UnknownToken(t *testing.T) {
|
||||
_, mux := probeAuthRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
|
||||
req.Header.Set("Apikeyheader", "aftertouch")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 for unknown token", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSpeakerAuth_MatchingNonce_Returns403(t *testing.T) {
|
||||
server, mux := probeAuthRouter(t)
|
||||
|
||||
// Register a probe nonce.
|
||||
server.authProbes.register("atp_testfixednonce", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
|
||||
req.Header.Set("Apikeyheader", "atp_testfixednonce")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403 for matching probe nonce", rec.Code)
|
||||
}
|
||||
|
||||
// Also confirm the probe was recorded.
|
||||
p, ok := server.authProbes.get("atp_testfixednonce")
|
||||
if !ok {
|
||||
t.Fatal("expected probe to still be in registry")
|
||||
}
|
||||
|
||||
if p.ObservedAt.IsZero() {
|
||||
t.Error("expected ObservedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSpeakerAuth_ExpiredNonce_Returns200(t *testing.T) {
|
||||
server, mux := probeAuthRouter(t)
|
||||
|
||||
// Register with a very short TTL.
|
||||
server.authProbes = newAuthProbeRegistry(1 * time.Millisecond)
|
||||
server.authProbes.register("atp_expirednonce", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
|
||||
req.Header.Set("Apikeyheader", "atp_expirednonce")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 for expired nonce", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- End-to-end loop test ---
|
||||
|
||||
// fakeSpeaker is an httptest server that:
|
||||
// 1. On POST /speaker: parses the app_key from the PlayInfo XML and calls
|
||||
// GET /v1/auth on afterTouchURL with Apikeyheader set to that key.
|
||||
// 2. Returns 200 for the /speaker request.
|
||||
func fakeSpeaker(t *testing.T, afterTouchURL string) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
var callCount int32
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/speaker" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
atomic.AddInt32(&callCount, 1)
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var play models.PlayInfo
|
||||
if err := xml.Unmarshal(body, &play); err != nil {
|
||||
http.Error(w, "xml parse error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate the speaker calling back /v1/auth on AfterTouch.
|
||||
authReq, err := http.NewRequest(http.MethodGet, afterTouchURL+"/v1/auth", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "build auth req error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
authReq.Header.Set("Apikeyheader", play.AppKey)
|
||||
_, _ = http.DefaultClient.Do(authReq) //nolint:bodyclose
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<status>OK</status>"))
|
||||
}))
|
||||
}
|
||||
|
||||
// probeRouter builds a minimal router with the probe and auth endpoints.
|
||||
func probeRouter(t *testing.T, server *Server) *http.ServeMux {
|
||||
t.Helper()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/setup/health/dns-path-probe", server.HandleDNSPathProbe)
|
||||
mux.HandleFunc("/v1/auth", server.HandleSpeakerAuth)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func TestHandleDNSPathProbe_EndToEnd_Success(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
// We need the AfterTouch test server URL to give to the fake speaker.
|
||||
// Use a two-step approach: create the server first, then wire up the URL.
|
||||
server := NewServer(ds, nil, "http://placeholder", false, false, false)
|
||||
server.authProbes = newAuthProbeRegistry(5 * time.Second)
|
||||
server.authProbeTimeoutOverride = 3 * time.Second
|
||||
|
||||
atServer := httptest.NewServer(probeRouter(t, server))
|
||||
defer atServer.Close()
|
||||
|
||||
// Update server to know its own URL (needed for the probe URL construction).
|
||||
server.serverURL = atServer.URL
|
||||
|
||||
// Fake speaker that calls /v1/auth on the AfterTouch test server.
|
||||
speaker := fakeSpeaker(t, atServer.URL)
|
||||
defer speaker.Close()
|
||||
|
||||
// Extract the speaker's host:port.
|
||||
speakerHost := strings.TrimPrefix(speaker.URL, "http://")
|
||||
|
||||
// Register the speaker as a known device so resolveTTSHost accepts it.
|
||||
if err := ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEVICEID01",
|
||||
AccountID: "1000001",
|
||||
IPAddress: speakerHost,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
body := `{"deviceId":"DEVICEID01"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp dnsProbeSpeakerResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v; body: %s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
t.Errorf("success = false, want true; reason: %s", resp.Reason)
|
||||
}
|
||||
|
||||
if resp.ObservedFrom == "" {
|
||||
t.Error("observedFrom is empty, expected a source IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDNSPathProbe_Timeout_NoCallback(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
// A fake speaker that accepts /speaker but never calls /v1/auth back.
|
||||
silentSpeaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/speaker" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<status>OK</status>"))
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer silentSpeaker.Close()
|
||||
|
||||
speakerHost := strings.TrimPrefix(silentSpeaker.URL, "http://")
|
||||
|
||||
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
|
||||
server.authProbes = newAuthProbeRegistry(5 * time.Second)
|
||||
server.authProbeTimeoutOverride = 300 * time.Millisecond // fast timeout for tests
|
||||
|
||||
if err := ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEVICEID01",
|
||||
AccountID: "1000001",
|
||||
IPAddress: speakerHost,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
body := `{"deviceId":"DEVICEID01"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp dnsProbeSpeakerResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v; body: %s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
if resp.Success {
|
||||
t.Error("success = true, want false (speaker never called back)")
|
||||
}
|
||||
|
||||
if resp.Remediation == "" {
|
||||
t.Error("remediation is empty, expected a hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDNSPathProbe_SpeakerUnreachable(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
// Find a loopback port with nothing listening so the TCP dial fails fast
|
||||
// (connection refused). We must register it as a known device first.
|
||||
closedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
|
||||
closedAddr := strings.TrimPrefix(closedServer.URL, "http://")
|
||||
closedServer.Close() // immediately close so the port becomes unreachable
|
||||
|
||||
if err := ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEVICEID01",
|
||||
AccountID: "1000001",
|
||||
IPAddress: closedAddr,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
|
||||
server.authProbes = newAuthProbeRegistry(5 * time.Second)
|
||||
server.authProbeTimeoutOverride = 300 * time.Millisecond
|
||||
|
||||
body := `{"deviceId":"DEVICEID01"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp dnsProbeSpeakerResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v; body: %s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
if resp.Success {
|
||||
t.Error("success = true, want false (speaker unreachable)")
|
||||
}
|
||||
|
||||
if !strings.Contains(resp.Reason, "speaker unreachable") {
|
||||
t.Errorf("reason = %q, want it to mention 'speaker unreachable'", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDNSPathProbe_InvalidBody(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(`not json`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDNSPathProbe_UnknownDevice(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
|
||||
|
||||
body := `{"deviceId":"NOPE"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -105,3 +105,70 @@ func TestHandleBMXRegistry_DNSDependent(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeServerURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"http://host:8000", "http://host:8000"},
|
||||
{"http://host:8000/", "http://host:8000"},
|
||||
{"http://host:8000///", "http://host:8000"},
|
||||
{" http://host:8000/ ", "http://host:8000"},
|
||||
{"https://127.0.0.1", "https://127.0.0.1"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := NormalizeServerURL(c.in); got != c.want {
|
||||
t.Errorf("NormalizeServerURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleBMXRegistry_TrailingSlashServerURL is a regression test for the
|
||||
// trailing-slash double-slash bug: a server_url configured with a trailing
|
||||
// slash must not produce a "//bmx/..."
|
||||
// base URL. The speaker concatenates "/v1/playback/station/{id}" onto the base,
|
||||
// so a doubled slash yields a "//bmx/tunein/..." request the router 404s and
|
||||
// TuneIn playback fails with INVALID_SOURCE.
|
||||
func TestHandleBMXRegistry_TrailingSlashServerURL(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "bmx-registry-slash-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Operator typed a trailing slash (the reported trailing-slash case).
|
||||
server := NewServer(ds, nil, "https://127.0.0.1/", false, false, false)
|
||||
server.SetDNSSettings(false, "", "")
|
||||
|
||||
req := httptest.NewRequest("GET", "/bmx/v1/services", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.HandleBMXRegistry(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "//bmx") || strings.Contains(body, "//media") {
|
||||
t.Errorf("registry response contains a doubled slash from the trailing-slash server_url:\n%s", body)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
for _, s := range resp["bmx_services"].([]interface{}) {
|
||||
service := s.(map[string]interface{})
|
||||
if service["id"].(map[string]interface{})["name"] == "TUNEIN" {
|
||||
if baseURL := service["baseUrl"].(string); baseURL != "https://127.0.0.1/bmx/tunein" {
|
||||
t.Errorf("Expected baseUrl https://127.0.0.1/bmx/tunein, got %s", baseURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("TuneIn service not found in registry")
|
||||
}
|
||||
|
||||