mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
Compare commits
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -305,7 +305,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install libpcap (required for Go build)
|
||||
if: matrix.language == 'go'
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
+79
-195
@@ -23,23 +23,27 @@ jobs:
|
||||
name: Validate Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Both triggers resolve to the same thing: the release tag. On a
|
||||
# `release` event inputs.tag is empty, so this falls back to the
|
||||
# published release's tag. Every other job checks out this same
|
||||
# tag (via needs.validate.outputs.tag) so the build is always the
|
||||
# tagged commit, never whatever branch the dispatch ran on (#525).
|
||||
ref: ${{ github.event.inputs.tag || github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag format
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
TAG_NAME="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
# Single source of truth for the tag, regardless of trigger.
|
||||
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
|
||||
|
||||
echo "Tag name: $TAG_NAME"
|
||||
|
||||
@@ -50,6 +54,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Confirm the tag actually exists in git. The dispatch path
|
||||
# re-releases an existing tag; it never creates one from a branch.
|
||||
if ! git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||
echo "❌ Tag $TAG_NAME does not exist in git. Push the tag first, then re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
# Extract version without 'v' prefix
|
||||
VERSION=${TAG_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -102,7 +115,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -125,6 +140,11 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
# Common variables
|
||||
# Single build timestamp shared across every binary in this job.
|
||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
# Commit of the checked-out tag (not GITHUB_SHA, which on a manual
|
||||
# dispatch is the branch HEAD the run started from, not the tag).
|
||||
COMMIT_SHA="$(git rev-parse HEAD)"
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [[ "${{ matrix.goarm }}" != "" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
@@ -150,9 +170,13 @@ jobs:
|
||||
# Ensure clean build environment for this binary
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
# Inject the validated version (plus commit/date) so the binary
|
||||
# reports the right version regardless of git checkout state.
|
||||
# Relying on Go's VCS stamping alone yields v0.0.0-… when built
|
||||
# from a shallow checkout or a non-tagged commit (see #525).
|
||||
if ! go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-ldflags="-s -w -X main.version=${{ needs.validate.outputs.tag }} -X main.commit=${COMMIT_SHA} -X main.date=${BUILD_DATE}" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
echo "❌ Build failed for $BINARY_NAME"
|
||||
@@ -173,10 +197,6 @@ jobs:
|
||||
# Build Player (formerly soundtouch-web)
|
||||
build_binary "soundtouch-player" "./cmd/soundtouch-player"
|
||||
|
||||
# Build Web: transitional alias of the player, built from the same
|
||||
# source. Dropped in a future release; keep in sync with player.
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-player"
|
||||
|
||||
# Build Backup
|
||||
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
|
||||
id: build
|
||||
@@ -186,7 +206,6 @@ jobs:
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
PLAYER_NAME="${{ steps.build.outputs.soundtouch-player }}"
|
||||
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
|
||||
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
@@ -204,7 +223,6 @@ jobs:
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$PLAYER_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
generate_checksums "$BCK_NAME"
|
||||
|
||||
# Cleanup
|
||||
@@ -219,7 +237,6 @@ jobs:
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-player-v*
|
||||
build/soundtouch-web-v*
|
||||
build/soundtouch-backup-v*
|
||||
retention-days: 1
|
||||
|
||||
@@ -247,7 +264,7 @@ jobs:
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -262,14 +279,14 @@ jobs:
|
||||
# Generate combined checksums (exclude individual .sha256/.sha512 files)
|
||||
if ls soundtouch-* 1> /dev/null 2>&1; then
|
||||
# Only checksum the actual binaries, not the .sha256/.sha512 files
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
|
||||
echo "📋 Generated combined checksums:"
|
||||
cat checksums.sha256
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=35 # 7 platforms * 5 binaries (player + its web alias)
|
||||
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
@@ -312,8 +329,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release assets
|
||||
@@ -325,172 +343,61 @@ jobs:
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
TAG_NAME="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
VERSION="${TAG_NAME#v}"
|
||||
|
||||
# Generate comprehensive release notes
|
||||
# Short, accurate header. GitHub's auto-generated "What's Changed"
|
||||
# + "Full Changelog" are appended after this (generate_release_notes).
|
||||
cat > release_notes.md << EOF
|
||||
# Bose SoundTouch Go Library $TAG_NAME
|
||||
# AfterTouch $TAG_NAME
|
||||
|
||||
A comprehensive Go library for controlling Bose SoundTouch speakers with 100% API coverage, real-time WebSocket events, and production-ready features.
|
||||
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
|
||||
|
||||
## 🎯 Key Features
|
||||
## What's included
|
||||
|
||||
- **100% API Coverage**: All 19 official endpoints + 6 useful extensions (25 total)
|
||||
- **Real-time Events**: WebSocket support with auto-reconnect and comprehensive event handling
|
||||
- **Multiroom Control**: Complete zone management and coordination
|
||||
- **Production Ready**: Connection pooling, error handling, circuit breakers, monitoring
|
||||
- **Excellent Documentation**: 4000+ lines including Getting Started, Cookbook, Troubleshooting, and Deployment guides
|
||||
- **CLI Tool**: Full-featured command-line interface with all endpoints
|
||||
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
|
||||
|
||||
## 🚀 Quick Start
|
||||
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
|
||||
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
|
||||
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
|
||||
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full guides, setup walkthroughs, and troubleshooting: https://gesellix.github.io/Bose-SoundTouch/
|
||||
|
||||
## Use as a Go library
|
||||
|
||||
The core client is also importable:
|
||||
|
||||
\`\`\`bash
|
||||
go get github.com/gesellix/bose-soundtouch@$TAG_NAME
|
||||
\`\`\`
|
||||
|
||||
\`\`\`go
|
||||
package main
|
||||
## Verifying downloads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create client
|
||||
c := client.New("192.0.2.100", 8090)
|
||||
|
||||
// Get device info
|
||||
info, err := c.GetInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Device: %s\\n", info.Name)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [Getting Started Guide](docs/GETTING-STARTED.md) - 10-minute tutorial from discovery to WebSocket monitoring
|
||||
- [API Cookbook](docs/API-COOKBOOK.md) - 1000+ lines of real-world patterns and examples
|
||||
- [Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Systematic issue resolution
|
||||
- [Deployment Guide](docs/DEPLOYMENT.md) - Production deployment examples (Docker, K8s, systemd)
|
||||
|
||||
## 🔧 CLI & Service Tools
|
||||
|
||||
Download the tools for your platform from the assets below:
|
||||
|
||||
### CLI Tool
|
||||
\`\`\`bash
|
||||
# Quick device discovery
|
||||
./soundtouch-cli -discover
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Service
|
||||
\`\`\`bash
|
||||
# Start the service
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Player (formerly soundtouch-web)
|
||||
\`\`\`bash
|
||||
# Start the LAN web player
|
||||
./soundtouch-player
|
||||
\`\`\`
|
||||
> Note: \`soundtouch-web\` has been renamed to \`soundtouch-player\`.
|
||||
> The \`soundtouch-web\` assets are still published as a transitional
|
||||
> alias and will be removed in a future release. Please switch your
|
||||
> downloads and scripts to \`soundtouch-player\`.
|
||||
|
||||
### SoundTouch Backup
|
||||
\`\`\`bash
|
||||
# Back up cloud account and all paired speakers in one go
|
||||
./soundtouch-backup all
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
|
||||
- Bose SoundTouch 10
|
||||
- Bose SoundTouch 20
|
||||
- All core functionality validated on real devices
|
||||
|
||||
## 📈 What's New in $TAG_NAME
|
||||
|
||||
$(git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 HEAD^)..HEAD 2>/dev/null || echo "- Initial release with complete feature set")
|
||||
|
||||
## 🏗️ Supported Platforms
|
||||
|
||||
This release includes pre-built binaries for:
|
||||
- Linux (amd64, arm64, armv7)
|
||||
- macOS (Intel & Apple Silicon)
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
`soundtouch-cli`, `soundtouch-service`, `soundtouch-player` (with `soundtouch-web` as a transitional alias), and `soundtouch-backup` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
Multiple checksum options are provided for download verification:
|
||||
|
||||
### Combined Checksums (Recommended)
|
||||
- \`checksums.sha256\` - SHA256 checksums for all binaries
|
||||
- \`checksums.sha512\` - SHA512 checksums for all binaries
|
||||
Each binary has its own \`.sha256\`/\`.sha512\`, and combined \`checksums.sha256\` / \`checksums.sha512\` cover all of them:
|
||||
|
||||
\`\`\`bash
|
||||
# Download any binary + combined checksums
|
||||
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
|
||||
curl -L -O https://github.com/.../checksums.sha256
|
||||
|
||||
# Verify your specific download
|
||||
sha256sum -c checksums.sha256 --ignore-missing
|
||||
\`\`\`
|
||||
|
||||
### Individual Checksums (Per Binary)
|
||||
Each binary also has its own dedicated checksum files:
|
||||
- \`soundtouch-cli-v$TAG_NAME-platform.sha256\`
|
||||
- \`soundtouch-cli-v$TAG_NAME-platform.sha512\`
|
||||
|
||||
\`\`\`bash
|
||||
# Download binary + its individual checksum
|
||||
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
|
||||
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
|
||||
|
||||
# Verify with individual checksum
|
||||
sha256sum -c soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
|
||||
\`\`\`
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! See our documentation for examples and patterns.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file.
|
||||
EOF
|
||||
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
name: ${{ needs.validate.outputs.tag }}
|
||||
body_path: ${{ steps.release_notes.outputs.release_notes_file }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-player-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
@@ -512,14 +419,13 @@ jobs:
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-player-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
@@ -534,11 +440,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Set build date
|
||||
- name: Set build metadata
|
||||
id: build_date
|
||||
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
|
||||
# Commit of the checked-out tag, not github.sha (the dispatch HEAD).
|
||||
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
@@ -570,8 +481,8 @@ jobs:
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=v${{ needs.validate.outputs.version }}
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=${{ needs.validate.outputs.tag }}
|
||||
COMMIT=${{ steps.build_date.outputs.commit }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -596,35 +507,8 @@ jobs:
|
||||
tags: ${{ steps.meta-player.outputs.tags }}
|
||||
labels: ${{ steps.meta-player.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=v${{ needs.validate.outputs.version }}
|
||||
COMMIT=${{ github.sha }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Transitional alias image (formerly the only web image). Dropped later.
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=v${{ needs.validate.outputs.version }}
|
||||
COMMIT=${{ github.sha }}
|
||||
VERSION=${{ needs.validate.outputs.tag }}
|
||||
COMMIT=${{ steps.build_date.outputs.commit }}
|
||||
DATE=${{ steps.build_date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -639,12 +523,12 @@ jobs:
|
||||
- name: Notify success
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, Player, and Backup)"
|
||||
echo "🐳 Docker image published to ghcr.io"
|
||||
echo "🔐 Checksums generated and verified"
|
||||
echo "📋 Release notes automatically generated"
|
||||
echo ""
|
||||
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
echo "🔗 Release URL: https://github.com/${{ github.repository }}/releases/tag/${TAG_NAME}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -43,7 +43,7 @@ Per-session pickup notes live in two local files at the repo root (they are `.gi
|
||||
make build # All binaries
|
||||
make build-cli # Just CLI
|
||||
make build-service # Just service
|
||||
make build-web # Just web UI
|
||||
make build-player # Just web player
|
||||
make build-all # Cross-platform builds (Linux, macOS, Windows)
|
||||
make install # Install to $GOPATH/bin
|
||||
|
||||
@@ -99,6 +99,34 @@ The rotate target is non-destructive (it moves, never deletes) and
|
||||
opt-in (no other target invokes it). Old archives stay around for
|
||||
retrospective diffing whenever something goes sideways.
|
||||
|
||||
## Decrypting diagnostic reports
|
||||
|
||||
Reporters attach an encrypted diagnostic archive
|
||||
(`aftertouch-diagnostic-*.age`), usually saved under `_/i_<reporter>/`.
|
||||
Decrypt it with **this repo's own tool**, not the generic `age` CLI:
|
||||
|
||||
```bash
|
||||
go run scripts/decrypt-diagnostic.go <file.age> | tar xz -C <dir containing the .age>
|
||||
```
|
||||
|
||||
- The tool is `scripts/decrypt-diagnostic.go`; the private key lives at
|
||||
`keys/private/diagnostic` (provisioned by `scripts/setup-diagnostic-key.sh`,
|
||||
and never committed). It writes the decrypted `.tar.gz` to stdout.
|
||||
- Always decrypt/unpack next to the `.age` (not a scratch/tmp dir), into a
|
||||
**per-file subfolder** so nothing collides: e.g.
|
||||
`mkdir -p <dir>/extracted-<timestamp> && go run scripts/decrypt-diagnostic.go <dir>/<file>.age | tar xz -C <dir>/extracted-<timestamp>`.
|
||||
This matters when a reporter folder holds multiple `.age` snapshots or
|
||||
already has other files: every archive uses the same inner names
|
||||
(`diagnostic.json`, `datastore/`, `http/`, ...), so extracting two into the
|
||||
same dir overwrites and mixes them.
|
||||
- The archive contains `diagnostic.json` (health/device summary), `datastore/`
|
||||
(raw speaker XML: DeviceInfo/Presets/Recents/Sources), `http/` (service
|
||||
`full.xml`, `sourceproviders.xml`, captured speaker responses), `logs/`,
|
||||
`settings.json`, `env.txt`, `system/`, `ssh/`. See
|
||||
`docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md`.
|
||||
- Reporter data stays under `_/` and is never committed (see "What never goes
|
||||
into this repo").
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
|
||||
+2
-21
@@ -46,7 +46,7 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
FROM alpine:3.23 AS soundtouch-service
|
||||
FROM alpine:3.24 AS soundtouch-service
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
@@ -95,7 +95,7 @@ EXPOSE 8000
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
|
||||
# soundtouch-player image
|
||||
FROM alpine:3.23 AS soundtouch-player
|
||||
FROM alpine:3.24 AS soundtouch-player
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
@@ -112,22 +112,3 @@ EXPOSE 8080
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-player"]
|
||||
|
||||
# soundtouch-web image: transitional alias of soundtouch-player. Built from the
|
||||
# same binary; the entrypoint name makes the binary print a rename notice on
|
||||
# start. Will be dropped in a future release.
|
||||
FROM alpine:3.23 AS soundtouch-web
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-player /app/soundtouch-web
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
|
||||
@@ -19,9 +19,6 @@ SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
PLAYER_NAME=soundtouch-player
|
||||
PLAYER_PATH=./cmd/$(PLAYER_NAME)
|
||||
# WEB_NAME is the previous name for the player, kept as a transitional alias
|
||||
# built from the same PLAYER_PATH source. It will be dropped in a future release.
|
||||
WEB_NAME=soundtouch-web
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
@@ -55,7 +52,7 @@ AUTH_SERVICE_URL ?= $(BACKEND_URL)
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-player build-web build-examples build-favicon-gen build-backup
|
||||
build: build-cli build-service build-player build-examples build-favicon-gen build-backup
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@@ -72,13 +69,6 @@ build-player:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(PLAYER_NAME) $(PLAYER_PATH)
|
||||
|
||||
# Transitional alias: builds the same source as build-player under the old
|
||||
# soundtouch-web name. Drop this target once the alias is retired.
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME) (transitional alias of $(PLAYER_NAME))..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(PLAYER_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@@ -376,12 +366,11 @@ dev-player-host: build-player
|
||||
fi
|
||||
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-player build-web build-backup
|
||||
install: build-cli build-service build-player build-backup
|
||||
@echo "Installing binaries to $(GOPATH)/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(PLAYER_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
|
||||
|
||||
update-static-deps:
|
||||
|
||||
@@ -72,7 +72,7 @@ See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/C
|
||||
|
||||
### soundtouch-player
|
||||
|
||||
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are still published as a transitional alias and will be removed in a future release; please switch to `soundtouch-player`.
|
||||
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are no longer published; please use `soundtouch-player`. (If you still run the binary under its old name, it prints a rename notice and works as before.)
|
||||
|
||||
A standalone, LAN-resident web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Because it reaches speakers directly on your network and can delegate cloud-only features (e.g. TTS) to a remote AfterTouch service via `--service-url`, it stays useful when `soundtouch-service` runs off-LAN (for example in the cloud), where the embedded `/app` player cannot reach your speakers.
|
||||
|
||||
|
||||
+359
-19
@@ -17,14 +17,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -47,6 +52,11 @@ const (
|
||||
func main() {
|
||||
port := flag.Int("port", 8200, "HTTP port to bind")
|
||||
name := flag.String("name", "AfterTouch Test Library", "UPnP friendlyName advertised over SSDP")
|
||||
mediaDir := flag.String("media-dir", "", "serve real audio files + artwork from this directory "+
|
||||
"(searched recursively, so an artist/album tree works) instead of the built-in silent test "+
|
||||
"tracks. Audio: .mp3/.wav/.flac/.m4a/.ogg. Art per track: a sibling <name>.jpg/.png, else a "+
|
||||
"cover.jpg/cover.png/folder.jpg in the same album folder. Files are loaded into memory, so "+
|
||||
"point it at an album or a modest folder, not your whole library")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
@@ -62,11 +72,34 @@ func main() {
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", *port)
|
||||
location := fmt.Sprintf("http://%s:%d/rootDesc.xml", lanIP, *port)
|
||||
|
||||
srv := dlnatest.NewServer(dlnatest.WithFriendlyName(*name))
|
||||
// Join the SSDP multicast group on the interface that owns the LAN IP. On
|
||||
// macOS net.Interfaces() lists lo0 (UP+MULTICAST) first, so picking the
|
||||
// "first" multicast interface would join on loopback and never receive the
|
||||
// LAN M-SEARCH from clients like AfterTouch.
|
||||
lanIface := interfaceForIP(lanIP)
|
||||
if lanIface != nil {
|
||||
logger.Info("SSDP: will join multicast on LAN interface", "iface", lanIface.Name, "ip", lanIP)
|
||||
}
|
||||
|
||||
opts := []dlnatest.Option{dlnatest.WithFriendlyName(*name)}
|
||||
|
||||
if *mediaDir != "" {
|
||||
tree, n, err := loadTreeFromDir(*mediaDir, *name)
|
||||
if err != nil {
|
||||
logger.Error("failed to load --media-dir", "dir", *mediaDir, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
opts = append(opts, dlnatest.WithTree(tree))
|
||||
|
||||
logger.Info("serving real media from directory", "dir", *mediaDir, "tracks", n)
|
||||
}
|
||||
|
||||
srv := dlnatest.NewServer(opts...)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: srv.HTTPHandler(),
|
||||
Handler: withAccessLog(logger, srv.HTTPHandler()),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
@@ -87,7 +120,7 @@ func main() {
|
||||
udn := srv.UDN
|
||||
|
||||
// Start SSDP listener + responder.
|
||||
go runSSDPListener(ctx, logger, udn, location)
|
||||
go runSSDPListener(ctx, logger, udn, location, lanIface)
|
||||
|
||||
// Start periodic ssdp:alive announcements.
|
||||
go runSSDPAlive(ctx, logger, udn, location)
|
||||
@@ -116,27 +149,25 @@ func main() {
|
||||
// SSDP listener: answers M-SEARCH requests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string) {
|
||||
func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string, ifi *net.Interface) {
|
||||
group := &net.UDPAddr{IP: net.ParseIP(ssdpMulticastIP), Port: ssdpPort}
|
||||
|
||||
// ListenMulticastUDP joins the multicast group on a system-chosen interface.
|
||||
// We iterate over all UP multicast-capable interfaces and listen on each.
|
||||
ifaces, err := multicastInterfaces()
|
||||
if err != nil {
|
||||
logger.Warn("SSDP: cannot list interfaces, using system default", "err", err)
|
||||
// Join the group on the LAN interface. If we could not resolve it, fall back
|
||||
// to the first non-loopback multicast interface (never loopback, which would
|
||||
// only ever receive same-host loopback traffic).
|
||||
if ifi == nil {
|
||||
if cands, err := multicastInterfaces(); err == nil {
|
||||
for _, c := range cands {
|
||||
if c != nil && c.Flags&net.FlagLoopback == 0 {
|
||||
ifi = c
|
||||
|
||||
ifaces = []*net.Interface{nil} // nil = system default
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ifaces) == 0 {
|
||||
ifaces = []*net.Interface{nil}
|
||||
}
|
||||
|
||||
// We only need one listening socket; use the first usable interface.
|
||||
// net.ListenMulticastUDP binds to 0.0.0.0:1900 internally, so a single
|
||||
// call is sufficient to receive multicast traffic on all interfaces on
|
||||
// most platforms.
|
||||
conn, err := net.ListenMulticastUDP("udp4", ifaces[0], group)
|
||||
conn, err := net.ListenMulticastUDP("udp4", ifi, group)
|
||||
if err != nil {
|
||||
logger.Warn("SSDP: ListenMulticastUDP failed (try running as root or check firewall)", "err", err)
|
||||
|
||||
@@ -385,6 +416,315 @@ func primaryLANIP() (string, error) {
|
||||
return "", fmt.Errorf("no usable LAN IPv4 address found")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// --media-dir loader
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// loadTreeFromDir walks dir recursively and builds a single flat content folder
|
||||
// from every audio file found, so an artist/album tree works. Album art for a
|
||||
// track is, in order of preference: a sibling <basename>.<img>, then a
|
||||
// cover.jpg/cover.png/folder.jpg in the track's own directory. Returns the tree
|
||||
// and track count.
|
||||
func loadTreeFromDir(dir, fallbackName string) (*dlnatest.Tree, int, error) {
|
||||
rootClean := filepath.Clean(dir)
|
||||
|
||||
// Cache the resolved cover per directory so we read each album's folder.jpg
|
||||
// once rather than for every track in it.
|
||||
type cover struct {
|
||||
data []byte
|
||||
mime string
|
||||
}
|
||||
|
||||
coverCache := map[string]cover{}
|
||||
|
||||
dirCover := func(d string) ([]byte, string) {
|
||||
if c, ok := coverCache[d]; ok {
|
||||
return c.data, c.mime
|
||||
}
|
||||
|
||||
var c cover
|
||||
|
||||
for _, n := range []string{"cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.png", "albumart.jpg", "albumart.png"} {
|
||||
if b, err := os.ReadFile(filepath.Join(d, n)); err == nil {
|
||||
c = cover{data: b, mime: imageMimeForExt(filepath.Ext(n))}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
coverCache[d] = c
|
||||
|
||||
return c.data, c.mime
|
||||
}
|
||||
|
||||
// Group tracks by their containing directory (preserving first-seen order),
|
||||
// so each real album folder becomes its own browsable + playable container
|
||||
// named after the directory, rather than one flat list named after --name.
|
||||
type group struct {
|
||||
dir string
|
||||
items []*dlnatest.Item
|
||||
}
|
||||
|
||||
groups := map[string]*group{}
|
||||
|
||||
var order []string
|
||||
|
||||
total := 0
|
||||
|
||||
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil //nolint:nilerr // skip unreadable entries and directories
|
||||
}
|
||||
|
||||
mime := audioMimeForExt(filepath.Ext(path))
|
||||
if mime == "" {
|
||||
return nil // not an audio file we recognise
|
||||
}
|
||||
|
||||
payload, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return nil //nolint:nilerr // skip unreadable file, keep walking
|
||||
}
|
||||
|
||||
trackDir := filepath.Dir(path)
|
||||
base := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))
|
||||
|
||||
// Prefer a per-track image sibling; fall back to the album-folder cover.
|
||||
art, artMime := dirCover(trackDir)
|
||||
|
||||
for _, ae := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||
if b, aerr := os.ReadFile(filepath.Join(trackDir, base+ae)); aerr == nil {
|
||||
art = b
|
||||
artMime = imageMimeForExt(ae)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
g := groups[trackDir]
|
||||
if g == nil {
|
||||
g = &group{dir: trackDir}
|
||||
groups[trackDir] = g
|
||||
order = append(order, trackDir)
|
||||
}
|
||||
|
||||
g.items = append(g.items, &dlnatest.Item{
|
||||
Title: base,
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: artistForDir(trackDir, rootClean),
|
||||
Album: albumTitle(trackDir, rootClean, fallbackName),
|
||||
MimeType: mime,
|
||||
Payload: payload,
|
||||
ArtPayload: art,
|
||||
ArtMime: artMime,
|
||||
})
|
||||
total++
|
||||
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return nil, 0, walkErr
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return nil, 0, fmt.Errorf("no audio files (.mp3/.wav/.flac/.m4a/.ogg) found under %s", dir)
|
||||
}
|
||||
|
||||
containers := make([]*dlnatest.Container, 0, len(order))
|
||||
|
||||
for ci, d := range order {
|
||||
cid := strconv.Itoa(ci + 1)
|
||||
g := groups[d]
|
||||
|
||||
for ti, it := range g.items {
|
||||
it.ID = fmt.Sprintf("%s$%d", cid, ti)
|
||||
it.ParentID = cid
|
||||
}
|
||||
|
||||
containers = append(containers, &dlnatest.Container{
|
||||
ID: cid,
|
||||
ParentID: "0",
|
||||
Title: albumTitle(d, rootClean, fallbackName),
|
||||
Class: "object.container.storageFolder",
|
||||
Children: g.items,
|
||||
})
|
||||
}
|
||||
|
||||
return &dlnatest.Tree{Containers: containers}, total, nil
|
||||
}
|
||||
|
||||
// albumTitle returns the display name for a track directory: the directory's own
|
||||
// name, or the fallback (the --name) when the tracks sit directly in the root.
|
||||
func albumTitle(trackDir, root, fallback string) string {
|
||||
if filepath.Clean(trackDir) == root {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return filepath.Base(trackDir)
|
||||
}
|
||||
|
||||
// artistForDir derives the artist from the directory above the album folder
|
||||
// (e.g. <root>/<artist>/<album>/track.mp3 → "<artist>"). Falls back to
|
||||
// "Unknown Artist" when there is no artist level (album directly under root, or
|
||||
// tracks directly in root).
|
||||
func artistForDir(trackDir, root string) string {
|
||||
clean := filepath.Clean(trackDir)
|
||||
if clean == root {
|
||||
return "Unknown Artist"
|
||||
}
|
||||
|
||||
parent := filepath.Dir(clean)
|
||||
if parent == root {
|
||||
return "Unknown Artist"
|
||||
}
|
||||
|
||||
return filepath.Base(parent)
|
||||
}
|
||||
|
||||
// audioMimeForExt maps an audio file extension to a MIME type, or "" if the
|
||||
// extension is not a recognised audio format.
|
||||
func audioMimeForExt(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".wav":
|
||||
return "audio/x-wav"
|
||||
case ".flac":
|
||||
return "audio/flac"
|
||||
case ".m4a", ".mp4":
|
||||
return "audio/mp4"
|
||||
case ".ogg":
|
||||
return "audio/ogg"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// imageMimeForExt maps an image file extension to a MIME type.
|
||||
func imageMimeForExt(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// HTTP access logging (debugging aid)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// statusRecorder captures the status code and byte count of a response.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// withAccessLog logs every HTTP request the server handles. For ContentDirectory
|
||||
// Browse POSTs it also surfaces the ObjectID and BrowseFlag so the speaker's
|
||||
// browse sequence (and whether it ever resolves a track's metadata) is visible.
|
||||
func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
var browseAttrs []any
|
||||
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "ContentDir") {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16))
|
||||
_ = r.Body.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
browseAttrs = []any{
|
||||
"objectID", between(string(body), "<ObjectID>", "</ObjectID>"),
|
||||
"browseFlag", between(string(body), "<BrowseFlag>", "</BrowseFlag>"),
|
||||
}
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
attrs := []any{
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"bytes", rec.bytes,
|
||||
"from", r.RemoteAddr,
|
||||
"dur", time.Since(start).String(),
|
||||
}
|
||||
attrs = append(attrs, browseAttrs...)
|
||||
|
||||
logger.Info("HTTP", attrs...)
|
||||
})
|
||||
}
|
||||
|
||||
// between returns the text between the first occurrence of openTag and the next
|
||||
// closeTag, or "" if not found. Used for lightweight SOAP field extraction in logs.
|
||||
func between(s, openTag, closeTag string) string {
|
||||
i := strings.Index(s, openTag)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
i += len(openTag)
|
||||
|
||||
j := strings.Index(s[i:], closeTag)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s[i : i+j]
|
||||
}
|
||||
|
||||
// interfaceForIP returns the UP, multicast-capable interface that owns the given
|
||||
// IPv4 address, or nil if none is found.
|
||||
func interfaceForIP(ip string) *net.Interface {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range ifaces {
|
||||
iface := &ifaces[i]
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, aerr := iface.Addrs()
|
||||
if aerr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok {
|
||||
if v4 := ipNet.IP.To4(); v4 != nil && v4.String() == ip {
|
||||
return iface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHeader extracts a header value from a raw HTTP-style SSDP message.
|
||||
// Key comparison is case-insensitive.
|
||||
func extractHeader(msg, key string) string {
|
||||
|
||||
@@ -14,7 +14,11 @@ var version = "dev"
|
||||
|
||||
func init() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Only fall back to build info when the version was not injected via
|
||||
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
|
||||
// This keeps an explicitly stamped release version from being clobbered
|
||||
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ func removePandoraAccount(c *cli.Context) error {
|
||||
func addStoredMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -340,13 +340,26 @@ func addStoredMusicAccount(c *cli.Context) error {
|
||||
fmt.Printf(" Display Name: %s\n", displayName)
|
||||
fmt.Printf(" Type: UPnP/DLNA Media Server\n")
|
||||
|
||||
err = client.AddStoredMusicAccount(user, displayName)
|
||||
err = stClient.AddStoredMusicAccount(user, displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add network music library: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Network music library added successfully")
|
||||
|
||||
// Send a sourcesUpdated nudge so the speaker re-fetches its account list and
|
||||
// registers the new source without requiring a power-cycle. This is
|
||||
// best-effort: a failure here does not abort the command.
|
||||
if info, infoErr := stClient.GetDeviceInfo(); infoErr == nil && info != nil && info.DeviceID != "" {
|
||||
if nudgeErr := stClient.NotifySourcesUpdated(info.DeviceID); nudgeErr == nil {
|
||||
fmt.Println(" Sent a sources refresh to the speaker (no reboot needed).")
|
||||
} else {
|
||||
fmt.Println(" Warning: could not send sources refresh; you may need to power-cycle the speaker for the new source to register.")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" Warning: could not retrieve device ID; you may need to power-cycle the speaker for the new source to register.")
|
||||
}
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
|
||||
@@ -538,6 +538,53 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet,
|
||||
// printing the device transcript as it goes. With fullConfig it sends the
|
||||
// #515 sequence (all four config URLs with the injection on margeServerUrl, not
|
||||
// just envswitch) and reboots afterwards; otherwise it sends the single-
|
||||
// envswitch default that fires on the speaker's next boseurls check.
|
||||
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool) error {
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
|
||||
if fullConfig {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with the injection on margeServerUrl, then reboot)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL)
|
||||
} else {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnet(host, serviceURL)
|
||||
}
|
||||
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if !fullConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Rebooting the speaker to apply the new configuration...")
|
||||
|
||||
rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet)
|
||||
if rlogs != "" {
|
||||
fmt.Print(rlogs)
|
||||
}
|
||||
|
||||
if rerr != nil {
|
||||
PrintError(rerr.Error())
|
||||
return rerr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupEnableSSHCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enable-ssh",
|
||||
@@ -556,6 +603,11 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
Value: 90 * time.Second,
|
||||
Usage: "How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "full-config",
|
||||
Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " +
|
||||
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-reset-urls",
|
||||
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
|
||||
@@ -589,15 +641,7 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
serviceURL = "https://aftertouch.invalid"
|
||||
}
|
||||
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", cfg.Host)
|
||||
|
||||
logs, err := m.EnableSSHViaTelnet(cfg.Host, serviceURL)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,12 @@ func getFlagName(flag cli.Flag) string {
|
||||
// updateBuildInfo extracts version information from debug.BuildInfo and updates package variables
|
||||
func updateBuildInfo() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
// Get version from module info
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Get version from module info. Only fall back to build info when the
|
||||
// version was not injected via -ldflags (i.e. still the "dev" default,
|
||||
// e.g. `go install …@vX.Y.Z`). This keeps an explicitly stamped release
|
||||
// version from being clobbered by a VCS pseudo-version (e.g. v0.0.0-…
|
||||
// from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// remote AfterTouch service via --service-url, which is why it stays useful
|
||||
// when soundtouch-service runs off-LAN (e.g. in the cloud).
|
||||
//
|
||||
// It was previously named soundtouch-web; that name is still published as a
|
||||
// transitional alias and will be dropped in a future release.
|
||||
// It was previously named soundtouch-web; that name is no longer published.
|
||||
// If you still run the binary under the old name, it prints a rename notice
|
||||
// and otherwise behaves identically.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -38,7 +39,11 @@ func updateBuildInfo() {
|
||||
repoURL = "https://" + info.Main.Path
|
||||
}
|
||||
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Only fall back to build info when the version was not injected via
|
||||
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
|
||||
// This keeps an explicitly stamped release version from being clobbered
|
||||
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
@@ -56,9 +61,8 @@ func updateBuildInfo() {
|
||||
}
|
||||
|
||||
// warnIfInvokedAsWeb prints a one-line deprecation notice when the binary is
|
||||
// run under its old name (soundtouch-web). The soundtouch-web artifact is a
|
||||
// transitional alias built from this same source; this nudges operators to
|
||||
// switch to soundtouch-player before the alias is dropped.
|
||||
// run under its old name (soundtouch-web). That name is no longer published,
|
||||
// but anyone who renamed the binary still gets nudged to soundtouch-player.
|
||||
func warnIfInvokedAsWeb() {
|
||||
if len(os.Args) == 0 {
|
||||
return
|
||||
@@ -67,8 +71,7 @@ func warnIfInvokedAsWeb() {
|
||||
name := filepath.Base(os.Args[0])
|
||||
if name == "soundtouch-web" || name == "soundtouch-web.exe" {
|
||||
log.Println("notice: 'soundtouch-web' has been renamed to 'soundtouch-player'. " +
|
||||
"This name is a transitional alias and will stop being published in a future release; " +
|
||||
"please switch to 'soundtouch-player'.")
|
||||
"The 'soundtouch-web' name is no longer published; please switch to 'soundtouch-player'.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,11 @@ func updateBuildInfo() {
|
||||
repoURL = "https://" + info.Main.Path
|
||||
}
|
||||
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
// Only fall back to build info when the version was not injected via
|
||||
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
|
||||
// This keeps an explicitly stamped release version from being clobbered
|
||||
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
|
||||
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
@@ -481,6 +485,12 @@ func main() {
|
||||
|
||||
if persisted.ServerURL == "" {
|
||||
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
|
||||
log.Printf("Data directory %s looks empty (first run). If you did NOT expect this "+
|
||||
"(e.g. after recreating a Docker container), your previous settings, datastore and "+
|
||||
"CA were not persisted; mount a persistent volume at the data dir (Docker: "+
|
||||
"-v <volume>:/app/data) so device state and the CA survive restarts. A lost CA "+
|
||||
"forces re-migrating speakers and re-trusting the new CA.",
|
||||
sanitizeLog(config.dataDir))
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for
|
||||
|
||||
- **Firmware 1.x–7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
|
||||
- **Firmware 8.x–14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
|
||||
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
|
||||
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target; on the ST Portable (Series I, FW `27.0.6.46330.5043500`) and some CineMate 520 units the SSH-enable injection persists but `sshd` does not start via the default path, which is what `setup enable-ssh --full-config` addresses (see "What we use to enable SSH" above).
|
||||
|
||||
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
|
||||
|
||||
@@ -265,6 +265,38 @@ Reboot is **not** part of these sequences — it stays a user-initiated action v
|
||||
|
||||
---
|
||||
|
||||
## What we use to enable SSH (`setup enable-ssh`, #471)
|
||||
|
||||
To open SSH on a speaker that has never had it (no USB recovery), the CLI abuses the boseurls value as a command-injection vehicle: when the device next parses it, the appended shell snippet touches the `remote_services` marker and starts `sshd`. The injected suffix is:
|
||||
|
||||
```
|
||||
;touch /tmp/remote_services;/etc/init.d/sshd start
|
||||
```
|
||||
|
||||
**Default path** (`soundtouch-cli setup enable-ssh`) writes that injection only via the persistence layer, then waits for `:22`:
|
||||
|
||||
```
|
||||
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/update"
|
||||
```
|
||||
|
||||
This is field-confirmed on the Wireless Link Adapter and on the CineMate 520 `lisa` variant (FW 27.0.6).
|
||||
|
||||
**`--full-config` path** (`soundtouch-cli setup enable-ssh --full-config`) is for devices where the default injection is *accepted and persisted* (`getpdo` confirms the value) but `sshd` never comes up, so `:22` stays "Connection refused". It mirrors the manual telnet sequence @Henri-be confirmed by hand on issue #515: it puts the injection on the runtime `sys configuration margeServerUrl` key as well as `envswitch`, writes all four URL keys, then reboots so the device re-parses the config at boot:
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl "<serverURL>/bmx/registry/v1/services"
|
||||
sys configuration statsServerUrl "<serverURL>"
|
||||
sys configuration margeServerUrl "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start"
|
||||
sys configuration swUpdateUrl "<serverURL>/updates/soundtouch"
|
||||
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/updates/soundtouch"
|
||||
getpdo CurrentSystemConfiguration
|
||||
sys reboot
|
||||
```
|
||||
|
||||
**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope here, but worth recording
|
||||
|
||||
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
|
||||
|
||||
@@ -21,13 +21,17 @@ Most SoundTouch devices run a modified Linux distribution. Accessing these logs
|
||||
|
||||
Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services:
|
||||
|
||||
1. **USB Method**:
|
||||
1. **CLI Method (recommended, no USB needed)**:
|
||||
- `soundtouch-cli --host <device-ip> setup enable-ssh` drives the port-17000 diagnostic shell to inject the `remote_services` marker and start `sshd`, then waits for `:22`. This is the #471 bootstrap; it needs no prior SSH and no USB stick.
|
||||
- If the command is accepted (the device persists it, confirmed by `getpdo`) but `sshd` never comes up and `:22` stays "Connection refused", retry with `--full-config`. That variant mirrors the manual telnet sequence confirmed on issue #515: it puts the injection on `sys configuration margeServerUrl` as well as `envswitch`, writes all four URL keys, and reboots.
|
||||
- **`--full-config` is meant for:** the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** and some **CineMate 520** units, where the default single-`envswitch` path leaves `sshd` down. The default path is sufficient on the Wireless Link Adapter and the CineMate 520 `lisa` variant. Some units (e.g. certain ST10 and CineMate 520 firmwares) do not respond to either path and need the serial / U-Boot console route instead. See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md#what-we-use-to-enable-ssh-setup-enable-ssh-471) for the exact commands and current confirmation status.
|
||||
2. **USB Method**:
|
||||
- Format a USB stick to **FAT32**.
|
||||
- Create an empty file named `remote_services` (no extension) in the root of the USB stick.
|
||||
- Insert the stick into the SoundTouch device.
|
||||
- Reboot the device (power cycle).
|
||||
- On some models, you may need to hold **4** and **Volume -** on the device while powering on to force a USB check.
|
||||
2. **TAP Command (Legacy)**:
|
||||
3. **TAP Command (Legacy)**:
|
||||
- On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`.
|
||||
|
||||
### Making Root Access Persistent
|
||||
|
||||
@@ -34,13 +34,13 @@ sudo bash install.sh
|
||||
```
|
||||
|
||||
The installer detects your Pi's architecture (armv7, arm64, or amd64), downloads
|
||||
the binary, creates a `soundtouch` system user, and registers a systemd unit that
|
||||
starts on boot.
|
||||
the latest release binary, creates a `soundtouch` system user, and registers a
|
||||
systemd unit that starts on boot.
|
||||
|
||||
To install a specific version:
|
||||
To pin a specific version instead of the latest:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.107.0
|
||||
sudo bash install.sh v0.111.3
|
||||
```
|
||||
|
||||
Check that the service is running:
|
||||
@@ -66,13 +66,28 @@ docker run -d \
|
||||
--name aftertouch \
|
||||
--network host \
|
||||
-e SERVER_URL=http://192.0.2.10:8000 \
|
||||
-v aftertouch-data:/data \
|
||||
-v aftertouch-data:/app/data \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
Replace `192.0.2.10` with the host machine's LAN IP. The `--network host` flag
|
||||
is required so AfterTouch can reach the speakers and respond to mDNS discovery.
|
||||
|
||||
> **Persist the data directory.** The container stores everything stateful under
|
||||
> `/app/data` (`DATA_DIR`): the datastore, `settings.json`, and the service CA.
|
||||
> Mount a volume there (`-v <volume>:/app/data`, as above) or this state is lost
|
||||
> when the container is recreated. Losing the CA forces you to re-migrate every
|
||||
> speaker and re-trust the new CA, so back this volume up before upgrading.
|
||||
|
||||
> **Windows / macOS (Docker Desktop):** `--network host` does not work the same
|
||||
> way as on Linux, so publish the ports explicitly instead, e.g.
|
||||
> `-p 8000:8000 -p 8443:8443`. mDNS discovery across the Docker Desktop network
|
||||
> boundary is unreliable; add speakers by IP in the Devices tab. If you also use
|
||||
> DNS interception (so the speaker resolves Bose hostnames to AfterTouch), you
|
||||
> additionally need to publish the DNS port (`-p 53:53/udp -p 53:53/tcp`) and
|
||||
> make AfterTouch reachable on `:443` (the hardcoded Bose hosts are plain HTTPS),
|
||||
> e.g. `-p 443:8443`. Keep the same `-v <volume>:/app/data` mount.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Note your host's LAN IP and open the Admin UI
|
||||
@@ -176,12 +191,12 @@ open **`http://<host-ip>:8080`** in your browser (default port 8080).
|
||||
### Installing soundtouch-player on a Raspberry Pi
|
||||
|
||||
`install.sh` only installs `soundtouch-service`. Use the dedicated
|
||||
`install-web.sh` script to add soundtouch-player:
|
||||
`install-player.sh` script to add soundtouch-player:
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
|
||||
sudo bash install-web.sh
|
||||
curl -fsSL -o install-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
|
||||
sudo bash install-player.sh
|
||||
```
|
||||
|
||||
For configuration, service management, updates, and removal see the
|
||||
@@ -263,7 +278,7 @@ curl -s http://192.0.2.1:8090/presets
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # updates to latest release
|
||||
sudo bash install.sh v0.107.0 # updates to a specific version
|
||||
sudo bash install.sh v0.111.3 # updates to a specific version
|
||||
```
|
||||
|
||||
The installer stops the service, downloads the new binary, and restarts
|
||||
|
||||
@@ -81,14 +81,15 @@ currently running binary, and starts the service:
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
```
|
||||
|
||||
To target a specific version instead of the default:
|
||||
By default this installs the **latest release** — the script resolves it from
|
||||
GitHub's `releases/latest` redirect. To target a specific version instead:
|
||||
|
||||
```bash
|
||||
# Via environment variable (works with pipe-to-sh)
|
||||
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Via command-line flag (pass args after sh -s --)
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
```
|
||||
|
||||
Verify the installed version:
|
||||
@@ -97,7 +98,7 @@ Verify the installed version:
|
||||
wget -qO- http://localhost:8000/health
|
||||
```
|
||||
|
||||
The JSON response should include `"version":"v0.107.0"` (or whichever
|
||||
The JSON response should include `"version":"v0.111.3"` (or whichever
|
||||
version you installed).
|
||||
|
||||
---
|
||||
@@ -185,13 +186,13 @@ next reboot — which is fine for a one-time setup run):
|
||||
cd /tmp
|
||||
|
||||
curl -L --fail -o soundtouch-cli \
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.107.0/soundtouch-cli-v0.107.0-linux-armv7
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.111.3/soundtouch-cli-v0.111.3-linux-armv7
|
||||
chmod +x soundtouch-cli
|
||||
|
||||
/tmp/soundtouch-cli --version
|
||||
```
|
||||
|
||||
Replace `v0.107.0` with the version you installed.
|
||||
Replace `v0.111.3` with the version you installed.
|
||||
|
||||
---
|
||||
|
||||
@@ -304,12 +305,12 @@ older artefacts to keep `/mnt/nv` free:
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Update to a specific version — three equivalent forms
|
||||
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.107.0
|
||||
sh install.sh --version 0.111.3
|
||||
```
|
||||
|
||||
**Rollback:** the installer keeps a `.backup` file alongside the binary:
|
||||
|
||||
@@ -6,13 +6,16 @@ host) using the provided installer scripts.
|
||||
|
||||
Two scripts are available, one per binary:
|
||||
|
||||
| Script | Binary | Role | Default port |
|
||||
|------------------|----------------------|-------------------------------------|--------------|
|
||||
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
|
||||
| `install-web.sh` | `soundtouch-player` | Browser control panel | 8080 |
|
||||
| Script | Binary | Role | Default port |
|
||||
|---------------------|----------------------|-------------------------------------|--------------|
|
||||
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
|
||||
| `install-player.sh` | `soundtouch-player` | Browser control panel | 8080 |
|
||||
|
||||
Both auto-detect CPU architecture (armv7 / arm64 / amd64), create a `soundtouch`
|
||||
system user, and install a systemd unit. They are safe to re-run for updates.
|
||||
Run without a version argument, they install the **latest release** (resolved
|
||||
from GitHub's `releases/latest` redirect); pass a tag to pin a specific version.
|
||||
Each installer has a matching uninstaller (`uninstall.sh`, `uninstall-player.sh`).
|
||||
|
||||
For a complete install-through-migration walkthrough see
|
||||
[EXTERNAL-HOST-WALKTHROUGH.md](EXTERNAL-HOST-WALKTHROUGH.md).
|
||||
@@ -34,14 +37,14 @@ sudo bash install.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.107.0
|
||||
sudo bash install.sh v0.111.3
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.107.0 \
|
||||
VERSION=v0.111.3 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
@@ -99,7 +102,7 @@ journalctl -u soundtouch-service -b # this boot only
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # update to latest release
|
||||
sudo bash install.sh v0.107.0 # update to a specific version
|
||||
sudo bash install.sh v0.111.3 # update to a specific version
|
||||
```
|
||||
|
||||
The script stops the service, downloads the new binary (backs up the old one to
|
||||
@@ -107,13 +110,30 @@ The script stops the service, downloads the new binary (backs up the old one to
|
||||
|
||||
### Removal
|
||||
|
||||
Use the uninstaller, which stops and disables the service and removes the unit,
|
||||
binary, and config. Your data directory is **preserved** by default:
|
||||
|
||||
```bash
|
||||
curl -fsSL -o uninstall.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall.sh
|
||||
sudo bash uninstall.sh # keep /var/lib/soundtouch-service
|
||||
sudo bash uninstall.sh --purge # also delete the data directory
|
||||
```
|
||||
|
||||
The `soundtouch:soundtouch` user/group is removed only once no other
|
||||
`soundtouch-*` install remains on the host.
|
||||
|
||||
Prefer to do it by hand? The equivalent manual steps are:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-service
|
||||
sudo rm /etc/systemd/system/soundtouch-service.service
|
||||
sudo rm -rf /etc/soundtouch-service
|
||||
sudo rm -rf /var/lib/soundtouch-service
|
||||
sudo rm /usr/local/bin/soundtouch-service
|
||||
sudo systemctl daemon-reload
|
||||
# Datastore (presets, device registrations, certs) — delete only if you are
|
||||
# sure you no longer need it:
|
||||
sudo rm -rf /var/lib/soundtouch-service
|
||||
```
|
||||
|
||||
---
|
||||
@@ -126,24 +146,24 @@ data and can be stopped or restarted at any time without data loss.
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
|
||||
sudo bash install-web.sh
|
||||
curl -fsSL -o install-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
|
||||
sudo bash install-player.sh
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install-web.sh v0.107.0
|
||||
sudo bash install-player.sh v0.111.3
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.107.0 \
|
||||
VERSION=v0.111.3 \
|
||||
HTTP_PORT=8081 \
|
||||
bash install-web.sh
|
||||
bash install-player.sh
|
||||
```
|
||||
|
||||
Once running, open **`http://<pi-ip>:8080`** in a browser.
|
||||
@@ -228,12 +248,25 @@ journalctl -u soundtouch-player -f
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
sudo bash install-web.sh # update to latest release
|
||||
sudo bash install-web.sh v0.107.0 # update to a specific version
|
||||
sudo bash install-player.sh # update to latest release
|
||||
sudo bash install-player.sh v0.111.3 # update to a specific version
|
||||
```
|
||||
|
||||
### Removal
|
||||
|
||||
Use the uninstaller:
|
||||
|
||||
```bash
|
||||
curl -fsSL -o uninstall-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-player.sh
|
||||
sudo bash uninstall-player.sh
|
||||
```
|
||||
|
||||
The `soundtouch:soundtouch` user/group is removed only once no other
|
||||
`soundtouch-*` install remains on the host.
|
||||
|
||||
Prefer to do it by hand? The equivalent manual steps are:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-player
|
||||
sudo rm /etc/systemd/system/soundtouch-player.service
|
||||
@@ -258,7 +291,7 @@ Override if needed:
|
||||
|
||||
```bash
|
||||
sudo ARCH_ASSET=linux-arm64 bash install.sh
|
||||
sudo ARCH_ASSET=linux-arm64 bash install-web.sh
|
||||
sudo ARCH_ASSET=linux-arm64 bash install-player.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -465,6 +465,30 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
### ❌ Radio sources never activate after an in-place migration {#radio-sources-after-migration}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- The speaker was migrated **in place** (not factory-reset first) and is reachable; account-bound sources (for example a music-streaming login) work and presets for them play.
|
||||
- **Every** radio-type source fails: selecting any `LOCAL_INTERNET_RADIO`, `TUNEIN`, or `RADIO_BROWSER` content returns `1005`, including the Health tab's "Play ding" test.
|
||||
- `curl http://<speaker-ip>:8090/sources` lists no radio source types at all.
|
||||
- The Health check warns that the speaker is "missing N source type(s) the service advertises".
|
||||
- The entries are present on disk in **both** the service-side `Sources.xml` **and** the speaker's own `/mnt/nv/BoseApp-Persistence/1/Sources.xml`, yet a reboot and a `sourcesUpdated` notification do not make them activate.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Not fully understood. After an in-place migration the firmware does not activate the radio source **types** in its runtime, even though the entries exist in the speaker's persisted `Sources.xml`. This is firmware behaviour and we have not confirmed the exact trigger. (If you hit this, an encrypted diagnostic report taken **before** you reset the speaker is very helpful, and now includes the speaker's on-device `Sources.xml`. See the "Getting More Help" section below.)
|
||||
|
||||
**Workaround (confirmed by users):**
|
||||
|
||||
Factory reset the speaker, then re-migrate it:
|
||||
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds).
|
||||
2. Reconnect the speaker to your network.
|
||||
3. Re-migrate it in AfterTouch.
|
||||
|
||||
After this the radio sources activate normally. Note the factory reset rewrites the speaker's `Sources.xml` to defaults, so any **account-bound** source (for example a music-streaming login) has to be re-added afterwards; your presets for it come back once the source is present again.
|
||||
|
||||
## 🔊 **Volume & Audio Issues**
|
||||
|
||||
### ❌ "Volume control not working"
|
||||
|
||||
@@ -338,6 +338,19 @@ Configures the clock display.
|
||||
### POST /speaker ✅ **Implemented**
|
||||
Plays TTS messages or URL content for notifications (ST-10 Series only).
|
||||
|
||||
> **Requires DNS interception.** Before playing a `play_info` notification the
|
||||
> speaker validates the `app_key` by calling `GET /v1/auth` against a hardcoded
|
||||
> Bose host (`audionotification.api.bosecm.com`, on some firmware the
|
||||
> `...dev...` variant). After the cloud shutdown that host no longer exists, so
|
||||
> unless the speaker resolves Bose hostnames through AfterTouch (DNS server +
|
||||
> the `/etc/resolv.conf` hook, so `*.api.bosecm.com` points at AfterTouch, which
|
||||
> answers `/v1/auth`), the request hangs and returns
|
||||
> `ALLEGROWEBSERVER_TIMEOUT` (error `1046`) after ~60s. If you cannot use DNS
|
||||
> interception, play the clip via the `LOCAL_INTERNET_RADIO` path instead (the
|
||||
> "radio" method used by the web player's TTS): it needs no `app_key` and no DNS
|
||||
> redirection, but it replaces the current source rather than ducking and
|
||||
> resuming it.
|
||||
|
||||
**TTS Request XML:**
|
||||
```xml
|
||||
<play_info>
|
||||
|
||||
@@ -43,6 +43,8 @@ curl -v -X POST http://<speaker-ip>:8090/notification \
|
||||
|
||||
Replace `<speaker-ip>` with your speaker's IP address and `<deviceID>` with its device ID (visible in `/info`). After this call the speaker re-fetches its sources from the service. Step 2 (source-type registration) still requires a reboot.
|
||||
|
||||
> **If a reboot doesn't help after an in-place migration:** on some speakers the radio source types (`LOCAL_INTERNET_RADIO`, `TUNEIN`, `RADIO_BROWSER`) never activate after an in-place migration, even though the entries are present in the device-local `Sources.xml` and you have rebooted and sent `sourcesUpdated`. The cause isn't fully understood; the confirmed remedy is a factory reset + re-migrate. See [Troubleshooting: Radio sources never activate after an in-place migration](../guides/TROUBLESHOOTING.md#radio-sources-after-migration).
|
||||
|
||||
### Search for stations
|
||||
|
||||
- Go to https://www.radio-browser.info and find a station you like.
|
||||
|
||||
@@ -8,16 +8,16 @@ require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.6
|
||||
github.com/hashicorp/mdns v1.0.7
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/term v0.44.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,10 +31,10 @@ require (
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
golang.org/x/image v0.43.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/tools v0.46.0 // indirect
|
||||
)
|
||||
|
||||
@@ -33,14 +33,13 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
|
||||
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
|
||||
github.com/hashicorp/mdns v1.0.7 h1:yWoQVMW5JOiDxQnIUcm3IDt0kCjf3TuXHDbdEKPsbAY=
|
||||
github.com/hashicorp/mdns v1.0.7/go.mod h1:yjuhYhZyPDqXXL48xC7cdpGwGUMwu7OViDmsuT5COvg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
@@ -62,101 +61,36 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+21
-5
@@ -1338,7 +1338,13 @@ func (c *Client) AddToZone(deviceID, ipAddress string) error {
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// RemoveFromZone removes a device from the current zone
|
||||
// RemoveFromZone removes a device from the current zone.
|
||||
//
|
||||
// It uses the dedicated /removeZoneSlave endpoint rather than rebuilding the
|
||||
// zone with /setZone and the remaining members: /setZone does not drop a member
|
||||
// from a multi-member zone (the speaker only goes standalone when the resulting
|
||||
// member set is empty), so a setZone rebuild silently fails to remove one of
|
||||
// several members. See #511.
|
||||
func (c *Client) RemoveFromZone(deviceID string) error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
@@ -1346,11 +1352,21 @@ func (c *Client) RemoveFromZone(deviceID string) error {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Convert to zone request and remove member
|
||||
zoneRequest := currentZone.ToZoneRequest()
|
||||
zoneRequest.RemoveMember(deviceID)
|
||||
if currentZone.IsStandalone() {
|
||||
return nil // nothing to remove
|
||||
}
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
// Carry the member's IP (as the speaker expects) when we know it.
|
||||
slaveIP := ""
|
||||
|
||||
for i := range currentZone.Members {
|
||||
if currentZone.Members[i].DeviceID == deviceID {
|
||||
slaveIP = currentZone.Members[i].IP
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c.RemoveZoneSlave(currentZone.Master, deviceID, slaveIP)
|
||||
}
|
||||
|
||||
// DissolveZone dissolves the current zone, making all devices standalone
|
||||
|
||||
+30
-6
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -295,12 +296,15 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
var removeBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
switch {
|
||||
case r.URL.Path == "/getZone" && r.Method == http.MethodGet:
|
||||
getZoneCalled = true
|
||||
// Return existing zone with members
|
||||
// Return existing zone with two members.
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
|
||||
@@ -309,11 +313,16 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
case r.URL.Path == "/removeZoneSlave" && r.Method == http.MethodPost:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
removeBody = string(b)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.URL.Path == "/setZone" && r.Method == http.MethodPost:
|
||||
setZoneCalled = true
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
@@ -321,6 +330,9 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
// Removing one of two members must target that member via /removeZoneSlave,
|
||||
// not rebuild the zone via /setZone (which does not drop a member from a
|
||||
// multi-member zone). Regression for #511.
|
||||
err := client.RemoveFromZone("EFGH5678IJKL")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
@@ -330,8 +342,20 @@ func TestClient_RemoveFromZone(t *testing.T) {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
if setZoneCalled {
|
||||
t.Error("RemoveFromZone must not use /setZone to drop a member from a multi-member zone")
|
||||
}
|
||||
|
||||
if !strings.Contains(removeBody, "EFGH5678IJKL") {
|
||||
t.Errorf("removeZoneSlave body should target the member, got: %s", removeBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(removeBody, `master="ABCD1234EFGH"`) {
|
||||
t.Errorf("removeZoneSlave body should name the master, got: %s", removeBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(removeBody, `ipaddress="192.0.2.11"`) {
|
||||
t.Errorf("removeZoneSlave body should carry the member IP from the zone, got: %s", removeBody)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+189
-61
@@ -13,14 +13,20 @@
|
||||
package dlnatest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serveModTime is a fixed modification time used for ServeContent so that
|
||||
// range requests and caching headers behave deterministically.
|
||||
var serveModTime = time.Unix(1136214245, 0)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Content tree model
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -45,6 +51,12 @@ type Item struct {
|
||||
MimeType string
|
||||
DurSec float64 // duration in seconds
|
||||
Payload []byte // raw audio bytes served at /MediaItems/<ID>.<ext>
|
||||
|
||||
// ArtPayload, when non-empty, is album-art image bytes served at
|
||||
// /AlbumArt/<ID>.<ext> and advertised in DIDL-Lite via <upnp:albumArtURI>.
|
||||
// ArtMime is the art image MIME type (e.g. "image/jpeg").
|
||||
ArtPayload []byte
|
||||
ArtMime string
|
||||
}
|
||||
|
||||
// mediaExt returns the file extension for this item's MIME type.
|
||||
@@ -52,11 +64,35 @@ func (it *Item) mediaExt() string {
|
||||
switch it.MimeType {
|
||||
case "audio/x-wav", "audio/wav":
|
||||
return "wav"
|
||||
case "audio/mpeg":
|
||||
return "mp3"
|
||||
case "audio/flac", "audio/x-flac":
|
||||
return "flac"
|
||||
case "audio/mp4", "audio/m4a", "audio/x-m4a":
|
||||
return "m4a"
|
||||
case "audio/ogg":
|
||||
return "ogg"
|
||||
default:
|
||||
return "bin"
|
||||
}
|
||||
}
|
||||
|
||||
// artExt returns the file extension for an album-art MIME type.
|
||||
func artExt(mime string) string {
|
||||
switch mime {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
default:
|
||||
return "img"
|
||||
}
|
||||
}
|
||||
|
||||
// Tree is the in-memory content tree. Root containers are stored by ID.
|
||||
type Tree struct {
|
||||
Containers []*Container // ordered; first container is the default music folder
|
||||
@@ -75,26 +111,30 @@ func DefaultTree() *Tree {
|
||||
Class: "object.container.storageFolder",
|
||||
Children: []*Item{
|
||||
{
|
||||
ID: "1$4$0",
|
||||
ParentID: "1$4",
|
||||
Title: "track01",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track01,
|
||||
ID: "1$4$0",
|
||||
ParentID: "1$4",
|
||||
Title: "track01",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track01,
|
||||
ArtPayload: tinyPNG,
|
||||
ArtMime: "image/png",
|
||||
},
|
||||
{
|
||||
ID: "1$4$1",
|
||||
ParentID: "1$4",
|
||||
Title: "track02",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track02,
|
||||
ID: "1$4$1",
|
||||
ParentID: "1$4",
|
||||
Title: "track02",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track02,
|
||||
ArtPayload: tinyPNG,
|
||||
ArtMime: "image/png",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -186,6 +226,7 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
mux.HandleFunc("/ctl/ContentDir", s.serveContentDir)
|
||||
mux.HandleFunc("/icons/sm.png", s.serveIcon)
|
||||
mux.HandleFunc("/MediaItems/", s.serveMediaItem)
|
||||
mux.HandleFunc("/AlbumArt/", s.serveAlbumArt)
|
||||
|
||||
return mux
|
||||
}
|
||||
@@ -322,18 +363,24 @@ func (s *Server) serveContentDir(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var total int
|
||||
|
||||
switch objectID {
|
||||
case "0":
|
||||
// Root: return containers.
|
||||
didl, total = s.browseRoot(startIndex, reqCount)
|
||||
default:
|
||||
// Try as a container ID.
|
||||
if c := s.tree.containerByID(objectID); c != nil {
|
||||
didl, total = s.browseContainer(c, startIndex, reqCount, base)
|
||||
} else {
|
||||
// Unknown object: return empty result.
|
||||
didl = emptyDIDL()
|
||||
total = 0
|
||||
if req.Body.Browse.BrowseFlag == "BrowseMetadata" {
|
||||
// Metadata for a single object (the speaker resolves a track's <res>
|
||||
// this way before playing it).
|
||||
didl, total = s.browseMetadata(objectID, base)
|
||||
} else {
|
||||
switch objectID {
|
||||
case "0":
|
||||
// Root: return containers.
|
||||
didl, total = s.browseRoot(startIndex, reqCount)
|
||||
default:
|
||||
// Try as a container ID.
|
||||
if c := s.tree.containerByID(objectID); c != nil {
|
||||
didl, total = s.browseContainer(c, startIndex, reqCount, base)
|
||||
} else {
|
||||
// Unknown object: return empty result.
|
||||
didl = emptyDIDL()
|
||||
total = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +438,44 @@ func (s *Server) browseRoot(start, count int) (string, int) {
|
||||
return b.String(), total
|
||||
}
|
||||
|
||||
// didlOpen is the opening tag (with namespaces) shared by all DIDL-Lite results.
|
||||
const didlOpen = `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
|
||||
`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
|
||||
`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ` +
|
||||
`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`
|
||||
|
||||
// writeItemDIDL writes a single DIDL-Lite <item> (title, artist/album, class,
|
||||
// optional albumArtURI, and the <res> media URL) into b.
|
||||
func writeItemDIDL(b *strings.Builder, it *Item, base string) {
|
||||
size := len(it.Payload)
|
||||
dur := formatDuration(it.DurSec)
|
||||
resURL := fmt.Sprintf("%s/MediaItems/%s.%s", base, urlPathEsc(it.ID), it.mediaExt())
|
||||
|
||||
_, _ = fmt.Fprintf(b, `<item id=%s parentID=%s restricted="1">`, xmlAttr(it.ID), xmlAttr(it.ParentID))
|
||||
b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)
|
||||
|
||||
if it.Artist != "" {
|
||||
b.WriteString(`<upnp:artist>` + xmlEsc(it.Artist) + `</upnp:artist>`)
|
||||
}
|
||||
|
||||
if it.Album != "" {
|
||||
b.WriteString(`<upnp:album>` + xmlEsc(it.Album) + `</upnp:album>`)
|
||||
}
|
||||
|
||||
b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)
|
||||
|
||||
if len(it.ArtPayload) > 0 {
|
||||
artURL := fmt.Sprintf("%s/AlbumArt/%s.%s", base, urlPathEsc(it.ID), artExt(it.ArtMime))
|
||||
b.WriteString(`<upnp:albumArtURI>` + xmlEsc(artURL) + `</upnp:albumArtURI>`)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(b,
|
||||
`<res size="%d" duration="%s" bitrate="128000" sampleFrequency="8000" nrAudioChannels="1" protocolInfo="http-get:*:%s:*">%s</res>`,
|
||||
size, dur, xmlEsc(it.MimeType), xmlEsc(resURL),
|
||||
)
|
||||
b.WriteString(`</item>`)
|
||||
}
|
||||
|
||||
// browseContainer returns DIDL-Lite for the items inside a container.
|
||||
func (s *Server) browseContainer(c *Container, start, count int, base string) (string, int) {
|
||||
items := c.Children
|
||||
@@ -399,36 +484,10 @@ func (s *Server) browseContainer(c *Container, start, count int, base string) (s
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(`<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" `)
|
||||
b.WriteString(`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" `)
|
||||
b.WriteString(`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" `)
|
||||
b.WriteString(`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`)
|
||||
b.WriteString(didlOpen)
|
||||
|
||||
for _, it := range pageItems {
|
||||
size := len(it.Payload)
|
||||
dur := formatDuration(it.DurSec)
|
||||
resURL := fmt.Sprintf("%s/MediaItems/%s.%s", base, urlPathEsc(it.ID), it.mediaExt())
|
||||
|
||||
_, _ = fmt.Fprintf(&b,
|
||||
`<item id=%s parentID=%s restricted="1">`,
|
||||
xmlAttr(it.ID), xmlAttr(it.ParentID),
|
||||
)
|
||||
b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)
|
||||
|
||||
if it.Artist != "" {
|
||||
b.WriteString(`<upnp:artist>` + xmlEsc(it.Artist) + `</upnp:artist>`)
|
||||
}
|
||||
|
||||
if it.Album != "" {
|
||||
b.WriteString(`<upnp:album>` + xmlEsc(it.Album) + `</upnp:album>`)
|
||||
}
|
||||
|
||||
b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)
|
||||
_, _ = fmt.Fprintf(&b,
|
||||
`<res size="%d" duration="%s" bitrate="128000" sampleFrequency="8000" nrAudioChannels="1" protocolInfo="http-get:*:%s:*">%s</res>`,
|
||||
size, dur, xmlEsc(it.MimeType), xmlEsc(resURL),
|
||||
)
|
||||
b.WriteString(`</item>`)
|
||||
writeItemDIDL(&b, it, base)
|
||||
}
|
||||
|
||||
b.WriteString(`</DIDL-Lite>`)
|
||||
@@ -436,6 +495,43 @@ func (s *Server) browseContainer(c *Container, start, count int, base string) (s
|
||||
return b.String(), total
|
||||
}
|
||||
|
||||
// browseMetadata returns DIDL-Lite describing a single object (BrowseMetadata),
|
||||
// which speakers request to resolve a track's <res> URL before playing it.
|
||||
// Without this, a STORED_MUSIC select of a track ID returns empty metadata and
|
||||
// the speaker reports INVALID_SOURCE.
|
||||
func (s *Server) browseMetadata(objectID, base string) (string, int) {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(didlOpen)
|
||||
|
||||
switch {
|
||||
case objectID == "0":
|
||||
_, _ = fmt.Fprintf(&b,
|
||||
`<container id="0" parentID="-1" restricted="1" childCount="%d"><dc:title>Root</dc:title><upnp:class>object.container.storageFolder</upnp:class></container>`,
|
||||
len(s.tree.Containers),
|
||||
)
|
||||
case s.tree.containerByID(objectID) != nil:
|
||||
c := s.tree.containerByID(objectID)
|
||||
_, _ = fmt.Fprintf(&b,
|
||||
`<container id=%s parentID=%s restricted="1" childCount="%d">`,
|
||||
xmlAttr(c.ID), xmlAttr(c.ParentID), len(c.Children),
|
||||
)
|
||||
b.WriteString(`<dc:title>` + xmlEsc(c.Title) + `</dc:title>`)
|
||||
b.WriteString(`<upnp:class>` + xmlEsc(c.Class) + `</upnp:class>`)
|
||||
b.WriteString(`</container>`)
|
||||
case s.tree.itemByID(objectID) != nil:
|
||||
writeItemDIDL(&b, s.tree.itemByID(objectID), base)
|
||||
default:
|
||||
b.WriteString(`</DIDL-Lite>`)
|
||||
|
||||
return b.String(), 0
|
||||
}
|
||||
|
||||
b.WriteString(`</DIDL-Lite>`)
|
||||
|
||||
return b.String(), 1
|
||||
}
|
||||
|
||||
func emptyDIDL() string {
|
||||
return `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
|
||||
`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
|
||||
@@ -489,9 +585,41 @@ func (s *Server) serveMediaItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", item.MimeType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(item.Payload)))
|
||||
_, _ = w.Write(item.Payload)
|
||||
// ServeContent gives us byte-range support, which real speakers use when
|
||||
// streaming audio (raw io.Writer with a fixed Content-Length does not).
|
||||
if item.MimeType != "" {
|
||||
w.Header().Set("Content-Type", item.MimeType)
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "media."+item.mediaExt(), serveModTime, bytes.NewReader(item.Payload))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// /AlbumArt/<id>.<ext>
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) serveAlbumArt(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, "/AlbumArt/")
|
||||
|
||||
dot := strings.LastIndexByte(rel, '.')
|
||||
id := rel
|
||||
|
||||
if dot >= 0 {
|
||||
id = rel[:dot]
|
||||
}
|
||||
|
||||
item := s.tree.itemByID(id)
|
||||
if item == nil || len(item.ArtPayload) == 0 {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if item.ArtMime != "" {
|
||||
w.Header().Set("Content-Type", item.ArtMime)
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "art."+artExt(item.ArtMime), serveModTime, bytes.NewReader(item.ArtPayload))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -86,32 +86,6 @@ func (zr *ZoneRequest) AddMemberByDeviceID(deviceID string) {
|
||||
zr.Members = append(zr.Members, member)
|
||||
}
|
||||
|
||||
// RemoveMember removes a device from the zone configuration
|
||||
func (zr *ZoneRequest) RemoveMember(deviceID string) {
|
||||
for i, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
zr.Members = append(zr.Members[:i], zr.Members[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearMembers removes all members from the zone (creates standalone configuration)
|
||||
func (zr *ZoneRequest) ClearMembers() {
|
||||
zr.Members = []MemberEntry{}
|
||||
}
|
||||
|
||||
// HasMember checks if a device is in the zone configuration
|
||||
func (zr *ZoneRequest) HasMember(deviceID string) bool {
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetMemberCount returns the number of members in the zone
|
||||
func (zr *ZoneRequest) GetMemberCount() int {
|
||||
return len(zr.Members)
|
||||
|
||||
@@ -56,63 +56,6 @@ func TestZoneRequest_AddMemberByDeviceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_RemoveMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.0.2.10")
|
||||
zr.AddMember("DEVICE789", "192.0.2.11")
|
||||
zr.AddMember("DEVICEABC", "192.0.2.12")
|
||||
|
||||
// Remove middle member
|
||||
zr.RemoveMember("DEVICE789")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removal, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
// Check that the correct member was removed
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == "DEVICE789" {
|
||||
t.Error("DEVICE789 should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove non-existent member (should not change anything)
|
||||
zr.RemoveMember("NONEXISTENT")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removing non-existent, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_ClearMembers(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.0.2.10")
|
||||
zr.AddMember("DEVICE789", "192.0.2.11")
|
||||
|
||||
zr.ClearMembers()
|
||||
|
||||
if len(zr.Members) != 0 {
|
||||
t.Errorf("Expected 0 members after clear, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_HasMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.0.2.10")
|
||||
|
||||
if !zr.HasMember("DEVICE456") {
|
||||
t.Error("Expected HasMember to return true for DEVICE456")
|
||||
}
|
||||
|
||||
if zr.HasMember("NONEXISTENT") {
|
||||
t.Error("Expected HasMember to return false for non-existent device")
|
||||
}
|
||||
|
||||
if zr.HasMember("MASTER123") {
|
||||
t.Error("Expected HasMember to return false for master device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_GetMemberCount(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
|
||||
@@ -1436,6 +1436,27 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
Recents []RecentXML `xml:"recent"`
|
||||
}
|
||||
|
||||
// Deduplicate by ID before saving; first occurrence wins. A speaker<->marge
|
||||
// recents sync can otherwise re-store the same recent (same ID) multiple
|
||||
// times — it then crowds the capped list and evicts other sources from the
|
||||
// speaker's recents. Mirrors SaveConfiguredSources.
|
||||
seen := make(map[string]bool)
|
||||
deduped := make([]models.ServiceRecent, 0, len(recents))
|
||||
|
||||
for i := range recents {
|
||||
if id := recents[i].ID; id != "" {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
deduped = append(deduped, recents[i])
|
||||
}
|
||||
|
||||
recents = deduped
|
||||
|
||||
wrap := RecentsXML{
|
||||
Recents: make([]RecentXML, 0, len(recents)),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TestSaveRecents_DeduplicatesByID is a regression test for the recents
|
||||
// duplication bug: a speaker<->marge sync could re-store the same recent (same
|
||||
// ID) multiple times, crowding the capped list and evicting other sources from
|
||||
// the speaker's recents. SaveRecents must dedup by ID (first occurrence wins).
|
||||
func TestSaveRecents_DeduplicatesByID(t *testing.T) {
|
||||
tmp, err := os.MkdirTemp("", "recents-dedup-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmp) }()
|
||||
|
||||
ds := NewDataStore(tmp)
|
||||
account, device := "6919733", "A81B6A536A98"
|
||||
|
||||
mk := func(id, name string) models.ServiceRecent {
|
||||
var r models.ServiceRecent
|
||||
r.ID = id
|
||||
r.Name = name
|
||||
r.Source = "7"
|
||||
r.SourceAccount = "4d696e69-444c-164e-9d41-72ecda78e4c1/0"
|
||||
r.Location = "1$4$2 TRACK"
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Same ID four times (the observed live state), plus two distinct recents.
|
||||
in := []models.ServiceRecent{
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614006", "03 - Salvation"),
|
||||
mk("260614004", "06 - Back Burner"),
|
||||
mk("260613001", "Artifact"),
|
||||
}
|
||||
|
||||
if err := ds.SaveRecents(account, device, in); err != nil {
|
||||
t.Fatalf("SaveRecents: %v", err)
|
||||
}
|
||||
|
||||
out, err := ds.GetRecents(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecents: %v", err)
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, r := range out {
|
||||
counts[r.ID]++
|
||||
}
|
||||
|
||||
if counts["260614006"] != 1 {
|
||||
t.Errorf("duplicate recent not deduped: id 260614006 appears %d times (want 1)", counts["260614006"])
|
||||
}
|
||||
|
||||
if len(out) != 3 {
|
||||
t.Errorf("expected 3 distinct recents, got %d: %+v", len(out), counts)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/export"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
speakerssh "github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/telnet"
|
||||
)
|
||||
@@ -312,6 +313,11 @@ var speakerSSHPaths = []string{
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/pki/tls/certs/ca-bundle.crt.original", // pre-migration CA bundle backup
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
// The speaker's own persisted source list. The firmware registers source
|
||||
// types from this file at boot; comparing it against the service's
|
||||
// Sources.xml (and the runtime /sources) is the key evidence when radio
|
||||
// source types won't activate after an in-place migration.
|
||||
speaker.SourcesFileLocation,
|
||||
// Redirection-relevant state: where the speaker resolves Bose hostnames
|
||||
// and what it had before migration. (The live marge/BMX URL config in
|
||||
// SoundTouchSdkPrivateCfg.xml is handled by collectSpeakerRedirectConfig,
|
||||
|
||||
@@ -3184,6 +3184,7 @@ function setPreflightItemStatus(li, status, message) {
|
||||
if (status === "running") { icon = "⟳"; color = "#1976d2"; suffix = " — running…"; }
|
||||
else if (status === "ok") { icon = "✅"; color = "green"; suffix = " — passed"; }
|
||||
else if (status === "skip") { icon = "—"; color = "#666"; suffix = message ? ` — ${message}` : " — skipped"; }
|
||||
else if (status === "warn") { icon = "⚠️"; color = "#ed6c02"; suffix = message ? ` — ${message}` : " — warning"; }
|
||||
else { icon = "❌"; color = "red"; suffix = message ? ` — ${message}` : " — failed"; }
|
||||
li.innerText = `${icon} ${li.dataset.name}${suffix}`;
|
||||
li.style.color = color;
|
||||
@@ -3272,7 +3273,7 @@ async function checkPeerReachability(deviceId) {
|
||||
}
|
||||
if (result.error) return {status: "fail", message: result.error.split("\n")[0]};
|
||||
if (result.result && result.result.reached === false) {
|
||||
return {status: "fail", message: "no inbound from device before timeout"};
|
||||
return {status: "warn", message: "no inbound seen within the wait window — usually just timing (the update daemon dials out on its own slow schedule; a reboot after Apply validates it), not a port or config problem. Safe to proceed."};
|
||||
}
|
||||
return {status: "fail", message: "probe failed"};
|
||||
} catch (e) {
|
||||
@@ -3386,9 +3387,11 @@ function awaitPreflightDecision(results) {
|
||||
summary.replaceChildren();
|
||||
|
||||
if (failed === 0) {
|
||||
summary.innerText = `✅ ${passed} of ${results.length} checks passed` +
|
||||
(skipped > 0 ? ` (${skipped} skipped)` : "");
|
||||
summary.style.color = "green";
|
||||
const warned = results.filter(r => r.status === "warn").length;
|
||||
let extras = skipped > 0 ? ` (${skipped} skipped)` : "";
|
||||
if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`;
|
||||
summary.innerText = `✅ ${passed} of ${results.length} checks passed${extras}`;
|
||||
summary.style.color = warned > 0 ? "#ed6c02" : "green";
|
||||
actions.replaceChildren();
|
||||
// Auto-proceed: caller adds a brief delay so the user sees the
|
||||
// green frame before Apply kicks off.
|
||||
@@ -3508,9 +3511,11 @@ function renderPreflightPreviewSummary(results) {
|
||||
if (!summary || !actions) return;
|
||||
|
||||
if (failed === 0) {
|
||||
summary.innerText = `✅ Pre-flight passed — ${passed} of ${results.length} checks`
|
||||
+ (skipped > 0 ? ` (${skipped} skipped)` : "");
|
||||
summary.style.color = "green";
|
||||
const warned = results.filter(r => r.status === "warn").length;
|
||||
let extras = skipped > 0 ? ` (${skipped} skipped)` : "";
|
||||
if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`;
|
||||
summary.innerText = `✅ Pre-flight passed — ${passed} of ${results.length} checks${extras}`;
|
||||
summary.style.color = warned > 0 ? "#ed6c02" : "green";
|
||||
} else {
|
||||
summary.innerText = `❌ Pre-flight: ${failed} of ${results.length} checks failed`;
|
||||
summary.style.color = "red";
|
||||
|
||||
@@ -100,7 +100,10 @@ func runDNSSpeakerUsageCheck(
|
||||
"Bose hostnames. It may simply not have played a TuneIn stream since the " +
|
||||
"last restart, or it may be using a different DNS resolver. " +
|
||||
"Click 'Test DNS path' to run an active probe that sends a silent " +
|
||||
"notification and waits for the speaker to call back through AfterTouch's DNS.",
|
||||
"notification and waits for the speaker to call back through AfterTouch's DNS. " +
|
||||
"The same DNS path is required for TTS / `/speaker` `play_info` notifications: " +
|
||||
"the speaker validates the app_key against a hardcoded Bose host, so without " +
|
||||
"DNS interception those requests time out with ALLEGROWEBSERVER_TIMEOUT (1046).",
|
||||
QuickFixes: []QuickFix{probeDNSPathQuickFix},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func diffSourcesForDeviceWithURL(ds *datastore.DataStore, account, deviceID, ipA
|
||||
"Speaker is missing %d source type(s) the service advertises: %s.",
|
||||
len(missingOnSpeaker), strings.Join(missingOnSpeaker, ", "),
|
||||
),
|
||||
Details: "After a power-cycle the speaker fetches /full from the service and re-registers its source list. Forcing a sourcesUpdated notification triggers the same refresh without rebooting.",
|
||||
Details: "After a power-cycle the speaker fetches /full from the service and re-registers its source list. Forcing a sourcesUpdated notification triggers the same refresh without rebooting. If radio source types still won't activate after an in-place migration (a reboot and this notification don't help), see the troubleshooting guide; the confirmed remedy is a factory reset + re-migrate: https://gesellix.github.io/Bose-SoundTouch/docs/guides/TROUBLESHOOTING/#radio-sources-after-migration",
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Trigger a sources refresh on the speaker:",
|
||||
Command: notifyCmd,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestAddSource_MultipleStoredMusicServersCoexist is a regression test for the
|
||||
// media-server eviction bug: AddSource deduped STORED_MUSIC by provider ID, so
|
||||
// registering a second DLNA media server overwrote the first. The first then
|
||||
// vanished from /full + /sources and the speaker dropped it, meaning only one
|
||||
// media server could ever stay registered.
|
||||
//
|
||||
// Each media server is a separate account (username = "<UDN>/0"), so two
|
||||
// distinct servers must coexist, while re-adding the same server (same account)
|
||||
// updates in place.
|
||||
func TestAddSource_MultipleStoredMusicServersCoexist(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "addsource-storedmusic-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "6919733"
|
||||
device := "A81B6A536A98"
|
||||
|
||||
if mkErr := os.MkdirAll(ds.AccountDeviceDir(account, device), 0o755); mkErr != nil {
|
||||
t.Fatalf("mkdir device dir: %v", mkErr)
|
||||
}
|
||||
|
||||
sm := strconv.Itoa(constants.StoredMusicProviderID)
|
||||
|
||||
const (
|
||||
fritzAcct = "fa095ecc-e13e-40e7-8e6c-e0286d5bc000/0"
|
||||
testAcct = "4d696e69-444c-164e-9d41-72ecda78e4c1/0"
|
||||
)
|
||||
|
||||
// Register two different media servers.
|
||||
if _, err := AddSource(ds, account, fritzAcct, sm, "", "", "fritz"); err != nil {
|
||||
t.Fatalf("add server 1: %v", err)
|
||||
}
|
||||
|
||||
if _, err := AddSource(ds, account, testAcct, sm, "", "", "AfterTouch Test Library"); err != nil {
|
||||
t.Fatalf("add server 2: %v", err)
|
||||
}
|
||||
|
||||
// storedMusicAccounts returns the set of STORED_MUSIC source accounts the
|
||||
// datastore would serve via /full + /sources. SourceKey.Account is the
|
||||
// persisted identity (the display name lives on the speaker, set via
|
||||
// setMusicServiceAccount, and does not round-trip here).
|
||||
storedMusicAccounts := func() map[string]bool {
|
||||
sources, gerr := ds.GetConfiguredSources(account, device)
|
||||
if gerr != nil {
|
||||
t.Fatalf("get sources: %v", gerr)
|
||||
}
|
||||
|
||||
out := map[string]bool{}
|
||||
|
||||
for _, s := range sources {
|
||||
if s.SourceProviderID == sm {
|
||||
out[s.SourceKey.Account] = true
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
got := storedMusicAccounts()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 STORED_MUSIC sources, got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if !got[fritzAcct] {
|
||||
t.Errorf("first media server was evicted (account %q missing)", fritzAcct)
|
||||
}
|
||||
|
||||
if !got[testAcct] {
|
||||
t.Errorf("second media server not registered (account %q missing)", testAcct)
|
||||
}
|
||||
|
||||
// Re-adding the SAME server (same account) updates in place; it must not
|
||||
// create a duplicate or drop the other server.
|
||||
if _, err := AddSource(ds, account, fritzAcct, sm, "", "", "fritz (renamed)"); err != nil {
|
||||
t.Fatalf("re-add server 1: %v", err)
|
||||
}
|
||||
|
||||
got = storedMusicAccounts()
|
||||
if len(got) != 2 || !got[fritzAcct] || !got[testAcct] {
|
||||
t.Fatalf("re-adding the same server should keep exactly both accounts; got %+v", got)
|
||||
}
|
||||
}
|
||||
+107
-23
@@ -4,6 +4,8 @@ package marge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -223,6 +225,27 @@ func (p presetParityXML) MarshalXML(e *xml.Encoder, start xml.StartElement) erro
|
||||
return e.EncodeElement(Alias(p), start)
|
||||
}
|
||||
|
||||
// recentSourceUsername returns the account/username to emit in a recent's
|
||||
// <source> block. The account (e.g. STORED_MUSIC's "<UDN>/0") is persisted in
|
||||
// SourceKey.Account, not Username, which does not round-trip through the
|
||||
// datastore — so fall back to SourceKeyAccount. Without this, STORED_MUSIC
|
||||
// media-server recents get an empty <username>, the speaker falls back to the
|
||||
// provider id (e.g. "7") as the account, and replay fails with INVALID_SOURCE.
|
||||
// Mirrors the fallback in mapToFullResponseSource; TuneIn / Internet Radio /
|
||||
// Local Internet Radio intentionally keep an empty username (parity).
|
||||
func recentSourceUsername(src *models.ConfiguredSource) string {
|
||||
if src.Username != "" {
|
||||
return src.Username
|
||||
}
|
||||
|
||||
switch src.SourceKeyType {
|
||||
case constants.ProviderTunein, constants.ProviderInternetRadio, constants.ProviderLocalInternetRadio:
|
||||
return ""
|
||||
}
|
||||
|
||||
return src.SourceKeyAccount
|
||||
}
|
||||
|
||||
func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentItemParitySource {
|
||||
sxml := &models.RecentItemParitySource{
|
||||
ID: src.ID,
|
||||
@@ -232,7 +255,7 @@ func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentI
|
||||
Name: src.DisplayName,
|
||||
SourceProviderID: src.SourceProviderID,
|
||||
SourceName: src.SourceName,
|
||||
Username: src.Username,
|
||||
Username: recentSourceUsername(src),
|
||||
Credential: &models.RecentItemParityCredential{
|
||||
Type: src.Credential.Type,
|
||||
Value: src.Credential.Value,
|
||||
@@ -593,20 +616,41 @@ func recentToXML(r *models.ServiceRecent, matchingSrc *models.ConfiguredSource)
|
||||
|
||||
// ProviderSettingsToXML generates provider settings XML for the specified account.
|
||||
func ProviderSettingsToXML(account string) string {
|
||||
return constants.XMLHeader + fmt.Sprintf(`<providerSettings>
|
||||
<providerSetting>
|
||||
<boseId>%s</boseId>
|
||||
<keyName>ELIGIBLE_FOR_TRIAL</keyName>
|
||||
<value>false</value>
|
||||
<providerId>14</providerId>
|
||||
</providerSetting>
|
||||
<providerSetting>
|
||||
<boseId>%s</boseId>
|
||||
<keyName>STREAMING_QUALITY</keyName>
|
||||
<value>2</value>
|
||||
<providerId>15</providerId>
|
||||
</providerSetting>
|
||||
</providerSettings>`, EscapeXML(account), EscapeXML(account))
|
||||
type providerSetting struct {
|
||||
BoseID string `xml:"boseId"`
|
||||
KeyName string `xml:"keyName"`
|
||||
Value string `xml:"value"`
|
||||
ProviderID string `xml:"providerId"`
|
||||
}
|
||||
|
||||
type providerSettings struct {
|
||||
XMLName xml.Name `xml:"providerSettings"`
|
||||
Settings []providerSetting `xml:"providerSetting"`
|
||||
}
|
||||
|
||||
payload := providerSettings{
|
||||
Settings: []providerSetting{
|
||||
{
|
||||
BoseID: account,
|
||||
KeyName: "ELIGIBLE_FOR_TRIAL",
|
||||
Value: "false",
|
||||
ProviderID: "14",
|
||||
},
|
||||
{
|
||||
BoseID: account,
|
||||
KeyName: "STREAMING_QUALITY",
|
||||
Value: "2",
|
||||
ProviderID: "15",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := xml.Marshal(payload)
|
||||
if err != nil {
|
||||
return constants.XMLHeader + `<providerSettings></providerSettings>`
|
||||
}
|
||||
|
||||
return constants.XMLHeader + string(out)
|
||||
}
|
||||
|
||||
// SoftwareUpdateToXML generates software update configuration XML.
|
||||
@@ -1996,11 +2040,22 @@ func updateOrCreateRecent(recents []models.ServiceRecent, name string, matchingS
|
||||
if sourceMatch && r.Location == location {
|
||||
recents[i].UtcTime = strconv.FormatInt(utcTime, 10)
|
||||
recents[i].UpdatedOn = FormatTime(time.Now())
|
||||
recentObj = &recents[i]
|
||||
// Move to front
|
||||
recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
|
||||
|
||||
return recentObj, recents
|
||||
// Move the matched recent to the front. Copy the value out FIRST,
|
||||
// then rebuild the slice into a fresh backing array. The previous
|
||||
// in-place `append(recents[:i], recents[i+1:]...)` shuffle aliased
|
||||
// the shared backing array and overwrote index i, which both
|
||||
// corrupted the returned pointer (it pointed at the neighbour) and,
|
||||
// because Go does not specify evaluation order between `*recentObj`
|
||||
// and the inner append, could drop the matched recent and duplicate
|
||||
// its neighbour in the saved list.
|
||||
matched := recents[i]
|
||||
reordered := make([]models.ServiceRecent, 0, len(recents))
|
||||
reordered = append(reordered, matched)
|
||||
reordered = append(reordered, recents[:i]...)
|
||||
reordered = append(reordered, recents[i+1:]...)
|
||||
|
||||
return &reordered[0], reordered
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2116,7 +2171,7 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C
|
||||
Name: matchingSrc.DisplayName,
|
||||
SourceProviderID: matchingSrc.SourceProviderID,
|
||||
SourceName: matchingSrc.SourceName,
|
||||
Username: matchingSrc.Username,
|
||||
Username: recentSourceUsername(matchingSrc),
|
||||
}
|
||||
|
||||
if res.Source.Name == "TuneIn" || res.Source.Name == "LOCAL_INTERNET_RADIO" {
|
||||
@@ -2322,11 +2377,27 @@ func RemoveSourceFromAccount(ds *datastore.DataStore, account, sourceID string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSourceID returns a unique opaque source ID. SaveConfiguredSources dedups
|
||||
// by ID, so it must be collision-free even for sources created in the same
|
||||
// instant; it uses crypto/rand (64 bits) and falls back to a nanosecond
|
||||
// timestamp only if the RNG ever fails.
|
||||
func newSourceID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "SRC_" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
|
||||
return "SRC_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// AddSource adds a new music source to the account and returns the generated source ID.
|
||||
func AddSource(ds *datastore.DataStore, account, username, providerID, secret, secretType, sourceName string) (string, error) {
|
||||
now := time.Now()
|
||||
createdOn := FormatTime(now)
|
||||
sourceID := "SRC_" + strconv.FormatInt(now.Unix(), 10)
|
||||
// SaveConfiguredSources dedups by ID, so IDs must be unique even when two
|
||||
// sources are added in the same instant. Use a random ID rather than a
|
||||
// timestamp (which can collide on coarse clocks or rapid calls).
|
||||
sourceID := newSourceID()
|
||||
|
||||
// List accounts directly from the account directory to be sure we find them.
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
@@ -2368,11 +2439,24 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
|
||||
PrepareConfiguredSource(&newSrc)
|
||||
|
||||
// Update or append. If it's the same provider, we replace it.
|
||||
// Update or append. Most providers are singletons (one account each), so
|
||||
// the same provider replaces the existing entry. STORED_MUSIC is the
|
||||
// exception: each DLNA media server is a separate account (username =
|
||||
// "<UDN>/0"), so it must only replace when the account also matches.
|
||||
// Otherwise registering a second media server overwrites the first, which
|
||||
// then vanishes from /full + /sources and the speaker drops it (only one
|
||||
// media server could ever stay registered).
|
||||
replaced := false
|
||||
|
||||
for i := range sources {
|
||||
if sources[i].SourceProviderID == providerID ||
|
||||
sameProvider := sources[i].SourceProviderID == providerID
|
||||
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
|
||||
// Match on the persisted account identity (SourceKey.Account),
|
||||
// not Username, which does not round-trip through the datastore.
|
||||
sameProvider = sameProvider && sources[i].SourceKey.Account == username
|
||||
}
|
||||
|
||||
if sameProvider ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
replaced = true
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TestUpdateOrCreateRecent_MoveToFrontPreservesList is a regression test for the
|
||||
// recents move-to-front corruption: when an existing recent is re-played, the
|
||||
// in-place slice shuffle at the match branch could (a) return the wrong recent
|
||||
// (a list neighbor) and (b) drop or duplicate entries in the saved list.
|
||||
//
|
||||
// Live evidence (account 6919733 / device A81B6A536A98, 2026-06-14): re-playing
|
||||
// the Spotify album "White Water" returned the neighboring "Sand Castle Tapes"
|
||||
// recent, and both Spotify recents subsequently vanished from a list that was
|
||||
// well under the 10-item cap.
|
||||
func TestUpdateOrCreateRecent_MoveToFrontPreservesList(t *testing.T) {
|
||||
spotify := &models.ConfiguredSource{}
|
||||
spotify.SourceKeyType = "SPOTIFY"
|
||||
spotify.SourceKeyAccount = "gesellix"
|
||||
|
||||
mk := func(id, name, loc string) models.ServiceRecent {
|
||||
var r models.ServiceRecent
|
||||
r.ID = id
|
||||
r.Name = name
|
||||
r.Source = "SPOTIFY"
|
||||
r.SourceAccount = "gesellix"
|
||||
r.Location = loc
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Three distinct Spotify recents sharing the same source; they differ only
|
||||
// by location (the discriminator in the match branch).
|
||||
recents := []models.ServiceRecent{
|
||||
mk("1", "Sunday", "loc-sunday"),
|
||||
mk("2", "White Water", "loc-white"),
|
||||
mk("3", "Sand Castle", "loc-sand"),
|
||||
}
|
||||
|
||||
// Re-play "White Water" (the middle entry) -> it should move to front,
|
||||
// the returned recent must BE White Water, and no entry may be lost.
|
||||
recentObj, out := updateOrCreateRecent(recents, "White Water", spotify, "tracklisturl", "loc-white", "DEVICEID01", 12345)
|
||||
|
||||
if recentObj.Name != "White Water" || recentObj.Location != "loc-white" {
|
||||
t.Errorf("returned recent = %q (loc %q), want White Water/loc-white (neighbor leakage)", recentObj.Name, recentObj.Location)
|
||||
}
|
||||
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("recents count = %d, want 3 (entry lost/duplicated): %s", len(out), names(out))
|
||||
}
|
||||
|
||||
seen := map[string]int{}
|
||||
for i := range out {
|
||||
seen[out[i].Location]++
|
||||
}
|
||||
|
||||
for _, loc := range []string{"loc-sunday", "loc-white", "loc-sand"} {
|
||||
if seen[loc] != 1 {
|
||||
t.Errorf("location %q appears %d times, want 1: %s", loc, seen[loc], names(out))
|
||||
}
|
||||
}
|
||||
|
||||
if out[0].Location != "loc-white" {
|
||||
t.Errorf("front entry = %q, want White Water moved to front: %s", out[0].Name, names(out))
|
||||
}
|
||||
}
|
||||
|
||||
func names(rs []models.ServiceRecent) string {
|
||||
s := "["
|
||||
for i := range rs {
|
||||
if i > 0 {
|
||||
s += ", "
|
||||
}
|
||||
|
||||
s += rs[i].Name + "(" + rs[i].Location + ")"
|
||||
}
|
||||
|
||||
return s + "]"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestRecent_StoredMusicKeepsAccount is a regression test for the recents-replay
|
||||
// bug: a STORED_MUSIC media server's account ("<UDN>/0") is persisted in
|
||||
// SourceKey.Account, not Username (which does not round-trip). The recent
|
||||
// <source> builders emitted Username verbatim, producing an empty <username>;
|
||||
// the speaker then fell back to the provider id (e.g. "7") as the account and
|
||||
// replaying the recent failed with INVALID_SOURCE.
|
||||
//
|
||||
// The speaker registers a recent by sourceid only (no account in the POST), so
|
||||
// the served recent's account must come from the matched source. This asserts
|
||||
// the served <source> carries the real UDN, and never the bare provider id.
|
||||
func TestRecent_StoredMusicKeepsAccount(t *testing.T) {
|
||||
tmp, err := os.MkdirTemp("", "recent-storedmusic-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmp) }()
|
||||
|
||||
ds := datastore.NewDataStore(tmp)
|
||||
account := "6919733"
|
||||
device := "A81B6A536A98"
|
||||
|
||||
if mkErr := os.MkdirAll(ds.AccountDeviceDir(account, device), 0o755); mkErr != nil {
|
||||
t.Fatalf("mkdir: %v", mkErr)
|
||||
}
|
||||
|
||||
const udn = "4d696e69-444c-164e-9d41-72ecda78e4c1/0"
|
||||
|
||||
sm := strconv.Itoa(constants.StoredMusicProviderID)
|
||||
|
||||
srcID, err := AddSource(ds, account, udn, sm, "", "", "AfterTouch Test Library")
|
||||
if err != nil {
|
||||
t.Fatalf("add source: %v", err)
|
||||
}
|
||||
|
||||
// The speaker POSTs a recent referencing the source by id only (matches the
|
||||
// captured Bose_Lisa payload: no <source> account/username).
|
||||
recXML := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?><recent>`+
|
||||
`<lastplayedat>2026-06-14T16:35:30+00:00</lastplayedat>`+
|
||||
`<sourceid>%s</sourceid>`+
|
||||
`<name>02 - The Raven</name>`+
|
||||
`<location>1$4$1 TRACK</location>`+
|
||||
`<contentItemType></contentItemType></recent>`, srcID)
|
||||
|
||||
postResp, err := AddRecent(ds, account, device, []byte(recXML))
|
||||
if err != nil {
|
||||
t.Fatalf("add recent: %v", err)
|
||||
}
|
||||
|
||||
// 1. The POST response (formatRecentResponse) must carry the real account.
|
||||
assertAccount(t, "AddRecent response", string(postResp), udn)
|
||||
|
||||
// 2. The served recents list (RecentsToXML) must carry it too.
|
||||
served, err := RecentsToXML(ds, account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentsToXML: %v", err)
|
||||
}
|
||||
|
||||
assertAccount(t, "RecentsToXML", string(served), udn)
|
||||
}
|
||||
|
||||
func assertAccount(t *testing.T, what, xml, udn string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(xml, "<username>"+udn+"</username>") {
|
||||
t.Errorf("%s: expected <username>%s</username>; got:\n%s", what, udn, xml)
|
||||
}
|
||||
|
||||
if strings.Contains(xml, "<username></username>") || strings.Contains(xml, "<username/>") {
|
||||
t.Errorf("%s: STORED_MUSIC source has an empty <username> (account lost):\n%s", what, xml)
|
||||
}
|
||||
|
||||
if strings.Contains(xml, "<username>7</username>") {
|
||||
t.Errorf("%s: STORED_MUSIC username is the bare provider id, not the account:\n%s", what, xml)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,88 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) {
|
||||
return m.setBoseURLsViaTelnet(deviceIP, serviceURL, serviceURL+"/update")
|
||||
}
|
||||
|
||||
// EnableSSHViaTelnetFullConfig is the #515 variant of EnableSSHViaTelnet for
|
||||
// devices where the single-envswitch injection is accepted and persisted but
|
||||
// sshd never starts (ST Portable, CineMate 520; see also memory note #471). It
|
||||
// replicates the sequence @Henri-be confirmed by hand over telnet :17000: it
|
||||
// writes all four `sys configuration` URL keys with the remote_services
|
||||
// injection on margeServerUrl (the runtime layer, not just the envswitch
|
||||
// persistence layer), mirrors the injection into `envswitch boseurls set`, and
|
||||
// verifies with getpdo. The caller should reboot afterwards (the injection
|
||||
// fires on the speaker's next full config re-parse at boot) and then
|
||||
// WaitForSSHPort.
|
||||
//
|
||||
// serviceURL is the AfterTouch service base the speaker should point at
|
||||
// (e.g. https://192.0.2.10:8443). It must not contain a double quote.
|
||||
func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string) (string, error) {
|
||||
u := defaultTelnetURLs(serviceURL)
|
||||
margeInjected := serviceURL + remoteServicesInjection
|
||||
|
||||
// All values are double-quoted: margeInjected contains spaces and
|
||||
// semicolons, so the device's command parser needs the quotes to keep it
|
||||
// one argument (the unquoted telnetURLs.Commands() is only safe for clean
|
||||
// migration URLs).
|
||||
cmds := []string{
|
||||
`sys configuration bmxRegistryUrl "` + u.BmxRegistry + `"`,
|
||||
`sys configuration statsServerUrl "` + u.Stats + `"`,
|
||||
`sys configuration margeServerUrl "` + margeInjected + `"`,
|
||||
`sys configuration swUpdateUrl "` + u.SwUpdate + `"`,
|
||||
`envswitch boseurls set "` + margeInjected + `" "` + u.SwUpdate + `"`,
|
||||
}
|
||||
|
||||
return m.runTelnetInjection(deviceIP, []string{serviceURL, u.SwUpdate}, cmds)
|
||||
}
|
||||
|
||||
// runTelnetInjection opens the port-17000 shell, runs an ordered list of
|
||||
// commands (aborting on the first transport error or "command not found"
|
||||
// rejection), then logs a getpdo verification. forbidQuote values are checked
|
||||
// for an embedded double quote, which would break the command parsing.
|
||||
// Verification is best-effort (logged, never fatal) to match enable-ssh's
|
||||
// forgiving philosophy and tolerate the aftertouch.invalid placeholder.
|
||||
func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
for _, v := range forbidQuote {
|
||||
if strings.Contains(v, `"`) {
|
||||
return "", errors.New("boseurls values must not contain a double quote")
|
||||
}
|
||||
}
|
||||
|
||||
var logs strings.Builder
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
if banner, _ := t.Probe(); banner != "" {
|
||||
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
|
||||
}
|
||||
|
||||
for _, cmd := range cmds {
|
||||
resp, err := t.SendCommand(cmd)
|
||||
if err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
if verify, err := t.SendCommand("getpdo CurrentSystemConfiguration"); err == nil {
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
}
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// setBoseURLsViaTelnet runs `envswitch boseurls set "<marge>" "<swUpdate>"`
|
||||
// over the port-17000 shell. Both arguments are double-quoted so values
|
||||
// containing spaces or semicolons (the SSH-enable injection) survive the
|
||||
|
||||
@@ -22,6 +22,43 @@ func TestEnableSSHViaTelnet_BuildsInjectedCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
|
||||
const injected = `https://192.0.2.10:8443;touch /tmp/remote_services;/etc/init.d/sshd start`
|
||||
|
||||
want := []string{
|
||||
`sys configuration bmxRegistryUrl "https://192.0.2.10:8443/bmx/registry/v1/services"`,
|
||||
`sys configuration statsServerUrl "https://192.0.2.10:8443"`,
|
||||
`sys configuration margeServerUrl "` + injected + `"`,
|
||||
`sys configuration swUpdateUrl "https://192.0.2.10:8443/updates/soundtouch"`,
|
||||
`envswitch boseurls set "` + injected + `" "https://192.0.2.10:8443/updates/soundtouch"`,
|
||||
`getpdo CurrentSystemConfiguration`,
|
||||
}
|
||||
|
||||
resp := map[string]string{}
|
||||
for _, c := range want {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc); err != nil {
|
||||
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != len(want) {
|
||||
t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want)
|
||||
}
|
||||
|
||||
for i, c := range want {
|
||||
if f.commands[i] != c {
|
||||
t.Errorf("command %d = %q\n want %q", i, f.commands[i], c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetBoseURLs_BuildsCleanCommand(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
|
||||
|
||||
+44
-40
@@ -807,58 +807,62 @@ func applyURLOverrides(cfg *PrivateCfg, options map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// checkCACertTrusted checks if the local CA certificate is already in
|
||||
// the device's trust store. The CALabel grep works regardless of whether
|
||||
// Manager.Crypto is configured — only the secondary "match cert payload"
|
||||
// fallback needs it. CLI callers without Crypto can therefore still
|
||||
// detect a previously-trusted CA.
|
||||
// checkCACertTrusted decides whether the device already trusts *this* service's
|
||||
// CA.
|
||||
//
|
||||
// When the service CA is available (Manager.Crypto set), the certificate
|
||||
// *payload* is authoritative: a previous migration may have left our label
|
||||
// (CALabel) in the bundle even though the actual CA has since changed — e.g. the
|
||||
// service CA was regenerated after its data dir was recreated without a
|
||||
// persistent volume. Matching the label alone would then falsely report the
|
||||
// device as trusted and skip re-installing the new CA, leaving the speaker
|
||||
// unable to validate TLS to the service (the symptom seen in #517). So we match
|
||||
// the current CA's payload and deliberately ignore the (possibly stale) label.
|
||||
//
|
||||
// Only when the CA can't be read (e.g. a CLI caller without Crypto) do we fall
|
||||
// back to the label, which is the best signal available there.
|
||||
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
|
||||
// Primary check: our injected label.
|
||||
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
|
||||
if err == nil && strings.Contains(output, CALabel) {
|
||||
summary.CACertTrusted = true
|
||||
return
|
||||
}
|
||||
if m.Crypto != nil {
|
||||
if certData, ok := m.firstCACertBodyLine(); ok {
|
||||
// Payload present -> trusted; absent -> not trusted (re-install),
|
||||
// regardless of a possibly-stale label.
|
||||
if _, err := client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath)); err == nil {
|
||||
summary.CACertTrusted = true
|
||||
}
|
||||
|
||||
// Secondary check (only when Manager.Crypto is configured): match
|
||||
// the actual cert payload — covers older injections that lack the
|
||||
// label.
|
||||
if m.Crypto == nil {
|
||||
return
|
||||
}
|
||||
|
||||
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We look for the first part of the certificate (e.g. the first 64 chars of the base64 data)
|
||||
// to see if it's already in the bundle.
|
||||
lines := strings.Split(string(caCertPEM), "\n")
|
||||
|
||||
var certData string
|
||||
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") && line != "" {
|
||||
certData = line
|
||||
break
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if certData == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Use grep to check for the certificate data in the bundle
|
||||
_, err = client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath))
|
||||
if err == nil {
|
||||
// Fallback for callers without the CA at hand: match our injected label.
|
||||
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
|
||||
if err == nil && strings.Contains(output, CALabel) {
|
||||
summary.CACertTrusted = true
|
||||
}
|
||||
}
|
||||
|
||||
// firstCACertBodyLine returns the first base64 body line of the service CA
|
||||
// certificate, used as a cheap fingerprint to check whether this exact CA is
|
||||
// already present in a device's trust bundle. Returns false when the cert can't
|
||||
// be read or has no body line.
|
||||
func (m *Manager) firstCACertBodyLine() (string, bool) {
|
||||
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(caCertPEM), "\n") {
|
||||
if line != "" && !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") {
|
||||
return line, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// MigrateSpeaker configures the speaker at the given IP to use this service.
|
||||
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
|
||||
if targetURL == "" {
|
||||
|
||||
@@ -453,6 +453,67 @@ func TestCheckCACertTrusted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCACertTrustedStaleLabel is the #517 regression: a previous
|
||||
// migration's CALabel survived a service-CA regeneration, so the label is in the
|
||||
// bundle but the *current* CA payload is not. The device must then be treated as
|
||||
// NOT trusting the new CA (so the migration re-installs it), instead of being
|
||||
// falsely skipped on the stale label.
|
||||
func TestCheckCACertTrustedStaleLabel(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "ca-trust-stale")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
t.Fatalf("Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", nil, cm)
|
||||
m.NewSSH = func(_ string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
// Stale label is still present...
|
||||
if strings.Contains(command, CALabel) {
|
||||
return CALabel, nil
|
||||
}
|
||||
// ...but the current CA payload is not in the bundle.
|
||||
return "", fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, "192.0.2.10")
|
||||
if summary.CACertTrusted {
|
||||
t.Error("stale label without a matching CA payload must not count as trusted (should re-install)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCACertTrustedNoCryptoFallback verifies that when the service CA is
|
||||
// not available (e.g. a CLI caller without Crypto), the injected label remains
|
||||
// the trust signal.
|
||||
func TestCheckCACertTrustedNoCryptoFallback(t *testing.T) {
|
||||
m := NewManager("http://localhost:8000", nil, nil)
|
||||
m.NewSSH = func(_ string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, CALabel) {
|
||||
return CALabel, nil
|
||||
}
|
||||
return "", fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, "192.0.2.10")
|
||||
if !summary.CACertTrusted {
|
||||
t.Error("without Crypto, a present label should count as trusted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnection(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "test-connection")
|
||||
if err != nil {
|
||||
|
||||
@@ -998,22 +998,22 @@ func (app *WebApp) HandleZoneRemove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if masterConn.Client == nil || slaveConn.DeviceInfo == nil {
|
||||
if masterConn.Client == nil || masterConn.DeviceInfo == nil || slaveConn.DeviceInfo == nil {
|
||||
app.sendError(w, "Device not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
zone, err := masterConn.Client.GetZone()
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
zoneReq := zone.ToZoneRequest()
|
||||
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
|
||||
masterHwID := masterConn.DeviceInfo.DeviceID
|
||||
slaveHwID := slaveConn.DeviceInfo.DeviceID
|
||||
|
||||
// Remove a single member with the dedicated /removeZoneSlave endpoint.
|
||||
// Rebuilding the zone via /setZone with the remaining members does not
|
||||
// reliably drop a member when the zone has more than one: the speaker only
|
||||
// goes standalone when the resulting member set is empty, so removing one of
|
||||
// several members appeared to do nothing (#511). /removeZoneSlave targets the
|
||||
// specific member.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Device removed from zone")
|
||||
app.sendControlResponse(w, masterConn.Client.RemoveZoneSlave(masterHwID, slaveHwID, slaveIP), "Device removed from zone")
|
||||
}
|
||||
|
||||
// HandleZoneDissolve dissolves the zone, making all devices standalone.
|
||||
@@ -1073,17 +1073,15 @@ func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
masterZone, err := masterConn.Client.GetZone()
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
zoneReq := masterZone.ToZoneRequest()
|
||||
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
|
||||
|
||||
// Drop this slave with the dedicated /removeZoneSlave endpoint sent to the
|
||||
// master. Rebuilding the zone via /setZone with the remaining members does
|
||||
// not drop a member from a multi-member zone (the master only goes standalone
|
||||
// when the resulting set is empty), so leaving a 3+ device zone did nothing
|
||||
// (#511). zone.Master is the master's hwID.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Left zone")
|
||||
app.sendControlResponse(w,
|
||||
masterConn.Client.RemoveZoneSlave(zone.Master, slaveConn.DeviceInfo.DeviceID, slaveIP),
|
||||
"Left zone")
|
||||
}
|
||||
|
||||
// HandleDeviceRecents returns recently played items for a device.
|
||||
@@ -1114,6 +1112,19 @@ func (app *WebApp) HandleDeviceRecents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// storedMusicTypeForReplay derives a STORED_MUSIC ContentItem type from the
|
||||
// speaker-native location, which ends with the item kind (e.g. "1$4$2 TRACK"
|
||||
// or a container's "… DIR"). Recents don't store the type, and the speaker
|
||||
// rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. Falls back to
|
||||
// "track" when the location has no kind suffix.
|
||||
func storedMusicTypeForReplay(location string) string {
|
||||
if fields := strings.Fields(location); len(fields) >= 2 {
|
||||
return strings.ToLower(fields[len(fields)-1])
|
||||
}
|
||||
|
||||
return "track"
|
||||
}
|
||||
|
||||
// HandleDevicePlay plays an arbitrary content item on a device. Generic
|
||||
// counterpart to HandlePlayTuneIn — used by the Recents panel to replay
|
||||
// items the speaker reports under /recents, regardless of their source.
|
||||
@@ -1151,9 +1162,18 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Recents don't carry a contentItemType for STORED_MUSIC, and the speaker
|
||||
// rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. The
|
||||
// speaker-native location ends with the item kind (e.g. "1$4$2 TRACK"), so
|
||||
// derive the type from it when the caller didn't supply one.
|
||||
ciType := req.Type
|
||||
if ciType == "" && req.Source == "STORED_MUSIC" {
|
||||
ciType = storedMusicTypeForReplay(req.Location)
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: req.Source,
|
||||
Type: req.Type,
|
||||
Type: ciType,
|
||||
Location: req.Location,
|
||||
ItemName: req.ItemName,
|
||||
ContainerArt: req.ContainerArt,
|
||||
|
||||
@@ -143,6 +143,12 @@ func (app *WebApp) HandleDeviceLibraryServers(w http.ResponseWriter, r *http.Req
|
||||
// contain {udn, name}. The account sent to the speaker is "<udn>/0" as
|
||||
// required by the STORED_MUSIC protocol. Error code 1024 from the speaker
|
||||
// means the account is already registered and is treated as success.
|
||||
//
|
||||
// After a successful registration the handler fires a best-effort
|
||||
// sourcesUpdated notification so the speaker re-fetches its account list and
|
||||
// registers the new source without requiring a power-cycle. The notification
|
||||
// outcome is reflected in the response field "refreshed" but never fails the
|
||||
// request.
|
||||
func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
@@ -183,11 +189,33 @@ func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the Bose device ID for the sourcesUpdated nudge. Prefer the
|
||||
// cached DeviceInfo (no extra round-trip); fall back to a live /info
|
||||
// fetch only if the cached value is absent or empty.
|
||||
boseDeviceID := ""
|
||||
if device.DeviceInfo != nil && device.DeviceInfo.DeviceID != "" {
|
||||
boseDeviceID = device.DeviceInfo.DeviceID
|
||||
} else {
|
||||
if info, infoErr := device.Client.GetDeviceInfo(); infoErr == nil && info != nil {
|
||||
boseDeviceID = info.DeviceID
|
||||
}
|
||||
}
|
||||
|
||||
// Send the sourcesUpdated nudge best-effort: the registration already
|
||||
// succeeded, so an error here must never fail the request.
|
||||
refreshed := false
|
||||
|
||||
if boseDeviceID != "" {
|
||||
if nudgeErr := device.Client.NotifySourcesUpdated(boseDeviceID); nudgeErr == nil {
|
||||
refreshed = true
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"account": account},
|
||||
Data: map[string]interface{}{"account": account, "refreshed": refreshed},
|
||||
}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -73,12 +73,14 @@ func setupSpeakerMock(t *testing.T, responseMap map[string]string) (*httptest.Se
|
||||
}
|
||||
|
||||
// newLibraryTestApp builds a WebApp with a single device whose Client points
|
||||
// at the given speaker URL. The device is registered under "lib-device".
|
||||
// at the given speaker URL. The device is registered under "lib-device" with
|
||||
// a non-empty DeviceID so HandleAddLibraryServer can resolve the Bose ID from
|
||||
// the cached DeviceInfo without a /info fallback.
|
||||
func newLibraryTestApp(speakerURL string) *WebApp {
|
||||
app := NewWebApp()
|
||||
|
||||
c := client.NewClient(&client.Config{Host: speakerURL})
|
||||
info := &models.DeviceInfo{Name: "Library Test Speaker"}
|
||||
info := &models.DeviceInfo{Name: "Library Test Speaker", DeviceID: "AABBCCDDEEFF"}
|
||||
conn := webtypes.NewDeviceConnection(c, info)
|
||||
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
|
||||
app.AddDevice("lib-device", conn)
|
||||
@@ -511,6 +513,8 @@ func TestNormalizeUDN(t *testing.T) {
|
||||
// TestHandleAddLibraryServer_AccountFormat verifies that the speaker receives
|
||||
// a setMusicServiceAccount call with the account set to "<bare-uuid>/0", i.e.
|
||||
// any "uuid:" prefix is stripped before the "/0" suffix is appended.
|
||||
// It also asserts that a POST /notification (sourcesUpdated nudge) is sent
|
||||
// after a successful registration and that the response carries refreshed=true.
|
||||
func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -532,8 +536,10 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// The client parses the response XML and checks for the success sentinel.
|
||||
// Also handle /notification so NotifySourcesUpdated succeeds.
|
||||
speaker, captured := setupSpeakerMock(t, map[string]string{
|
||||
"/setMusicServiceAccount": `<status>/setMusicServiceAccount</status>`,
|
||||
"/notification": `<status>/notification</status>`,
|
||||
})
|
||||
defer speaker.Close()
|
||||
|
||||
@@ -572,7 +578,17 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
|
||||
t.Errorf("setMusicServiceAccount XML should contain %q, got:\n%s", tt.wantAccount, setXML)
|
||||
}
|
||||
|
||||
// The response account field must also be the bare form.
|
||||
// A sourcesUpdated nudge must have been POSTed to /notification.
|
||||
notifXML := captured["/notification"]
|
||||
if notifXML == "" {
|
||||
t.Fatal("speaker /notification was never called (sourcesUpdated nudge missing)")
|
||||
}
|
||||
|
||||
if !strings.Contains(notifXML, "sourcesUpdated") {
|
||||
t.Errorf("/notification body should contain 'sourcesUpdated', got:\n%s", notifXML)
|
||||
}
|
||||
|
||||
// The response must carry the account and refreshed=true.
|
||||
data, ok := resp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resp.Data is not a map: %T", resp.Data)
|
||||
@@ -581,10 +597,71 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
|
||||
if got, _ := data["account"].(string); got != tt.wantAccount {
|
||||
t.Errorf("response account = %q, want %q", got, tt.wantAccount)
|
||||
}
|
||||
|
||||
if refreshed, _ := data["refreshed"].(bool); !refreshed {
|
||||
t.Errorf("response refreshed should be true, got %v", data["refreshed"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered verifies that
|
||||
// the sourcesUpdated nudge is also fired for the 1024 (already-registered)
|
||||
// idempotent path, since the source still needs to be re-registered on the
|
||||
// speaker.
|
||||
func TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered(t *testing.T) {
|
||||
alreadyRegistered := `<errors deviceID="AABBCCDDEEFF">
|
||||
<error value="1024">1024: Account already exists</error>
|
||||
</errors>`
|
||||
|
||||
var notifCalled int
|
||||
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/setMusicServiceAccount":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(alreadyRegistered))
|
||||
case "/notification":
|
||||
notifCalled++
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<status>/notification</status>`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer speaker.Close()
|
||||
|
||||
app := newLibraryTestApp(speaker.URL)
|
||||
|
||||
body := strings.NewReader(`{"udn":"uuid:nas-udn","name":"My NAS"}`)
|
||||
req := httptest.NewRequest("POST",
|
||||
"/api/control/devices/lib-device/library/servers",
|
||||
body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAddLibraryServer(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
t.Errorf("expected success=true when error contains 1024, got error=%s", resp.Error)
|
||||
}
|
||||
|
||||
if notifCalled == 0 {
|
||||
t.Error("expected /notification to be called for already-registered path, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleAddLibraryServer_MissingUDN checks that omitting udn returns 400.
|
||||
func TestHandleAddLibraryServer_MissingUDN(t *testing.T) {
|
||||
speaker, _ := setupSpeakerMock(t, nil)
|
||||
|
||||
@@ -769,3 +769,127 @@ func TestHandleSourceControl_ForwardsAccount(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleZoneRemove_UsesRemoveZoneSlave is the #511 regression: removing one
|
||||
// member from a multi-member zone must target that member via /removeZoneSlave.
|
||||
// The previous implementation rebuilt the zone with /setZone and the remaining
|
||||
// members, which the speaker only honoured when the resulting member set was
|
||||
// empty — so removing one of several members appeared to do nothing, while
|
||||
// removing the last member (empty set == dissolve) worked.
|
||||
func TestHandleZoneRemove_UsesRemoveZoneSlave(t *testing.T) {
|
||||
var gotPath, gotBody string
|
||||
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer speaker.Close()
|
||||
|
||||
app := NewWebApp()
|
||||
|
||||
// Registry is IP-keyed; use RFC-5737 documentation addresses.
|
||||
master := webtypes.NewDeviceConnection(
|
||||
client.NewClient(&client.Config{Host: speaker.URL}),
|
||||
&models.DeviceInfo{Name: "Master", DeviceID: "MASTERHW01"},
|
||||
)
|
||||
master.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
|
||||
app.AddDevice("192.0.2.10", master)
|
||||
|
||||
slave := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Slave", DeviceID: "SLAVEHW02"})
|
||||
app.AddDevice("192.0.2.20", slave)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/control/devices/192.0.2.10/zone/remove/192.0.2.20", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "192.0.2.10", "slaveId": "192.0.2.20"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleZoneRemove(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if gotPath != "/removeZoneSlave" {
|
||||
t.Errorf("expected POST to /removeZoneSlave, got %q (a /setZone rebuild does not drop a member from a multi-member zone)", gotPath)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotBody, "SLAVEHW02") {
|
||||
t.Errorf("removeZoneSlave body should target the slave device ID, got: %s", gotBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotBody, `master="MASTERHW01"`) {
|
||||
t.Errorf("removeZoneSlave body should name the master, got: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleZoneLeave_UsesRemoveZoneSlave is the #511 regression for the slave's
|
||||
// "Leave zone" path: it must drop the slave via /removeZoneSlave on the master,
|
||||
// not rebuild the master's zone with /setZone (which leaves a 3+ device zone
|
||||
// unchanged). The leaving slave's "id" is its IP; it carries the master's hwID
|
||||
// in its /getZone, which we resolve to the master's registry entry.
|
||||
func TestHandleZoneLeave_UsesRemoveZoneSlave(t *testing.T) {
|
||||
var masterPath, masterBody string
|
||||
|
||||
// Master speaker captures the /removeZoneSlave call.
|
||||
masterSpeaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
masterPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
masterBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer masterSpeaker.Close()
|
||||
|
||||
// Slave speaker answers /getZone naming the master by hwID.
|
||||
slaveSpeaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/getZone" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="MASTERHW01">
|
||||
<member ipaddress="192.0.2.20">SLAVEHW02</member>
|
||||
<member ipaddress="192.0.2.30">SLAVEHW03</member>
|
||||
</zone>`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer slaveSpeaker.Close()
|
||||
|
||||
app := NewWebApp()
|
||||
|
||||
master := webtypes.NewDeviceConnection(
|
||||
client.NewClient(&client.Config{Host: masterSpeaker.URL}),
|
||||
&models.DeviceInfo{Name: "Master", DeviceID: "MASTERHW01"},
|
||||
)
|
||||
master.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
|
||||
app.AddDevice("192.0.2.10", master)
|
||||
|
||||
slave := webtypes.NewDeviceConnection(
|
||||
client.NewClient(&client.Config{Host: slaveSpeaker.URL}),
|
||||
&models.DeviceInfo{Name: "Slave", DeviceID: "SLAVEHW02"},
|
||||
)
|
||||
slave.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
|
||||
app.AddDevice("192.0.2.20", slave)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/control/devices/192.0.2.20/zone/leave", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "192.0.2.20"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleZoneLeave(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if masterPath != "/removeZoneSlave" {
|
||||
t.Errorf("expected POST to master's /removeZoneSlave, got %q", masterPath)
|
||||
}
|
||||
|
||||
if !strings.Contains(masterBody, "SLAVEHW02") {
|
||||
t.Errorf("removeZoneSlave body should target the leaving slave, got: %s", masterBody)
|
||||
}
|
||||
|
||||
if !strings.Contains(masterBody, `master="MASTERHW01"`) {
|
||||
t.Errorf("removeZoneSlave body should name the master, got: %s", masterBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,37 @@ img { display: block; max-width: 100%; }
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Small screens (phones, portrait): the absolutely-centered page title and the
|
||||
right-aligned icon bar share the same space in the fixed-height navbar and
|
||||
collide (#500). Let the navbar wrap into two rows: row 1 keeps the logo with
|
||||
the title beside it (the title fills the remaining width and ellipsizes), and
|
||||
the icon bar drops onto its own full-width row below. */
|
||||
@media (max-width: 600px) {
|
||||
.navbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: 52px;
|
||||
row-gap: 0.25rem;
|
||||
padding-top: 0.4rem;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
position: static;
|
||||
transform: none;
|
||||
flex: 1 1 auto; /* sit next to the logo and fill the rest of row 1 */
|
||||
width: auto;
|
||||
min-width: 0; /* allow the title to shrink + ellipsize */
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 1; /* force the icon bar onto its own row below */
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
@@ -96,10 +96,13 @@ export function Library({ devices }) {
|
||||
}
|
||||
|
||||
async function playEntry(entry) {
|
||||
// Pass the entry's own type so a folder selects as a container ("dir")
|
||||
// rather than a single track — that lets the speaker queue the folder so
|
||||
// next/previous and auto-advance work, instead of stopping after one item.
|
||||
await api.libraryPlay(deviceId, {
|
||||
account: server.account,
|
||||
location: entry.location,
|
||||
type: 'track',
|
||||
type: entry.type || 'track',
|
||||
name: entry.name,
|
||||
});
|
||||
setPlayingName(entry.name);
|
||||
@@ -251,10 +254,10 @@ export function Library({ devices }) {
|
||||
<span class="tunein-item-name">${entry.name}</span>
|
||||
${entry.type ? html`<span class="tunein-item-desc">${entry.type}</span>` : null}
|
||||
</div>
|
||||
${entry.playable && !entry.isDir ? html`
|
||||
${entry.playable || entry.isDir ? html`
|
||||
<button
|
||||
class="tunein-play-btn"
|
||||
title="Play on ${devices[deviceId]?.info?.name || deviceId}"
|
||||
title="${entry.isDir ? 'Play folder' : 'Play'} on ${devices[deviceId]?.info?.name || deviceId}"
|
||||
onClick=${(e) => { e.stopPropagation(); playEntry(entry); }}
|
||||
>▶</button>
|
||||
` : null}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function Zone({ deviceId, devices }) {
|
||||
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
|
||||
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
|
||||
|
||||
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
|
||||
const deviceName = (ip) => devices[ip]?.info?.name || ip;
|
||||
|
||||
return html`
|
||||
<div class="zone-section">
|
||||
@@ -76,7 +76,7 @@ export function Zone({ deviceId, devices }) {
|
||||
${(zone.members || []).map(m => html`
|
||||
<div class="zone-member" key=${m.ip}>
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">${m.name || m.ip}</span>
|
||||
<span class="zone-member-name">${m.name || deviceName(m.ip)}</span>
|
||||
<button class="btn-icon zone-remove" title="Remove from zone"
|
||||
onClick=${() => removeDevice(m.ip)}>✕</button>
|
||||
</div>
|
||||
@@ -93,7 +93,7 @@ export function Zone({ deviceId, devices }) {
|
||||
${zone.isSlave && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
|
||||
<span class="zone-member-name">Zone: ${zone.masterName || deviceName(zone.masterIp)}</span>
|
||||
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
|
||||
</div>
|
||||
`}
|
||||
@@ -105,7 +105,10 @@ export function Zone({ deviceId, devices }) {
|
||||
<div class="picker-devices">
|
||||
${available.map(([ip, d]) => html`
|
||||
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
|
||||
${d.info?.Name ?? ip}
|
||||
<div class="picker-device-info">
|
||||
<span class="picker-device-name">${d.info?.name || ip}</span>
|
||||
<span class="picker-device-ip">${d.info?.ip_address || ip}</span>
|
||||
</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package soundtouchweb
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestStoredMusicTypeForReplay covers deriving the ContentItem type from a
|
||||
// STORED_MUSIC recent's location when the stored type is empty. Without a type
|
||||
// the speaker rejects the select with INVALID_SOURCE.
|
||||
func TestStoredMusicTypeForReplay(t *testing.T) {
|
||||
cases := []struct {
|
||||
location string
|
||||
want string
|
||||
}{
|
||||
{"1$4$2 TRACK", "track"},
|
||||
{"5:audio5:part13:5521:5 TRACK", "track"},
|
||||
{"1 DIR", "dir"},
|
||||
{"1 CONTAINER", "container"},
|
||||
{"noSuffix", "track"}, // fallback
|
||||
{"", "track"}, // fallback
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := storedMusicTypeForReplay(c.location); got != c.want {
|
||||
t.Errorf("storedMusicTypeForReplay(%q) = %q, want %q", c.location, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ package fakespeaker
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -19,6 +20,8 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml testdata/networkinfo.xml testdata/sources.xml testdata/supportedurls.xml testdata/now_playing.xml
|
||||
@@ -319,46 +322,37 @@ func handleRemoveGroup(w http.ResponseWriter, r *http.Request) {
|
||||
serveEmptyGroup(w, r)
|
||||
}
|
||||
|
||||
// buildAddGroupResponse inserts <status>GROUP_OK</status> before the
|
||||
// closing </group> tag of the posted body. If the body is empty or does
|
||||
// not contain </group>, it falls back to a minimal canned success
|
||||
// response so callers still see a 200 + parseable XML.
|
||||
// buildAddGroupResponse echoes the posted group payload back with a
|
||||
// <status>GROUP_OK</status> appended, mimicking a real speaker's success
|
||||
// response. The body is parsed into typed fields and re-marshalled (rather
|
||||
// than splicing raw bytes) so every echoed value is XML-escaped. If the body
|
||||
// is empty or unparseable, it falls back to a minimal canned success response
|
||||
// so callers still see a 200 + parseable XML.
|
||||
func buildAddGroupResponse(posted []byte) []byte {
|
||||
const closeTag = "</group>"
|
||||
|
||||
const okFragment = " <status>GROUP_OK</status>\n"
|
||||
cannedOK := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<group>
|
||||
<status>GROUP_OK</status>
|
||||
</group>
|
||||
`)
|
||||
|
||||
if len(posted) == 0 {
|
||||
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
|
||||
return cannedOK
|
||||
}
|
||||
|
||||
idx := indexOfClose(posted, closeTag)
|
||||
if idx < 0 {
|
||||
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
|
||||
// Parse into the canonical request type and re-marshal it (rather than
|
||||
// splicing the raw bytes) so every echoed value is XML-escaped and the
|
||||
// fake stays in lock-step with whatever fields the client actually sends.
|
||||
var in models.Group
|
||||
if err := xml.Unmarshal(posted, &in); err != nil {
|
||||
return cannedOK
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(posted)+len(okFragment))
|
||||
out = append(out, posted[:idx]...)
|
||||
out = append(out, []byte(okFragment)...)
|
||||
out = append(out, posted[idx:]...)
|
||||
in.Status = "GROUP_OK"
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// indexOfClose returns the index of the last occurrence of needle in b,
|
||||
// or -1 if not present. We scan from the right because real-world
|
||||
// payloads can technically nest <group> blocks (e.g. inside <roles>),
|
||||
// even though the documented stereo-pair payload does not.
|
||||
func indexOfClose(b []byte, needle string) int {
|
||||
if len(needle) == 0 || len(b) < len(needle) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := len(b) - len(needle); i >= 0; i-- {
|
||||
if string(b[i:i+len(needle)]) == needle {
|
||||
return i
|
||||
}
|
||||
data, err := xml.Marshal(in)
|
||||
if err != nil {
|
||||
return cannedOK
|
||||
}
|
||||
|
||||
return -1
|
||||
return append([]byte(`<?xml version="1.0" encoding="UTF-8"?>`+"\n"), data...)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ Then, run the following command to install AfterTouch on the device.
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
```
|
||||
|
||||
This installs the **latest release** by default (the script resolves it from
|
||||
GitHub's `releases/latest` redirect). To pin a specific version, see
|
||||
[Updating AfterTouch](#updating-aftertouch) below.
|
||||
|
||||
After the installation check if you can access AfterTouch from your local device by navigating to `http://<IP_ADDRESS_OF_SPEAKER>:8000`. If you can access the AfterTouch UI, you're good to go!
|
||||
|
||||
### If `http://<IP_ADDRESS_OF_SPEAKER>:8000` fails: SSH port forwarding
|
||||
@@ -91,17 +95,21 @@ Run the installer again with the version you want to install. The script backs u
|
||||
|
||||
```bash
|
||||
# 1. Environment variable (works when piping into sh)
|
||||
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# 2. Command-line flag (pass args after `sh -s --`)
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
|
||||
# 3. Download first, then run with a flag
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.107.0
|
||||
sh install.sh --version 0.111.3
|
||||
```
|
||||
|
||||
Running **without** a version override installs the version hard-coded in the script (the latest release at the time the script was published). That default is updated with each release; if you're running from `main`, it reflects the most recent tagged version.
|
||||
Running **without** a version override installs the latest release: the script
|
||||
follows GitHub's `https://github.com/gesellix/Bose-SoundTouch/releases/latest`
|
||||
redirect to discover the newest tag. If that lookup fails (offline, or a `curl`
|
||||
build without `-w` support), it falls back to a pinned version baked into the
|
||||
script.
|
||||
|
||||
> **Tip — rollback:** if the new binary misbehaves, the installer left a `.backup` file alongside it:
|
||||
> ```bash
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Default version installed when no override is provided. Update this value
|
||||
# each time a new release is cut so that running the canonical one-liner
|
||||
# Version to install. Left empty by default so the canonical one-liner
|
||||
# curl -sSL .../install.sh | sh
|
||||
# picks up the latest binary without extra arguments.
|
||||
# resolves and installs the latest release automatically (see below).
|
||||
#
|
||||
# Override via environment variable or the --version/-v flag:
|
||||
# VERSION=0.107.0 curl -sSL .../install.sh | sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.107.0
|
||||
VERSION=${VERSION:-0.107.0}
|
||||
# Pin a specific version via environment variable or the --version/-v flag:
|
||||
# VERSION=0.111.3 curl -sSL .../install.sh | sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.111.3
|
||||
VERSION=${VERSION:-}
|
||||
|
||||
# Parse optional command-line arguments so the script can be invoked as:
|
||||
# install.sh --version 0.107.0
|
||||
# install.sh -v 0.107.0
|
||||
# install.sh --version 0.111.3
|
||||
# install.sh -v 0.111.3
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version|-v)
|
||||
@@ -27,6 +26,30 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
|
||||
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited /
|
||||
# a curl without -w support).
|
||||
FALLBACK_VERSION=${FALLBACK_VERSION:-0.111.3}
|
||||
|
||||
# Resolve the latest release when no explicit version was provided, by
|
||||
# following the stable redirect https://github.com/<repo>/releases/latest
|
||||
# -> .../releases/tag/vX.Y.Z and taking the tag from the final URL. The
|
||||
# leading "v" is stripped because the URLs below add it back (v$VERSION).
|
||||
if [ -z "$VERSION" ]; then
|
||||
LATEST_URL="https://github.com/$GH_REPO/releases/latest"
|
||||
echo "Resolving latest release via $LATEST_URL ..."
|
||||
EFFECTIVE=$(curl -sSLI -o /dev/null -w '%{url_effective}' "$LATEST_URL" 2>/dev/null) || true
|
||||
TAG=${EFFECTIVE##*/}
|
||||
VER=${TAG#v}
|
||||
case "$VER" in
|
||||
[0-9]*.[0-9]*) VERSION="$VER" ;;
|
||||
*)
|
||||
VERSION="$FALLBACK_VERSION"
|
||||
echo "WARNING: could not resolve latest release; using fallback $VERSION" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
|
||||
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
|
||||
|
||||
|
||||
@@ -21,14 +21,43 @@ sudo bash install.sh
|
||||
**soundtouch-player** (browser control panel):
|
||||
|
||||
```bash
|
||||
curl -fsSL -o install-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
|
||||
sudo bash install-web.sh
|
||||
curl -fsSL -o install-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
|
||||
sudo bash install-player.sh
|
||||
```
|
||||
|
||||
Pass a version tag as the first argument to pin a specific release:
|
||||
Both install the **latest release** by default (resolved from GitHub's
|
||||
`releases/latest` redirect). Pass a version tag as the first argument to pin a
|
||||
specific release:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.107.0
|
||||
sudo bash install-web.sh v0.107.0
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install-player.sh v0.111.3
|
||||
```
|
||||
|
||||
## Removal
|
||||
|
||||
Matching uninstallers reverse each installer (stop and disable the service,
|
||||
remove the unit, binary, and config). You can also remove things by hand — see
|
||||
the [full guide](../../docs/content/docs/guides/RASPBERRY-PI.md) for the manual
|
||||
commands.
|
||||
|
||||
```bash
|
||||
# soundtouch-service (keeps the data directory unless you pass --purge):
|
||||
curl -fsSL -o uninstall.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall.sh
|
||||
sudo bash uninstall.sh
|
||||
|
||||
# soundtouch-player:
|
||||
curl -fsSL -o uninstall-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-player.sh
|
||||
sudo bash uninstall-player.sh
|
||||
|
||||
# soundtouch-web (deprecated; switch to soundtouch-player afterwards):
|
||||
curl -fsSL -o uninstall-web.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-web.sh
|
||||
sudo bash uninstall-web.sh
|
||||
```
|
||||
|
||||
The shared `soundtouch:soundtouch` user/group is removed only once no other
|
||||
`soundtouch-*` install remains on the host.
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.107.0 \
|
||||
# VERSION=v0.111.3 \
|
||||
# HTTP_PORT=8081 \
|
||||
# bash install-player.sh
|
||||
#
|
||||
@@ -21,7 +21,7 @@ set -euo pipefail
|
||||
# bash install-player.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install-player.sh v0.107.0
|
||||
# sudo bash install-player.sh v0.111.3
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -32,11 +32,16 @@ set -euo pipefail
|
||||
# - Safe to re-run; it will update the binary, env file, and unit and restart.
|
||||
# ==============================================================================
|
||||
|
||||
VERSION="${1:-${VERSION:-v0.107.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
# Release to install. Empty means "resolve the latest release" (see
|
||||
# resolve_version). Pass a tag/number as $1 or VERSION=... to pin a release.
|
||||
VERSION="${1:-${VERSION:-}}"
|
||||
# Normalize version prefix for an explicitly provided version.
|
||||
if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-player}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-player}"
|
||||
|
||||
@@ -157,6 +162,40 @@ download_binary() {
|
||||
log "Installed binary to ${BIN_PATH}"
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
# When no explicit version was given, resolve the latest release tag by
|
||||
# following the documented stable redirect:
|
||||
# https://github.com/<owner>/<repo>/releases/latest
|
||||
# which 302-redirects to .../releases/tag/vX.Y.Z. We read the final URL and
|
||||
# take the tag from it. Falls back to FALLBACK_VERSION on any failure
|
||||
# (offline, rate-limited, no usable curl/wget).
|
||||
if [[ -n "$VERSION" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local latest_url="https://github.com/${GH_REPO}/releases/latest"
|
||||
log "Resolving latest release via ${latest_url}"
|
||||
|
||||
local effective="" tag=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
effective="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null)" || true
|
||||
else
|
||||
# wget: don't follow the redirect, read the Location header instead.
|
||||
effective="$(wget -S --max-redirect=0 -O /dev/null "$latest_url" 2>&1 \
|
||||
| awk 'tolower($1) ~ /location:/ {print $2}' | tr -d '\r' | tail -1)" || true
|
||||
fi
|
||||
tag="${effective##*/}"
|
||||
|
||||
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+ ]]; then
|
||||
[[ "$tag" =~ ^v ]] || tag="v${tag}"
|
||||
VERSION="$tag"
|
||||
log "Latest release is ${VERSION}"
|
||||
else
|
||||
VERSION="$FALLBACK_VERSION"
|
||||
log "⚠️ Could not resolve latest release; falling back to ${VERSION}"
|
||||
fi
|
||||
}
|
||||
|
||||
self_update() {
|
||||
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
|
||||
return
|
||||
@@ -324,6 +363,7 @@ main() {
|
||||
apt_install_if_missing curl
|
||||
fi
|
||||
|
||||
resolve_version
|
||||
self_update "$@"
|
||||
|
||||
ensure_user_group
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# DEPRECATED: soundtouch-web has been renamed to soundtouch-player.
|
||||
# This installer still works (the soundtouch-web release asset is published as
|
||||
# a transitional alias) but will be removed in a future release. Please switch
|
||||
# to install-player.sh, which installs the soundtouch-player binary/service.
|
||||
# ==============================================================================
|
||||
echo "NOTICE: soundtouch-web has been renamed to soundtouch-player." >&2
|
||||
echo " This installer (install-web.sh) is deprecated and will be removed" >&2
|
||||
echo " in a future release; please switch to install-player.sh." >&2
|
||||
echo >&2
|
||||
|
||||
# ==============================================================================
|
||||
# Bose-SoundTouch soundtouch-web installer (systemd, headless)
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash install-web.sh [vX.Y.Z]
|
||||
#
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.107.0 \
|
||||
# HTTP_PORT=8081 \
|
||||
# bash install-web.sh
|
||||
#
|
||||
# # With an AfterTouch service link for TTS (HTTPS + self-signed CA):
|
||||
# sudo \
|
||||
# SERVICE_URL=https://soundtouch.local \
|
||||
# SERVICE_CA=/var/lib/soundtouch-service/certs/ca.crt \
|
||||
# bash install-web.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install-web.sh v0.107.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
# - soundtouch-web is stateless (no data directory) — it is safe to stop/restart freely.
|
||||
# - Default port is 8080 (unprivileged — no special capabilities needed).
|
||||
# - If soundtouch-service is already installed, soundtouch-web reuses the
|
||||
# existing soundtouch:soundtouch user/group.
|
||||
# - Safe to re-run; it will update the binary, env file, and unit and restart.
|
||||
# ==============================================================================
|
||||
|
||||
VERSION="${1:-${VERSION:-v0.107.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
fi
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-web}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-web}"
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-web}"
|
||||
ENV_FILE="${ENV_FILE:-$CONFIG_DIR/soundtouch-web.env}"
|
||||
|
||||
SERVICE_USER="${SERVICE_USER:-soundtouch}"
|
||||
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
|
||||
|
||||
# Port (unprivileged — no CAP_NET_BIND_SERVICE needed)
|
||||
HTTP_PORT="${HTTP_PORT:-8080}"
|
||||
|
||||
# Optional discovery / device config
|
||||
BIND_ADDR="${BIND_ADDR:-}"
|
||||
DISCOVERY_INTERFACE="${DISCOVERY_INTERFACE:-}"
|
||||
SOUNDTOUCH_DEVICES="${SOUNDTOUCH_DEVICES:-}"
|
||||
|
||||
# Optional AfterTouch service link (needed for TTS / "Speak").
|
||||
# SERVICE_URL: base URL of soundtouch-service, e.g. https://soundtouch.local
|
||||
# SERVICE_CA: path to the service CA cert when it serves HTTPS with its own
|
||||
# self-signed certificate, e.g. /var/lib/soundtouch-service/certs/ca.crt
|
||||
SERVICE_URL="${SERVICE_URL:-}"
|
||||
SERVICE_CA="${SERVICE_CA:-}"
|
||||
|
||||
# Override if you want to force a specific asset suffix:
|
||||
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
|
||||
ARCH_ASSET="${ARCH_ASSET:-}"
|
||||
|
||||
# Internal variables
|
||||
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || echo "$0")"
|
||||
IS_SELF_UPDATE="${IS_SELF_UPDATE:-false}"
|
||||
|
||||
log() { printf "\n==> %s\n" "$*"; }
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
need_root() {
|
||||
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
|
||||
}
|
||||
|
||||
ensure_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
apt_install_if_missing() {
|
||||
log "Installing dependencies: $*"
|
||||
apt-get update -y
|
||||
apt-get install -y --no-install-recommends "$@"
|
||||
}
|
||||
|
||||
detect_arch_asset() {
|
||||
local m
|
||||
m="$(uname -m)"
|
||||
|
||||
case "$m" in
|
||||
armv7l|armv6l)
|
||||
echo "linux-armv7"
|
||||
;;
|
||||
aarch64)
|
||||
echo "linux-arm64"
|
||||
;;
|
||||
x86_64|amd64)
|
||||
echo "linux-amd64"
|
||||
;;
|
||||
*)
|
||||
die "Unsupported architecture from uname -m: $m (set ARCH_ASSET manually)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-web-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
ensure_user_group() {
|
||||
log "Ensuring service user/group exist: ${SERVICE_USER}:${SERVICE_GROUP}"
|
||||
if ! getent group "${SERVICE_GROUP}" >/dev/null; then
|
||||
groupadd --system "${SERVICE_GROUP}"
|
||||
fi
|
||||
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
useradd --system \
|
||||
--no-create-home \
|
||||
--shell /usr/sbin/nologin \
|
||||
--gid "${SERVICE_GROUP}" \
|
||||
"${SERVICE_USER}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
log "Creating config directory"
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
chmod 0755 "${CONFIG_DIR}"
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local asset url tmp=""
|
||||
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
|
||||
url="$(download_url_for "$asset")"
|
||||
|
||||
log "Downloading binary for ${asset}: ${url}"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp}"' EXIT
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL -o "${tmp}/soundtouch-web" "${url}"
|
||||
else
|
||||
wget -qO "${tmp}/soundtouch-web" "${url}"
|
||||
fi
|
||||
|
||||
chmod +x "${tmp}/soundtouch-web"
|
||||
|
||||
if [[ -f "${BIN_PATH}" ]]; then
|
||||
log "Backing up existing binary to ${BIN_PATH}.old"
|
||||
cp -p "${BIN_PATH}" "${BIN_PATH}.old"
|
||||
fi
|
||||
|
||||
install -m 0755 "${tmp}/soundtouch-web" "${BIN_PATH}"
|
||||
log "Installed binary to ${BIN_PATH}"
|
||||
}
|
||||
|
||||
self_update() {
|
||||
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local url="https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/${VERSION}/scripts/raspberry-pi/install-web.sh"
|
||||
local tmp_script="/tmp/soundtouch-web-install-${VERSION}.sh"
|
||||
|
||||
log "Checking for installer updates for ${VERSION}..."
|
||||
log "URL: ${url}"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if ! curl -fsSL -o "${tmp_script}" "${url}"; then
|
||||
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
|
||||
return
|
||||
fi
|
||||
else
|
||||
if ! wget -qO "${tmp_script}" "${url}"; then
|
||||
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if diff -q "${SCRIPT_PATH}" "${tmp_script}" >/dev/null 2>&1; then
|
||||
log "Installer is already up to date."
|
||||
rm -f "${tmp_script}"
|
||||
return
|
||||
fi
|
||||
|
||||
log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..."
|
||||
install -m 0755 "${tmp_script}" "${SCRIPT_PATH}"
|
||||
rm -f "${tmp_script}"
|
||||
|
||||
export IS_SELF_UPDATE="true"
|
||||
export VERSION HTTP_PORT BIND_ADDR DISCOVERY_INTERFACE SOUNDTOUCH_DEVICES
|
||||
export SERVICE_URL SERVICE_CA
|
||||
export BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
|
||||
|
||||
exec "${SCRIPT_PATH}" "$@"
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
log "Updating env file: ${ENV_FILE}"
|
||||
|
||||
local vars=(
|
||||
"PORT=${HTTP_PORT}"
|
||||
"BIND_ADDR=${BIND_ADDR}"
|
||||
"DISCOVERY_INTERFACE=${DISCOVERY_INTERFACE}"
|
||||
"SOUNDTOUCH_DEVICES=${SOUNDTOUCH_DEVICES}"
|
||||
"SERVICE_URL=${SERVICE_URL}"
|
||||
"SERVICE_CA=${SERVICE_CA}"
|
||||
)
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
for entry in "${vars[@]}"; do
|
||||
echo "${entry}" >> "${ENV_FILE}"
|
||||
done
|
||||
else
|
||||
for entry in "${vars[@]}"; do
|
||||
local key="${entry%%=*}"
|
||||
local val="${entry#*=}"
|
||||
if ! grep -q "^${key}=" "${ENV_FILE}"; then
|
||||
echo "${key}=${val}" >> "${ENV_FILE}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
chmod 0640 "${ENV_FILE}"
|
||||
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
|
||||
}
|
||||
|
||||
write_systemd_unit() {
|
||||
log "Writing systemd unit: /etc/systemd/system/${SERVICE_NAME}.service"
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
[Unit]
|
||||
Description=Bose SoundTouch Web UI
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_GROUP}
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${BIN_PATH}
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
reload_enable_start() {
|
||||
log "Reloading systemd, enabling and starting service"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}.service"
|
||||
systemctl restart "${SERVICE_NAME}.service"
|
||||
|
||||
log "Verifying service health..."
|
||||
local health_url="http://localhost:${HTTP_PORT}/health"
|
||||
local max_retries=5
|
||||
local count=0
|
||||
local success=false
|
||||
|
||||
while [[ $count -lt $max_retries ]]; do
|
||||
if curl -fs "$health_url" >/dev/null 2>&1; then
|
||||
success=true
|
||||
break
|
||||
fi
|
||||
echo "Waiting for service to respond at $health_url... ($((count+1))/$max_retries)"
|
||||
sleep 2
|
||||
count=$((count+1))
|
||||
done
|
||||
|
||||
if [[ "$success" = true ]]; then
|
||||
log "✅ soundtouch-web is healthy and responding!"
|
||||
else
|
||||
log "⚠️ Service started but did not respond at $health_url within timeout."
|
||||
log "Check logs with: journalctl -u ${SERVICE_NAME}.service -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
show_status() {
|
||||
log "Service status"
|
||||
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
|
||||
|
||||
log "Listening socket (:${HTTP_PORT})"
|
||||
ss -tulpn | grep -E ":${HTTP_PORT}\b" || true
|
||||
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
|
||||
log "Firewall check (UFW is active)"
|
||||
if ! ufw status | grep -qE "${HTTP_PORT}.*ALLOW"; then
|
||||
log "⚠️ UFW is active but port ${HTTP_PORT} might be blocked."
|
||||
log "Run: sudo ufw allow ${HTTP_PORT}/tcp"
|
||||
else
|
||||
log "✅ UFW rule for port ${HTTP_PORT} appears to be in place."
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Open in your browser:
|
||||
http://<pi-ip>:${HTTP_PORT}/
|
||||
|
||||
soundtouch-web is a control panel — you can stop it when not in use:
|
||||
sudo systemctl stop ${SERVICE_NAME}
|
||||
sudo systemctl start ${SERVICE_NAME}
|
||||
|
||||
Logs:
|
||||
journalctl -u ${SERVICE_NAME}.service -e --no-pager
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
need_root
|
||||
ensure_cmd systemctl
|
||||
ensure_cmd ss
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
apt_install_if_missing curl
|
||||
fi
|
||||
|
||||
self_update "$@"
|
||||
|
||||
ensure_user_group
|
||||
ensure_dirs
|
||||
download_binary
|
||||
write_env_file
|
||||
write_systemd_unit
|
||||
reload_enable_start
|
||||
show_status
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.107.0 \
|
||||
# VERSION=v0.111.3 \
|
||||
# HOSTNAME_FQDN=soundtouch.local \
|
||||
# HTTP_PORT=80 \
|
||||
# HTTPS_PORT=443 \
|
||||
@@ -18,7 +18,7 @@ set -euo pipefail
|
||||
# bash install.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install.sh v0.107.0
|
||||
# sudo bash install.sh v0.111.3
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -28,11 +28,16 @@ set -euo pipefail
|
||||
# - Safe to re-run; it will update binary/config/unit and restart the service.
|
||||
# ==============================================================================
|
||||
|
||||
VERSION="${1:-${VERSION:-v0.107.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
# Release to install. Empty means "resolve the latest release" (see
|
||||
# resolve_version). Pass a tag/number as $1 or VERSION=... to pin a release.
|
||||
VERSION="${1:-${VERSION:-}}"
|
||||
# Normalize version prefix for an explicitly provided version.
|
||||
if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
|
||||
|
||||
@@ -117,7 +122,7 @@ detect_arch_asset() {
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
# Release asset pattern used by you earlier:
|
||||
# soundtouch-service-v0.107.0-linux-armv7
|
||||
# soundtouch-service-v0.111.3-linux-armv7
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
@@ -176,6 +181,40 @@ download_binary() {
|
||||
log "Installed binary to ${BIN_PATH}"
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
# When no explicit version was given, resolve the latest release tag by
|
||||
# following the documented stable redirect:
|
||||
# https://github.com/<owner>/<repo>/releases/latest
|
||||
# which 302-redirects to .../releases/tag/vX.Y.Z. We read the final URL and
|
||||
# take the tag from it. Falls back to FALLBACK_VERSION on any failure
|
||||
# (offline, rate-limited, no usable curl/wget).
|
||||
if [[ -n "$VERSION" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local latest_url="https://github.com/${GH_REPO}/releases/latest"
|
||||
log "Resolving latest release via ${latest_url}"
|
||||
|
||||
local effective="" tag=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
effective="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null)" || true
|
||||
else
|
||||
# wget: don't follow the redirect, read the Location header instead.
|
||||
effective="$(wget -S --max-redirect=0 -O /dev/null "$latest_url" 2>&1 \
|
||||
| awk 'tolower($1) ~ /location:/ {print $2}' | tr -d '\r' | tail -1)" || true
|
||||
fi
|
||||
tag="${effective##*/}"
|
||||
|
||||
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+ ]]; then
|
||||
[[ "$tag" =~ ^v ]] || tag="v${tag}"
|
||||
VERSION="$tag"
|
||||
log "Latest release is ${VERSION}"
|
||||
else
|
||||
VERSION="$FALLBACK_VERSION"
|
||||
log "⚠️ Could not resolve latest release; falling back to ${VERSION}"
|
||||
fi
|
||||
}
|
||||
|
||||
self_update() {
|
||||
# If we are already a self-update re-exec, don't do it again
|
||||
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
|
||||
@@ -364,6 +403,7 @@ main() {
|
||||
apt_install_if_missing curl
|
||||
fi
|
||||
|
||||
resolve_version
|
||||
self_update "$@"
|
||||
|
||||
ensure_user_group
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# Bose-SoundTouch soundtouch-player uninstaller (systemd, headless)
|
||||
#
|
||||
# Reverses scripts/raspberry-pi/install-player.sh: stops and disables the
|
||||
# systemd service, removes the unit, binary, and config directory.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash uninstall-player.sh
|
||||
#
|
||||
# Notes:
|
||||
# - soundtouch-player is stateless (no data directory) — nothing to preserve.
|
||||
# - The shared soundtouch:soundtouch user/group is removed only when no other
|
||||
# soundtouch-{service,player,web} install remains on this host.
|
||||
# - Safe to re-run; every step tolerates already-missing pieces.
|
||||
# ==============================================================================
|
||||
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-player}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-player}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-player}"
|
||||
SERVICE_USER="${SERVICE_USER:-soundtouch}"
|
||||
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
|
||||
|
||||
log() { printf "\n==> %s\n" "$*"; }
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
need_root() {
|
||||
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
|
||||
}
|
||||
|
||||
ensure_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
stop_remove_service() {
|
||||
log "Stopping and disabling ${SERVICE_NAME}.service"
|
||||
systemctl disable --now "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
|
||||
local unit="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
if [[ -f "${unit}" ]]; then
|
||||
log "Removing systemd unit: ${unit}"
|
||||
rm -f "${unit}"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
}
|
||||
|
||||
remove_binary() {
|
||||
if [[ -e "${BIN_PATH}" || -e "${BIN_PATH}.old" ]]; then
|
||||
log "Removing binary: ${BIN_PATH} (and ${BIN_PATH}.old)"
|
||||
rm -f "${BIN_PATH}" "${BIN_PATH}.old"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_config() {
|
||||
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||
log "Removing config directory: ${CONFIG_DIR}"
|
||||
rm -rf "${CONFIG_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove the shared soundtouch:soundtouch user/group only when no other
|
||||
# soundtouch-{service,player,web} install remains on this host.
|
||||
remove_user_group_if_unused() {
|
||||
local n
|
||||
for n in service player web; do
|
||||
if [[ -f "/etc/systemd/system/soundtouch-${n}.service" ]] || \
|
||||
[[ -e "/usr/local/bin/soundtouch-${n}" ]]; then
|
||||
log "Keeping ${SERVICE_USER}:${SERVICE_GROUP} — still used by soundtouch-${n}."
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
if id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
log "No other soundtouch installs remain; removing user ${SERVICE_USER}"
|
||||
userdel "${SERVICE_USER}" 2>/dev/null || true
|
||||
fi
|
||||
if getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
|
||||
groupdel "${SERVICE_GROUP}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
need_root
|
||||
ensure_cmd systemctl
|
||||
|
||||
stop_remove_service
|
||||
remove_binary
|
||||
remove_config
|
||||
remove_user_group_if_unused
|
||||
|
||||
log "✅ soundtouch-player has been removed."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# Bose-SoundTouch soundtouch-web uninstaller (systemd, headless)
|
||||
#
|
||||
# soundtouch-web was renamed to soundtouch-player. This uninstaller removes a
|
||||
# leftover soundtouch-web install created by the (now deprecated) install-web.sh:
|
||||
# it stops and disables the systemd service, removes the unit, binary, and config
|
||||
# directory.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash uninstall-web.sh
|
||||
#
|
||||
# Notes:
|
||||
# - soundtouch-web is stateless (no data directory) — nothing to preserve.
|
||||
# - The shared soundtouch:soundtouch user/group is removed only when no other
|
||||
# soundtouch-{service,player,web} install remains on this host.
|
||||
# - Safe to re-run; every step tolerates already-missing pieces.
|
||||
# ==============================================================================
|
||||
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-web}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-web}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-web}"
|
||||
SERVICE_USER="${SERVICE_USER:-soundtouch}"
|
||||
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
|
||||
|
||||
log() { printf "\n==> %s\n" "$*"; }
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
need_root() {
|
||||
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
|
||||
}
|
||||
|
||||
ensure_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
stop_remove_service() {
|
||||
log "Stopping and disabling ${SERVICE_NAME}.service"
|
||||
systemctl disable --now "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
|
||||
local unit="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
if [[ -f "${unit}" ]]; then
|
||||
log "Removing systemd unit: ${unit}"
|
||||
rm -f "${unit}"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
}
|
||||
|
||||
remove_binary() {
|
||||
if [[ -e "${BIN_PATH}" || -e "${BIN_PATH}.old" ]]; then
|
||||
log "Removing binary: ${BIN_PATH} (and ${BIN_PATH}.old)"
|
||||
rm -f "${BIN_PATH}" "${BIN_PATH}.old"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_config() {
|
||||
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||
log "Removing config directory: ${CONFIG_DIR}"
|
||||
rm -rf "${CONFIG_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove the shared soundtouch:soundtouch user/group only when no other
|
||||
# soundtouch-{service,player,web} install remains on this host.
|
||||
remove_user_group_if_unused() {
|
||||
local n
|
||||
for n in service player web; do
|
||||
if [[ -f "/etc/systemd/system/soundtouch-${n}.service" ]] || \
|
||||
[[ -e "/usr/local/bin/soundtouch-${n}" ]]; then
|
||||
log "Keeping ${SERVICE_USER}:${SERVICE_GROUP} — still used by soundtouch-${n}."
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
if id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
log "No other soundtouch installs remain; removing user ${SERVICE_USER}"
|
||||
userdel "${SERVICE_USER}" 2>/dev/null || true
|
||||
fi
|
||||
if getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
|
||||
groupdel "${SERVICE_GROUP}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
need_root
|
||||
ensure_cmd systemctl
|
||||
|
||||
stop_remove_service
|
||||
remove_binary
|
||||
remove_config
|
||||
remove_user_group_if_unused
|
||||
|
||||
log "✅ soundtouch-web has been removed."
|
||||
cat <<'EOF'
|
||||
|
||||
soundtouch-web is the old name for soundtouch-player. To install the current
|
||||
control panel instead:
|
||||
|
||||
curl -fsSL -o install-player.sh \
|
||||
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
|
||||
sudo bash install-player.sh
|
||||
EOF
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# Bose-SoundTouch soundtouch-service uninstaller (systemd, headless)
|
||||
#
|
||||
# Reverses scripts/raspberry-pi/install.sh: stops and disables the systemd
|
||||
# service, removes the unit, binary, and config directory.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash uninstall.sh # remove service, KEEP the data directory
|
||||
# sudo PURGE_DATA=true bash uninstall.sh # also delete the data directory
|
||||
# sudo bash uninstall.sh --purge # same as PURGE_DATA=true
|
||||
#
|
||||
# Notes:
|
||||
# - The data directory (${DATA_DIR}) holds your datastore: presets, device
|
||||
# registrations, and certificates. It is PRESERVED by default; deleting it is
|
||||
# opt-in via PURGE_DATA=true (or --purge).
|
||||
# - The shared soundtouch:soundtouch user/group is removed only when no other
|
||||
# soundtouch-{service,player,web} install remains on this host.
|
||||
# - Safe to re-run; every step tolerates already-missing pieces.
|
||||
# ==============================================================================
|
||||
|
||||
PURGE_DATA="${PURGE_DATA:-false}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--purge) PURGE_DATA="true" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-service}"
|
||||
DATA_DIR="${DATA_DIR:-/var/lib/soundtouch-service}"
|
||||
SERVICE_USER="${SERVICE_USER:-soundtouch}"
|
||||
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
|
||||
|
||||
log() { printf "\n==> %s\n" "$*"; }
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
need_root() {
|
||||
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
|
||||
}
|
||||
|
||||
ensure_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
stop_remove_service() {
|
||||
log "Stopping and disabling ${SERVICE_NAME}.service"
|
||||
systemctl disable --now "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
|
||||
local unit="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
if [[ -f "${unit}" ]]; then
|
||||
log "Removing systemd unit: ${unit}"
|
||||
rm -f "${unit}"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
}
|
||||
|
||||
remove_binary() {
|
||||
if [[ -e "${BIN_PATH}" || -e "${BIN_PATH}.old" ]]; then
|
||||
log "Removing binary: ${BIN_PATH} (and ${BIN_PATH}.old)"
|
||||
rm -f "${BIN_PATH}" "${BIN_PATH}.old"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_config() {
|
||||
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||
log "Removing config directory: ${CONFIG_DIR}"
|
||||
rm -rf "${CONFIG_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
handle_data_dir() {
|
||||
if [[ ! -d "${DATA_DIR}" ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ "${PURGE_DATA}" == "true" ]]; then
|
||||
log "Removing data directory: ${DATA_DIR}"
|
||||
rm -rf "${DATA_DIR}"
|
||||
else
|
||||
log "Preserving data directory: ${DATA_DIR}"
|
||||
echo " This holds your datastore (presets, device registrations, certs)."
|
||||
echo " To delete it as well, run: sudo rm -rf ${DATA_DIR}"
|
||||
echo " (or re-run this uninstaller with --purge / PURGE_DATA=true)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove the shared soundtouch:soundtouch user/group only when no other
|
||||
# soundtouch-{service,player,web} install remains on this host.
|
||||
remove_user_group_if_unused() {
|
||||
local n
|
||||
for n in service player web; do
|
||||
if [[ -f "/etc/systemd/system/soundtouch-${n}.service" ]] || \
|
||||
[[ -e "/usr/local/bin/soundtouch-${n}" ]]; then
|
||||
log "Keeping ${SERVICE_USER}:${SERVICE_GROUP} — still used by soundtouch-${n}."
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
if id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
log "No other soundtouch installs remain; removing user ${SERVICE_USER}"
|
||||
userdel "${SERVICE_USER}" 2>/dev/null || true
|
||||
fi
|
||||
if getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
|
||||
groupdel "${SERVICE_GROUP}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
need_root
|
||||
ensure_cmd systemctl
|
||||
|
||||
stop_remove_service
|
||||
remove_binary
|
||||
remove_config
|
||||
handle_data_dir
|
||||
remove_user_group_if_unused
|
||||
|
||||
log "✅ soundtouch-service has been removed."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user