mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1288a619f7 | ||
|
|
ec8bbb2f86 | ||
|
|
e75e2bea0c | ||
|
|
dd5aa2ad53 | ||
|
|
aced0f3f81 | ||
|
|
a886518cad | ||
|
|
dc81b0aa81 | ||
|
|
c648027735 | ||
|
|
fced88a8a6 | ||
|
|
0ee673c097 | ||
|
|
395b2fec8e | ||
|
|
be7e44e14b | ||
|
|
a87783d8c6 | ||
|
|
be017440b7 | ||
|
|
10de011c18 | ||
|
|
f7b74db3ea | ||
|
|
72d75133c4 | ||
|
|
e4c12471b4 | ||
|
|
3329149282 | ||
|
|
523ff0eb17 | ||
|
|
025e15d65c | ||
|
|
7d140b3e2a | ||
|
|
69210638e5 | ||
|
|
6aef2b807d | ||
|
|
95f5e9c831 | ||
|
|
7337296ae9 | ||
|
|
92a5d3592c | ||
|
|
2f04af872b | ||
|
|
9479d6d11d | ||
|
|
f687ba0d82 | ||
|
|
ab2bf0731a | ||
|
|
cafaba1be0 | ||
|
|
93082d2cdc | ||
|
|
087006c483 | ||
|
|
b7013a5ec8 | ||
|
|
7d76b3fab2 | ||
|
|
6ca206053f | ||
|
|
090eb162fb | ||
|
|
972824e07f | ||
|
|
1e2148d53b | ||
|
|
9a070da1ef | ||
|
|
d4b518da23 | ||
|
|
89bafd97b6 | ||
|
|
d616bc09fd | ||
|
|
8af60c7e4b | ||
|
|
8a21db3517 | ||
|
|
742484568e | ||
|
|
ed2d8680e4 | ||
|
|
6dc8c23f04 | ||
|
|
fa57ee9574 | ||
|
|
e438db05d9 | ||
|
|
8c02a009dc | ||
|
|
f20cfcb319 | ||
|
|
a453059d6d | ||
|
|
505e6dd760 | ||
|
|
735187cae8 | ||
|
|
e8622cc382 | ||
|
|
c59052bdb4 | ||
|
|
15a6c4b0a0 | ||
|
|
ae3a3765db | ||
|
|
59019cf55c | ||
|
|
5e612e57ec | ||
|
|
02026a9f3a | ||
|
|
aaf067088a | ||
|
|
358ea18138 | ||
|
|
0c5c1803a5 | ||
|
|
cdf80a793e | ||
|
|
b511e052e2 | ||
|
|
b7197a8679 | ||
|
|
5bfc24b7fb | ||
|
|
1e61adbb46 | ||
|
|
b8ab4b5723 | ||
|
|
5da7e001b2 | ||
|
|
5269c05e56 | ||
|
|
d7a15c4dbe | ||
|
|
701889076d | ||
|
|
3acc983183 | ||
|
|
7d40c61cad | ||
|
|
93cfd9dbbc | ||
|
|
e084f8db1f | ||
|
|
a19d34b55e | ||
|
|
dcf2e29c16 | ||
|
|
9be1c7d588 | ||
|
|
133c07fefa | ||
|
|
ef90b4e848 | ||
|
|
c8ef1a9de4 | ||
|
|
e47fa4c92c | ||
|
|
1a39c14b35 | ||
|
|
c9f648096e | ||
|
|
408753c33e | ||
|
|
dff060565e | ||
|
|
b5df6ab91f | ||
|
|
c7e055eb51 | ||
|
|
00d5bfcb69 | ||
|
|
0186fead6e | ||
|
|
bf4ead033c | ||
|
|
5eee3ec31e | ||
|
|
30e09ab7a0 | ||
|
|
e429d92124 | ||
|
|
f3162b7ed9 | ||
|
|
8094ac70bd | ||
|
|
11919f7fa9 | ||
|
|
c560d399b5 | ||
|
|
059498b16e | ||
|
|
1281af7f6f | ||
|
|
44e48f7307 | ||
|
|
e65b1ac110 | ||
|
|
6504c301f6 | ||
|
|
210fd587de | ||
|
|
79ca666785 | ||
|
|
5b9ab48897 | ||
|
|
285f85efa2 | ||
|
|
dd6b3941d4 | ||
|
|
0d5746a6a5 | ||
|
|
7ec4ee67af | ||
|
|
1ec3c6950c |
@@ -0,0 +1,33 @@
|
||||
# .dockerignore
|
||||
|
||||
# Exclude large firmware files and archives
|
||||
firmware/
|
||||
data/
|
||||
|
||||
# Exclude local build artifacts
|
||||
build/
|
||||
soundtouch-cli
|
||||
soundtouch-service
|
||||
|
||||
# Exclude Go specific files that aren't needed for build context
|
||||
# (go.mod and go.sum ARE needed, but other local stuff isn't)
|
||||
.cache/
|
||||
vendor/
|
||||
|
||||
# Exclude IDE and system files
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# Exclude Git history
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Exclude documentation and other non-essential files for the binary build
|
||||
docs/
|
||||
examples/
|
||||
scripts/
|
||||
CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -1,6 +1,9 @@
|
||||
# Bose SoundTouch Configuration
|
||||
# Copy this file to .env and customize for your setup
|
||||
|
||||
# Docker/Service Settings
|
||||
SOUNDTOUCH_HOSTNAME=soundtouch.local
|
||||
|
||||
# Discovery Settings
|
||||
DISCOVERY_TIMEOUT=5s
|
||||
UPNP_ENABLED=true
|
||||
@@ -38,3 +41,20 @@ PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.
|
||||
# Alternative format examples:
|
||||
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
|
||||
|
||||
# Spotify Integration
|
||||
# Create an app at https://developer.spotify.com/dashboard
|
||||
# SPOTIFY_CLIENT_ID=your_client_id
|
||||
# SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
# Auth confirmation url using GET, works in browsers
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/callback
|
||||
# Auth confirmation url using POST, works with the ueberboese-app (https://github.com/julius-d/ueberboese-app)
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/confirm
|
||||
|
||||
# Management API Authentication
|
||||
# Protects /mgmt/* endpoints (Spotify token access, account management)
|
||||
MGMT_USERNAME=admin
|
||||
MGMT_PASSWORD=change_me!
|
||||
|
||||
# External base URL (required when behind a reverse proxy for OAuth callbacks)
|
||||
# BASE_URL=https://your-server.example.com
|
||||
|
||||
@@ -77,3 +77,24 @@ updates:
|
||||
- "*scan*"
|
||||
- "securecodewarrior/*"
|
||||
- "codecov/*"
|
||||
|
||||
# Docker dependency updates
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "gesellix"
|
||||
assignees:
|
||||
- "gesellix"
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
@@ -149,8 +149,8 @@ jobs:
|
||||
# Check that all documented endpoints exist in code
|
||||
echo "Validating API documentation consistency..."
|
||||
|
||||
# Extract endpoint patterns from cookbook
|
||||
if [ -f "docs/API-COOKBOOK.md" ]; then
|
||||
# Check API cookbook
|
||||
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
|
||||
echo "✓ API Cookbook exists"
|
||||
else
|
||||
echo "✗ API Cookbook missing"
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Check getting started guide
|
||||
if [ -f "docs/GETTING-STARTED.md" ]; then
|
||||
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
|
||||
echo "✓ Getting Started guide exists"
|
||||
else
|
||||
echo "✗ Getting Started guide missing"
|
||||
@@ -218,10 +218,51 @@ jobs:
|
||||
go run test_import.go
|
||||
rm test_import.go
|
||||
|
||||
docker:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
notify:
|
||||
name: Notify Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, lint, build, security, docs]
|
||||
needs: [test, lint, build, security, docs, docker]
|
||||
if: always()
|
||||
permissions:
|
||||
statuses: write
|
||||
@@ -234,7 +275,8 @@ jobs:
|
||||
"${{ needs.lint.result }}" == "success" && \
|
||||
"${{ needs.build.result }}" == "success" && \
|
||||
"${{ needs.security.result }}" == "success" && \
|
||||
"${{ needs.docs.result }}" == "success" ]]; then
|
||||
"${{ needs.docs.result }}" == "success" && \
|
||||
"${{ needs.docker.result }}" == "success" ]]; then
|
||||
echo "✅ All CI checks passed!"
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -244,6 +286,7 @@ jobs:
|
||||
echo "Build: ${{ needs.build.result }}"
|
||||
echo "Security: ${{ needs.security.result }}"
|
||||
echo "Docs: ${{ needs.docs.result }}"
|
||||
echo "Docker: ${{ needs.docker.result }}"
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
id: status
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Deploy Documentation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+114
-77
@@ -13,6 +13,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GO_VERSION_FILE: "go.mod"
|
||||
@@ -113,100 +114,90 @@ jobs:
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Build binary
|
||||
- name: Build binaries
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
# Determine output filename
|
||||
BINARY_NAME="soundtouch-cli"
|
||||
# Common variables
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
|
||||
if [[ "${{ matrix.goarm }}" != "" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
fi
|
||||
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
else
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
fi
|
||||
# Function to build a binary
|
||||
build_binary() {
|
||||
local BINARY_NAME=$1
|
||||
local CMD_PATH=$2
|
||||
local OUTPUT_NAME
|
||||
|
||||
echo "Building: $OUTPUT_NAME"
|
||||
# Ensure build directory exists
|
||||
mkdir -p build
|
||||
|
||||
# Debug: Show current state
|
||||
echo "Working directory: $(pwd)"
|
||||
echo "Go version: $(go version)"
|
||||
echo "Files before build:"
|
||||
ls -la
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
else
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
fi
|
||||
|
||||
# Debug: Show Go cache and module cache
|
||||
echo "Go build cache location: $(go env GOCACHE)"
|
||||
echo "Go module cache location: $(go env GOMODCACHE)"
|
||||
echo "Go build cache contents:"
|
||||
ls -la "$(go env GOCACHE)" 2>/dev/null || echo "Cache directory not accessible"
|
||||
echo "Go module cache contents (top level):"
|
||||
ls -la "$(go env GOMODCACHE)" 2>/dev/null || echo "Module cache directory not accessible"
|
||||
echo "Building $BINARY_NAME: $OUTPUT_NAME"
|
||||
|
||||
# Ensure clean build environment
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
go clean -cache
|
||||
# Ensure clean build environment for this binary
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
# Build with optimizations (using debug.BuildInfo for version info)
|
||||
if ! go build \
|
||||
-ldflags="-s -w" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
./cmd/soundtouch-cli; then
|
||||
echo "❌ Build failed"
|
||||
echo "Files after failed build:"
|
||||
ls -la
|
||||
exit 1
|
||||
fi
|
||||
if ! go build \
|
||||
-ldflags="-s -w" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
echo "❌ Build failed for $BINARY_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Debug: Show post-build state
|
||||
echo "Files after successful build:"
|
||||
ls -la
|
||||
# Verify binary was created
|
||||
ls -la "$OUTPUT_NAME"
|
||||
echo "$BINARY_NAME=$OUTPUT_NAME" >> $GITHUB_OUTPUT
|
||||
}
|
||||
|
||||
# Verify binary was created and is executable
|
||||
ls -la "$OUTPUT_NAME"
|
||||
file "$OUTPUT_NAME"
|
||||
# Build CLI
|
||||
build_binary "soundtouch-cli" "./cmd/soundtouch-cli"
|
||||
|
||||
echo "binary_name=$OUTPUT_NAME" >> $GITHUB_OUTPUT
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
id: build
|
||||
|
||||
- name: Generate individual checksum
|
||||
- name: Generate individual checksums
|
||||
run: |
|
||||
OUTPUT_NAME="${{ steps.build.outputs.binary_name }}"
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
|
||||
echo "Building checksums for: $OUTPUT_NAME"
|
||||
echo "Matrix: ${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
generate_checksums() {
|
||||
local FILE=$1
|
||||
echo "Building checksums for: $FILE"
|
||||
sha256sum "$FILE" > "${TEMP_DIR}/$(basename "$FILE").sha256"
|
||||
sha512sum "$FILE" > "${TEMP_DIR}/$(basename "$FILE").sha512"
|
||||
mv "${TEMP_DIR}/$(basename "$FILE").sha256" "$FILE.sha256"
|
||||
mv "${TEMP_DIR}/$(basename "$FILE").sha512" "$FILE.sha512"
|
||||
}
|
||||
|
||||
# Generate checksums in temp directory first
|
||||
sha256sum "$OUTPUT_NAME" > "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha256"
|
||||
sha512sum "$OUTPUT_NAME" > "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha512"
|
||||
|
||||
# Move to final location atomically
|
||||
mv "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha256" "$OUTPUT_NAME.sha256"
|
||||
mv "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha512" "$OUTPUT_NAME.sha512"
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo "✅ Checksums generated successfully"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.build.outputs.binary_name }}
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
${{ steps.build.outputs.binary_name }}
|
||||
${{ steps.build.outputs.binary_name }}.sha256
|
||||
${{ steps.build.outputs.binary_name }}.sha512
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
@@ -215,9 +206,10 @@ jobs:
|
||||
needs: [validate, build]
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: binaries-*
|
||||
path: ./binaries
|
||||
|
||||
- name: Generate checksums
|
||||
@@ -226,13 +218,13 @@ jobs:
|
||||
|
||||
# Debug: Show the downloaded structure
|
||||
echo "📁 Downloaded artifact structure:"
|
||||
find . -type f -name "soundtouch-cli-*"
|
||||
ls -R
|
||||
|
||||
# Create a collection directory to avoid naming conflicts
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f -name "soundtouch-cli-*" -exec mv {} release-files/ \;
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -242,20 +234,20 @@ jobs:
|
||||
|
||||
# Debug: Show flattened structure
|
||||
echo "📁 Flattened structure:"
|
||||
ls -la soundtouch-cli-* || echo "No files found matching pattern"
|
||||
ls -la soundtouch-* || echo "No files found matching pattern"
|
||||
|
||||
# Generate combined checksums (exclude individual .sha256/.sha512 files)
|
||||
if ls soundtouch-cli-v* 1> /dev/null 2>&1; then
|
||||
if ls soundtouch-* 1> /dev/null 2>&1; then
|
||||
# Only checksum the actual binaries, not the .sha256/.sha512 files
|
||||
ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-* | 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=7 # Based on build matrix
|
||||
ACTUAL_COUNT=$(ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
EXPECTED_COUNT=14 # 7 platforms * 2 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
echo "❌ Expected $EXPECTED_COUNT binaries, found $ACTUAL_COUNT"
|
||||
@@ -369,19 +361,20 @@ jobs:
|
||||
- [Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Systematic issue resolution
|
||||
- [Deployment Guide](docs/DEPLOYMENT.md) - Production deployment examples (Docker, K8s, systemd)
|
||||
|
||||
## 🔧 CLI Tool
|
||||
## 🔧 CLI & Service Tools
|
||||
|
||||
Download the CLI tool for your platform from the assets below:
|
||||
Download the tools for your platform from the assets below:
|
||||
|
||||
### CLI Tool
|
||||
\`\`\`bash
|
||||
# Quick device discovery
|
||||
./soundtouch-cli -discover
|
||||
\`\`\`
|
||||
|
||||
# Get device information
|
||||
./soundtouch-cli -host 192.168.1.100 -info
|
||||
|
||||
# Monitor real-time events
|
||||
./soundtouch-cli -host 192.168.1.100 -nowplaying
|
||||
### SoundTouch Service
|
||||
\`\`\`bash
|
||||
# Start the service
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
@@ -402,6 +395,8 @@ jobs:
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
Both `soundtouch-cli` and `soundtouch-service` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
Multiple checksum options are provided for download verification:
|
||||
@@ -454,6 +449,7 @@ jobs:
|
||||
prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -479,23 +475,64 @@ jobs:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docker:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
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 Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
notify:
|
||||
name: Post-Release Notifications
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate, create_release, update_release]
|
||||
if: always() && (needs.create_release.result == 'success' || needs.update_release.result == 'success')
|
||||
needs: [validate, create_release, update_release, docker]
|
||||
if: always() && (needs.create_release.result == 'success' || needs.update_release.result == 'success' || needs.docker.result == 'success')
|
||||
|
||||
steps:
|
||||
- name: Notify success
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
|
||||
echo "📦 Binaries built for 7 platforms"
|
||||
echo "📦 Binaries built for 7 platforms (CLI and Service)"
|
||||
echo "🐳 Docker image published to ghcr.io"
|
||||
echo "🔐 Checksums generated and verified"
|
||||
echo "📋 Release notes automatically generated"
|
||||
echo ""
|
||||
|
||||
@@ -13,19 +13,23 @@ dist/
|
||||
|
||||
# Root-level binary executables (exclude built binaries in root)
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
|
||||
# Environment configuration
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
docker-compose.override.yml
|
||||
|
||||
# Test coverage reports
|
||||
coverage.out
|
||||
coverage*.out
|
||||
coverage.html
|
||||
*.prof
|
||||
|
||||
@@ -59,6 +63,7 @@ Thumbs.db
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.output.txt
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
|
||||
+11
-1
@@ -50,7 +50,12 @@ linters:
|
||||
linters:
|
||||
- gocritic # Can be overly strict for test code
|
||||
- wsl # Whitespace less critical in tests
|
||||
- wsl_v5 # Whitespace less critical in tests
|
||||
- gocyclo # Complexity less critical in tests
|
||||
- govet # Avoid shadow warnings in tests
|
||||
- revive # Avoid exported/package-comments in tests
|
||||
- errcheck # Avoid mandatory error checks in tests
|
||||
- unparam # Often parameters are fixed in test setups
|
||||
|
||||
# Exclude specific rules for generated files
|
||||
- path: ".*\\.pb\\.go$"
|
||||
@@ -62,6 +67,11 @@ linters:
|
||||
- staticcheck
|
||||
text: "SA9003:" # Empty branch
|
||||
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "SA1008: keys in http.Header are canonicalized"
|
||||
path: pkg/service/handlers/handlers_etag_test.go
|
||||
|
||||
# Allow main functions to not check errors in examples
|
||||
- path: cmd/.*\.go
|
||||
text: "Error return value of.*is not checked"
|
||||
@@ -85,7 +95,7 @@ linters:
|
||||
- fieldalignment # Can be overly aggressive
|
||||
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
min-complexity: 20
|
||||
|
||||
gocritic:
|
||||
enabled-checks:
|
||||
|
||||
+4
-4
@@ -76,7 +76,7 @@ When filing a bug report, include:
|
||||
Feature requests are welcome! Please:
|
||||
|
||||
1. **Check if the feature already exists** in documentation
|
||||
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/API-Endpoints-Overview.md))
|
||||
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
|
||||
3. **Explain the use case** and how it benefits users
|
||||
|
||||
### 🔧 Contributing Code
|
||||
@@ -469,10 +469,10 @@ Contributors will be:
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
- [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
- [Bose SoundTouch API Documentation](docs/API-Endpoints-Overview.md)
|
||||
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
|
||||
- [Project Architecture](docs/PROJECT-PATTERNS.md)
|
||||
- [Development Status](docs/STATUS.md)
|
||||
- [Development Status](docs/archive/STATUS.md)
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
|
||||
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Build stage
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod and sum files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Build the soundtouch-service
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /soundtouch-service ./cmd/soundtouch-service
|
||||
|
||||
# Final stage
|
||||
FROM alpine:3.23
|
||||
|
||||
# Install necessary runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Set environment variables with defaults
|
||||
ENV PORT=8000
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LOG_PROXY_BODY=false
|
||||
ENV REDACT_PROXY_LOGS=true
|
||||
|
||||
# Expose the service port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the service
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
@@ -12,6 +12,8 @@ GOFMT=gofmt
|
||||
# Build parameters
|
||||
BINARY_NAME=soundtouch-cli
|
||||
BINARY_PATH=./cmd/$(BINARY_NAME)
|
||||
SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
@@ -25,13 +27,18 @@ BUILD_DIR=./build
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-examples
|
||||
build: build-cli build-service build-examples
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
|
||||
build-service:
|
||||
@echo "Building $(SERVICE_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@@ -47,17 +54,21 @@ build-linux:
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
|
||||
build-darwin:
|
||||
@echo "Building for macOS..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
|
||||
build-windows:
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
|
||||
build-examples-all:
|
||||
@echo "Building examples for all platforms..."
|
||||
@@ -108,6 +119,18 @@ dev: build-cli
|
||||
@echo "Starting development CLI..."
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -help
|
||||
|
||||
dev-service: build-service
|
||||
@echo "Starting development service..."
|
||||
$(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
dev-service-proxy: build-service
|
||||
@echo "Starting development service with proxy..."
|
||||
@if [ -z "$(PROXY_URL)" ]; then \
|
||||
echo "Usage: make dev-service-proxy PROXY_URL=http://localhost:8001"; \
|
||||
exit 1; \
|
||||
fi
|
||||
PYTHON_BACKEND_URL=$(PROXY_URL) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
dev-discover: build-cli
|
||||
@echo "Running device discovery..."
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -discover
|
||||
@@ -164,9 +187,10 @@ dev-scan-http: build-examples
|
||||
@echo "Scanning for HTTP mDNS services..."
|
||||
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
|
||||
|
||||
install: build-cli
|
||||
@echo "Installing $(BINARY_NAME) to $(GOPATH)/bin..."
|
||||
install: build-cli build-service
|
||||
@echo "Installing binaries to $(GOPATH)/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@@ -177,7 +201,7 @@ clean:
|
||||
release: clean check build-all
|
||||
@echo "Creating release archive..."
|
||||
@mkdir -p $(BUILD_DIR)/release
|
||||
@for binary in $(BUILD_DIR)/$(BINARY_NAME)-*; do \
|
||||
@for binary in $(BUILD_DIR)/$(BINARY_NAME)-* $(BUILD_DIR)/$(SERVICE_NAME)-*; do \
|
||||
if [ -f "$$binary" ]; then \
|
||||
cp "$$binary" $(BUILD_DIR)/release/; \
|
||||
fi \
|
||||
@@ -186,16 +210,22 @@ release: clean check build-all
|
||||
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build -t soundtouch-go:$(VERSION) .
|
||||
docker build -t soundtouch-service .
|
||||
|
||||
docker-dev: docker-build
|
||||
@echo "Running development container..."
|
||||
docker run --rm -it --network host soundtouch-go:$(VERSION)
|
||||
docker-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@echo "Note: --network host is used for discovery (Linux only). For macOS/Windows use port mapping."
|
||||
docker run --rm -it --network host -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
docker-run-ports:
|
||||
@echo "Running Docker container with port mapping (discovery will be manual)..."
|
||||
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the CLI tool and examples"
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@echo " build-cli - Build only the CLI tool"
|
||||
@echo " build-service - Build only the service"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " test - Run tests"
|
||||
@@ -206,6 +236,8 @@ help:
|
||||
@echo " lint - Run golangci-lint"
|
||||
@echo " tidy - Tidy dependencies"
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@echo " dev-mdns - Build and run mDNS discovery example"
|
||||
@@ -217,14 +249,17 @@ help:
|
||||
@echo " dev-scan-all - Scan all mDNS services on network"
|
||||
@echo " dev-scan-soundtouch - Scan specifically for SoundTouch mDNS services"
|
||||
@echo " dev-scan-http - Scan for HTTP mDNS services"
|
||||
@echo " install - Install binary to GOPATH/bin"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " release - Create release binaries"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " docker-dev - Run development container"
|
||||
@echo " docker-run-host - Run container with host networking (Linux discovery)"
|
||||
@echo " docker-run-ports - Run container with port mapping (macOS/Windows/No discovery)"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make dev-service"
|
||||
@echo " make dev-service-proxy PROXY_URL=http://192.168.1.50:8001"
|
||||
@echo " make dev-discover"
|
||||
@echo " make dev-info HOST=192.168.1.10"
|
||||
@echo " make dev-mdns"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bose SoundTouch API Client
|
||||
# Bose SoundTouch Toolkit
|
||||
|
||||
A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices via their Web API.
|
||||
A comprehensive solution for controlling and preserving Bose SoundTouch devices, including a Go library, CLI tool, and a local service for cloud emulation.
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
@@ -17,8 +17,16 @@ A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices
|
||||
- ⚡ **Real-time Events**: WebSocket connection for live device state monitoring
|
||||
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
|
||||
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
|
||||
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
|
||||
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
|
||||
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
|
||||
- 📊 **Traffic Analysis**: Proxy and log device communications
|
||||
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
|
||||
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
|
||||
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
|
||||
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
|
||||
|
||||
@@ -26,9 +34,10 @@ A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices
|
||||
|
||||
### Installation
|
||||
|
||||
#### Install CLI Tool
|
||||
#### Install CLI and Service Tools
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
```
|
||||
|
||||
#### Add Library to Your Project
|
||||
@@ -38,41 +47,53 @@ go get github.com/gesellix/bose-soundtouch
|
||||
|
||||
### CLI Usage
|
||||
|
||||
#### Discover Devices
|
||||
Find SoundTouch devices on your network:
|
||||
```bash
|
||||
# Find SoundTouch devices on your network
|
||||
soundtouch-cli discover devices
|
||||
```
|
||||
|
||||
# Control a Device
|
||||
Control a device (replace `192.168.1.100` with your speaker's IP):
|
||||
```bash
|
||||
# Basic device information
|
||||
soundtouch-cli --host 192.168.1.100 info get
|
||||
# Basic information
|
||||
soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
# Media controls
|
||||
soundtouch-cli --host 192.168.1.100 play start
|
||||
soundtouch-cli --host 192.168.1.100 volume set --level 50
|
||||
soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
|
||||
|
||||
# Preset management
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
|
||||
soundtouch-cli --host 192.168.1.100 preset select --slot 1
|
||||
|
||||
# Browse and discover content
|
||||
soundtouch-cli --host 192.168.1.100 browse tunein
|
||||
soundtouch-cli --host 192.168.1.100 station search-tunein --query "jazz"
|
||||
soundtouch-cli --host 192.168.1.100 station add --source TUNEIN --token <token> --name "Jazz Radio"
|
||||
|
||||
# Speaker notifications (ST-10 only)
|
||||
soundtouch-cli --host 192.168.1.100 speaker tts --text "Welcome home" --app-key YOUR_KEY
|
||||
soundtouch-cli --host 192.168.1.100 speaker url --url "https://example.com/doorbell.mp3" --app-key YOUR_KEY
|
||||
soundtouch-cli --host 192.168.1.100 speaker beep
|
||||
|
||||
# Real-time monitoring
|
||||
soundtouch-cli --host 192.168.1.100 events subscribe
|
||||
```
|
||||
|
||||
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
|
||||
|
||||
### SoundTouch Service (Cloud Shutdown Protection)
|
||||
|
||||
The `soundtouch-service` is a local server that emulates Bose's cloud services. This is critical for keeping your speakers functional after the **Bose Cloud Shutdown in May 2026**.
|
||||
|
||||
#### Key Features:
|
||||
- **🏠 Local Emulation**: BMX and Marge service implementation
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
|
||||
|
||||
#### Quick Start:
|
||||
```bash
|
||||
# Start the service
|
||||
soundtouch-service
|
||||
```
|
||||
Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface.
|
||||
|
||||
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html).
|
||||
|
||||
Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html).
|
||||
|
||||
For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
|
||||
|
||||
### Library Usage
|
||||
|
||||
#### Basic Control
|
||||
@@ -300,8 +321,8 @@ func main() {
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Play Text-to-Speech message
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", 70)
|
||||
// Play Text-to-Speech message (language code "EN", "DE", etc.)
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -364,18 +385,19 @@ This library supports all Bose SoundTouch-compatible devices, including:
|
||||
## Documentation
|
||||
|
||||
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
|
||||
- 📚 [API Reference](docs/API-Endpoints-Overview.md) - Complete endpoint documentation
|
||||
- 🔧 [CLI Reference](docs/CLI-REFERENCE.md) - Command-line tool guide
|
||||
- 🎯 [Getting Started](docs/GETTING-STARTED.md) - Detailed setup and usage
|
||||
- 📻 [Preset Quick Start](docs/PRESET-QUICKSTART.md) - Favorite content management
|
||||
- 🧭 [Navigation Guide](docs/NAVIGATION-GUIDE.md) - Content browsing and station management
|
||||
- 📋 [Navigation API Reference](docs/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
|
||||
- ⚙️ [Advanced Features](docs/SYSTEM-ENDPOINTS.md) - Advanced functionality
|
||||
- 🏠 [Multiroom Setup](docs/zone-management.md) - Zone configuration guide
|
||||
- ⚡ [WebSocket Events](docs/websocket-events.md) - Real-time event handling
|
||||
- 🔔 [Speaker Notifications](docs/SPEAKER_ENDPOINT.md) - TTS and audio notifications guide
|
||||
- 🔍 [Device Discovery](docs/DISCOVERY.md) - Discovery configuration
|
||||
- 🛠️ [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation
|
||||
- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide
|
||||
- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration
|
||||
- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage
|
||||
- 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management
|
||||
- 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management
|
||||
- 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
|
||||
- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality
|
||||
- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide
|
||||
- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling
|
||||
- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide
|
||||
- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration
|
||||
- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions
|
||||
|
||||
## Development
|
||||
|
||||
@@ -456,29 +478,68 @@ This Go library will continue to work as it uses the local Web API for direct de
|
||||
|
||||
**Community Alternatives**: See the [Related Projects](#related-projects) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
|
||||
|
||||
## Related Projects
|
||||
## Related Projects & Credits
|
||||
|
||||
### SoundTouch Plus
|
||||
This project builds upon the excellent work of several community projects:
|
||||
|
||||
### SoundCork 🍾
|
||||
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Kaplan and contributors
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
|
||||
- **License**: MIT License
|
||||
|
||||
### ÜberBöse API 🎵
|
||||
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
|
||||
- **Author**: Julius
|
||||
- **Our Implementation**: This project provided valuable insights into advanced SoundTouch API endpoints and helped make our implementation more complete, particularly for content navigation and advanced device features.
|
||||
- **Key Contributions**: Extended API endpoint documentation, advanced feature discovery
|
||||
- **License**: MIT License
|
||||
|
||||
### SoundTouch Plus 🏠
|
||||
- **Project**: [SoundTouch Plus Home Assistant Component](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)
|
||||
- **Wiki**: [SoundTouch WebServices API Documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- **Description**: Comprehensive Home Assistant integration with extensive API documentation
|
||||
- **Contribution**: The SoundTouch Plus Wiki provided invaluable documentation of working endpoints beyond the official API, enabling the preset management and content navigation features in this library
|
||||
- **Author**: Todd Lucas
|
||||
- **Our Implementation**: The comprehensive API documentation in the SoundTouch Plus Wiki provided invaluable insights into undocumented endpoints beyond the official API, enabling our preset management and content navigation features.
|
||||
- **Key Contributions**: Extensive API endpoint documentation, real-world usage patterns
|
||||
- **License**: MIT License
|
||||
|
||||
### SoundCork
|
||||
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
|
||||
- **Description**: Intercept API for Bose SoundTouch devices after cloud service discontinuation
|
||||
- **Purpose**: Provides a local alternative to cloud-based SoundTouch services post-sunset
|
||||
- **Compatibility**: Complements this Go library by extending functionality beyond the local device API
|
||||
### SoundTouch Hook 🪝
|
||||
- **Project**: [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)
|
||||
- **Author**: Adrian Böckenkamp
|
||||
- **Our Implementation**: This project provides a powerful framework for intercepting and hooking into internal device processes using `LD_PRELOAD`. It was instrumental in verifying internal function calls and understanding how the device validates cloud domains.
|
||||
- **Key Contributions**: Reverse engineering framework, process hooking, cross-compilation toolchain
|
||||
- **License**: GPL-3.0 License
|
||||
|
||||
These projects form a comprehensive ecosystem for SoundTouch device management and provide alternatives to Bose's discontinued cloud services.
|
||||
### Community Ecosystem
|
||||
|
||||
These projects together form a comprehensive ecosystem for SoundTouch device management:
|
||||
|
||||
- **This Project**: Go library + CLI + service for programmatic control and offline operation
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundTouch Plus**: Home Assistant integration with extensive device support
|
||||
- **ÜberBöse**: API research and advanced endpoint discovery
|
||||
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
|
||||
|
||||
We are grateful to these projects and their maintainers for paving the way and providing the foundation that made this comprehensive Go implementation possible. The SoundTouch community's collaborative approach to reverse engineering and documentation has been invaluable.
|
||||
|
||||
### Contributing Back
|
||||
|
||||
If you discover new endpoints, features, or improvements through this library, please consider contributing back to these projects as well. The stronger our community ecosystem becomes, the better we can support SoundTouch devices beyond Bose's official support timeline.
|
||||
|
||||
## Support
|
||||
|
||||
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new)
|
||||
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- 📖 **Documentation**: Browse the [docs/](docs/) directory
|
||||
- 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
- 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html)
|
||||
- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html)
|
||||
- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
|
||||
- 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md)
|
||||
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
|
||||
|
||||
---
|
||||
|
||||
**Star this project** ⭐ if you find it useful!
|
||||
**Star this project** ⭐ if you find it useful!
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// createCredentialsForSource creates credentials for the specified source type
|
||||
func createCredentialsForSource(source, user, password, displayName string) *models.MusicServiceCredentials {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
return models.NewSpotifyCredentials(user, password)
|
||||
case "PANDORA":
|
||||
return models.NewPandoraCredentials(user, password)
|
||||
case "AMAZON":
|
||||
return models.NewAmazonMusicCredentials(user, password)
|
||||
case "DEEZER":
|
||||
return models.NewDeezerCredentials(user, password)
|
||||
case "IHEART":
|
||||
return models.NewIHeartRadioCredentials(user, password)
|
||||
case "STORED_MUSIC":
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
return models.NewStoredMusicCredentials(user, displayName)
|
||||
default:
|
||||
// Generic credentials for other services
|
||||
if displayName == "" {
|
||||
displayName = source
|
||||
}
|
||||
|
||||
return models.NewMusicServiceCredentials(source, displayName, user, password)
|
||||
}
|
||||
}
|
||||
|
||||
// validateAccountInput validates the input parameters for account management
|
||||
func validateAccountInput(source, user, password string) error {
|
||||
if source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
// STORED_MUSIC doesn't require a password
|
||||
if source != "STORED_MUSIC" && password == "" {
|
||||
return fmt.Errorf("password is required for %s (use --password)", source)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addMusicServiceAccount handles adding a music service account
|
||||
func addMusicServiceAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
displayName := c.String("name")
|
||||
|
||||
if validationErr := validateAccountInput(source, user, password); validationErr != nil {
|
||||
return validationErr
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Adding %s account", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
credentials := createCredentialsForSource(source, user, password, displayName)
|
||||
|
||||
// Override display name if provided
|
||||
if c.IsSet("name") {
|
||||
credentials.DisplayName = displayName
|
||||
}
|
||||
|
||||
fmt.Printf(" Service: %s\n", credentials.GetDescription())
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
if source == "STORED_MUSIC" {
|
||||
fmt.Printf(" Type: Network Music Library\n")
|
||||
} else {
|
||||
fmt.Printf(" Type: Streaming Service\n")
|
||||
}
|
||||
|
||||
err = client.SetMusicServiceAccount(credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add music service account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("%s account added successfully", source))
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select this source: soundtouch-cli --host %s source select --source %s --account %s\n", clientConfig.Host, source, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeMusicServiceAccount handles removing a music service account
|
||||
func removeMusicServiceAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
user := c.String("user")
|
||||
displayName := c.String("name")
|
||||
|
||||
if source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Removing %s account", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
var credentials *models.MusicServiceCredentials
|
||||
|
||||
// Create credentials for removal (empty password)
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
credentials = models.NewSpotifyCredentials(user, "")
|
||||
case "PANDORA":
|
||||
credentials = models.NewPandoraCredentials(user, "")
|
||||
case "AMAZON":
|
||||
credentials = models.NewAmazonMusicCredentials(user, "")
|
||||
case "DEEZER":
|
||||
credentials = models.NewDeezerCredentials(user, "")
|
||||
case "IHEART":
|
||||
credentials = models.NewIHeartRadioCredentials(user, "")
|
||||
case "STORED_MUSIC":
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
credentials = models.NewStoredMusicCredentials(user, displayName)
|
||||
default:
|
||||
// Generic credentials for other services
|
||||
if displayName == "" {
|
||||
displayName = source
|
||||
}
|
||||
|
||||
credentials = models.NewMusicServiceCredentials(source, displayName, user, "")
|
||||
}
|
||||
|
||||
// Override display name if provided
|
||||
if c.IsSet("name") {
|
||||
credentials.DisplayName = displayName
|
||||
}
|
||||
|
||||
fmt.Printf(" Service: %s\n", credentials.GetDescription())
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveMusicServiceAccount(credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove music service account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("%s account removed successfully", source))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSpotifyAccount is a convenience command for adding Spotify accounts
|
||||
func addSpotifyAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Spotify Premium account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Spotify Premium\n")
|
||||
|
||||
err = client.AddSpotifyAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Spotify account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Spotify account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Spotify: soundtouch-cli --host %s source spotify\n", clientConfig.Host)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeSpotifyAccount is a convenience command for removing Spotify accounts
|
||||
func removeSpotifyAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Spotify account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveSpotifyAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Spotify account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Spotify account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPandoraAccount is a convenience command for adding Pandora accounts
|
||||
func addPandoraAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Pandora account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Pandora Music Service\n")
|
||||
|
||||
err = client.AddPandoraAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Pandora account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pandora account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Pandora: soundtouch-cli --host %s source select --source PANDORA --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removePandoraAccount is a convenience command for removing Pandora accounts
|
||||
func removePandoraAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Pandora account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemovePandoraAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Pandora account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pandora account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addStoredMusicAccount is a convenience command for adding STORED_MUSIC accounts
|
||||
func addStoredMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
displayName := c.String("name")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user) - this should be the UPnP server GUID with /0 suffix")
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding network music library", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" Server ID: %s\n", user)
|
||||
fmt.Printf(" Display Name: %s\n", displayName)
|
||||
fmt.Printf(" Type: UPnP/DLNA Media Server\n")
|
||||
|
||||
err = client.AddStoredMusicAccount(user, displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add network music library: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Network music library added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Browse library: soundtouch-cli --host %s browse stored-music --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addAmazonMusicAccount is a convenience command for adding Amazon Music accounts
|
||||
func addAmazonMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Amazon Music account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Amazon Music\n")
|
||||
|
||||
err = client.AddAmazonMusicAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Amazon Music account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Amazon Music account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Amazon Music: soundtouch-cli --host %s source select --source AMAZON --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeAmazonMusicAccount is a convenience command for removing Amazon Music accounts
|
||||
func removeAmazonMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Amazon Music account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveAmazonMusicAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Amazon Music account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Amazon Music account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addDeezerAccount is a convenience command for adding Deezer accounts
|
||||
func addDeezerAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Deezer Premium account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Deezer Premium\n")
|
||||
|
||||
err = client.AddDeezerAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Deezer account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Deezer account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Deezer: soundtouch-cli --host %s source select --source DEEZER --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDeezerAccount is a convenience command for removing Deezer accounts
|
||||
func removeDeezerAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Deezer account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveDeezerAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Deezer account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Deezer account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addIHeartRadioAccount is a convenience command for adding iHeartRadio accounts
|
||||
func addIHeartRadioAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding iHeartRadio account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: iHeartRadio\n")
|
||||
|
||||
err = client.AddIHeartRadioAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add iHeartRadio account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("iHeartRadio account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select iHeartRadio: soundtouch-cli --host %s source select --source IHEART --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeIHeartRadioAccount is a convenience command for removing iHeartRadio accounts
|
||||
func removeIHeartRadioAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing iHeartRadio account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveIHeartRadioAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove iHeartRadio account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("iHeartRadio account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeStoredMusicAccount is a convenience command for removing STORED_MUSIC accounts
|
||||
func removeStoredMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
displayName := c.String("name")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing network music library", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" Server ID: %s\n", user)
|
||||
fmt.Printf(" Display Name: %s\n", displayName)
|
||||
|
||||
err = client.RemoveStoredMusicAccount(user, displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove network music library: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Network music library removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listMusicServiceAccounts shows configured music service accounts from sources
|
||||
func listMusicServiceAccounts(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Music service accounts", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
sources, err := client.GetSources()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sources: %w", err)
|
||||
}
|
||||
|
||||
// Filter for streaming/music service sources
|
||||
musicSources := []string{"SPOTIFY", "PANDORA", "AMAZON", "DEEZER", "IHEART", "STORED_MUSIC", "LOCAL_MUSIC"}
|
||||
|
||||
found := false
|
||||
|
||||
for _, musicSource := range musicSources {
|
||||
sourcesOfType := sources.GetSourcesByType(musicSource)
|
||||
if len(sourcesOfType) > 0 {
|
||||
found = true
|
||||
|
||||
fmt.Printf("\n📱 %s:\n", getServiceDisplayName(musicSource))
|
||||
|
||||
for _, source := range sourcesOfType {
|
||||
status := "🔴 Unavailable"
|
||||
if source.Status == models.SourceStatusReady {
|
||||
status = "🟢 Ready"
|
||||
}
|
||||
|
||||
accountInfo := ""
|
||||
if source.SourceAccount != "" && source.SourceAccount != source.Source {
|
||||
accountInfo = fmt.Sprintf(" (%s)", source.SourceAccount)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s%s\n", status, source.GetDisplayName(), accountInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
fmt.Printf(" 📭 No music service accounts configured\n")
|
||||
fmt.Printf("\n💡 Add accounts with:\n")
|
||||
fmt.Printf(" • soundtouch-cli --host %s account add-spotify --user <email> --password <pass>\n", clientConfig.Host)
|
||||
fmt.Printf(" • soundtouch-cli --host %s account add-pandora --user <user> --password <pass>\n", clientConfig.Host)
|
||||
fmt.Printf(" • soundtouch-cli --host %s account add --source AMAZON --user <user> --password <pass>\n", clientConfig.Host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceDisplayName returns a user-friendly display name for a service
|
||||
func getServiceDisplayName(source string) string {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
return "Spotify"
|
||||
case "PANDORA":
|
||||
return "Pandora"
|
||||
case "AMAZON":
|
||||
return "Amazon Music"
|
||||
case "DEEZER":
|
||||
return "Deezer"
|
||||
case "IHEART":
|
||||
return "iHeartRadio"
|
||||
case "STORED_MUSIC":
|
||||
return "Network Libraries"
|
||||
case "LOCAL_MUSIC":
|
||||
return "Local Music Servers"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
@@ -389,6 +389,10 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
|
||||
if !filters["userActivity"] {
|
||||
return
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
if !filters["userInactivity"] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +406,12 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
|
||||
case models.MessageTypeUserActivity:
|
||||
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// introspectService handles getting introspect data for a specific service
|
||||
func introspectService(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
sourceAccount := c.String("account")
|
||||
|
||||
// Check service availability first
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable(source, fmt.Sprintf("get introspect data for %s", strings.ToLower(source))) {
|
||||
PrintWarning(fmt.Sprintf("Service %s may not be available, but continuing with introspect request...", source))
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Getting introspect data for %s", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if sourceAccount != "" {
|
||||
fmt.Printf("Source Account: %s\n", sourceAccount)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
response, err := client.Introspect(source, sourceAccount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get introspect data: %w", err)
|
||||
}
|
||||
|
||||
// Print basic information
|
||||
fmt.Printf("=== %s Service Introspect Data ===\n", source)
|
||||
printIntrospectBasicInfo(response)
|
||||
|
||||
// Print service state
|
||||
fmt.Printf("\n=== Service State ===\n")
|
||||
printIntrospectServiceState(response)
|
||||
|
||||
// Print capabilities
|
||||
fmt.Printf("\n=== Service Capabilities ===\n")
|
||||
printIntrospectCapabilities(response)
|
||||
|
||||
// Print history information
|
||||
if response.GetMaxHistorySize() > 0 {
|
||||
fmt.Printf("\n=== Content History ===\n")
|
||||
printIntrospectHistory(response)
|
||||
}
|
||||
|
||||
// Print technical details
|
||||
if response.TokenLastChangedTimeSeconds > 0 || response.PlayStatusState != "" {
|
||||
fmt.Printf("\n=== Technical Details ===\n")
|
||||
printIntrospectTechnicalDetails(response)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// introspectSpotify handles getting Spotify introspect data using convenience method
|
||||
func introspectSpotify(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
|
||||
// Check Spotify availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.ValidateSpotifyAvailable("get Spotify introspect data") {
|
||||
PrintWarning("Spotify may not be available, but continuing with introspect request...")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting Spotify introspect data", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if sourceAccount != "" {
|
||||
fmt.Printf("Spotify Account: %s\n", sourceAccount)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
response, err := client.IntrospectSpotify(sourceAccount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get Spotify introspect data: %w", err)
|
||||
}
|
||||
|
||||
// Print Spotify-specific information
|
||||
fmt.Printf("=== Spotify Service Introspect Data ===\n")
|
||||
printIntrospectBasicInfo(response)
|
||||
|
||||
// Print service state with Spotify context
|
||||
fmt.Printf("\n=== Spotify Service State ===\n")
|
||||
printIntrospectServiceState(response)
|
||||
|
||||
// Print Spotify capabilities
|
||||
fmt.Printf("\n=== Spotify Service Capabilities ===\n")
|
||||
printIntrospectCapabilities(response)
|
||||
|
||||
// Show Spotify-specific recommendations
|
||||
if response.IsInactive() {
|
||||
fmt.Printf("\n💡 Spotify Setup Recommendations:\n")
|
||||
|
||||
if !response.HasUser() {
|
||||
fmt.Printf(" • Sign in to your Spotify account on the device\n")
|
||||
}
|
||||
|
||||
fmt.Printf(" • Use 'soundtouch-cli source select --source SPOTIFY' to activate Spotify\n")
|
||||
fmt.Printf(" • Ensure you have Spotify Premium for full functionality\n")
|
||||
}
|
||||
|
||||
// Print history information
|
||||
if response.GetMaxHistorySize() > 0 {
|
||||
fmt.Printf("\n=== Spotify Content History ===\n")
|
||||
printIntrospectHistory(response)
|
||||
}
|
||||
|
||||
// Print technical details
|
||||
if response.TokenLastChangedTimeSeconds > 0 || response.PlayStatusState != "" {
|
||||
fmt.Printf("\n=== Technical Details ===\n")
|
||||
printIntrospectTechnicalDetails(response)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// introspectAllServices handles getting introspect data for all available services
|
||||
func introspectAllServices(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting introspect data for all services", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// Get service availability to know which services to check
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service availability: %w", err)
|
||||
}
|
||||
|
||||
// Services to introspect (only streaming services that support introspect)
|
||||
servicesToCheck := []string{"SPOTIFY", "PANDORA", "TUNEIN", "AMAZON", "DEEZER"}
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
for i, source := range servicesToCheck {
|
||||
if i > 0 {
|
||||
fmt.Println("\n" + strings.Repeat("─", 50))
|
||||
}
|
||||
|
||||
// Check if service is available
|
||||
serviceType := sourceToServiceType(source)
|
||||
if serviceType != "" && !serviceAvailability.IsServiceAvailable(serviceType) {
|
||||
fmt.Printf("\n❌ %s: Service not available on this device\n", source)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n🔍 Getting introspect data for %s...\n", source)
|
||||
|
||||
response, err := client.Introspect(source, "")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ %s: Failed to get introspect data - %v\n", source, err)
|
||||
|
||||
failCount++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("✅ %s: Successfully retrieved introspect data\n", source)
|
||||
printIntrospectSummary(source, response)
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
// Print summary
|
||||
fmt.Print("\n" + strings.Repeat("═", 50) + "\n")
|
||||
fmt.Printf("📊 Introspect Summary:\n")
|
||||
fmt.Printf(" ✅ Successful: %d services\n", successCount)
|
||||
fmt.Printf(" ❌ Failed: %d services\n", failCount)
|
||||
fmt.Printf(" 📡 Total checked: %d services\n", len(servicesToCheck))
|
||||
|
||||
if successCount > 0 {
|
||||
PrintSuccess(fmt.Sprintf("Successfully retrieved introspect data for %d services", successCount))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printIntrospectBasicInfo prints basic introspect information
|
||||
func printIntrospectBasicInfo(response *models.IntrospectResponse) {
|
||||
fmt.Printf("State: %s\n", response.State)
|
||||
|
||||
if response.HasUser() {
|
||||
fmt.Printf("User: %s\n", response.User)
|
||||
}
|
||||
|
||||
fmt.Printf("Currently Playing: %s\n", formatBooleanStatus(response.IsPlaying))
|
||||
|
||||
if response.HasCurrentContent() {
|
||||
fmt.Printf("Current Content: %s\n", response.CurrentURI)
|
||||
}
|
||||
|
||||
fmt.Printf("Shuffle Mode: %s\n", response.ShuffleMode)
|
||||
|
||||
if response.HasSubscription() {
|
||||
fmt.Printf("Subscription Type: %s\n", response.SubscriptionType)
|
||||
}
|
||||
}
|
||||
|
||||
// printIntrospectServiceState prints service state information
|
||||
func printIntrospectServiceState(response *models.IntrospectResponse) {
|
||||
if response.IsActive() {
|
||||
fmt.Printf("✅ Service is ACTIVE\n")
|
||||
} else if response.IsInactive() {
|
||||
fmt.Printf("❌ Service is INACTIVE")
|
||||
|
||||
if response.GetState() == models.IntrospectStateInactiveUnselected {
|
||||
fmt.Printf(" (Never been used)")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Additional state information
|
||||
if response.IsPlaying {
|
||||
fmt.Printf("🎵 Currently playing content\n")
|
||||
} else {
|
||||
fmt.Printf("⏸️ Not currently playing\n")
|
||||
}
|
||||
|
||||
if response.IsShuffleEnabled() {
|
||||
fmt.Printf("🔀 Shuffle mode is ON\n")
|
||||
} else {
|
||||
fmt.Printf("➡️ Shuffle mode is OFF\n")
|
||||
}
|
||||
}
|
||||
|
||||
// printIntrospectCapabilities prints service capabilities
|
||||
func printIntrospectCapabilities(response *models.IntrospectResponse) {
|
||||
capabilities := []struct {
|
||||
supported bool
|
||||
feature string
|
||||
icon string
|
||||
}{
|
||||
{response.SupportsSkipPrevious(), "Skip Previous", "⏮️"},
|
||||
{response.SupportsSeek(), "Seek within tracks", "🎯"},
|
||||
{response.SupportsResume(), "Resume playback", "▶️"},
|
||||
}
|
||||
|
||||
for _, cap := range capabilities {
|
||||
status := "❌"
|
||||
if cap.supported {
|
||||
status = "✅"
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s %s\n", status, cap.icon, cap.feature)
|
||||
}
|
||||
|
||||
// Data collection status
|
||||
if response.CollectsData() {
|
||||
fmt.Printf("📊 Data collection: ENABLED\n")
|
||||
} else {
|
||||
fmt.Printf("🚫 Data collection: DISABLED\n")
|
||||
}
|
||||
}
|
||||
|
||||
// printIntrospectHistory prints content history information
|
||||
func printIntrospectHistory(response *models.IntrospectResponse) {
|
||||
fmt.Printf("Max History Size: %d items\n", response.GetMaxHistorySize())
|
||||
}
|
||||
|
||||
// printIntrospectTechnicalDetails prints technical details
|
||||
func printIntrospectTechnicalDetails(response *models.IntrospectResponse) {
|
||||
if response.TokenLastChangedTimeSeconds > 0 {
|
||||
// Convert timestamp to readable format
|
||||
tokenTime := time.Unix(response.TokenLastChangedTimeSeconds, 0)
|
||||
fmt.Printf("Token Last Changed: %s\n", tokenTime.Format("2006-01-02 15:04:05 MST"))
|
||||
fmt.Printf("Token Timestamp: %d seconds since Unix epoch\n", response.TokenLastChangedTimeSeconds)
|
||||
|
||||
if response.TokenLastChangedTimeMicroseconds > 0 {
|
||||
fmt.Printf("Token Microseconds: %d\n", response.TokenLastChangedTimeMicroseconds)
|
||||
}
|
||||
}
|
||||
|
||||
if response.PlayStatusState != "" {
|
||||
fmt.Printf("Play Status State: %s\n", response.PlayStatusState)
|
||||
}
|
||||
|
||||
fmt.Printf("Received Playback Request: %s\n", formatBooleanStatus(response.ReceivedPlaybackRequest))
|
||||
}
|
||||
|
||||
// printIntrospectSummary prints a brief summary for the "all" command
|
||||
func printIntrospectSummary(_ string, response *models.IntrospectResponse) {
|
||||
fmt.Printf(" State: %s", response.State)
|
||||
|
||||
if response.HasUser() {
|
||||
fmt.Printf(" (User: %s)", response.User)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
fmt.Printf(" Playing: %s", formatBooleanStatus(response.IsPlaying))
|
||||
|
||||
if response.HasCurrentContent() {
|
||||
fmt.Printf(" | Content: %.50s", response.CurrentURI)
|
||||
|
||||
if len(response.CurrentURI) > 50 {
|
||||
fmt.Printf("...")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
var capabilities []string
|
||||
if response.SupportsSkipPrevious() {
|
||||
capabilities = append(capabilities, "Skip")
|
||||
}
|
||||
|
||||
if response.SupportsSeek() {
|
||||
capabilities = append(capabilities, "Seek")
|
||||
}
|
||||
|
||||
if response.SupportsResume() {
|
||||
capabilities = append(capabilities, "Resume")
|
||||
}
|
||||
|
||||
if len(capabilities) > 0 {
|
||||
fmt.Printf(" Capabilities: %s\n", strings.Join(capabilities, ", "))
|
||||
} else {
|
||||
fmt.Printf(" Capabilities: None\n")
|
||||
}
|
||||
}
|
||||
|
||||
// formatBooleanStatus formats boolean values for display
|
||||
func formatBooleanStatus(value bool) string {
|
||||
if value {
|
||||
return "✅ Yes"
|
||||
}
|
||||
|
||||
return "❌ No"
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestIntrospectCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedOutput []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "introspect service with source flag",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY"},
|
||||
expectedOutput: []string{
|
||||
"Getting introspect data for SPOTIFY",
|
||||
"=== SPOTIFY Service Introspect Data ===",
|
||||
"State: Active",
|
||||
"User: test_user",
|
||||
"Currently Playing: ✅ Yes",
|
||||
"Current Content: spotify://track/123",
|
||||
"Shuffle Mode: ON",
|
||||
"Subscription Type: Premium",
|
||||
"=== Service State ===",
|
||||
"✅ Service is ACTIVE",
|
||||
"🎵 Currently playing content",
|
||||
"🔀 Shuffle mode is ON",
|
||||
"=== Service Capabilities ===",
|
||||
"✅ ⏮️ Skip Previous",
|
||||
"✅ 🎯 Seek within tracks",
|
||||
"✅ ▶️ Resume playback",
|
||||
"🚫 Data collection: DISABLED",
|
||||
"=== Spotify Content History ===",
|
||||
"Max History Size: 15 items",
|
||||
"=== Technical Details ===",
|
||||
"Token Last Changed:",
|
||||
"Token Timestamp: 1702566495",
|
||||
"Play Status State: 2",
|
||||
"Received Playback Request: ❌ No",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "introspect spotify convenience command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect-spotify"},
|
||||
expectedOutput: []string{
|
||||
"Getting Spotify introspect data",
|
||||
"=== Spotify Service Introspect Data ===",
|
||||
"State: Active",
|
||||
"User: test_user",
|
||||
"=== Spotify Service State ===",
|
||||
"✅ Service is ACTIVE",
|
||||
"=== Spotify Service Capabilities ===",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "introspect with account parameter",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
|
||||
expectedOutput: []string{
|
||||
"Getting introspect data for SPOTIFY",
|
||||
"Source Account: my_spotify_account",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "introspect missing source flag",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "introspect missing host",
|
||||
args: []string{"soundtouch-cli", "source", "introspect", "--source", "SPOTIFY"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Skip actual execution for now - these would need mock HTTP servers
|
||||
// This test structure shows how the CLI commands would be tested
|
||||
t.Skip("Integration test - requires mock HTTP server setup")
|
||||
|
||||
// Example of how you would set up the test:
|
||||
// app := createTestApp()
|
||||
//
|
||||
// var buf bytes.Buffer
|
||||
// app.Writer = &buf
|
||||
// app.ErrWriter = &buf
|
||||
//
|
||||
// err := app.Run(tt.args)
|
||||
//
|
||||
// if tt.expectError {
|
||||
// if err == nil {
|
||||
// t.Error("expected error, got nil")
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if err != nil {
|
||||
// t.Fatalf("unexpected error: %v", err)
|
||||
// }
|
||||
//
|
||||
// output := buf.String()
|
||||
// for _, expected := range tt.expectedOutput {
|
||||
// if !strings.Contains(output, expected) {
|
||||
// t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
// }
|
||||
// }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectBasicInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "active spotify response",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "test_user",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
CurrentURI: "spotify://track/123",
|
||||
SubscriptionType: "Premium",
|
||||
},
|
||||
expected: []string{
|
||||
"State: Active",
|
||||
"User: test_user",
|
||||
"Currently Playing: ✅ Yes",
|
||||
"Current Content: spotify://track/123",
|
||||
"Shuffle Mode: ON",
|
||||
"Subscription Type: Premium",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inactive response",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
User: "",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
CurrentURI: "",
|
||||
},
|
||||
expected: []string{
|
||||
"State: InactiveUnselected",
|
||||
"Currently Playing: ❌ No",
|
||||
"Shuffle Mode: OFF",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectBasicInfo(tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
// Check unwanted strings are not present
|
||||
if tt.response.User == "" && containsSubstring(output, "User:") {
|
||||
t.Error("expected no user information when user is empty")
|
||||
}
|
||||
|
||||
if tt.response.CurrentURI == "" && containsSubstring(output, "Current Content:") {
|
||||
t.Error("expected no current content when URI is empty")
|
||||
}
|
||||
|
||||
if tt.response.SubscriptionType == "" && containsSubstring(output, "Subscription Type:") {
|
||||
t.Error("expected no subscription information when type is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectServiceState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "active playing with shuffle",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
},
|
||||
expected: []string{
|
||||
"✅ Service is ACTIVE",
|
||||
"🎵 Currently playing content",
|
||||
"🔀 Shuffle mode is ON",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inactive unselected",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
},
|
||||
expected: []string{
|
||||
"❌ Service is INACTIVE (Never been used)",
|
||||
"⏸️ Not currently playing",
|
||||
"➡️ Shuffle mode is OFF",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inactive but configured",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Inactive",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
},
|
||||
expected: []string{
|
||||
"❌ Service is INACTIVE",
|
||||
"⏸️ Not currently playing",
|
||||
"➡️ Shuffle mode is OFF",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectServiceState(tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "full capabilities enabled",
|
||||
response: &models.IntrospectResponse{
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: true,
|
||||
ResumeSupported: true,
|
||||
CollectData: true,
|
||||
},
|
||||
},
|
||||
expected: []string{
|
||||
"✅ ⏮️ Skip Previous",
|
||||
"✅ 🎯 Seek within tracks",
|
||||
"✅ ▶️ Resume playback",
|
||||
"📊 Data collection: ENABLED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "limited capabilities",
|
||||
response: &models.IntrospectResponse{
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: false,
|
||||
SeekSupported: false,
|
||||
ResumeSupported: true,
|
||||
CollectData: false,
|
||||
},
|
||||
},
|
||||
expected: []string{
|
||||
"❌ ⏮️ Skip Previous",
|
||||
"❌ 🎯 Seek within tracks",
|
||||
"✅ ▶️ Resume playback",
|
||||
"🚫 Data collection: DISABLED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no capabilities info",
|
||||
response: &models.IntrospectResponse{
|
||||
NowPlaying: nil,
|
||||
},
|
||||
expected: []string{
|
||||
"❌ ⏮️ Skip Previous",
|
||||
"❌ 🎯 Seek within tracks",
|
||||
"❌ ▶️ Resume playback",
|
||||
"🚫 Data collection: DISABLED",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectCapabilities(tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "full spotify summary",
|
||||
source: "SPOTIFY",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "spotify_user",
|
||||
IsPlaying: true,
|
||||
CurrentURI: "spotify://track/very_long_track_uri_that_should_be_truncated_because_its_too_long_for_display",
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: true,
|
||||
ResumeSupported: true,
|
||||
},
|
||||
},
|
||||
expected: []string{
|
||||
"State: Active (User: spotify_user)",
|
||||
"Playing: ✅ Yes | Content: spotify://track/very_long_track_uri_that_should_be...",
|
||||
"Capabilities: Skip, Seek, Resume",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal summary",
|
||||
source: "PANDORA",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Inactive",
|
||||
IsPlaying: false,
|
||||
},
|
||||
expected: []string{
|
||||
"State: Inactive",
|
||||
"Playing: ❌ No",
|
||||
"Capabilities: None",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectSummary(tt.source, tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBooleanStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "true value",
|
||||
value: true,
|
||||
expected: "✅ Yes",
|
||||
},
|
||||
{
|
||||
name: "false value",
|
||||
value: false,
|
||||
expected: "❌ No",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatBooleanStatus(tt.value)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if output contains a substring
|
||||
func containsSubstring(output, substring string) bool {
|
||||
return bytes.Contains([]byte(output), []byte(substring))
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getRecents handles getting recently played content
|
||||
func getRecents(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting recently played content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Printf("📭 No recent items found\n")
|
||||
fmt.Printf("💡 Play some content to populate the recent items list\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display summary
|
||||
fmt.Printf("📊 Recent Items Summary:\n")
|
||||
fmt.Printf(" Total Items: %d\n", response.GetItemCount())
|
||||
|
||||
// Show source breakdown
|
||||
sources := map[string]int{
|
||||
"Spotify": len(response.GetSpotifyItems()),
|
||||
"Local Music": len(response.GetLocalMusicItems()),
|
||||
"Stored Music": len(response.GetStoredMusicItems()),
|
||||
"TuneIn": len(response.GetTuneInItems()),
|
||||
"Pandora": len(response.GetPandoraItems()),
|
||||
}
|
||||
|
||||
fmt.Printf(" By Source:\n")
|
||||
|
||||
for source, count := range sources {
|
||||
if count > 0 {
|
||||
fmt.Printf(" • %s: %d items\n", source, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Show type breakdown
|
||||
tracks := len(response.GetTracks())
|
||||
stations := len(response.GetStations())
|
||||
playlists := len(response.GetPlaylistsAndAlbums())
|
||||
presetable := len(response.GetPresetableItems())
|
||||
|
||||
fmt.Printf(" By Type:\n")
|
||||
|
||||
if tracks > 0 {
|
||||
fmt.Printf(" • 🎵 Tracks: %d\n", tracks)
|
||||
}
|
||||
|
||||
if stations > 0 {
|
||||
fmt.Printf(" • 📻 Stations: %d\n", stations)
|
||||
}
|
||||
|
||||
if playlists > 0 {
|
||||
fmt.Printf(" • 📋 Playlists/Albums: %d\n", playlists)
|
||||
}
|
||||
|
||||
if presetable > 0 {
|
||||
fmt.Printf(" • ⭐ Presetable: %d\n", presetable)
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Recent Items ===\n")
|
||||
|
||||
// Display items with details
|
||||
maxItems := c.Int("limit")
|
||||
if maxItems <= 0 || maxItems > len(response.Items) {
|
||||
maxItems = len(response.Items)
|
||||
}
|
||||
|
||||
for i, item := range response.Items[:maxItems] {
|
||||
printRecentItem(i+1, &item, c.Bool("detailed"))
|
||||
}
|
||||
|
||||
if len(response.Items) > maxItems {
|
||||
fmt.Printf("\n... and %d more items (use --limit to show more)\n", len(response.Items)-maxItems)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRecentsFiltered handles getting filtered recent content
|
||||
// buildFilterDescription creates a description string for the applied filters
|
||||
func buildFilterDescription(source, contentType string) string {
|
||||
switch {
|
||||
case source != "" && contentType != "":
|
||||
return fmt.Sprintf(" (filtered by source: %s, type: %s)", source, contentType)
|
||||
case source != "":
|
||||
return fmt.Sprintf(" (filtered by source: %s)", source)
|
||||
case contentType != "":
|
||||
return fmt.Sprintf(" (filtered by type: %s)", contentType)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// applyContentTypeFilter filters items by content type
|
||||
func applyContentTypeFilter(items []models.RecentsResponseItem, contentType string) []models.RecentsResponseItem {
|
||||
if contentType == "" {
|
||||
return items
|
||||
}
|
||||
|
||||
var typeFiltered []models.RecentsResponseItem
|
||||
|
||||
for _, item := range items {
|
||||
if shouldIncludeItemByType(item, contentType) {
|
||||
typeFiltered = append(typeFiltered, item)
|
||||
}
|
||||
}
|
||||
|
||||
return typeFiltered
|
||||
}
|
||||
|
||||
// shouldIncludeItemByType checks if an item matches the specified content type
|
||||
func shouldIncludeItemByType(item models.RecentsResponseItem, contentType string) bool {
|
||||
switch contentType {
|
||||
case "track", "tracks":
|
||||
return item.IsTrack()
|
||||
case "station", "stations":
|
||||
return item.IsStation()
|
||||
case "playlist", "playlists":
|
||||
return item.IsPlaylist()
|
||||
case "album", "albums":
|
||||
return item.IsAlbum()
|
||||
case "container", "containers":
|
||||
return item.IsContainer()
|
||||
case "presetable":
|
||||
return item.IsPresetable()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// displayFilteredResults prints the filtered recent items
|
||||
func displayFilteredResults(filteredItems []models.RecentsResponseItem, c *cli.Context) {
|
||||
maxItems := c.Int("limit")
|
||||
if maxItems <= 0 || maxItems > len(filteredItems) {
|
||||
maxItems = len(filteredItems)
|
||||
}
|
||||
|
||||
for i, item := range filteredItems[:maxItems] {
|
||||
printRecentItem(i+1, &item, c.Bool("detailed"))
|
||||
}
|
||||
|
||||
if len(filteredItems) > maxItems {
|
||||
fmt.Printf("\n... and %d more items (use --limit to show more)\n", len(filteredItems)-maxItems)
|
||||
}
|
||||
}
|
||||
|
||||
func getRecentsFiltered(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
contentType := strings.ToLower(c.String("type"))
|
||||
filterDesc := buildFilterDescription(source, contentType)
|
||||
|
||||
PrintDeviceHeader("Getting filtered recent content"+filterDesc, clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Printf("📭 No recent items found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply source filter
|
||||
var filteredItems []models.RecentsResponseItem
|
||||
if source != "" {
|
||||
filteredItems = response.GetItemsBySource(source)
|
||||
} else {
|
||||
filteredItems = response.Items
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
filteredItems = applyContentTypeFilter(filteredItems, contentType)
|
||||
|
||||
if len(filteredItems) == 0 {
|
||||
fmt.Printf("📭 No items match the specified filters\n")
|
||||
fmt.Printf("💡 Try different filter criteria or check available content\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("📊 Filtered Results: %d items\n\n", len(filteredItems))
|
||||
displayFilteredResults(filteredItems, c)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRecentsMostRecent shows only the most recent item
|
||||
func getRecentsMostRecent(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting most recent item", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent == nil {
|
||||
fmt.Printf("📭 No recent items found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("🕒 Most Recent Item:\n\n")
|
||||
printRecentItem(1, mostRecent, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printRecentItem prints details about a recent item
|
||||
func printRecentItem(index int, item *models.RecentsResponseItem, detailed bool) {
|
||||
// Basic information
|
||||
displayName := item.GetDisplayName()
|
||||
source := item.GetSource()
|
||||
contentType := item.GetContentType()
|
||||
|
||||
// Format source display
|
||||
sourceDisplay := formatSourceForDisplay(source)
|
||||
|
||||
// Content type icon
|
||||
typeIcon := getContentTypeIcon(item)
|
||||
|
||||
fmt.Printf("%d. %s %s\n", index, typeIcon, displayName)
|
||||
fmt.Printf(" Source: %s", sourceDisplay)
|
||||
|
||||
if contentType != "" {
|
||||
fmt.Printf(" | Type: %s", contentType)
|
||||
}
|
||||
|
||||
fmt.Printf("\n")
|
||||
|
||||
// Time information
|
||||
if item.GetUTCTime() > 0 {
|
||||
playTime := time.Unix(item.GetUTCTime(), 0)
|
||||
fmt.Printf(" Played: %s\n", playTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// Additional details if requested
|
||||
if detailed {
|
||||
if item.HasID() {
|
||||
fmt.Printf(" ID: %s\n", item.GetID())
|
||||
}
|
||||
|
||||
if item.IsPresetable() {
|
||||
fmt.Printf(" ⭐ Can be saved as preset\n")
|
||||
}
|
||||
|
||||
if item.HasArtwork() {
|
||||
fmt.Printf(" 🎨 Has artwork: %s\n", truncateString(item.GetArtwork(), 50))
|
||||
}
|
||||
|
||||
location := item.GetLocation()
|
||||
if location != "" {
|
||||
fmt.Printf(" 📍 Location: %s\n", truncateString(location, 50))
|
||||
}
|
||||
|
||||
sourceAccount := item.GetSourceAccount()
|
||||
if sourceAccount != "" && sourceAccount != source {
|
||||
fmt.Printf(" 👤 Account: %s\n", truncateString(sourceAccount, 30))
|
||||
}
|
||||
|
||||
// Content classification
|
||||
var classifications []string
|
||||
if item.IsStreamingContent() {
|
||||
classifications = append(classifications, "Streaming")
|
||||
}
|
||||
|
||||
if item.IsLocalContent() {
|
||||
classifications = append(classifications, "Local")
|
||||
}
|
||||
|
||||
if len(classifications) > 0 {
|
||||
fmt.Printf(" 🏷️ Classification: %s\n", strings.Join(classifications, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// getContentTypeIcon returns an emoji icon for the content type
|
||||
func getContentTypeIcon(item *models.RecentsResponseItem) string {
|
||||
switch {
|
||||
case item.IsTrack():
|
||||
return "🎵"
|
||||
case item.IsStation():
|
||||
return "📻"
|
||||
case item.IsPlaylist():
|
||||
return "📋"
|
||||
case item.IsAlbum():
|
||||
return "💿"
|
||||
case item.IsContainer():
|
||||
return "📁"
|
||||
default:
|
||||
return "🎶"
|
||||
}
|
||||
}
|
||||
|
||||
// formatSourceForDisplay formats source names for user-friendly display
|
||||
func formatSourceForDisplay(source string) string {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
return "Spotify"
|
||||
case "LOCAL_MUSIC":
|
||||
return "Local Music"
|
||||
case "STORED_MUSIC":
|
||||
return "Stored Music"
|
||||
case "TUNEIN":
|
||||
return "TuneIn Radio"
|
||||
case "PANDORA":
|
||||
return "Pandora"
|
||||
case "AMAZON":
|
||||
return "Amazon Music"
|
||||
case "DEEZER":
|
||||
return "Deezer"
|
||||
case "IHEART":
|
||||
return "iHeartRadio"
|
||||
case "BLUETOOTH":
|
||||
return "Bluetooth"
|
||||
case "AUX":
|
||||
return "AUX Input"
|
||||
case "AIRPLAY":
|
||||
return "AirPlay"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// truncateString truncates a string to the specified length with ellipsis
|
||||
func truncateString(s string, maxLength int) string {
|
||||
if len(s) <= maxLength {
|
||||
return s
|
||||
}
|
||||
|
||||
if maxLength <= 3 {
|
||||
return "..."
|
||||
}
|
||||
|
||||
return s[:maxLength-3] + "..."
|
||||
}
|
||||
|
||||
// printBasicStats prints overall statistics about recent items
|
||||
func printBasicStats(response *models.RecentsResponse) {
|
||||
fmt.Printf("Overall Statistics:\n")
|
||||
fmt.Printf(" Total Items: %d\n", response.GetItemCount())
|
||||
|
||||
if !response.IsEmpty() {
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent != nil {
|
||||
lastPlayTime := time.Unix(mostRecent.GetUTCTime(), 0)
|
||||
fmt.Printf(" Last Played: %s\n", lastPlayTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printSourceStats prints statistics broken down by source
|
||||
func printSourceStats(response *models.RecentsResponse) {
|
||||
fmt.Printf("\nBy Source:\n")
|
||||
|
||||
sourceStats := map[string]int{
|
||||
"Spotify": len(response.GetSpotifyItems()),
|
||||
"Pandora": len(response.GetPandoraItems()),
|
||||
"TuneIn": len(response.GetTuneInItems()),
|
||||
"Local Music": len(response.GetLocalMusicItems()),
|
||||
"Stored Music": len(response.GetStoredMusicItems()),
|
||||
}
|
||||
|
||||
// Add other sources if they exist
|
||||
otherSources := make(map[string]int)
|
||||
|
||||
for _, item := range response.Items {
|
||||
source := item.GetSource()
|
||||
found := false
|
||||
|
||||
for knownSource := range sourceStats {
|
||||
if strings.Contains(strings.ToLower(knownSource), strings.ToLower(source)) ||
|
||||
strings.Contains(strings.ToLower(source), strings.ToLower(knownSource)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found && source != "" {
|
||||
otherSources[formatSourceForDisplay(source)]++
|
||||
}
|
||||
}
|
||||
|
||||
// Merge other sources
|
||||
for source, count := range otherSources {
|
||||
sourceStats[source] = count
|
||||
}
|
||||
|
||||
for source, count := range sourceStats {
|
||||
if count > 0 {
|
||||
percentage := float64(count) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", source+":", count, percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printContentTypeStats prints statistics broken down by content type
|
||||
func printContentTypeStats(response *models.RecentsResponse) {
|
||||
fmt.Printf("\nBy Content Type:\n")
|
||||
|
||||
tracks := len(response.GetTracks())
|
||||
stations := len(response.GetStations())
|
||||
playlists := len(response.GetPlaylistsAndAlbums())
|
||||
|
||||
if tracks > 0 {
|
||||
percentage := float64(tracks) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Tracks:", tracks, percentage)
|
||||
}
|
||||
|
||||
if stations > 0 {
|
||||
percentage := float64(stations) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Stations:", stations, percentage)
|
||||
}
|
||||
|
||||
if playlists > 0 {
|
||||
percentage := float64(playlists) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Playlists/Albums:", playlists, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// printSpecialCategoryStats prints statistics for special content categories
|
||||
func printSpecialCategoryStats(response *models.RecentsResponse) {
|
||||
presetable := len(response.GetPresetableItems())
|
||||
if presetable > 0 {
|
||||
fmt.Printf("\nSpecial Categories:\n")
|
||||
|
||||
percentage := float64(presetable) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Presetable:", presetable, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// printSourceAnalysisStats prints streaming vs local content analysis
|
||||
func printSourceAnalysisStats(response *models.RecentsResponse) {
|
||||
streamingCount := 0
|
||||
localCount := 0
|
||||
|
||||
for _, item := range response.Items {
|
||||
if item.IsStreamingContent() {
|
||||
streamingCount++
|
||||
} else if item.IsLocalContent() {
|
||||
localCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSource Analysis:\n")
|
||||
|
||||
if streamingCount > 0 {
|
||||
percentage := float64(streamingCount) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Streaming:", streamingCount, percentage)
|
||||
}
|
||||
|
||||
if localCount > 0 {
|
||||
percentage := float64(localCount) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Local:", localCount, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// recentsStats shows statistics about recent items
|
||||
func recentsStats(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting recent items statistics", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Printf("📊 Statistics: No recent items found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("📊 Recent Items Statistics\n\n")
|
||||
|
||||
printBasicStats(response)
|
||||
printSourceStats(response)
|
||||
printContentTypeStats(response)
|
||||
printSpecialCategoryStats(response)
|
||||
printSourceAnalysisStats(response)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestRecentsCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedOutput []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "recents list command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "list"},
|
||||
expectedOutput: []string{
|
||||
"Getting recently played content",
|
||||
"Recent Items Summary:",
|
||||
"Recent Items",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents filter by source",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "filter", "--source", "SPOTIFY"},
|
||||
expectedOutput: []string{
|
||||
"Getting filtered recent content",
|
||||
"filtered by source: SPOTIFY",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents latest command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "latest"},
|
||||
expectedOutput: []string{
|
||||
"Getting most recent item",
|
||||
"Most Recent Item:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents stats command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "stats"},
|
||||
expectedOutput: []string{
|
||||
"Getting recent items statistics",
|
||||
"Recent Items Statistics",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents missing host",
|
||||
args: []string{"soundtouch-cli", "recents", "list"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Skip actual execution for now - these would need mock HTTP servers
|
||||
// This test structure shows how the CLI commands would be tested
|
||||
t.Skip("Integration test - requires mock HTTP server setup")
|
||||
|
||||
// Example of how you would set up the test:
|
||||
// app := createTestApp()
|
||||
//
|
||||
// var buf bytes.Buffer
|
||||
// app.Writer = &buf
|
||||
// app.ErrWriter = &buf
|
||||
//
|
||||
// err := app.Run(tt.args)
|
||||
//
|
||||
// if tt.expectError {
|
||||
// if err == nil {
|
||||
// t.Error("expected error, got nil")
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if err != nil {
|
||||
// t.Fatalf("unexpected error: %v", err)
|
||||
// }
|
||||
//
|
||||
// output := buf.String()
|
||||
// for _, expected := range tt.expectedOutput {
|
||||
// if !strings.Contains(output, expected) {
|
||||
// t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
// }
|
||||
// }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintRecentItem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item *models.RecentsResponseItem
|
||||
detailed bool
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "basic track item",
|
||||
item: &models.RecentsResponseItem{
|
||||
DeviceID: "device1",
|
||||
UTCTime: 1701200000,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "track",
|
||||
ItemName: "Test Song",
|
||||
},
|
||||
},
|
||||
detailed: false,
|
||||
expected: []string{
|
||||
"🎵 Test Song",
|
||||
"Source: Spotify",
|
||||
"Type: track",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "detailed station item",
|
||||
item: &models.RecentsResponseItem{
|
||||
DeviceID: "device1",
|
||||
UTCTime: 1701200000,
|
||||
ID: "station123",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
ItemName: "Rock FM",
|
||||
Location: "tunein:station:s12345",
|
||||
SourceAccount: "tunein_account",
|
||||
IsPresetable: true,
|
||||
},
|
||||
},
|
||||
detailed: true,
|
||||
expected: []string{
|
||||
"📻 Rock FM",
|
||||
"Source: TuneIn Radio",
|
||||
"ID: station123",
|
||||
"Can be saved as preset",
|
||||
"Location: tunein:station:s12345",
|
||||
"Classification: Streaming",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printRecentItem(1, tt.item, tt.detailed)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !bytes.Contains(buf.Bytes(), []byte(expected)) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentTypeIcon(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item *models.RecentsResponseItem
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "track item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "track"},
|
||||
},
|
||||
expected: "🎵",
|
||||
},
|
||||
{
|
||||
name: "station item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "stationurl"},
|
||||
},
|
||||
expected: "📻",
|
||||
},
|
||||
{
|
||||
name: "playlist item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "playlist"},
|
||||
},
|
||||
expected: "📋",
|
||||
},
|
||||
{
|
||||
name: "album item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "album"},
|
||||
},
|
||||
expected: "💿",
|
||||
},
|
||||
{
|
||||
name: "container item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "container"},
|
||||
},
|
||||
expected: "📁",
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "unknown"},
|
||||
},
|
||||
expected: "🎶",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getContentTypeIcon(tt.item)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSourceForDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
expected string
|
||||
}{
|
||||
{"Spotify", "SPOTIFY", "Spotify"},
|
||||
{"Local Music", "LOCAL_MUSIC", "Local Music"},
|
||||
{"Stored Music", "STORED_MUSIC", "Stored Music"},
|
||||
{"TuneIn", "TUNEIN", "TuneIn Radio"},
|
||||
{"Pandora", "PANDORA", "Pandora"},
|
||||
{"Amazon", "AMAZON", "Amazon Music"},
|
||||
{"Deezer", "DEEZER", "Deezer"},
|
||||
{"iHeart", "IHEART", "iHeartRadio"},
|
||||
{"Bluetooth", "BLUETOOTH", "Bluetooth"},
|
||||
{"AUX", "AUX", "AUX Input"},
|
||||
{"AirPlay", "AIRPLAY", "AirPlay"},
|
||||
{"Unknown", "UNKNOWN_SOURCE", "UNKNOWN_SOURCE"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatSourceForDisplay(tt.source)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLength int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "short string",
|
||||
input: "hello",
|
||||
maxLength: 10,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "exact length",
|
||||
input: "hello",
|
||||
maxLength: 5,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "long string",
|
||||
input: "this is a very long string that needs truncation",
|
||||
maxLength: 20,
|
||||
expected: "this is a very lo...",
|
||||
},
|
||||
{
|
||||
name: "very short max length",
|
||||
input: "hello world",
|
||||
maxLength: 3,
|
||||
expected: "...",
|
||||
},
|
||||
{
|
||||
name: "zero length",
|
||||
input: "hello",
|
||||
maxLength: 0,
|
||||
expected: "...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := truncateString(tt.input, tt.maxLength)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test helper functions that would be used in full integration tests
|
||||
func createTestRecentsResponse() *models.RecentsResponse {
|
||||
return &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701300000,
|
||||
ID: "spotify1",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "track",
|
||||
Location: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh",
|
||||
SourceAccount: "spotify_user",
|
||||
IsPresetable: true,
|
||||
ItemName: "Shape of You - Ed Sheeran",
|
||||
ContainerArt: "https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701200000,
|
||||
ID: "local1",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "LOCAL_MUSIC",
|
||||
Type: "track",
|
||||
Location: "/music/local_song.mp3",
|
||||
IsPresetable: false,
|
||||
ItemName: "Local Song - Local Artist",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701100000,
|
||||
ID: "tunein1",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "tunein:station:s24939",
|
||||
SourceAccount: "tunein",
|
||||
IsPresetable: true,
|
||||
ItemName: "BBC Radio 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestRecentsResponse(t *testing.T) {
|
||||
response := createTestRecentsResponse()
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response, got nil")
|
||||
}
|
||||
|
||||
if response.GetItemCount() != 3 {
|
||||
t.Errorf("expected 3 items, got %d", response.GetItemCount())
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
t.Error("expected response not to be empty")
|
||||
}
|
||||
|
||||
// Test filtering
|
||||
spotifyItems := response.GetSpotifyItems()
|
||||
if len(spotifyItems) != 1 {
|
||||
t.Errorf("expected 1 Spotify item, got %d", len(spotifyItems))
|
||||
}
|
||||
|
||||
localItems := response.GetLocalMusicItems()
|
||||
if len(localItems) != 1 {
|
||||
t.Errorf("expected 1 local music item, got %d", len(localItems))
|
||||
}
|
||||
|
||||
tuneInItems := response.GetTuneInItems()
|
||||
if len(tuneInItems) != 1 {
|
||||
t.Errorf("expected 1 TuneIn item, got %d", len(tuneInItems))
|
||||
}
|
||||
|
||||
tracks := response.GetTracks()
|
||||
if len(tracks) != 2 {
|
||||
t.Errorf("expected 2 tracks, got %d", len(tracks))
|
||||
}
|
||||
|
||||
stations := response.GetStations()
|
||||
if len(stations) != 1 {
|
||||
t.Errorf("expected 1 station, got %d", len(stations))
|
||||
}
|
||||
|
||||
presetableItems := response.GetPresetableItems()
|
||||
if len(presetableItems) != 2 {
|
||||
t.Errorf("expected 2 presetable items, got %d", len(presetableItems))
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,224 @@ func selectAux(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectLocalInternetRadio handles selecting LOCAL_INTERNET_RADIO source
|
||||
func selectLocalInternetRadio(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
|
||||
// Check LOCAL_INTERNET_RADIO availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select internet radio") {
|
||||
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting internet radio stream", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Station: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
|
||||
err = client.SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select internet radio: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Internet radio stream selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectLocalMusic handles selecting LOCAL_MUSIC source
|
||||
func selectLocalMusic(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
if sourceAccount == "" {
|
||||
return fmt.Errorf("account is required for LOCAL_MUSIC (use --account)")
|
||||
}
|
||||
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
|
||||
// Check LOCAL_MUSIC availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_MUSIC", "select local music") {
|
||||
return fmt.Errorf("LOCAL_MUSIC is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting local music content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Content: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
|
||||
err = client.SelectLocalMusic(location, sourceAccount, itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select local music: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Local music content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectStoredMusic handles selecting STORED_MUSIC source
|
||||
func selectStoredMusic(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
if sourceAccount == "" {
|
||||
return fmt.Errorf("account is required for STORED_MUSIC (use --account)")
|
||||
}
|
||||
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
|
||||
// Check STORED_MUSIC availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("STORED_MUSIC", "select stored music") {
|
||||
return fmt.Errorf("STORED_MUSIC is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting stored music content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Content: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
|
||||
err = client.SelectStoredMusic(location, sourceAccount, itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select stored music: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Stored music content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectContent handles selecting content using a ContentItem directly
|
||||
func selectContent(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Required parameters
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
if source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
// Optional parameters
|
||||
sourceAccount := c.String("account")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
itemType := c.String("type")
|
||||
isPresetable := c.Bool("presetable")
|
||||
|
||||
// Create ContentItem
|
||||
contentItem := &models.ContentItem{
|
||||
Source: source,
|
||||
Type: itemType,
|
||||
Location: location,
|
||||
SourceAccount: sourceAccount,
|
||||
IsPresetable: isPresetable,
|
||||
ItemName: itemName,
|
||||
ContainerArt: containerArt,
|
||||
}
|
||||
|
||||
// Set default type if not specified
|
||||
if itemType == "" {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
contentItem.Type = "uri"
|
||||
case "TUNEIN", "LOCAL_INTERNET_RADIO":
|
||||
contentItem.Type = "stationurl"
|
||||
case "LOCAL_MUSIC":
|
||||
contentItem.Type = "album" // default, could be track, artist, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// Set default item name if not specified
|
||||
if itemName == "" {
|
||||
contentItem.ItemName = source
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" Source: %s\n", source)
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
|
||||
if sourceAccount != "" {
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
}
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Name: %s\n", itemName)
|
||||
}
|
||||
|
||||
if itemType != "" {
|
||||
fmt.Printf(" Type: %s\n", itemType)
|
||||
}
|
||||
|
||||
err = client.SelectContentItem(contentItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select content: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceAvailability handles displaying service availability information
|
||||
func getServiceAvailability(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -35,23 +34,12 @@ func playTTS(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// URL encode the text for Google TTS
|
||||
encodedText := url.QueryEscape(text)
|
||||
|
||||
// Build TTS URL with language support
|
||||
ttsURL := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, encodedText)
|
||||
|
||||
// Create PlayInfo for TTS
|
||||
playInfo := &models.PlayInfo{
|
||||
URL: ttsURL,
|
||||
AppKey: appKey,
|
||||
Service: "TTS Notification",
|
||||
Message: "Google TTS",
|
||||
Reason: text,
|
||||
}
|
||||
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo.SetVolume(volume)
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language, volume)
|
||||
} else {
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
@@ -121,10 +109,11 @@ func playURL(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Create PlayInfo for URL content
|
||||
playInfo := models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
|
||||
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo.SetVolume(volume)
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason, volume)
|
||||
} else {
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
@@ -147,10 +136,16 @@ func playURL(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
|
||||
func playNotificationBeep(c *cli.Context) error {
|
||||
// playNotification plays a notification sound or a local file on the speaker
|
||||
func playNotification(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
|
||||
path := c.String("path")
|
||||
|
||||
if path != "" {
|
||||
PrintDeviceHeader(fmt.Sprintf("Playing notification file: %s", path), clientConfig.Host, clientConfig.Port)
|
||||
} else {
|
||||
PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
|
||||
}
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
@@ -158,18 +153,31 @@ func playNotificationBeep(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use the existing playNotification endpoint
|
||||
err = client.PlayNotificationBeep()
|
||||
err = client.PlayNotification(path)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
|
||||
if path != "" {
|
||||
PrintError(fmt.Sprintf("Failed to play notification file: %v", err))
|
||||
} else {
|
||||
PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Notification beep played successfully\n")
|
||||
if path != "" {
|
||||
fmt.Printf("✅ Notification file sent successfully: %s\n", path)
|
||||
} else {
|
||||
fmt.Printf("✅ Notification beep played successfully\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
|
||||
func playNotificationBeep(c *cli.Context) error {
|
||||
return playNotification(c)
|
||||
}
|
||||
|
||||
// showSpeakerHelp displays help information about speaker functionality
|
||||
func showSpeakerHelp(_ *cli.Context) error {
|
||||
fmt.Println("SoundTouch Speaker Playback Commands")
|
||||
@@ -189,6 +197,10 @@ func showSpeakerHelp(_ *cli.Context) error {
|
||||
fmt.Println(" Play a simple notification sound")
|
||||
fmt.Println(" Example: soundtouch-cli speaker beep")
|
||||
fmt.Println()
|
||||
fmt.Println("• Custom Notification:")
|
||||
fmt.Println(" Play a device-local PCM file as notification")
|
||||
fmt.Println(" Example: soundtouch-cli speaker notify --path \"/opt/Bose/chimes/grouped.pcm\"")
|
||||
fmt.Println()
|
||||
fmt.Println("Notes:")
|
||||
fmt.Println("• Only ST-10 (Series III) speakers support the /speaker endpoint")
|
||||
fmt.Println("• ST-300 and other models may not support this functionality")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -331,7 +332,7 @@ func PrintWarning(message string) {
|
||||
|
||||
// showVersionInfo displays detailed version information including build details
|
||||
func showVersionInfo(_ *cli.Context) error {
|
||||
fmt.Printf("soundtouch-cli version %s\n", version)
|
||||
fmt.Printf("%s version %s\n", os.Args[0], version)
|
||||
fmt.Printf("Build commit: %s\n", commit)
|
||||
fmt.Printf("Build date: %s\n", date)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
|
||||
+527
-1
@@ -104,7 +104,7 @@ func main() {
|
||||
Version: version,
|
||||
Authors: []*cli.Author{
|
||||
{
|
||||
Name: "Tobias Gesellchen, and the SoundTouch CLI Contributors",
|
||||
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
|
||||
},
|
||||
},
|
||||
Flags: CommonFlags,
|
||||
@@ -209,6 +209,72 @@ func main() {
|
||||
Action: getPresets,
|
||||
Before: RequireHost,
|
||||
},
|
||||
// Recent content commands
|
||||
{
|
||||
Name: "recents",
|
||||
Aliases: []string{"recent"},
|
||||
Usage: "Recently played content commands",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List recently played content",
|
||||
Action: getRecents,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Usage: "Maximum number of items to display (0 for all)",
|
||||
Value: 10,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "detailed",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Show detailed information for each item",
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "filter",
|
||||
Usage: "List recently played content with filters",
|
||||
Action: getRecentsFiltered,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Filter by source (SPOTIFY, LOCAL_MUSIC, TUNEIN, etc.)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Filter by content type (track, station, playlist, album, presetable)",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Usage: "Maximum number of items to display (0 for all)",
|
||||
Value: 10,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "detailed",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Show detailed information for each item",
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "latest",
|
||||
Usage: "Show only the most recent item",
|
||||
Action: getRecentsMostRecent,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "stats",
|
||||
Usage: "Show statistics about recent content",
|
||||
Action: recentsStats,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Playback commands
|
||||
{
|
||||
Name: "play",
|
||||
@@ -830,6 +896,136 @@ func main() {
|
||||
Action: selectAux,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "internet-radio",
|
||||
Usage: "Select internet radio stream (LOCAL_INTERNET_RADIO)",
|
||||
Action: selectLocalInternetRadio,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "location",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Stream location URL (direct stream or streamUrl format)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Source account (optional)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Station name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Station artwork URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "local-music",
|
||||
Usage: "Select local music content (LOCAL_MUSIC)",
|
||||
Action: selectLocalMusic,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "location",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Content location (e.g., album:983, track:2579)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Source account GUID (required)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Content name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Content artwork URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stored-music",
|
||||
Usage: "Select stored music content (STORED_MUSIC)",
|
||||
Action: selectStoredMusic,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "location",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Content location ID (e.g., 6_a2874b5d_4f83d999)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Source account GUID (required)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Content name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Content artwork URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "content",
|
||||
Usage: "Select content using ContentItem (advanced)",
|
||||
Action: selectContent,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Content source (SPOTIFY, TUNEIN, LOCAL_INTERNET_RADIO, etc.)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "location",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Content location",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Source account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Content name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Content type (uri, stationurl, album, track, etc.)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Content artwork URL",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "presetable",
|
||||
Usage: "Mark content as presetable",
|
||||
Value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "availability",
|
||||
Usage: "Show service availability",
|
||||
@@ -842,6 +1038,44 @@ func main() {
|
||||
Action: compareSourcesAndAvailability,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "introspect",
|
||||
Usage: "Get introspect data for a music service",
|
||||
Action: introspectService,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Music service source (SPOTIFY, PANDORA, TUNEIN, etc.)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Source account name (optional)",
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "introspect-spotify",
|
||||
Usage: "Get Spotify introspect data (convenience command)",
|
||||
Action: introspectSpotify,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Spotify account name (optional)",
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "introspect-all",
|
||||
Usage: "Get introspect data for all available services",
|
||||
Action: introspectAllServices,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Bass commands
|
||||
@@ -1473,6 +1707,19 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "notify",
|
||||
Usage: "Play a notification sound or local file",
|
||||
Action: playNotification,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "path",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Device-local path to a PCM file (e.g. /opt/Bose/chimes/grouped.pcm)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "beep",
|
||||
Usage: "Play a notification beep sound",
|
||||
@@ -1486,6 +1733,285 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Account management commands
|
||||
{
|
||||
Name: "account",
|
||||
Aliases: []string{"acc"},
|
||||
Usage: "Music service account management commands",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List configured music service accounts",
|
||||
Action: listMusicServiceAccounts,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "add",
|
||||
Usage: "Add a music service account",
|
||||
Action: addMusicServiceAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Music service source (SPOTIFY, PANDORA, AMAZON, DEEZER, IHEART, STORED_MUSIC)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Username or account identifier",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Account password (not required for STORED_MUSIC)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Display name for the service",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Usage: "Remove a music service account",
|
||||
Action: removeMusicServiceAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Music service source (SPOTIFY, PANDORA, AMAZON, DEEZER, IHEART, STORED_MUSIC)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Username or account identifier",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Display name for the service",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add-spotify",
|
||||
Usage: "Add a Spotify Premium account",
|
||||
Action: addSpotifyAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Spotify username/email",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Spotify password",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-spotify",
|
||||
Usage: "Remove a Spotify account",
|
||||
Action: removeSpotifyAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Spotify username/email to remove",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add-pandora",
|
||||
Usage: "Add a Pandora account",
|
||||
Action: addPandoraAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Pandora username",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Pandora password",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-pandora",
|
||||
Usage: "Remove a Pandora account",
|
||||
Action: removePandoraAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Pandora username to remove",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add-nas",
|
||||
Usage: "Add a network music library (NAS/UPnP)",
|
||||
Action: addStoredMusicAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "UPnP server GUID with /0 suffix (e.g., d09708a1-5953-44bc-a413-123456789012/0)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Display name for the music library",
|
||||
Value: "Network Music Library",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-nas",
|
||||
Usage: "Remove a network music library",
|
||||
Action: removeStoredMusicAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "UPnP server GUID with /0 suffix to remove",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Display name for the music library",
|
||||
Value: "Network Music Library",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add-amazon",
|
||||
Usage: "Add an Amazon Music account",
|
||||
Action: addAmazonMusicAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Amazon Music username",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Amazon Music password",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-amazon",
|
||||
Usage: "Remove an Amazon Music account",
|
||||
Action: removeAmazonMusicAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Amazon Music username to remove",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add-deezer",
|
||||
Usage: "Add a Deezer Premium account",
|
||||
Action: addDeezerAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Deezer username",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Deezer password",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-deezer",
|
||||
Usage: "Remove a Deezer account",
|
||||
Action: removeDeezerAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Deezer username to remove",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add-iheart",
|
||||
Usage: "Add an iHeartRadio account",
|
||||
Action: addIHeartRadioAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "iHeartRadio username",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "iHeartRadio password",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-iheart",
|
||||
Usage: "Remove an iHeartRadio account",
|
||||
Action: removeIHeartRadioAccount,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "iHeartRadio username to remove",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Token commands
|
||||
{
|
||||
Name: "token",
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
// Package main provides the SoundTouch service daemon that acts as a proxy and management
|
||||
// interface for Bose SoundTouch devices, providing Marge service emulation and device discovery.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
func updateBuildInfo() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
|
||||
for _, setting := range info.Settings {
|
||||
switch setting.Key {
|
||||
case "vcs.revision":
|
||||
commit = setting.Value
|
||||
case "vcs.time":
|
||||
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
|
||||
date = t.Format("2006-01-02_15:04:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-service",
|
||||
Usage: "Local service for Bose SoundTouch cloud emulation and management",
|
||||
Description: `⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ A local server that emulates Bose cloud services (BMX, Marge).
|
||||
It enables offline operation, device migration, and HTTP interaction recording.`,
|
||||
Version: version,
|
||||
Authors: []*cli.Author{
|
||||
{
|
||||
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
|
||||
},
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to bind the service to",
|
||||
Value: "8000",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "soundcork-url",
|
||||
Usage: "URL for Soundcork-based service components (legacy)",
|
||||
Value: "http://localhost:8001",
|
||||
EnvVars: []string{"SOUNDCORK_BACKEND_URL", "TARGET_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "enable-soundcork-proxy",
|
||||
Usage: "Enable proxying unknown requests to the Soundcork backend",
|
||||
EnvVars: []string{"ENABLE_SOUNDCORK_PROXY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
Value: "data",
|
||||
EnvVars: []string{"DATA_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "server-url",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "External URL of this service",
|
||||
EnvVars: []string{"SERVER_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-port",
|
||||
Usage: "HTTPS port to bind the service to",
|
||||
Value: "8443",
|
||||
EnvVars: []string{"HTTPS_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-server-url",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "External HTTPS URL",
|
||||
EnvVars: []string{"HTTPS_SERVER_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "redact-logs",
|
||||
Usage: "Redact sensitive data in proxy logs",
|
||||
Value: true,
|
||||
EnvVars: []string{"REDACT_PROXY_LOGS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "log-bodies",
|
||||
Usage: "Log full request/response bodies",
|
||||
EnvVars: []string{"LOG_PROXY_BODY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "record-interactions",
|
||||
Usage: "Record HTTP interactions to disk",
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server for non-Bose queries",
|
||||
Value: "8.8.8.8",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI",
|
||||
Value: "ueberboese-login://spotify",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
persisted := applyPersistedSettings(ds, &config)
|
||||
|
||||
if persisted.ServerURL == "" {
|
||||
log.Printf("Creating default settings.json in %s", config.dataDir)
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
// Recalculate domains if settings changed
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
|
||||
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetSoundcorkURL(config.soundcorkURL)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
server.SetBaseURL(config.baseURL)
|
||||
|
||||
if config.spotifyClientID != "" {
|
||||
spotifyService := spotify.NewSpotifyService(
|
||||
config.spotifyClientID,
|
||||
config.spotifyClientSecret,
|
||||
config.spotifyRedirectURI,
|
||||
config.dataDir,
|
||||
)
|
||||
server.SetSpotifyService(spotifyService)
|
||||
|
||||
clientIDPrefix := config.spotifyClientID
|
||||
if len(clientIDPrefix) > 8 {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
|
||||
}
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
if err == nil && len(dnsDiscoveries) > 0 {
|
||||
initial := make(map[string]*discovery.DiscoveredHost)
|
||||
for _, entry := range dnsDiscoveries {
|
||||
initial[entry.Hostname] = &discovery.DiscoveredHost{
|
||||
Hostname: entry.Hostname,
|
||||
FirstSeen: entry.FirstSeen,
|
||||
LastSeen: entry.LastSeen,
|
||||
QueryCount: entry.QueryCount,
|
||||
IsBoseService: entry.IsBoseService,
|
||||
IsIntercepted: entry.IsIntercepted,
|
||||
RemoteAddr: entry.RemoteAddr,
|
||||
}
|
||||
}
|
||||
|
||||
server.SetDNSDiscoveries(initial)
|
||||
}
|
||||
|
||||
server.SetShortcuts(persisted.Shortcuts)
|
||||
|
||||
for path, status := range persisted.Shortcuts {
|
||||
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
|
||||
}
|
||||
|
||||
recorder := proxy.NewRecorder(config.dataDir)
|
||||
recorder.Redact = config.redact
|
||||
patternsPath := filepath.Join(config.dataDir, "patterns.json")
|
||||
|
||||
patterns, err := proxy.LoadPatterns(patternsPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
|
||||
}
|
||||
|
||||
if len(patterns) == 0 {
|
||||
log.Printf("Creating default patterns at %s", patternsPath)
|
||||
|
||||
patterns = proxy.DefaultPatterns()
|
||||
|
||||
patternsData, jsonErr := json.MarshalIndent(patterns, "", " ")
|
||||
if jsonErr != nil {
|
||||
log.Printf("Warning: Failed to marshal default patterns: %v", jsonErr)
|
||||
} else {
|
||||
_ = os.WriteFile(patternsPath, patternsData, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
if len(patterns) > 0 {
|
||||
recorder.Patterns = patterns
|
||||
}
|
||||
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
}
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server)
|
||||
|
||||
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
|
||||
|
||||
if tlsConfig != nil {
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
}
|
||||
|
||||
return http.ListenAndServe(config.addr, r)
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "version",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Show detailed version information",
|
||||
Action: showVersionInfo,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func showVersionInfo(_ *cli.Context) error {
|
||||
fmt.Printf("%s version %s\n", os.Args[0], version)
|
||||
fmt.Printf("Build commit: %s\n", commit)
|
||||
fmt.Printf("Build date: %s\n", date)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
soundcorkURL string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
enableSoundcorkProxy bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
port := c.String("port")
|
||||
bindAddr := c.String("bind")
|
||||
|
||||
addr := bindAddr + ":" + port
|
||||
if bindAddr == "" {
|
||||
addr = ":" + port
|
||||
}
|
||||
|
||||
soundcorkURL := c.String("soundcork-url")
|
||||
dataDir := c.String("data-dir")
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
serverURL := c.String("server-url")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://" + hostname + ":" + port
|
||||
}
|
||||
|
||||
httpsPort := c.String("https-port")
|
||||
|
||||
httpsAddr := bindAddr + ":" + httpsPort
|
||||
if bindAddr == "" {
|
||||
httpsAddr = ":" + httpsPort
|
||||
}
|
||||
|
||||
httpsServerURL := c.String("https-server-url")
|
||||
if httpsServerURL == "" {
|
||||
httpsServerURL = "https://" + hostname + ":" + httpsPort
|
||||
}
|
||||
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname)
|
||||
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
record := c.Bool("record-interactions")
|
||||
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
|
||||
|
||||
dnsEnabled := c.Bool("dns-discovery")
|
||||
dnsUpstream := c.String("dns-upstream")
|
||||
dnsBind := c.String("dns-bind")
|
||||
|
||||
discoveryIntervalStr := c.String("discovery-interval")
|
||||
|
||||
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", discoveryIntervalStr, err)
|
||||
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
spotifyClientID := c.String("spotify-client-id")
|
||||
spotifyClientSecret := c.String("spotify-client-secret")
|
||||
spotifyRedirectURI := c.String("spotify-redirect-uri")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
baseURL := c.String("base-url")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
soundcorkURL: soundcorkURL,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
enableSoundcorkProxy: enableSoundcorkProxy,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
domainsMap := map[string]bool{
|
||||
"streaming.bose.com": true,
|
||||
"updates.bose.com": true,
|
||||
"stats.bose.com": true,
|
||||
"bmx.bose.com": true,
|
||||
"content.api.bose.io": true,
|
||||
setup.TestDomain: true,
|
||||
hostname: true,
|
||||
"localhost": true,
|
||||
"127.0.0.1": true,
|
||||
}
|
||||
|
||||
if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" {
|
||||
domainsMap[strings.ToLower(u.Hostname())] = true
|
||||
}
|
||||
|
||||
if u, err := url.Parse(httpsServerURL); err == nil && u.Hostname() != "" {
|
||||
domainsMap[strings.ToLower(u.Hostname())] = true
|
||||
}
|
||||
|
||||
domains := make([]string, 0, len(domainsMap))
|
||||
for d := range domainsMap {
|
||||
domains = append(domains, d)
|
||||
}
|
||||
|
||||
return domains
|
||||
}
|
||||
|
||||
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
return datastore.Settings{}
|
||||
}
|
||||
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
}
|
||||
|
||||
if persisted.SoundcorkURL != "" {
|
||||
config.soundcorkURL = persisted.SoundcorkURL
|
||||
}
|
||||
|
||||
if persisted.HTTPServerURL != "" {
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
}
|
||||
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
config.discoveryInterval = d
|
||||
}
|
||||
}
|
||||
|
||||
config.redact = persisted.RedactLogs
|
||||
config.logBody = persisted.LogBodies
|
||||
config.record = persisted.RecordInteractions
|
||||
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
|
||||
|
||||
config.dnsEnabled = persisted.DNSEnabled
|
||||
if persisted.DNSUpstream != "" {
|
||||
config.dnsUpstream = persisted.DNSUpstream
|
||||
}
|
||||
|
||||
if persisted.DNSBindAddr != "" {
|
||||
config.dnsBind = persisted.DNSBindAddr
|
||||
}
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
SoundcorkURL: config.soundcorkURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
EnableSoundcorkProxy: config.enableSoundcorkProxy,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: config.dnsUpstream,
|
||||
DNSBindAddr: config.dnsBind,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
_ = ds.SaveSettings(settings)
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func initDataStore(dataDir string) *datastore.DataStore {
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
log.Printf("Warning: Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
return cm
|
||||
}
|
||||
|
||||
func startDeviceDiscovery(server *handlers.Server) {
|
||||
go func() {
|
||||
for {
|
||||
currentInterval, enabled := server.GetDiscoverySettings()
|
||||
if enabled {
|
||||
server.DiscoverDevices(context.Background())
|
||||
}
|
||||
|
||||
time.Sleep(currentInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = "/media/favicon-braille.svg"
|
||||
server.HandleMedia()(w, r)
|
||||
})
|
||||
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/web/*", server.HandleWeb())
|
||||
r.Get("/docs/*", server.HandleDocs)
|
||||
|
||||
r.Route("/bmx", func(r chi.Router) {
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /bmx prefix
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
r.Get("/account/{account}", server.HandleMargeAccountProfile)
|
||||
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
|
||||
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
|
||||
})
|
||||
|
||||
r.Route("/streaming/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callback — no auth required (Spotify redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
// short-lived, and useless without the client_secret.
|
||||
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
|
||||
|
||||
// All other management endpoints require Basic Auth.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, httpsServerURL string) {
|
||||
httpsServer := &http.Server{
|
||||
Addr: httpsAddr,
|
||||
Handler: r,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
|
||||
|
||||
go func() {
|
||||
if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("HTTPS server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestApplyPersistedSettings(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "main-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
|
||||
t.Run("overrides true with false", func(t *testing.T) {
|
||||
config := &serviceConfig{
|
||||
redact: true,
|
||||
logBody: true,
|
||||
record: true,
|
||||
enableSoundcorkProxy: true,
|
||||
}
|
||||
|
||||
// Simulate the bug by using the old bitwise OR logic in the test,
|
||||
// which should fail if we expect false.
|
||||
// config.redact = config.redact || false -> stays true
|
||||
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: false,
|
||||
LogBodies: false,
|
||||
RecordInteractions: false,
|
||||
EnableSoundcorkProxy: false,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != false {
|
||||
t.Errorf("Expected redact to be false, got true")
|
||||
}
|
||||
if config.logBody != false {
|
||||
t.Errorf("Expected logBody to be false, got true")
|
||||
}
|
||||
if config.record != false {
|
||||
t.Errorf("Expected record to be false, got true")
|
||||
}
|
||||
if config.enableSoundcorkProxy != false {
|
||||
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retains false when settings are false", func(t *testing.T) {
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: false,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
config := &serviceConfig{
|
||||
redact: false,
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != false {
|
||||
t.Errorf("Expected redact to be false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overrides false with true", func(t *testing.T) {
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: true,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
config := &serviceConfig{
|
||||
redact: false,
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != true {
|
||||
t.Errorf("Expected redact to be true, got false")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
patterns.json
|
||||
settings.json
|
||||
@@ -1,8 +1,11 @@
|
||||
// Package soundtouch provides a comprehensive Go library and CLI tool for controlling Bose SoundTouch devices.
|
||||
// Package soundtouch provides a comprehensive Go library, CLI tool, and local service for controlling and emulating Bose SoundTouch devices.
|
||||
//
|
||||
// This library implements the complete Bose SoundTouch Web API, enabling programmatic control
|
||||
// This project implements the complete Bose SoundTouch Web API, enabling programmatic control
|
||||
// of SoundTouch speakers including playback control, volume management, source selection,
|
||||
// multiroom zone management, and real-time event monitoring via WebSocket connections.
|
||||
// multiroom zone management, and real-time event monitoring.
|
||||
//
|
||||
// It also provides a local service (`soundtouch-service`) that can emulate the Bose Cloud,
|
||||
// allowing for offline control and enhanced debugging through HTTP interaction recording.
|
||||
//
|
||||
// # Quick Start
|
||||
//
|
||||
@@ -41,63 +44,20 @@
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Set volume
|
||||
// err = client.SetVolume(50)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Device Discovery
|
||||
// # SoundTouch Service
|
||||
//
|
||||
// Automatically discover SoundTouch devices on your network:
|
||||
// The `soundtouch-service` provides several advanced features:
|
||||
//
|
||||
// import "github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
// - Bose Cloud Emulation: Allows speakers to work without an internet connection.
|
||||
// - HTTP Interaction Recording: Captures all traffic as IntelliJ-compatible .http files.
|
||||
// - Speaker Migration: Automated tools to redirect speakers to the local service.
|
||||
// - Web Interface: A management dashboard for proxy settings and speaker setup.
|
||||
//
|
||||
// // Discover devices using UPnP/SSDP
|
||||
// service := discovery.NewService(5*time.Second)
|
||||
// devices, err := service.DiscoverDevices(ctx)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// Install the service:
|
||||
//
|
||||
// for _, device := range devices {
|
||||
// fmt.Printf("Found device: %s at %s\n", device.Name, device.Host)
|
||||
// }
|
||||
//
|
||||
// # Real-time Events
|
||||
//
|
||||
// Monitor device state changes in real-time using WebSocket connections:
|
||||
//
|
||||
// // Subscribe to device events
|
||||
// events, err := client.SubscribeToEvents(ctx)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// for event := range events {
|
||||
// switch e := event.(type) {
|
||||
// case *models.NowPlayingUpdated:
|
||||
// fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
|
||||
// case *models.VolumeUpdated:
|
||||
// fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Multiroom Zone Management
|
||||
//
|
||||
// Create and manage multiroom zones:
|
||||
//
|
||||
// // Create a zone with multiple speakers
|
||||
// zone := &models.Zone{
|
||||
// Master: "192.168.1.100",
|
||||
// Members: []models.ZoneMember{
|
||||
// {IPAddress: "192.168.1.101"},
|
||||
// {IPAddress: "192.168.1.102"},
|
||||
// },
|
||||
// }
|
||||
// err = client.SetZone(zone)
|
||||
// go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
//
|
||||
// # CLI Tool
|
||||
//
|
||||
@@ -111,45 +71,33 @@
|
||||
//
|
||||
// # Control a device
|
||||
// soundtouch-cli --host 192.168.1.100 play start
|
||||
// soundtouch-cli --host 192.168.1.100 volume set --level 50
|
||||
// soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
|
||||
//
|
||||
// # Supported Features
|
||||
//
|
||||
// - ✅ Device Information & Capabilities
|
||||
// - ✅ Playback Control (Play/Pause/Stop/Next/Previous)
|
||||
// - ✅ Volume, Bass, and Balance Control
|
||||
// - ✅ Source Selection (Spotify, Bluetooth, AUX, etc.)
|
||||
// - ✅ Preset Management
|
||||
// - ✅ Clock/Time Management
|
||||
// - ✅ Network Information
|
||||
// - ✅ Playback, Volume, Bass, and Balance Control
|
||||
// - ✅ Source Selection & Preset Management
|
||||
// - ✅ Real-time WebSocket Events
|
||||
// - ✅ Multiroom Zone Management
|
||||
// - ✅ Device Discovery (UPnP/SSDP and mDNS)
|
||||
// - ✅ Cross-platform Support (Windows, macOS, Linux)
|
||||
// - ✅ Local Cloud Emulation (soundtouch-service)
|
||||
// - ✅ HTTP Traffic Recording & Sanitization
|
||||
// - ✅ Automated Speaker Migration & Revert
|
||||
//
|
||||
// # Package Structure
|
||||
//
|
||||
// - client: HTTP client for SoundTouch Web API
|
||||
// - discovery: Device discovery using UPnP/SSDP and mDNS
|
||||
// - models: Data structures for API requests/responses
|
||||
// - config: Configuration management
|
||||
// - service: Core logic for the soundtouch-service (proxy, recording, setup)
|
||||
// - cmd/soundtouch-cli: Command-line interface tool
|
||||
//
|
||||
// # Hardware Compatibility
|
||||
//
|
||||
// This library has been tested with real Bose SoundTouch hardware and supports
|
||||
// all SoundTouch-compatible devices including:
|
||||
// - SoundTouch 10, 20, 30 series
|
||||
// - SoundTouch Portable
|
||||
// - Wave SoundTouch music system
|
||||
// - And other SoundTouch-enabled Bose speakers
|
||||
// - cmd/soundtouch-service: Local cloud emulation service
|
||||
//
|
||||
// # Implementation Notes
|
||||
//
|
||||
// This implementation is based on the official Bose SoundTouch Web API documentation
|
||||
// and provides 90% coverage of all available endpoints. It is an independent project
|
||||
// and is not affiliated with or endorsed by Bose Corporation.
|
||||
// This project is an independent effort to preserve the functionality of Bose SoundTouch
|
||||
// devices and provide enhanced debugging and control capabilities. It is not
|
||||
// affiliated with or endorsed by Bose Corporation.
|
||||
//
|
||||
// For detailed API documentation, examples, and advanced usage patterns, visit:
|
||||
// https://pkg.go.dev/github.com/gesellix/bose-soundtouch
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
image: ghcr.io/gesellix/bose-soundtouch:latest
|
||||
# build: .
|
||||
container_name: soundtouch-service
|
||||
# Linux only, required for discovery. Swarm requires host network at the task level.
|
||||
# network_mode: host
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
environment:
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
- DATA_DIR=/app/data
|
||||
- LOG_PROXY_BODY=false
|
||||
- REDACT_PROXY_LOGS=true
|
||||
- RECORD_INTERACTIONS=true
|
||||
- DISCOVERY_INTERVAL=5m
|
||||
- SERVER_URL=http://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8000
|
||||
- HTTPS_SERVER_URL=https://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8443
|
||||
volumes:
|
||||
- soundtouch-data:/app/data
|
||||
# Use host volume for local development if preferred:
|
||||
# - ./data:/app/data
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
|
||||
volumes:
|
||||
soundtouch-data:
|
||||
# Named volumes are preferred in Swarm. For multi-node persistence,
|
||||
# consider using a volume driver like NFS or GlusterFS.
|
||||
+2
-3
@@ -4,9 +4,9 @@
|
||||
|
||||
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
|
||||
|
||||
- **[PLAN.md](PLAN.md)** - Project planning and roadmap
|
||||
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
|
||||
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
|
||||
- **[API-Endpoints-Overview.md](API-Endpoints-Overview.md)** - API endpoints overview
|
||||
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
|
||||
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
|
||||
|
||||
## Development Guidelines
|
||||
@@ -95,4 +95,3 @@ When creating test data for API endpoints, prefer real device responses over hyp
|
||||
- **Documentation**: Completely in English for international accessibility
|
||||
- Conduct regular code reviews
|
||||
- Consider performance from the beginning
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Content Selection Implementation Summary
|
||||
|
||||
This document summarizes the implementation of advanced content selection features for the Bose SoundTouch Go client, including full support for the LOCAL_INTERNET_RADIO streamUrl format and LOCAL_MUSIC/STORED_MUSIC content selection.
|
||||
|
||||
## ✅ Implementation Status: COMPLETE
|
||||
|
||||
All content selection features from the [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) are now fully implemented with comprehensive API methods, CLI commands, tests, and documentation.
|
||||
|
||||
## 🎯 Features Implemented
|
||||
|
||||
### 1. Core API Methods
|
||||
|
||||
#### `SelectContentItem(contentItem *models.ContentItem) error`
|
||||
- **Purpose**: Generic method for selecting any content using a ContentItem directly
|
||||
- **Use Case**: Maximum flexibility for complex content selection scenarios
|
||||
- **Validation**: Ensures ContentItem is not nil and has a valid source
|
||||
|
||||
#### `SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select LOCAL_INTERNET_RADIO content with streamUrl format support
|
||||
- **Features**:
|
||||
- Direct stream URLs (e.g., `https://stream.example.com/radio`)
|
||||
- streamUrl proxy format (e.g., `http://contentapi.gmuth.de/station.php?name=Station&streamUrl=ActualStream`)
|
||||
- Automatic defaults for missing parameters
|
||||
- **Use Cases**: Internet radio streams, proxy-based radio services
|
||||
|
||||
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
|
||||
- **Requirements**: SoundTouch App Media Server running on a computer
|
||||
- **Content Types**: Albums, tracks, artists, playlists
|
||||
- **Validation**: Requires both location and sourceAccount
|
||||
|
||||
#### `SelectStoredMusic(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select STORED_MUSIC content from UPnP/DLNA media servers
|
||||
- **Requirements**: UPnP/DLNA media server (Windows Media Player, NAS, etc.)
|
||||
- **Content Types**: NAS libraries, network music collections
|
||||
- **Validation**: Requires both location and sourceAccount
|
||||
|
||||
### 2. CLI Commands
|
||||
|
||||
All API methods are exposed through comprehensive CLI commands:
|
||||
|
||||
#### `soundtouch-cli source internet-radio`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Station" \
|
||||
--artwork "https://example.com/art.png"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source local-music`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source local-music \
|
||||
--location "album:983" \
|
||||
--account "3f205110-4a57-4e91-810a-123456789012" \
|
||||
--name "Welcome to the New"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source stored-music`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source stored-music \
|
||||
--location "6_a2874b5d_4f83d999" \
|
||||
--account "d09708a1-5953-44bc-a413-123456789012/0" \
|
||||
--name "Christmas Album"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source content` (Advanced)
|
||||
```bash
|
||||
soundtouch-cli --host <device> source content \
|
||||
--source LOCAL_INTERNET_RADIO \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "My Stream" \
|
||||
--type stationurl \
|
||||
--presetable
|
||||
```
|
||||
|
||||
## 🧪 Test Coverage
|
||||
|
||||
Comprehensive test suites implemented for all new functionality:
|
||||
|
||||
### Unit Tests
|
||||
- **TestClient_SelectContentItem**: 5 test cases covering valid/invalid inputs
|
||||
- **TestClient_SelectLocalInternetRadio**: 4 test cases including streamUrl format
|
||||
- **TestClient_SelectLocalMusic**: 4 test cases with validation
|
||||
- **TestClient_SelectStoredMusic**: 4 test cases with error handling
|
||||
|
||||
### Test Coverage Summary
|
||||
- ✅ Valid content selection scenarios
|
||||
- ✅ streamUrl format validation
|
||||
- ✅ Parameter validation and error handling
|
||||
- ✅ Default value assignment
|
||||
- ✅ HTTP request formatting verification
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Updated Documentation
|
||||
1. **CLI-REFERENCE.md**: Added comprehensive CLI command examples
|
||||
2. **Content Selection Example**: New `/examples/content-selection/` with working code
|
||||
3. **README Updates**: Added streamUrl format examples
|
||||
4. **API Documentation**: Inline Go documentation for all methods
|
||||
|
||||
### Example Code
|
||||
Complete working example demonstrating:
|
||||
- LOCAL_INTERNET_RADIO with streamUrl proxy format
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_MUSIC content selection
|
||||
- STORED_MUSIC content selection
|
||||
- Generic ContentItem usage
|
||||
|
||||
## 🔍 streamUrl Format Support
|
||||
|
||||
### What is the streamUrl Format?
|
||||
The streamUrl format uses a proxy server that accepts the actual stream URL as a parameter:
|
||||
|
||||
```
|
||||
http://contentapi.gmuth.de/station.php?name=StationName&streamUrl=ActualStreamURL
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
- **Full Support**: All streamUrl format URLs work seamlessly
|
||||
- **Example from Wiki**: Exact implementation matches the wiki specification
|
||||
- **ContentItem Structure**:
|
||||
```go
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp",
|
||||
IsPresetable: false,
|
||||
ItemName: "Antenne Chillout",
|
||||
ContainerArt: "https://www.radio.net/300/antennechillout.png",
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Design Principles
|
||||
1. **Consistency**: All methods follow the same parameter patterns
|
||||
2. **Flexibility**: `SelectContentItem()` allows maximum control
|
||||
3. **Convenience**: Specific methods (`SelectLocalInternetRadio()`, etc.) provide simpler interfaces
|
||||
4. **Validation**: Comprehensive input validation with clear error messages
|
||||
5. **Defaults**: Sensible defaults when optional parameters are empty
|
||||
|
||||
### ContentItem Construction
|
||||
All convenience methods create properly structured `ContentItem` objects:
|
||||
- Automatic `Type` assignment based on source
|
||||
- `IsPresetable` defaults to `true`
|
||||
- Default `ItemName` when not provided
|
||||
- Proper source-specific validation
|
||||
|
||||
## 🎵 Related Features
|
||||
|
||||
### Sibling Features (Also Implemented)
|
||||
Based on the wiki structure, these related features are also supported:
|
||||
|
||||
1. **LOCAL_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
3. **SPOTIFY**: ✅ Previously implemented
|
||||
4. **TUNEIN**: ✅ Previously implemented
|
||||
5. **BLUETOOTH**: ✅ Previously implemented
|
||||
6. **AIRPLAY**: ✅ Previously implemented
|
||||
|
||||
## 📋 Usage Examples
|
||||
|
||||
### API Usage
|
||||
```go
|
||||
// streamUrl format
|
||||
location := "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio"
|
||||
err := client.SelectLocalInternetRadio(location, "", "My Station", "")
|
||||
|
||||
// Direct ContentItem
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: location,
|
||||
ItemName: "My Station",
|
||||
IsPresetable: true,
|
||||
}
|
||||
err := client.SelectContentItem(contentItem)
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
# streamUrl format
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Station"
|
||||
|
||||
# Direct stream
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "Direct Stream"
|
||||
```
|
||||
|
||||
## 🔗 References
|
||||
|
||||
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
|
||||
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
|
||||
- [Content Selection Example](../examples/content-selection/README.md)
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- [Content Selection Example (Direct)](../examples/content-selection/)
|
||||
|
||||
## ✅ Verification
|
||||
|
||||
This implementation has been verified to:
|
||||
1. ✅ Support exact wiki specification for streamUrl format
|
||||
2. ✅ Handle all LOCAL_INTERNET_RADIO, LOCAL_MUSIC, and STORED_MUSIC scenarios
|
||||
3. ✅ Pass comprehensive test suite
|
||||
4. ✅ Work with CLI commands
|
||||
5. ✅ Include complete documentation and examples
|
||||
6. ✅ Maintain backward compatibility
|
||||
|
||||
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
|
||||
@@ -0,0 +1,107 @@
|
||||
# Device Logging & Troubleshooting
|
||||
|
||||
Accessing logs from SoundTouch devices is critical for debugging custom service integrations and understanding internal device behavior. This document outlines the methods for collecting logs, as discovered by the **SoundCork** and **ÜberBöse API** communities.
|
||||
|
||||
## Log Types
|
||||
|
||||
1. **System Logs**: Internal OS logs (Linux-based) including `dmesg`, `syslog`, and process-specific logs.
|
||||
2. **Traffic Logs**: Real-time HTTP/HTTPS requests sent by the device to cloud or local services.
|
||||
3. **Proxy Logs**: Logs generated by the `soundtouch-service` when it acts as a man-in-the-middle.
|
||||
|
||||
---
|
||||
|
||||
## 1. Accessing System Logs (Requires Root)
|
||||
|
||||
Most SoundTouch devices run a modified Linux distribution. Accessing these logs requires root SSH or Telnet access.
|
||||
|
||||
### Enabling Root Access (Remote Services)
|
||||
|
||||
Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services:
|
||||
|
||||
1. **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)**:
|
||||
- On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`.
|
||||
|
||||
### Making Root Access Persistent
|
||||
Once you have logged in as `root` (usually no password or a well-known community password), you can make the access survive reboots without the USB stick:
|
||||
```bash
|
||||
touch /mnt/nv/remote_services
|
||||
/etc/init.d/sshd start
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
Once inside via SSH:
|
||||
- **Kernel Logs**: `dmesg`
|
||||
- **System Logs**: `cat /var/log/messages` or `tail -f /tmp/soundtouch.log` (paths vary by firmware).
|
||||
- **Real-time Monitoring**: `logread -f`
|
||||
- **Process List**: `ps w`
|
||||
|
||||
#### Pro-Tip: Filtered Real-time Monitoring
|
||||
To focus on cloud service and preset interactions (Marge), use the following command on the device:
|
||||
```bash
|
||||
logread -f | grep -Ei '(marge|preset)'
|
||||
```
|
||||
This is particularly useful for debugging preset synchronization and service redirection issues.
|
||||
|
||||
---
|
||||
|
||||
## 2. Traffic Logging & Interception
|
||||
|
||||
If you cannot or do not want to root the device, you can monitor its outbound traffic by redirecting it to a proxy.
|
||||
|
||||
### Via `soundtouch-service`
|
||||
The `soundtouch-service` included in this repository includes a built-in proxy. When a device is migrated to use this service, all of its cloud-bound traffic is logged to the service console.
|
||||
|
||||
**Key Traffic to Monitor**:
|
||||
- `POST /v1/scmudc/{deviceId}`: Real-time telemetry events.
|
||||
- `GET /marge/...`: Account and streaming configuration requests.
|
||||
- `POST /streaming/support/power_on`: Boot-time diagnostics.
|
||||
|
||||
### Via Packet Sniffing (Advanced)
|
||||
If you have a managed switch or a router capable of port mirroring, you can use **Wireshark** or `tcpdump` to capture traffic.
|
||||
- **Filter**: `tcp port 80 or tcp port 443`
|
||||
- **Target**: The IP address of your SoundTouch device.
|
||||
|
||||
---
|
||||
|
||||
## 3. Troubleshooting Common Issues
|
||||
|
||||
### "IsItBose" Validation Failures
|
||||
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
|
||||
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
|
||||
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
|
||||
|
||||
### Disappearing Sources (TuneIn/Local Radio)
|
||||
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
|
||||
- **Cause**: These sources are validated against the cloud only during the initial boot sequence.
|
||||
- **Solution**: Ensure your emulated service is reachable and responding correctly to `/streaming/support/power_on` and `/streaming/sourceproviders` during the device's boot-up.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTP Protocol Quirks
|
||||
|
||||
### ETag Case-Sensitivity
|
||||
Research in **SoundCork Issue #129** revealed a significant bug in the SoundTouch device firmware regarding HTTP `ETag` headers.
|
||||
|
||||
- **The Issue**: The device firmware expects the `ETag` header to be exactly title-cased (`ETag`). Many modern web servers or frameworks (like FastAPI/Uvicorn) return headers in all lowercase (`etag`) per HTTP/2 or standard case-insensitive conventions.
|
||||
- **The Symptom**: If the server returns a lowercase `etag`, the device fails to recognize it. Consequently, the device will never send an `If-None-Match` header in subsequent requests, breaking preset synchronization and efficient caching.
|
||||
- **The Workaround**: If you are using a custom service, you may need to use a reverse proxy (like **Nginx**) or a middleware to force the header casing to `ETag`.
|
||||
|
||||
**Example Nginx Fix**:
|
||||
```nginx
|
||||
proxy_hide_header etag;
|
||||
add_header ETag $upstream_http_etag;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
- [SoundCork Issue #112: Enabling Remote Services](https://github.com/deborahgu/soundcork/issues/112)
|
||||
- [SoundCork Issue #149: Debugging with Systemd/Gunicorn](https://github.com/deborahgu/soundcork/issues/149)
|
||||
- [ÜberBöse API: Telemetry Documentation](https://github.com/julius-d/ueberboese-api)
|
||||
- [SoundCork Issue #129: ETag Case-Sensitivity & Preset Sync](https://github.com/deborahgu/soundcork/issues/129)
|
||||
@@ -231,6 +231,20 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Parameter Validation**: Complete input validation and error handling
|
||||
- **Usage Examples**: Extensive real-world usage examples
|
||||
|
||||
### Phase 9: Bug Fixes and Stability (February 2025)
|
||||
|
||||
#### Critical Bug Fixes
|
||||
- **PlayNotificationBeep HTTP Method Fix**: Corrected `/playNotification` endpoint to use GET instead of POST
|
||||
- **Issue**: `go run ./cmd/soundtouch-cli --host <device> sp beep` was failing with HTTP 400 status
|
||||
- **Root Cause**: Go client was sending POST requests while SoundTouch devices expect GET requests
|
||||
- **Fix**: Updated `PlayNotificationBeep()` method to use the existing `c.get()` method with `StationResponse` model
|
||||
- **Verification**: Tested with SoundTouch 20, confirmed compatibility with curl equivalent (`curl http://<device>:8090/playNotification`)
|
||||
|
||||
#### Code Quality Improvements
|
||||
- **Consistent HTTP Method Usage**: Leveraged existing client patterns instead of manual HTTP handling
|
||||
- **Model Reuse**: Used existing `StationResponse` struct for `/playNotification` XML response parsing
|
||||
- **Documentation Updates**: Added troubleshooting guide for speaker notification issues
|
||||
|
||||
## Feature Implementation Statistics
|
||||
|
||||
### API Endpoint Coverage Evolution
|
||||
@@ -245,6 +259,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
| Phase 6 | 2 | 24 | 92% |
|
||||
| Phase 7 | 3 | 27 | 96% |
|
||||
| Phase 8 | 2 | 29 | 100% |
|
||||
| Phase 9 | 0 | 29 | 100% (Bug fixes) |
|
||||
|
||||
### Testing Evolution
|
||||
|
||||
@@ -257,6 +272,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Phase 6**: Zone management tests (250 tests)
|
||||
- **Phase 7**: Advanced audio tests (300+ tests)
|
||||
- **Phase 8**: Speaker notification tests (330+ tests)
|
||||
- **Phase 9**: Bug fix verification tests (335+ tests)
|
||||
|
||||
#### Integration Test Coverage
|
||||
- **Real Device Testing**: SoundTouch 10 and SoundTouch 20
|
||||
@@ -275,6 +291,7 @@ This document tracks the detailed evolution of features and capabilities in the
|
||||
- **Phase 6**: `zone`
|
||||
- **Phase 7**: Advanced audio commands
|
||||
- **Phase 8**: `speaker` (TTS, URL, beep notifications)
|
||||
- **Phase 9**: Bug fixes (speaker beep reliability)
|
||||
|
||||
#### CLI Feature Enhancements
|
||||
- **Host:Port Parsing**: Support for `192.168.1.100:8090` format
|
||||
|
||||
@@ -895,4 +895,4 @@ For additional help:
|
||||
|
||||
---
|
||||
|
||||
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](PRESET-MANAGEMENT.md).*
|
||||
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
|
||||
|
||||
@@ -332,14 +332,14 @@ soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
## Next Steps
|
||||
|
||||
- 📖 [Complete CLI Reference](CLI-REFERENCE.md)
|
||||
- 🔧 [Full Implementation Guide](preset-store.md)
|
||||
- 📡 [WebSocket Events Documentation](websocket-events.md)
|
||||
- 📖 [Complete CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- 🔧 [Full Implementation Guide](reference/PRESET-MANAGEMENT.md)
|
||||
- 📡 [WebSocket Events Documentation](reference/WEBSOCKET-EVENTS.md)
|
||||
- 💻 [Preset Management Example](../examples/preset-management/)
|
||||
- 📚 [API Endpoints Overview](API-Endpoints-Overview.md)
|
||||
- 📚 [API Endpoints Overview](reference/API-ENDPOINTS.md)
|
||||
|
||||
## Need Help?
|
||||
|
||||
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues)
|
||||
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Bose SoundTouch Toolkit Documentation
|
||||
|
||||
Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026.
|
||||
|
||||
## 📖 Quick Links
|
||||
|
||||
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- [Getting Started](guides/GETTING-STARTED.md)
|
||||
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)
|
||||
|
||||
## 🗂 Documentation Structure
|
||||
|
||||
### User Guides
|
||||
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
- [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
- [Deployment Guide](guides/DEPLOYMENT.md)
|
||||
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
|
||||
- [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
|
||||
### Technical Reference
|
||||
- [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
- [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
- [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
|
||||
### Analysis & Research
|
||||
- [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
|
||||
For a complete list of all documents, see the [Summary](SUMMARY.md).
|
||||
@@ -23,7 +23,7 @@ This document summarizes the implementation of the `/serviceAvailability` endpoi
|
||||
### Modified Files
|
||||
|
||||
1. **`pkg/client/client.go`** - Added `GetServiceAvailability()` method
|
||||
2. **`docs/API-Endpoints-Overview.md`** - Updated implementation status
|
||||
2. **`docs/reference/API-ENDPOINTS.md`** - Updated implementation status
|
||||
3. **`docs/UNIMPLEMENTED-ENDPOINTS.md`** - Marked as implemented
|
||||
|
||||
## API Interface
|
||||
@@ -263,4 +263,4 @@ BenchmarkGetServiceAvailability-8 1000 1.2ms/op
|
||||
✅ **Performance benchmarks established**
|
||||
✅ **Error handling verified**
|
||||
|
||||
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
|
||||
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# 🎉 Introducing SoundTouch Service: Local Cloud Service Emulation
|
||||
|
||||
**Date**: January 2024
|
||||
**Version**: v2.0.0+
|
||||
**Status**: Production Ready
|
||||
|
||||
## What's New?
|
||||
|
||||
We're excited to announce the addition of `soundtouch-service`, a comprehensive local server that emulates Bose's cloud services for SoundTouch devices. This major addition provides offline operation capabilities and advanced device management features.
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
### 🏠 Complete Service Emulation
|
||||
- **BMX Services**: Full Bose Media eXchange implementation for TuneIn, podcasts, and media streaming
|
||||
- **Marge Services**: Account and device management, preset synchronization, recent items tracking
|
||||
- **Offline Operation**: Continue using your devices without internet connectivity to Bose servers
|
||||
|
||||
### 🔧 Device Migration
|
||||
- **Seamless Migration**: One-click migration from Bose cloud to local services
|
||||
- **Configuration Backup**: Automatic backup of existing device settings
|
||||
- **Rollback Support**: Easy restoration to original Bose cloud configuration
|
||||
- **Migration Preview**: Analyze what will change before applying updates
|
||||
|
||||
### 📊 Advanced Debugging
|
||||
- **Traffic Proxying**: Intercept and log all device communications
|
||||
- **Real-time Monitoring**: Live device event streaming and status tracking
|
||||
- **Analytics Dashboard**: Usage statistics and error reporting
|
||||
- **Debug Tools**: Comprehensive troubleshooting utilities
|
||||
|
||||
### 🌐 Web Management Interface
|
||||
- **Device Dashboard**: Visual overview of all discovered devices
|
||||
- **Migration Wizard**: Step-by-step guided device configuration
|
||||
- **Live Monitoring**: Real-time device status and event streaming
|
||||
- **Configuration Viewer**: Inspect and modify device settings
|
||||
|
||||
## 🚨 Why This Matters
|
||||
|
||||
### Bose Cloud Service Discontinuation
|
||||
Bose has announced that [SoundTouch cloud support will end on May 6, 2026](https://www.bose.com/soundtouch-end-of-life). This service provides a complete local alternative, ensuring your devices continue to work with full functionality beyond the official support timeline.
|
||||
|
||||
### Enhanced Privacy & Control
|
||||
- **Local Processing**: All data stays on your network
|
||||
- **No External Dependencies**: Operate completely offline
|
||||
- **Custom Integrations**: Build your own automation and controls
|
||||
- **Traffic Visibility**: See exactly what your devices are doing
|
||||
|
||||
## 🛠️ Installation & Quick Start
|
||||
|
||||
### Install
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
```
|
||||
|
||||
### Run
|
||||
```bash
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
### Access Web UI
|
||||
Open `http://localhost:8000` in your browser and start managing your devices!
|
||||
|
||||
## 📖 Implementation Credits
|
||||
|
||||
This service implementation builds upon excellent community work:
|
||||
|
||||
### 🍾 SoundCork Foundation
|
||||
Our implementation is heavily inspired by and based on [SoundCork](https://github.com/deborahgu/soundcork) by Deborah Gu and contributors. SoundCork pioneered the approach of intercepting Bose's cloud services and provided the architectural foundation for offline SoundTouch operation.
|
||||
|
||||
**Key contributions from SoundCork:**
|
||||
- Service emulation architecture
|
||||
- BMX/Marge endpoint discovery
|
||||
- Device migration strategies
|
||||
- Python implementation reference
|
||||
|
||||
### 🎵 ÜberBöse API Insights
|
||||
[ÜberBöse API](https://github.com/julius-d/ueberboese-api) by Julius D. provided valuable insights into advanced SoundTouch API endpoints, helping make our implementation more complete and robust.
|
||||
|
||||
### 🏠 SoundTouch Plus Documentation
|
||||
The [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) provided comprehensive API documentation that enabled many of the advanced features.
|
||||
|
||||
## 🔄 What's Different in Our Go Implementation
|
||||
|
||||
While inspired by SoundCork's Python implementation, our Go service offers:
|
||||
|
||||
### Performance & Efficiency
|
||||
- **Native Compilation**: Single binary deployment with no runtime dependencies
|
||||
- **Low Resource Usage**: ~50MB memory footprint vs Python's higher overhead
|
||||
- **Concurrent Processing**: Go's goroutines enable efficient concurrent device handling
|
||||
- **Fast Startup**: Sub-second service startup time
|
||||
|
||||
### Enhanced Features
|
||||
- **Web Management UI**: Built-in browser-based interface (SoundCork is API-only)
|
||||
- **Real-time Event Streaming**: WebSocket-based live device monitoring
|
||||
- **Advanced Migration Tools**: Migration preview and rollback capabilities
|
||||
- **Comprehensive Logging**: Structured logging with multiple output formats
|
||||
|
||||
### Production Readiness
|
||||
- **Zero Dependencies**: Single binary with embedded web UI
|
||||
- **Cross-Platform**: Windows, macOS, Linux support out of the box
|
||||
- **Docker Ready**: Containerization support (planned)
|
||||
- **Monitoring Integration**: Health checks and metrics endpoints
|
||||
|
||||
### Developer Experience
|
||||
- **Go Ecosystem**: Integrates with existing Go applications and infrastructure
|
||||
- **Type Safety**: Compile-time checks and robust error handling
|
||||
- **Documentation**: Comprehensive API documentation and examples
|
||||
- **Testing**: Extensive test coverage with real device validation
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
### Home Automation Enthusiasts
|
||||
```bash
|
||||
# Migrate all devices and integrate with Home Assistant
|
||||
soundtouch-service
|
||||
# Configure HA to use local service endpoints
|
||||
```
|
||||
|
||||
### Developers & Integrators
|
||||
```go
|
||||
// Build custom applications on top of local services
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Get("http://localhost:8000/devices")
|
||||
```
|
||||
|
||||
### Privacy-Conscious Users
|
||||
```bash
|
||||
# Run completely offline with full device functionality
|
||||
soundtouch-service --bind 127.0.0.1 # localhost only
|
||||
```
|
||||
|
||||
### Network Administrators
|
||||
```bash
|
||||
# Monitor and log all device traffic
|
||||
LOG_PROXY_BODY=true soundtouch-service
|
||||
```
|
||||
|
||||
## 🚀 Future Plans
|
||||
|
||||
- **Docker Images**: Official container images for easy deployment
|
||||
- **Cluster Support**: Multi-instance deployment for high availability
|
||||
- **Advanced Analytics**: Machine learning-powered usage insights
|
||||
- **Extended Protocol Support**: Additional Bose protocol implementations
|
||||
- **Mobile App**: Companion mobile application for device management
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **[Complete Service Guide](guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
|
||||
- **[API Reference](guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
|
||||
- **[Migration Guide](guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
|
||||
- **[Troubleshooting](guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions to improve the service! Areas where help is especially appreciated:
|
||||
|
||||
- **Protocol Research**: Discovering new Bose service endpoints
|
||||
- **Testing**: Validation with different device models and firmware versions
|
||||
- **Documentation**: Usage examples and troubleshooting guides
|
||||
- **Features**: Additional service implementations and integrations
|
||||
|
||||
## 🙏 Community Thanks
|
||||
|
||||
This implementation wouldn't have been possible without the groundbreaking work of the SoundTouch community:
|
||||
|
||||
- **SoundCork Team**: For pioneering service interception and providing the implementation blueprint
|
||||
- **ÜberBöse Project**: For advanced API research and endpoint discovery
|
||||
- **SoundTouch Plus**: For comprehensive API documentation and real-world usage patterns
|
||||
- **Community Contributors**: For testing, feedback, and continued development
|
||||
|
||||
The collaborative spirit of reverse engineering and documentation in the SoundTouch community has been invaluable. We're proud to contribute back to this ecosystem and help ensure SoundTouch devices remain useful beyond Bose's official support timeline.
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- **[Main Repository](https://github.com/gesellix/bose-soundtouch)**
|
||||
- **[Service Documentation](guides/SOUNDTOUCH-SERVICE.md)**
|
||||
- **[CLI Documentation](guides/CLI-REFERENCE.md)**
|
||||
- **[Getting Started Guide](guides/GETTING-STARTED.md)**
|
||||
- **[SoundCork Project](https://github.com/deborahgu/soundcork)**
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)**
|
||||
|
||||
---
|
||||
|
||||
**Ready to take control of your SoundTouch devices?** Get started with `soundtouch-service` today!
|
||||
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
|
||||
@@ -0,0 +1,67 @@
|
||||
# Table of Contents
|
||||
|
||||
* [Introduction](README.md)
|
||||
|
||||
## User Guides
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
* [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
* [Getting Started](guides/GETTING-STARTED.md)
|
||||
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
|
||||
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
* [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
* [Deployment](guides/DEPLOYMENT.md)
|
||||
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
|
||||
* [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
* [Useful Links](#useful-links)
|
||||
|
||||
### Useful Links
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
|
||||
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
|
||||
* [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
|
||||
## Technical Reference
|
||||
* [API Cookbook](reference/API-COOKBOOK.md)
|
||||
* [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
* [Cloud API Emulation](reference/CLOUD-API.md)
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
* [Discovery](reference/DISCOVERY.md)
|
||||
* [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
* [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
* [Source Selection](reference/SOURCE-SELECTION.md)
|
||||
* [Volume Controls](reference/VOLUME-CONTROLS.md)
|
||||
* [RadioBrowser](reference/radio-browser.md)
|
||||
* [Bass Controls](reference/BASS-CONTROLS.md)
|
||||
* [Key Controls](reference/KEY-CONTROLS.md)
|
||||
* [Feature Mapping](reference/FEATURE-MAPPING.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
* [Supported URLs](analysis/SUPPORTED-URLS.md)
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
|
||||
* [Claude Instructions](CLAUDE.md)
|
||||
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
|
||||
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
|
||||
* [Device Logging](DEVICE-LOGGING.md)
|
||||
* [Feature History](FEATURE_HISTORY.md)
|
||||
* [Host/Port Parsing](HOST-PORT-PARSING.md)
|
||||
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
|
||||
* [Navigation Guide](NAVIGATION-GUIDE.md)
|
||||
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
|
||||
* [Preset Quickstart](PRESET-QUICKSTART.md)
|
||||
* [Project Patterns](PROJECT-PATTERNS.md)
|
||||
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
|
||||
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
|
||||
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
|
||||
* [Preset Store](preset-store.md)
|
||||
@@ -0,0 +1,46 @@
|
||||
# Undocumented Community Features & API Discoveries
|
||||
This document captures advanced API endpoints and device behaviors discovered by the SoundTouch community through reverse engineering projects like **SoundCork** and **ÜberBöse API**. These features are not documented in the official Bose SoundTouch Web API v1.0 but are crucial for full device emulation and offline operation.
|
||||
## Cloud Emulation (Marge/BMX) Discoveries
|
||||
While the local `/8090` API is well-documented, the cloud-side service emulation reveals deeper device integration points.
|
||||
### 1. Stereo Pairing & Cloud-Side Grouping
|
||||
SoundCork has pioneered the emulation of "Marge" group endpoints, which differ from the local `/getGroup` API. These are primarily used for persistent configurations like **Stereo Pairs** (e.g., two ST-10s).
|
||||
- **GET** `/marge/streaming/account/{account}/device/{device}/group`
|
||||
Returns `<group/>` if ungrouped, or full group configuration for stereo pairs.
|
||||
- **POST** `/marge/streaming/account/{account}/group`
|
||||
Creates a new group (returns a 7-digit group ID). Used for initial pairing.
|
||||
- **DELETE** `/marge/streaming/account/{account}/group/{group}`
|
||||
Dissolves a group configuration.
|
||||
### 2. Device Analytics & Event Reporting
|
||||
Devices report real-time telemetry to the cloud. Intercepting these provides a window into device usage without polling.
|
||||
- **Endpoint**: `POST /v1/scmudc/{deviceId}`
|
||||
- **Function**: Submits event data including `play-state-changed`, `preset-pressed`, `power-pressed`, `source-state-changed`, and `art-changed` (Metadata updates). This endpoint was first extensively documented in the **ÜberBöse API** specification.
|
||||
### 3. Power-On Lifecycle
|
||||
When a SoundTouch device boots or "powers on" (distinct from waking from standby), it contacts specific support endpoints.
|
||||
- **Endpoint**: `POST /streaming/support/power_on`
|
||||
- **Behavior**: Reports device serial number, IP address, and diagnostic data.
|
||||
- **Critical Finding**: SoundTouch devices fetch `TUNEIN` and `LOCAL_INTERNET_RADIO` source availability from the cloud **ONLY at boot time**. If the cloud is unreachable during a hard reboot (power cycle), these sources will disappear from the device's `/sources` list and become unavailable, even if the local API is working. This behavior was analyzed and reported by the **ÜberBöse API** project (Issue #3).
|
||||
### 4. OAuth & Service Tokens
|
||||
Integration with music services (Spotify, Pandora, etc.) involves specific token management endpoints.
|
||||
- **Endpoint**: `POST /oauth/device/{deviceId}/music/musicprovider/{providerId}/token/{tokenType}`
|
||||
- **Usage**: Used to refresh or validate session tokens for cloud-based music providers.
|
||||
## Community-Driven Extensions
|
||||
The community is working on extending SoundTouch functionality beyond its original design.
|
||||
### 1. Radio-Browser.info Integration
|
||||
There is an active effort to add `radio-browser.info` as a native `sourceprovider`. This would allow devices to browse a massive directory of thousands of stations without relying on the TuneIn cloud service.
|
||||
- **Status**: Research phase in SoundCork (Issue #150).
|
||||
- **Implementation**: Requires adding a new source provider entry in the emulated `/streaming/sourceproviders` response.
|
||||
### 2. Stockholm Internal App Analysis
|
||||
Deep analysis of the Stockholm (device firmware) internal web application reveals a set of internal AJAX/XML calls used by the device's own control interface.
|
||||
- **Internal Domains**: `Marge` (XML-based) and `Gabbo` (App-send based).
|
||||
- **Reference**: See SoundCork Issue #128 for a comprehensive list of internal JS controllers and their functions.
|
||||
### 3. ETag Case-Sensitivity Bug
|
||||
The SoundTouch device firmware has a case-sensitivity bug regarding HTTP `ETag` headers.
|
||||
- **Discovery**: SoundCork Issue #129.
|
||||
- **Detail**: The device expects the `ETag` header to be exactly title-cased. If a server returns `etag` (lowercase), the device fails to use it for `If-None-Match` requests, breaking efficient preset synchronization.
|
||||
- **Solution**: Force title-casing of the header via a reverse proxy like Nginx or mitmproxy.
|
||||
## References
|
||||
- [SoundCork GitHub Repo](https://github.com/deborahgu/soundcork)
|
||||
- [ÜberBöse API Spec](https://github.com/julius-d/ueberboese-api)
|
||||
- [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [IsItBose Regex Research](https://github.com/deborahgu/soundcork/issues/62#issuecomment-3610563908)
|
||||
- [SoundTouch Hook Repo](https://github.com/CodeFinder2/bose-soundtouch-hook)
|
||||
@@ -267,24 +267,7 @@ Rates currently playing media (Pandora only).
|
||||
|
||||
### System Information
|
||||
|
||||
#### GET /recents 🔥 **CRITICAL**
|
||||
Returns recently played media content.
|
||||
|
||||
**Response Example:**
|
||||
```xml
|
||||
<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701202831">
|
||||
<contentItem source="STORED_MUSIC" location="6_a2874b5d_4f83d999" sourceAccount="d09708a1-5953-44bc-a413-123456789012/0" isPresetable="true">
|
||||
<itemName>MercyMe, It's Christmas!</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1700232917" id="2487503626">
|
||||
<contentItem source="LOCAL_MUSIC" type="track" location="track:2590" sourceAccount="3f205110-4a57-4e91-810a-123456789012" isPresetable="true">
|
||||
<itemName>Baby It's Cold Outside - ANNE MURRAY</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>
|
||||
```
|
||||
|
||||
#### GET /listMediaServers 🔥 **CRITICAL**
|
||||
Returns detected UPnP/DLNA media servers.
|
||||
@@ -323,22 +306,7 @@ Returns source service availability status.
|
||||
</serviceAvailability>
|
||||
```
|
||||
|
||||
#### POST /introspect 🔥 **CRITICAL**
|
||||
Retrieves introspect data for specified music service.
|
||||
|
||||
**Request Example:**
|
||||
```xml
|
||||
<introspect source="SPOTIFY" sourceAccount="SpotifyConnectUserName" />
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```xml
|
||||
<spotifyAccountIntrospectResponse state="InactiveUnselected" user="SpotifyConnectUserName" isPlaying="false" tokenLastChangedTimeSeconds="1702566495" tokenLastChangedTimeMicroseconds="427884" shuffleMode="OFF" playStatusState="2" currentUri="" receivedPlaybackRequest="false" subscriptionType="">
|
||||
<cachedPlaybackRequest />
|
||||
<nowPlaying skipPreviousSupported="false" seekSupported="false" resumeSupported="true" collectData="true" />
|
||||
<contentItemHistory maxSize="10" />
|
||||
</spotifyAccountIntrospectResponse>
|
||||
```
|
||||
|
||||
### Power Management
|
||||
|
||||
@@ -402,7 +370,7 @@ soundtouch-cli speaker beep
|
||||
**Go Client Usage:**
|
||||
```go
|
||||
// Text-to-Speech
|
||||
client.PlayTTS("Hello World", "your-app-key", 70)
|
||||
client.PlayTTS("Hello World", "your-app-key", "EN", 70)
|
||||
|
||||
// URL content
|
||||
client.PlayURL("https://example.com/audio.mp3", "your-app-key", "Service", "Message", "Reason", 60)
|
||||
@@ -1076,4 +1044,4 @@ The SoundTouch Plus Wiki provides comprehensive documentation for **64 additiona
|
||||
|
||||
This documentation provides the complete foundation for implementing all endpoints from the SoundTouch Plus Wiki, enabling this Go library to become the definitive SoundTouch integration solution for everything from basic home automation to professional audio installations.
|
||||
|
||||
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
|
||||
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
title: Bose SoundTouch Toolkit
|
||||
description: Documentation for controlling and preserving Bose SoundTouch devices
|
||||
remote_theme: pages-themes/minimal@v0.2.0
|
||||
plugins:
|
||||
- jekyll-remote-theme
|
||||
- jekyll-relative-links
|
||||
relative_links:
|
||||
enabled: true
|
||||
collections: true
|
||||
include:
|
||||
- SUMMARY.md
|
||||
@@ -0,0 +1,198 @@
|
||||
# Device Redirect Methods & Custom Service Setup
|
||||
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
## Overview of Redirection Targets
|
||||
|
||||
SoundTouch devices primarily communicate with the following domains:
|
||||
- `streaming.bose.com`: Marge (Account and streaming services)
|
||||
- `updates.bose.com`: Software updates
|
||||
- `stats.bose.com`: Telemetry and analytics
|
||||
- `bmx.bose.com`: Bose Media eXchange registry
|
||||
- `events.api.bosecm.com`: Stockholm app analytics
|
||||
- `bose-prod.apigee.net`: Apigee gateway (used by some services)
|
||||
- `worldwide.bose.com`: Software update metadata and secondary services
|
||||
|
||||
---
|
||||
|
||||
## Method 1: XML Configuration Modification (Recommended)
|
||||
|
||||
The most robust and granular method involves modifying the device's private configuration file. This is the primary method used by **SoundCork**'s migration logic to redirect devices to a local service instance.
|
||||
|
||||
### Technical Details
|
||||
- **File Path**: `/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml`
|
||||
- **Mechanism**: The device firmware reads this XML file at boot to determine service URLs.
|
||||
- **Fields to Modify**:
|
||||
- `<margeServerUrl>`: Redirects account/streaming calls.
|
||||
- `<statsServerUrl>`: Redirects telemetry.
|
||||
- `<swUpdateUrl>`: Redirects update checks.
|
||||
- `<bmxRegistryUrl>`: Redirects service discovery.
|
||||
|
||||
### Implementation
|
||||
Requires SSH access to the device.
|
||||
```xml
|
||||
<SoundTouchSdkPrivateCfg>
|
||||
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
|
||||
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
</SoundTouchSdkPrivateCfg>
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
|
||||
---
|
||||
|
||||
## Method 2: `/etc/hosts` DNS Override
|
||||
|
||||
This method uses the standard Linux hosts file to redirect traffic at the network level within the device. It is often used as a quick alternative in the **ÜberBöse API** community for global redirection.
|
||||
|
||||
### Technical Details
|
||||
- **File Path**: `/etc/hosts`
|
||||
- **Mechanism**: Overrides DNS resolution for Bose domains to point to a local IP.
|
||||
- **Resolution Order**: SoundTouch devices use the standard Linux Name Service Switch (`/etc/nsswitch.conf`). The default configuration (`hosts: files dns`) ensures that `/etc/hosts` is consulted *before* any external DNS lookups. This makes the redirection highly reliable for all system processes, including `curl`, `BoseApp`, and `IoT`.
|
||||
|
||||
### Implementation
|
||||
Requires SSH access. Add entries for the target domains:
|
||||
```text
|
||||
192.168.1.10 streaming.bose.com
|
||||
192.168.1.10 updates.bose.com
|
||||
192.168.1.10 stats.bose.com
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
|
||||
---
|
||||
|
||||
## Method 3: Binary Patching
|
||||
|
||||
A low-level approach where the actual compiled binaries (e.g., `BoseApp`, `IoT`) are modified to change hardcoded URL patterns. Research into these patterns has been documented in both **SoundCork** (Issue #128) and **ÜberBöse API** research.
|
||||
|
||||
### Technical Details
|
||||
- **Target Binaries**: `/opt/Bose/BoseApp`, `/opt/Bose/IoT`, `/opt/Bose/lib/libBmxAccountHsm.so`
|
||||
- **Mechanism**:
|
||||
- **URL Replacement**: Using a hex editor to search for string patterns like `https://streaming.bose.com` and replacing them with a custom URL of the **exact same length**.
|
||||
- **Regex Neutralization**: Some libraries (like `libBmxAccountHsm.so`) perform a validation check called `IsItBose` using a hardcoded regex. This regex prevents the device from connecting to non-Bose domains even if the URL is changed in the configuration.
|
||||
|
||||
#### The `IsItBose` Regex Patch
|
||||
Research in the **SoundCork** community (Issue #62) identified a specific regex in `libBmxAccountHsm.so` that enforces Bose/Apigee domain usage:
|
||||
`^https:\/\/bose-[a-zA-Z0-9\.\_\-\$\%]\+\.apigee\.net\/`
|
||||
|
||||
By patching this regex to be more "lax", the device can be made to accept any custom domain.
|
||||
|
||||
**Example Patch**:
|
||||
Using `sed` to replace the strict regex with a broad match while preserving the original string length:
|
||||
```bash
|
||||
sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]*#g" \
|
||||
< libBmxAccountHsm.so.orig > libBmxAccountHsm.so.patched
|
||||
```
|
||||
|
||||
### Implementation
|
||||
1. Copy the target binary or library from the device to a PC.
|
||||
2. Use a hex editor or `sed` to locate and patch the URL strings or regex patterns.
|
||||
3. Copy the patched file back to the device.
|
||||
4. Restore execution permissions and reboot.
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
|
||||
---
|
||||
|
||||
## Comparison & Usage Strategy
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
| :--- | :--- | :---: | :---: | :---: | :---: |
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## Combining Methods: When is one not enough?
|
||||
|
||||
A common question is whether these methods can be used in isolation or if they must be combined. The answer depends on your specific firmware version and the target service.
|
||||
|
||||
### Scenario A: XML Config Only (The Ideal Case)
|
||||
If your firmware does not strictly enforce the `IsItBose` check for the specific URLs you are changing, **Method 1 (XML)** is sufficient. This is the cleanest approach and is used by the `soundtouch-service` migration tool.
|
||||
|
||||
### Scenario B: XML Config + Binary Patching (The "Locked" Case)
|
||||
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.168.1.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
|
||||
* **Symptom**: The device ignores the XML setting or fails to connect despite the correct URL being present.
|
||||
* **Solution**: You **must** apply the **Binary Patch (Method 3)** to neutralize the `IsItBose` check *in addition* to the XML change.
|
||||
|
||||
### Scenario C: `/etc/hosts` + Custom CA (The "Clean Deep Redirect")
|
||||
If you use `/etc/hosts` to point `streaming.bose.com` to a local IP and want to avoid binary patching.
|
||||
* **Requirement 1**: Your local server must handle HTTPS (port 443).
|
||||
* **Requirement 2**: You must inject your Root CA into the device's trust store.
|
||||
* **Automated Tool**: The `soundtouch-service` now supports this via the `/setup/migrate/{deviceIP}?method=hosts` endpoint.
|
||||
* **CA Download**: You can download the auto-generated Root CA from `http://<your-server>:8000/setup/ca.crt`.
|
||||
* **Benefit**: Maintains system integrity (no binary changes) and full end-to-end encryption.
|
||||
|
||||
### Scenario D: `/etc/hosts` + Binary Patching (The "Legacy Deep Redirect")
|
||||
If you cannot or do not want to manage certificates, but still use `/etc/hosts` for DNS redirection.
|
||||
* **Requirement 1**: Your local server must handle HTTPS (port 443).
|
||||
* **Requirement 2**: Since the certificate will be invalid (mismatched domain/CA), you must patch the binary to **skip SSL verification** (see [Option 2](#option-2-ssl-verification-bypass) below).
|
||||
* **Risk**: Less secure and higher risk of bricking due to binary modification.
|
||||
|
||||
### Scenario E: The Triple-Threat (Total Control)
|
||||
For developers creating a completely isolated "dark" environment (no internet at all):
|
||||
1. **XML**: Point all URLs to local services.
|
||||
2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs.
|
||||
3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud.
|
||||
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. This is particularly useful for handling unknown hostnames or deep-hooking into service discovery logic that might bypass standard DNS lookups.
|
||||
|
||||
---
|
||||
|
||||
## Handling HTTPS & SSL Certificates
|
||||
|
||||
When redirecting HTTPS traffic to a custom service, SoundTouch devices will fail the SSL handshake because they do not trust your local server's certificate.
|
||||
|
||||
### Option 1: Custom CA Certificate (Recommended)
|
||||
|
||||
As suggested by community members, you can configure the device to trust your own Root CA. This allows for secure HTTPS communication without patching binaries.
|
||||
|
||||
**Technical Steps**:
|
||||
1. **Generate a Root CA** and issue a certificate for the target domain (e.g., `streaming.bose.com`).
|
||||
2. **SSH into the device** and copy your `rootCA.crt` to `/usr/share/ca-certificates/custom/`.
|
||||
3. **Update the Trust Store**:
|
||||
- **Method A (Append to Bundle)**: `cat /usr/share/ca-certificates/custom/rootCA.crt >> /etc/pki/tls/certs/ca-bundle.crt`
|
||||
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
|
||||
|
||||
**Pros & Cons**:
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
|
||||
|
||||
### Option 2: SSL Verification Bypass
|
||||
|
||||
If you cannot or do not want to manage certificates, you can patch the binary to skip certificate verification.
|
||||
|
||||
**Target**: `libBmxAccountHsm.so` or `BoseApp`
|
||||
**Mechanism**: Locating the SSL verification function (often in the internal curl-based or openssl-based logic) and forcing it to return "Success" regardless of the certificate status.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
1. **Start with Method 1 (XML Modification)**. It is the least invasive and most likely to work across different models.
|
||||
2. **Verify connectivity**. If the device refuses to connect to your custom endpoint, check logs for "IsItBose" or validation failures.
|
||||
3. **Apply Method 3 (Binary Patching)** only if Method 1 is being actively blocked by the firmware's validation logic.
|
||||
4. **Avoid Method 2 (`/etc/hosts`)** unless you are prepared to handle SSL certificate complexities or are performing quick temporary tests.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Upstream URLs & Domains Analysis
|
||||
|
||||
This document provides a comprehensive overview of the upstream Bose cloud services and domains that SoundTouch devices communicate with. These details were gathered from firmware analysis of ST10/ST20 devices, binary string extraction, and community research from the **SoundCork** project (Issue #128).
|
||||
|
||||
## Core Service Domains
|
||||
|
||||
SoundTouch devices use a set of primary domains for their operation. These are often configurable via the `SoundTouchSdkPrivateCfg.xml` file.
|
||||
|
||||
| Service | Primary Domain | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| **Marge** | `streaming.bose.com` | Account management, streaming source providers, and preset sync. |
|
||||
| **BMX Registry** | `content.api.bose.io` | Bose Media eXchange service discovery and registry. |
|
||||
| **Stats/Analytics** | `events.api.bosecm.com` | Telemetry, device events, and usage statistics. |
|
||||
| **Software Update** | `worldwide.bose.com` | Firmware update checks and downloads (path: `/updates/soundtouch`). |
|
||||
| **Voice/Alexa** | `voice.api.bose.io` | Token management for Amazon Alexa integration. |
|
||||
|
||||
## Internal & Development Domains
|
||||
|
||||
Analysis of device binaries (`BoseApp`, `IoT`) and community findings revealed several internal, integration, and development domains used by Bose.
|
||||
|
||||
### Marge & Auth Proxies
|
||||
- `bose-test.apigee.net/margeproxy` (Integration/Test proxy)
|
||||
- `bose-test.apigee.net/margeproxyefe`
|
||||
- `streamingstg.bose.com` (Staging)
|
||||
- `streamingintoauth.bose.com` (Internal Auth)
|
||||
- `streamingefeintoauth.bose.com` (Internal EFE Auth)
|
||||
- `streamingefeint.bose.com`
|
||||
|
||||
### BMX & Content Registry
|
||||
- `test.content.api.bose.io`
|
||||
- `content.api.bose.io/bmx/registry/v1/services`
|
||||
- `test.content.api.bose.io/bmx/int-registry/v1/services`
|
||||
- `test.content.api.bose.io/bmx/efe-registry/v1/services`
|
||||
|
||||
### Stats & Analytics
|
||||
- `eventsdev.api.bosecm.com`
|
||||
- `eventsefe.api.bosecm.com`
|
||||
- `eventsdev.bosecm.com`
|
||||
|
||||
### Software Updates
|
||||
- `worldwide.bose.com/updates/soundtouch-int`
|
||||
- `worldwide.bose.com/updates/soundtouch-efe`
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
Devices also communicate directly with third-party providers for specific features.
|
||||
|
||||
- **Pandora**:
|
||||
- `device-tuner.pandora.com`
|
||||
- `device-tuner-beta.savagebeast.com`
|
||||
- **Amazon AVS**:
|
||||
- `avs.na.amazonalexa.com`
|
||||
|
||||
## Hardcoded Validation (IsItBose)
|
||||
|
||||
As documented in [DEVICE-REDIRECT-METHODS.md](DEVICE-REDIRECT-METHODS.md#method-3-binary-patching), the `libBmxAccountHsm.so` library contains a hardcoded regex to validate these URLs:
|
||||
|
||||
`^https:\/\/bose-[a-zA-Z0-9\.\_\-\$\%]\+\.apigee\.net\/`
|
||||
|
||||
This regex ensures that certain critical services must reside on the `apigee.net` domain under a `bose-` prefix, unless patched.
|
||||
|
||||
## Configuration File References
|
||||
|
||||
On-device, these URLs are primarily managed in the following files:
|
||||
|
||||
1. **`/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml`**:
|
||||
* `<margeServerUrl>`
|
||||
* `<statsServerUrl>`
|
||||
* `<swUpdateUrl>`
|
||||
* `<bmxRegistryUrl>`
|
||||
2. **`/opt/Bose/etc/Voice.xml`**:
|
||||
* `<TPDATokenUrl>` (Points to `voice.api.bose.io`)
|
||||
3. **`/opt/Bose/etc/HandCraftedWebServer-SoundTouch.xml`**:
|
||||
* Contains internal local API mapping.
|
||||
|
||||
## Conclusion for Offline Operation
|
||||
|
||||
To achieve full offline operation or redirection to a custom service (like `soundtouch-service`), all of the above domains must either be redirected via DNS (`/etc/hosts`) or updated in the device's XML configuration files. For domains not exposed in XML, binary patching or DNS-level redirection is the only option.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
- [SoundCork Issue #128: Endpoint and URL Listing](https://github.com/deborahgu/soundcork/issues/128#issuecomment-3892933337)
|
||||
- [Bose SoundTouch Web API v1.0 Specification](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
|
||||
@@ -0,0 +1,52 @@
|
||||
### Stockholm App Analysis Report
|
||||
|
||||
#### 1. Overview
|
||||
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
|
||||
|
||||
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
|
||||
- **Key Libraries**:
|
||||
- **jQuery**: Core DOM manipulation and event handling.
|
||||
- **iScroll**: Used for smooth scrolling in lists and carousels.
|
||||
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
|
||||
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
|
||||
|
||||
#### 2. Directory Structure
|
||||
- `js/`: Core application logic.
|
||||
- `app/`: Main application entry point (`app.js`).
|
||||
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
|
||||
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
|
||||
- `views/`: UI view templates and logic.
|
||||
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
|
||||
- `json/`: Configuration files and static data.
|
||||
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
|
||||
- `sourceFeatures.json`: Capability mapping for different sources.
|
||||
- `setup/`: Onboarding and initial device setup logic.
|
||||
- `lang/`: Localization files for multi-language support.
|
||||
|
||||
#### 3. Communication Architecture
|
||||
The app uses several communication channels to interact with the SoundTouch ecosystem:
|
||||
|
||||
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
|
||||
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
|
||||
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
|
||||
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
|
||||
|
||||
#### 4. Key Features & Functionality
|
||||
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
|
||||
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
|
||||
- **Preset Management**: Browsing and setting presets directly from the UI.
|
||||
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
|
||||
- **Onboarding**: A dedicated setup flow for new devices.
|
||||
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
|
||||
|
||||
#### 5. Integration Opportunities for Bose-SoundTouch Project
|
||||
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
|
||||
|
||||
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
|
||||
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
|
||||
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
|
||||
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
|
||||
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
|
||||
|
||||
#### 6. Conclusion
|
||||
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Merging Bose-SoundTouch-API into Bose-SoundTouch
|
||||
|
||||
This document outlines the plan to merge the [Bose-SoundTouch-API](https://github.com/gesellix/Bose-SoundTouch-API) project into this repository. The actual Go implementation in that repository is located in the `soundcork-go` subdirectory. The goal is to provide both a CLI (`soundtouch-cli`) and a service (`soundtouch-service`) from a single codebase.
|
||||
|
||||
## Goals
|
||||
|
||||
- [x] Maintain the existing `soundtouch-cli` functionality.
|
||||
- [x] Introduce `soundtouch-service` as a new command (based on the `soundcork-go` project).
|
||||
- [x] Consolidate shared logic (models, clients, discovery) into the `pkg/` directory.
|
||||
- [x] Simplify maintenance by having a single Go module and shared CI/CD pipeline.
|
||||
|
||||
## Current Directory Structure
|
||||
|
||||
```text
|
||||
.
|
||||
├── cmd/
|
||||
│ ├── soundtouch-cli/ # Existing CLI implementation
|
||||
│ │ └── main.go
|
||||
│ └── soundtouch-service/ # New service implementation (REST API / Websocket)
|
||||
│ └── main.go
|
||||
├── pkg/
|
||||
│ ├── client/ # Shared SoundTouch API client
|
||||
│ ├── models/ # Shared data models
|
||||
│ ├── discovery/ # Shared device discovery logic
|
||||
│ └── service/ # Service-specific logic (from Bose-SoundTouch-API)
|
||||
│ ├── bmx/ # BMX service logic
|
||||
│ ├── marge/ # Marge service logic
|
||||
│ ├── datastore/ # Device and configuration storage
|
||||
│ ├── proxy/ # Logging proxy logic
|
||||
│ ├── setup/ # Device setup and migration logic
|
||||
│ └── handlers/ # HTTP handlers (adapted from soundcork-go/soundcork-go)
|
||||
│ └── soundcork/ # Embedded resources (index.html, media/, etc.)
|
||||
├── docs/
|
||||
│ └── MERGE_PROJECTS.md # This document
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
## Step-by-Step Merge Status
|
||||
|
||||
### 1. Preparation
|
||||
- [x] Review `go.mod` in both projects to identify dependency overlaps and conflicts.
|
||||
|
||||
### 2. Code Integration
|
||||
- [x] **Models & Client**: Merged missing functionality from `soundcork-go/internal/models` into `pkg/models`. Renamed overlapping models to `Service*` (e.g., `ServiceContentItem`, `ServicePreset`).
|
||||
- [x] **Service Logic**: Adapted internal packages from `soundcork-go/internal/` to `pkg/service/`.
|
||||
- [x] **Handlers**: Moved and adapted HTTP handlers into `pkg/service/handlers/`.
|
||||
- [x] **New Command**: Created `cmd/soundtouch-service/main.go` as the service entry point using `chi` router.
|
||||
- [x] **Embedded Resources**: Integrated `index.html`, `bmx_services.json`, `swupdate.xml`, and `media/` folder into the binary using `//go:embed`.
|
||||
|
||||
### 3. Dependency Management
|
||||
- [x] Update `go.mod` to include:
|
||||
- `github.com/go-chi/chi/v5`
|
||||
- `github.com/srwiley/oksvg` and `github.com/srwiley/rasterx`
|
||||
- `golang.org/x/crypto`
|
||||
- [x] Run `go mod tidy` to clean up dependencies.
|
||||
|
||||
### 4. Shared Logic Refactoring
|
||||
- [x] Identify common code between `soundtouch-cli` and the new service.
|
||||
- [x] Move shared logic into `pkg/` to ensure both commands use the same underlying implementation.
|
||||
|
||||
### 5. Documentation & Examples
|
||||
- [x] Update `README.md` to mention the new `soundtouch-service` command.
|
||||
- [x] Add service-specific documentation in `docs/SOUNDTOUCH-SERVICE.md`.
|
||||
- [x] Provide examples of how to run and interact with the service in `examples/service-demo/`.
|
||||
|
||||
### 6. CI/CD Updates
|
||||
- [x] Update `.github/workflows/release.yml` to build and release the `soundtouch-service` binary alongside `soundtouch-cli`.
|
||||
- [x] Update any test workflows to include tests for the service logic.
|
||||
|
||||
## Verification
|
||||
- [x] `go build ./cmd/soundtouch-cli` works as expected.
|
||||
- [x] `go build ./cmd/soundtouch-service` works as expected.
|
||||
- [x] All tests pass: `go test ./...`.
|
||||
- [x] Resources are correctly served from the embedded filesystem.
|
||||
@@ -784,4 +784,4 @@ docker-compose up # Mock devices + web app
|
||||
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
|
||||
- [Go Embed Directive](https://pkg.go.dev/embed)
|
||||
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
|
||||
- [PROJECT-PATTERNS.md](./PROJECT-PATTERNS.md) - Detailed pattern documentation
|
||||
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
|
||||
@@ -206,14 +206,14 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
|
||||
### ✅ Complete Documentation
|
||||
- `README.md` - Project overview and usage examples ✅
|
||||
- `docs/API-Endpoints-Overview.md` - API reference with status ✅
|
||||
- `docs/KEY-CONTROLS.md` - Media control implementation ✅
|
||||
- `docs/VOLUME-CONTROLS.md` - Volume management guide ✅
|
||||
- `docs/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
|
||||
- `docs/reference/API-ENDPOINTS.md` - API reference with status ✅
|
||||
- `docs/reference/KEY-CONTROLS.md` - Media control implementation ✅
|
||||
- `docs/guides/VOLUME-CONTROLS.md` - Volume management guide ✅
|
||||
- `docs/reference/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
|
||||
- `docs/HOST-PORT-PARSING.md` - Enhanced CLI feature ✅
|
||||
- `docs/PLAN.md` - Development roadmap (updated) ✅
|
||||
- `docs/archive/PLAN.md` - Development roadmap (updated) ✅
|
||||
- `docs/PROJECT-PATTERNS.md` - Development guidelines ✅
|
||||
- `SPEAKER_ENDPOINT.md` - Complete speaker notification documentation ✅
|
||||
- `docs/reference/SPEAKER-ENDPOINT.md` - Complete speaker notification documentation ✅
|
||||
|
||||
### 📝 Documentation Notes
|
||||
- All docs are synchronized with current implementation
|
||||
@@ -108,8 +108,8 @@ Essential for browsing music libraries and searching content.
|
||||
// pkg/api/content.go (new file)
|
||||
func (c *Client) Navigate(source, sourceAccount string, options NavigateOptions) (*NavigateResponse, error)
|
||||
func (c *Client) Search(source, sourceAccount, searchTerm string, options SearchOptions) (*SearchResponse, error)
|
||||
func (c *Client) GetRecents() (*RecentsResponse, error)
|
||||
func (c *Client) Introspect(source, sourceAccount string) (*IntrospectResponse, error)
|
||||
func (c *Client) GetRecents() (*RecentsResponse, error) // ✅ IMPLEMENTED
|
||||
func (c *Client) Introspect(source, sourceAccount string) (*IntrospectResponse, error) // ✅ IMPLEMENTED
|
||||
```
|
||||
|
||||
#### Data Structures:
|
||||
@@ -174,6 +174,77 @@ soundtouch-cli --host 192.168.1.10 play now
|
||||
soundtouch-cli --host 192.168.1.10 play now --verbose
|
||||
```
|
||||
|
||||
### Recent Content
|
||||
|
||||
Recently played content management.
|
||||
|
||||
#### `recents <subcommand>`
|
||||
|
||||
Recently played content commands.
|
||||
|
||||
```bash
|
||||
# List recently played items
|
||||
soundtouch-cli --host <device> recents list [--limit <number>] [--detailed]
|
||||
|
||||
# Filter recent items by source or type
|
||||
soundtouch-cli --host <device> recents filter --source <SOURCE> [--type <TYPE>] [--limit <number>]
|
||||
|
||||
# Show only the most recent item
|
||||
soundtouch-cli --host <device> recents latest
|
||||
|
||||
# Show statistics about recent content
|
||||
soundtouch-cli --host <device> recents stats
|
||||
```
|
||||
|
||||
**Basic Usage Examples:**
|
||||
```bash
|
||||
# List last 10 recent items (default)
|
||||
soundtouch-cli --host 192.168.1.10 recents list
|
||||
|
||||
# Show all recent items with detailed information
|
||||
soundtouch-cli --host 192.168.1.10 recents list --limit 0 --detailed
|
||||
|
||||
# Show only the most recent item
|
||||
soundtouch-cli --host 192.168.1.10 recents latest
|
||||
```
|
||||
|
||||
**Filtering Examples:**
|
||||
```bash
|
||||
# Show only Spotify items
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --source SPOTIFY
|
||||
|
||||
# Show only tracks (no stations or playlists)
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --type track
|
||||
|
||||
# Show only presetable items
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --type presetable
|
||||
|
||||
# Show last 5 local music items
|
||||
soundtouch-cli --host 192.168.1.10 recents filter --source LOCAL_MUSIC --limit 5
|
||||
```
|
||||
|
||||
**Available Sources:**
|
||||
- `SPOTIFY` - Spotify streaming
|
||||
- `LOCAL_MUSIC` - Local music files
|
||||
- `STORED_MUSIC` - Stored music library
|
||||
- `TUNEIN` - TuneIn radio stations
|
||||
- `PANDORA` - Pandora music
|
||||
- `AMAZON` - Amazon Music
|
||||
- `DEEZER` - Deezer streaming
|
||||
|
||||
**Available Types:**
|
||||
- `track` - Individual songs
|
||||
- `station` - Radio stations
|
||||
- `playlist` - Music playlists
|
||||
- `album` - Music albums
|
||||
- `presetable` - Items that can be saved as presets
|
||||
|
||||
**Statistics Example:**
|
||||
```bash
|
||||
# Get detailed statistics about recent content
|
||||
soundtouch-cli --host 192.168.1.10 recents stats
|
||||
```
|
||||
|
||||
#### `presets` (Legacy)
|
||||
|
||||
Get configured presets (legacy command for backward compatibility).
|
||||
@@ -322,6 +393,12 @@ soundtouch-cli --host <device> source select --source <SOURCE> [--account <ACCOU
|
||||
soundtouch-cli --host <device> source spotify
|
||||
soundtouch-cli --host <device> source bluetooth
|
||||
soundtouch-cli --host <device> source aux
|
||||
|
||||
# Advanced content selection
|
||||
soundtouch-cli --host <device> source internet-radio --location <URL> [--name <NAME>]
|
||||
soundtouch-cli --host <device> source local-music --location <LOCATION> --account <ACCOUNT>
|
||||
soundtouch-cli --host <device> source stored-music --location <LOCATION> --account <ACCOUNT>
|
||||
soundtouch-cli --host <device> source content --source <SOURCE> --location <LOCATION>
|
||||
```
|
||||
|
||||
**Source Names:**
|
||||
@@ -329,9 +406,12 @@ soundtouch-cli --host <device> source aux
|
||||
- `BLUETOOTH` - Bluetooth input
|
||||
- `AUX` - AUX input
|
||||
- `AIRPLAY` - AirPlay
|
||||
- `STORED_MUSIC` - Local music library
|
||||
- `INTERNET_RADIO` - Internet radio
|
||||
- `PRODUCT` - Product-specific sources
|
||||
- `LOCAL_MUSIC` - SoundTouch App Media Server content
|
||||
- `LOCAL_INTERNET_RADIO` - Internet radio streams
|
||||
- `STORED_MUSIC` - UPnP/DLNA media server content
|
||||
- `TUNEIN` - TuneIn radio stations
|
||||
- `PANDORA` - Pandora music service
|
||||
- `PRODUCT` - Product-specific sources (TV, HDMI)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
@@ -346,8 +426,218 @@ soundtouch-cli --host 192.168.1.10 source select --source SPOTIFY --account user
|
||||
|
||||
# Select Bluetooth
|
||||
soundtouch-cli --host 192.168.1.10 source bluetooth
|
||||
|
||||
# Select internet radio with streamUrl format
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Radio Station" \
|
||||
--artwork "https://example.com/art.png"
|
||||
|
||||
# Select internet radio with direct stream URL
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "My Stream"
|
||||
|
||||
# Select local music content (requires SoundTouch App Media Server)
|
||||
soundtouch-cli --host 192.168.1.10 source local-music \
|
||||
--location "album:983" \
|
||||
--account "3f205110-4a57-4e91-810a-123456789012" \
|
||||
--name "Welcome to the New"
|
||||
|
||||
# Select stored music content (requires UPnP/DLNA media server)
|
||||
soundtouch-cli --host 192.168.1.10 source stored-music \
|
||||
--location "6_a2874b5d_4f83d999" \
|
||||
--account "d09708a1-5953-44bc-a413-123456789012/0" \
|
||||
--name "Christmas Album"
|
||||
|
||||
# Advanced content selection with all options
|
||||
soundtouch-cli --host 192.168.1.10 source content \
|
||||
--source LOCAL_INTERNET_RADIO \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "My Stream" \
|
||||
--type stationurl \
|
||||
--presetable
|
||||
|
||||
# Get introspect data for Spotify
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY
|
||||
|
||||
# Get introspect data with account
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account user@spotify.com
|
||||
|
||||
# Spotify introspect (convenience command)
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-spotify
|
||||
|
||||
# Get introspect data for all available services
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-all
|
||||
|
||||
# Check service availability
|
||||
soundtouch-cli --host 192.168.1.10 source availability
|
||||
|
||||
# Compare sources and availability
|
||||
soundtouch-cli --host 192.168.1.10 source compare
|
||||
```
|
||||
|
||||
**Content Selection Commands:**
|
||||
|
||||
| Command | Description | Requirements |
|
||||
|---------|-------------|--------------|
|
||||
| `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL |
|
||||
| `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server |
|
||||
| `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server |
|
||||
| `content` | Generic content selection (advanced) | Source and location |
|
||||
|
||||
**streamUrl Format Support:**
|
||||
|
||||
The `internet-radio` command supports the streamUrl proxy format from the [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format):
|
||||
|
||||
```bash
|
||||
# Using contentapi.gmuth.de proxy for complex streams
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout"
|
||||
```
|
||||
|
||||
#### Service Introspection
|
||||
|
||||
Get detailed information about music service states, user accounts, capabilities, and authentication status.
|
||||
|
||||
**Introspect Commands:**
|
||||
|
||||
```bash
|
||||
# Get introspect data for specific service
|
||||
soundtouch-cli --host <device> source introspect --source <SERVICE> [--account <ACCOUNT>]
|
||||
|
||||
# Spotify introspect (convenience)
|
||||
soundtouch-cli --host <device> source introspect-spotify [--account <ACCOUNT>]
|
||||
|
||||
# Get introspect data for all services
|
||||
soundtouch-cli --host <device> source introspect-all
|
||||
```
|
||||
|
||||
**Supported Services for Introspect:**
|
||||
- `SPOTIFY` - Spotify streaming service
|
||||
- `PANDORA` - Pandora music service
|
||||
- `TUNEIN` - TuneIn radio service
|
||||
- `AMAZON` - Amazon Music service
|
||||
- `DEEZER` - Deezer streaming service
|
||||
|
||||
**Introspect Information Includes:**
|
||||
- Service state (Active, Inactive, InactiveUnselected)
|
||||
- User account information
|
||||
- Current playback status and content URI
|
||||
- Service capabilities (skip, seek, resume support)
|
||||
- Authentication token status
|
||||
- Subscription type and content history limits
|
||||
- Shuffle mode and data collection settings
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get Spotify service status
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY
|
||||
|
||||
# Get Spotify status with specific account
|
||||
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account my_spotify_user
|
||||
|
||||
# Use Spotify convenience command
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-spotify
|
||||
|
||||
# Get status for all available streaming services
|
||||
soundtouch-cli --host 192.168.1.10 source introspect-all
|
||||
|
||||
# Check which services are available before introspecting
|
||||
soundtouch-cli --host 192.168.1.10 source availability
|
||||
```
|
||||
|
||||
### Music Service Account Management
|
||||
|
||||
Manage music streaming service accounts and network music library connections.
|
||||
|
||||
#### `account <subcommand>`
|
||||
|
||||
Music service account management commands.
|
||||
|
||||
```bash
|
||||
# List configured accounts
|
||||
soundtouch-cli --host <device> account list
|
||||
|
||||
# Add music service account (generic)
|
||||
soundtouch-cli --host <device> account add --source <SOURCE> --user <USER> --password <PASS> [--name <NAME>]
|
||||
|
||||
# Remove music service account (generic)
|
||||
soundtouch-cli --host <device> account remove --source <SOURCE> --user <USER> [--name <NAME>]
|
||||
|
||||
# Service-specific convenience commands
|
||||
soundtouch-cli --host <device> account add-spotify --user <EMAIL> --password <PASS>
|
||||
soundtouch-cli --host <device> account add-pandora --user <USER> --password <PASS>
|
||||
soundtouch-cli --host <device> account add-amazon --user <USER> --password <PASS>
|
||||
soundtouch-cli --host <device> account add-deezer --user <USER> --password <PASS>
|
||||
soundtouch-cli --host <device> account add-iheart --user <USER> --password <PASS>
|
||||
soundtouch-cli --host <device> account add-nas --user <GUID/0> [--name <NAME>]
|
||||
|
||||
# Remove accounts
|
||||
soundtouch-cli --host <device> account remove-spotify --user <EMAIL>
|
||||
soundtouch-cli --host <device> account remove-pandora --user <USER>
|
||||
soundtouch-cli --host <device> account remove-amazon --user <USER>
|
||||
soundtouch-cli --host <device> account remove-deezer --user <USER>
|
||||
soundtouch-cli --host <device> account remove-iheart --user <USER>
|
||||
soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>]
|
||||
```
|
||||
|
||||
**Supported Services:**
|
||||
- **SPOTIFY**: Spotify Premium accounts
|
||||
- **PANDORA**: Pandora Music Service accounts
|
||||
- **AMAZON**: Amazon Music accounts
|
||||
- **DEEZER**: Deezer Premium accounts
|
||||
- **IHEART**: iHeartRadio accounts
|
||||
- **STORED_MUSIC**: Network music libraries (NAS/UPnP/DLNA servers)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# List all configured music service accounts
|
||||
soundtouch-cli --host 192.168.1.10 account list
|
||||
|
||||
# Add a Spotify Premium account
|
||||
soundtouch-cli --host 192.168.1.10 account add-spotify \
|
||||
--user "user@spotify.com" \
|
||||
--password "mypassword"
|
||||
|
||||
# Add a Pandora account
|
||||
soundtouch-cli --host 192.168.1.10 account add-pandora \
|
||||
--user "pandora_username" \
|
||||
--password "pandora_password"
|
||||
|
||||
# Add an Amazon Music account
|
||||
soundtouch-cli --host 192.168.1.10 account add-amazon \
|
||||
--user "amazon_user" \
|
||||
--password "amazon_password"
|
||||
|
||||
# Add a network music library (NAS/UPnP)
|
||||
soundtouch-cli --host 192.168.1.10 account add-nas \
|
||||
--user "d09708a1-5953-44bc-a413-123456789012/0" \
|
||||
--name "My Music Server"
|
||||
|
||||
# Remove a Spotify account
|
||||
soundtouch-cli --host 192.168.1.10 account remove-spotify \
|
||||
--user "user@spotify.com"
|
||||
|
||||
# Generic account management
|
||||
soundtouch-cli --host 192.168.1.10 account add \
|
||||
--source DEEZER \
|
||||
--user "deezer_user" \
|
||||
--password "deezer_pass" \
|
||||
--name "Deezer Premium"
|
||||
|
||||
soundtouch-cli --host 192.168.1.10 account remove \
|
||||
--source DEEZER \
|
||||
--user "deezer_user"
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Music service accounts must be configured before you can browse or play content from those services
|
||||
- Network music libraries (STORED_MUSIC) don't require passwords, only the UPnP server GUID
|
||||
- After adding an account, use `source list` to verify it appears as available
|
||||
- Some services may require additional authentication steps through their mobile apps
|
||||
|
||||
### Bass Control
|
||||
|
||||
Adjust bass levels (equalizer).
|
||||
@@ -958,6 +1248,6 @@ SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
|
||||
## See Also
|
||||
|
||||
- [Getting Started Guide](GETTING-STARTED.md) - Basic setup and usage
|
||||
- [WebSocket Events](websocket-events.md) - Real-time monitoring
|
||||
- [Zone Management](zone-management.md) - Multi-room setup
|
||||
- [API Endpoints](API-Endpoints-Overview.md) - Complete API reference
|
||||
- [WebSocket Events](../reference/WEBSOCKET-EVENTS.md) - Real-time monitoring
|
||||
- [Zone Management](../reference/ZONE-MANAGEMENT.md) - Multi-room setup
|
||||
- [API Endpoints](../reference/API-ENDPOINTS.md) - Complete API reference
|
||||
@@ -13,6 +13,10 @@ This guide covers everything you need to know to deploy robust, scalable SoundTo
|
||||
- [Performance Optimization](#performance-optimization)
|
||||
- [Error Handling Recovery](#error-handling-recovery)
|
||||
- [Deployment Strategies](#deployment-strategies)
|
||||
- [Docker Deployment](#docker-deployment)
|
||||
- [Kubernetes Deployment](#kubernetes-deployment)
|
||||
- [Systemd Service](#systemd-service)
|
||||
- [Raspberry Pi Installer](#raspberry-pi-installer)
|
||||
- [Maintenance Operations](#maintenance-operations)
|
||||
|
||||
---
|
||||
@@ -926,39 +930,49 @@ data:
|
||||
device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102"
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
#### Systemd Service
|
||||
|
||||
A standard systemd unit for manual installation. This example assumes the binary is at `/usr/local/bin/soundtouch-service` and data is stored in `/var/lib/soundtouch-service`.
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/soundtouch.service
|
||||
# /etc/systemd/system/soundtouch-service.service
|
||||
[Unit]
|
||||
Description=SoundTouch Control Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
Description=Bose SoundTouch Service
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=soundtouch
|
||||
Group=soundtouch
|
||||
WorkingDirectory=/opt/soundtouch
|
||||
ExecStart=/opt/soundtouch/bin/soundtouch-app
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=DEVICE_HOSTS=192.168.1.100,192.168.1.101
|
||||
Environment=LOG_LEVEL=info
|
||||
Environment=CONFIG_FILE=/opt/soundtouch/config/production.yaml
|
||||
WorkingDirectory=/var/lib/soundtouch-service
|
||||
ExecStart=/usr/local/bin/soundtouch-service
|
||||
Environment=PORT=80
|
||||
Environment=SERVER_URL=http://soundtouch.local
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/soundtouch/logs
|
||||
# Allow binding to privileged ports (80/443) without running as root
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Security hardening
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/soundtouch-service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### Raspberry Pi Installer
|
||||
|
||||
For users deploying on a Raspberry Pi, we provide a specialized automated installer that handles everything from architecture detection to security hardening.
|
||||
|
||||
See the [Raspberry Pi Installation Guide](RASPBERRY-PI.md) for step-by-step instructions.
|
||||
|
||||
---
|
||||
|
||||
## Maintenance Operations
|
||||
@@ -1071,4 +1085,4 @@ func init() {
|
||||
|
||||
// Set GC target percentage
|
||||
if os.Getenv("GOGC") == "" {
|
||||
debug.SetGCPerc
|
||||
debug.SetGCPerc
|
||||
@@ -0,0 +1,80 @@
|
||||
# SoundTouch Device Initial Setup Variants
|
||||
|
||||
Based on community research from the **SoundCork** and **ÜberBöse API** projects, as well as analysis of the Stockholm firmware (`firmware/Stockholm/.../setup/`), this document outlines the methods used for the "out-of-the-box" setup of SoundTouch devices.
|
||||
|
||||
## Setup Overview
|
||||
|
||||
Initial setup is the process of connecting a new or factory-reset device to a local Wi-Fi network and a Bose (or custom) account. This is distinct from the "Migration" process (handled by `soundtouch-service`), which redirects an already-configured device to a new server.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bluetooth Low Energy (BLE) Setup
|
||||
Used by most modern SoundTouch devices (ST-10, ST-20/30 Series III, SoundTouch 300).
|
||||
|
||||
- **Mechanism**: The SoundTouch app communicates with the device over BLE to exchange Wi-Fi credentials.
|
||||
- **Protocol**: Internal research refers to this as the **Gabbo** protocol (see `gabbo_setup_bco.js` in firmware).
|
||||
- **Process**:
|
||||
1. Put the device in setup mode (usually by holding the '2' and '-' buttons).
|
||||
2. The app discovers the device via BLE.
|
||||
3. The app sends the Wi-Fi SSID and Password to the device.
|
||||
4. The device connects to Wi-Fi and disables BLE setup.
|
||||
|
||||
---
|
||||
|
||||
## 2. Access Point (AP) Mode / Web Setup
|
||||
The classic "failover" or "alternate" setup method.
|
||||
|
||||
- **Mechanism**: The device creates its own Wi-Fi network (SSID: `Bose SoundTouch ...` or `Bose Home Speaker ...`).
|
||||
- **IP Address**: Typically `192.168.1.1` or `10.0.0.1` (device-side).
|
||||
- **Web Interface**: The device hosts a web server on port 80.
|
||||
- **Process**:
|
||||
1. Connect a PC/Phone to the device's Wi-Fi.
|
||||
2. Open a browser to `http://192.168.1.1`.
|
||||
3. The device serves `setup.html`, which redirects to a setup wizard (`setup/index.html`).
|
||||
4. Use the `gabbo_wifi` form to select a network and enter credentials.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wireless Accessory Configuration (WAC)
|
||||
Specific to Apple iOS devices.
|
||||
|
||||
- **Mechanism**: Uses Apple's MFi/WAC protocol to pass Wi-Fi settings from an iPhone/iPad directly to the device without manual password entry.
|
||||
- **Status**: Detected automatically by iOS when a new SoundTouch device is in setup mode.
|
||||
|
||||
---
|
||||
|
||||
## 4. USB Setup (Legacy)
|
||||
Primarily used for older SoundTouch Series I and II devices or as a last resort.
|
||||
|
||||
- **Mechanism**: Physical connection via Micro-USB to a computer running the SoundTouch Setup application.
|
||||
- **Process**:
|
||||
1. Connect USB cable.
|
||||
2. The desktop app communicates via a proprietary HID or Serial-over-USB protocol.
|
||||
3. The app pushes Wi-Fi credentials.
|
||||
4. References to this exist in the firmware as `lost_USB_connection` and `connect_device` (see `setup_wizard.xml`).
|
||||
|
||||
---
|
||||
|
||||
## Technical Details: The "Gabbo" Protocol
|
||||
The Stockholm firmware contains references to a communication layer called **Gabbo**.
|
||||
- **File**: `setup/js/gabbo_setup_bco.js`
|
||||
- **Function**: Handles the state machine for Wi-Fi connection, account pairing, and error handling during setup.
|
||||
- **Relationship**: It appears to be an internal wrapper for the messages sent between the setup client (App or Browser) and the device firmware.
|
||||
|
||||
## Redirection during Setup
|
||||
While the `soundtouch-service` focuses on migrating existing devices, a truly "clean" setup to a custom service would require:
|
||||
1. Intercepting the initial account pairing request.
|
||||
2. Providing a mock "Marge" service that accepts any credentials.
|
||||
3. Patching the `SoundTouchSdkPrivateCfg.xml` during or immediately after the Wi-Fi connection phase.
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
| Feature | Initial Setup | Migration (soundtouch-service) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Connectivity** | BLE, AP Mode, USB, WAC | Ethernet/Wi-Fi (existing) |
|
||||
| **Credentials** | Required (SSID/Pass) | Not required (uses existing) |
|
||||
| **Access** | Web UI / App protocol | SSH (root) |
|
||||
| **Primary File** | `setup/index.html` | `SoundTouchSdkPrivateCfg.xml` |
|
||||
| **Use Case** | Out-of-the-box / Reset | Redirecting active devices |
|
||||
@@ -0,0 +1,102 @@
|
||||
# HTTPS Setup & Custom CA Certificate
|
||||
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
|
||||
|
||||
## 1. Automated Migration (Hosts Method)
|
||||
|
||||
The `soundtouch-service` can automatically configure a device to use the `/etc/hosts` method:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Connect to the device via SSH.
|
||||
2. Update `/etc/hosts` to point Bose domains to the service IP.
|
||||
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
4. Reboot the device.
|
||||
|
||||
## 2. Managing the Root CA
|
||||
|
||||
The AfterTouch service automatically generates a Root CA when it first starts.
|
||||
|
||||
- **CA Certificate**: `data/certs/ca.crt`
|
||||
- **CA Private Key**: `data/certs/ca.key`
|
||||
|
||||
### Downloading the CA Certificate
|
||||
You can download the CA certificate for manual installation on other devices (like your phone or PC) from:
|
||||
`http://<server-ip>:8000/setup/ca.crt`
|
||||
|
||||
### 3. Built-in HTTPS Support
|
||||
|
||||
The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies the `/etc/hosts` redirection method by automatically presenting the correct certificates for Bose domains.
|
||||
|
||||
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
|
||||
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
|
||||
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
|
||||
|
||||
#### TLS Security
|
||||
|
||||
The built-in HTTPS listener is configured to use modern and secure TLS settings while maintaining compatibility with SoundTouch devices (which support up to TLS 1.2 with OpenSSL 1.0.2).
|
||||
|
||||
- **Minimum TLS Version**: TLS 1.2
|
||||
- **Preferred Cipher Suites**:
|
||||
- `ECDHE-RSA-AES128-GCM-SHA256`
|
||||
- `ECDHE-RSA-AES256-GCM-SHA384`
|
||||
- `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- `RSA-AES128-GCM-SHA256` (Legacy support)
|
||||
- `RSA-AES256-GCM-SHA384` (Legacy support)
|
||||
|
||||
#### Binding to Port 443
|
||||
SoundTouch devices expect HTTPS on the default port 443. Since binding to port 443 usually requires root privileges, you have two options:
|
||||
|
||||
1. **Port Forwarding (Recommended)**: Run the service on a high port (e.g., 8443) and use `iptables` or your firewall to forward traffic from 443 to 8443.
|
||||
2. **Capabilities**: Grant the binary permission to bind to low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`.
|
||||
3. **Reverse Proxy**: Use Nginx or Caddy as described below.
|
||||
|
||||
### 4. Reverse Proxy (Optional)
|
||||
|
||||
1. **Generate a certificate** for the Bose domains signed by your Root CA.
|
||||
2. **Configure Nginx** to use this certificate and proxy requests to `soundtouch-service`.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name streaming.bose.com bmx.bose.com stats.bose.com updates.bose.com;
|
||||
|
||||
ssl_certificate /path/to/generated-cert.crt;
|
||||
ssl_certificate_key /path/to/generated-cert.key;
|
||||
|
||||
# Secure TLS configuration (matches soundtouch-service defaults)
|
||||
ssl_protocols TLSv1.2;
|
||||
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:AES128-GCM-SHA256:AES256-GCM-SHA384';
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Manual CA Injection (Legacy/Manual)
|
||||
|
||||
If you prefer to inject the CA certificate manually:
|
||||
|
||||
1. Copy `ca.crt` to the device:
|
||||
```bash
|
||||
scp data/certs/ca.crt root@{deviceIP}:/tmp/
|
||||
```
|
||||
2. Append it to the trust store on the device:
|
||||
```bash
|
||||
ssh root@{deviceIP} "(rw || mount -o remount,rw /) && cat /tmp/ca.crt >> /etc/pki/tls/certs/ca-bundle.crt"
|
||||
```
|
||||
|
||||
## 6. Verifying Connectivity
|
||||
|
||||
You can verify that your device can correctly reach the `soundtouch-service` over HTTPS using the management web UI.
|
||||
|
||||
In the **Migration Summary** for a device, you will find an **HTTPS Connection Test** section:
|
||||
- **Test with Explicit CA.crt**: Uploads a temporary copy of the Root CA to the device and uses `curl --cacert` to verify the connection. Use this to verify your HTTPS setup *before* modifying the device's shared trust store.
|
||||
- **Test with Shared Trust Store**: Uses the device's default trust store. Use this to verify that your CA injection was successful and the device now natively trusts your local server.
|
||||
@@ -0,0 +1,44 @@
|
||||
### Professional Migration & Safety Guide
|
||||
|
||||
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
|
||||
|
||||
#### 🛠 Technical Safety Enhancements
|
||||
|
||||
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
|
||||
|
||||
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
|
||||
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
|
||||
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
|
||||
|
||||
#### 📋 Professional Migration Checklist
|
||||
|
||||
Before you proceed with the actual migration, follow these steps:
|
||||
|
||||
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
|
||||
- Create an empty file named `remote_services` on a USB stick.
|
||||
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
|
||||
- Reboot the speaker (unplug and replug).
|
||||
- The speaker will now allow SSH connections as `root` with no password.
|
||||
- **Verify**: Run `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>` to confirm access. (Note: older devices may require enabling `ssh-rsa` support).
|
||||
2. **Network Isolation (Optional but Recommended)**: Ensure the device is on a stable wired connection if possible, or a dedicated 2.4GHz SSID to avoid drops during SSH operations.
|
||||
3. **Initial Discovery & Sync**:
|
||||
- Run `soundtouch-cli discover devices` to ensure connectivity.
|
||||
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
|
||||
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
|
||||
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
|
||||
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
|
||||
5. **Migration Methods**:
|
||||
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
|
||||
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
|
||||
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
|
||||
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
|
||||
|
||||
#### 🔄 Rollback Strategy
|
||||
|
||||
If something goes wrong or you want to return to the original Bose cloud services:
|
||||
|
||||
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
|
||||
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
|
||||
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
|
||||
|
||||
By using the built-in off-device backups and pre-flight checks, the risk of "bricking" or losing configuration during the transition is significantly reduced.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Raspberry Pi Installation Guide
|
||||
|
||||
This guide explains how to install the `soundtouch-service` as a persistent systemd service on a Raspberry Pi (tested on Raspberry Pi Zero 2W, 3, and 4).
|
||||
|
||||
## Automated Installer
|
||||
|
||||
We provide a specialized installer script located in the `scripts/raspberry-pi/` directory of the repository.
|
||||
|
||||
### Features
|
||||
* **Automatic start on boot**: Installs a systemd unit.
|
||||
* **Non-root operation**: Uses `AmbientCapabilities` to bind to ports 80/443 without root privileges.
|
||||
* **Arch Detection**: Automatically selects the correct binary for `armv7`, `arm64`, or `amd64`.
|
||||
* **Easy Updates**: Re-running the script updates the binary to the latest version.
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. **Download the installer**:
|
||||
```bash
|
||||
curl -fsSL -o install.sh https://raw.githubusercontent.com/gesellix/bose-soundtouch/main/scripts/raspberry-pi/install.sh
|
||||
```
|
||||
|
||||
2. **Run with sudo**:
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
### Overriding Defaults
|
||||
|
||||
You can customize the installation using environment variables:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.17.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
### Updating the Service
|
||||
|
||||
To update the service to a specific version, run the installer with the version as an argument:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.18.1
|
||||
```
|
||||
|
||||
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
|
||||
|
||||
## Management
|
||||
|
||||
Once installed, use standard `systemctl` commands to manage the service:
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
systemctl status soundtouch-service
|
||||
|
||||
# Follow logs
|
||||
journalctl -u soundtouch-service -f
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `/etc/soundtouch-service/soundtouch-service.env`. Note that settings saved via the Web UI (in `settings.json`) will take precedence over these environment variables once the service is running.
|
||||
|
||||
For more details, see the [scripts/raspberry-pi/README.md](../../scripts/raspberry-pi/README.md) in the repository.
|
||||
@@ -0,0 +1,836 @@
|
||||
# SoundTouch Service
|
||||
|
||||
The `soundtouch-service` is a comprehensive local server that emulates Bose's cloud services, enabling offline SoundTouch device operation and advanced debugging capabilities. This service is particularly valuable given Bose's announcement that cloud support will end in May 2026.
|
||||
|
||||
## Overview
|
||||
|
||||
The service provides:
|
||||
|
||||
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
|
||||
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
|
||||
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
|
||||
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
|
||||
- **🌐 Web Management UI**: Browser-based interface for device management
|
||||
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
|
||||
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
|
||||
- **🔒 Offline Operation**: Continue using full device functionality without internet
|
||||
- **🔗 Bose Proxy & Soundcork Fallback**: Dynamic proxying with automatic fallback to local [SoundCork](https://github.com/deborahgu/soundcork) emulation if enabled
|
||||
|
||||
## Architecture
|
||||
|
||||
The service consists of several key components:
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
- **TuneIn Integration**: Direct playback of radio stations and podcasts
|
||||
- **Service Registry**: Media service discovery and configuration
|
||||
- **Playback Control**: Stream URL resolution and audio metadata
|
||||
|
||||
### Marge Services (Account & Device Management)
|
||||
- **Account Management**: User account simulation and device association
|
||||
- **Preset Synchronization**: Cross-device preset storage and sync
|
||||
- **Recent Items**: Playback history tracking and management
|
||||
- **Configuration Management**: Device settings and preferences
|
||||
|
||||
### Discovery & Migration
|
||||
- **Network Scanning**: UPnP/SSDP and mDNS device discovery
|
||||
- **Device Analysis**: Configuration assessment and compatibility checking
|
||||
- **Service Migration**: Automated configuration updates for local service usage
|
||||
- **Health Monitoring**: Device connectivity and service status tracking
|
||||
|
||||
## Installation
|
||||
|
||||
### Install from Source
|
||||
```bash
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
```
|
||||
|
||||
### Build from Repository
|
||||
```bash
|
||||
git clone https://github.com/gesellix/bose-soundtouch.git
|
||||
cd Bose-SoundTouch
|
||||
go build -o soundtouch-service ./cmd/soundtouch-service
|
||||
```
|
||||
|
||||
### Docker Support
|
||||
|
||||
You can run the SoundTouch service using Docker or Docker Compose.
|
||||
|
||||
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
|
||||
|
||||
#### Using Docker
|
||||
|
||||
**Linux (with host networking for discovery):**
|
||||
```bash
|
||||
docker run -d \
|
||||
--name soundtouch-service \
|
||||
--network host \
|
||||
-v $(pwd)/data:/app/data \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
**macOS / Windows (with port mapping):**
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
-p 8000:8000 -p 8443:8443 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--env SERVER_URL=http://soundtouch.local:8000 \
|
||||
--env HTTPS_SERVER_URL=https://soundtouch.local:8443 \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
> **Note**: The hostnames configured via `SERVER_URL` and `HTTPS_SERVER_URL` are automatically added as Subject Alternative Names (SAN) to the generated TLS certificate, ensuring valid SSL connections.
|
||||
|
||||
#### Using Docker Compose
|
||||
|
||||
Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
soundtouch-service:
|
||||
image: ghcr.io/gesellix/bose-soundtouch:latest
|
||||
container_name: soundtouch-service
|
||||
# Linux users: use host networking for device discovery
|
||||
# network_mode: host
|
||||
# macOS/Windows users: use port mapping (discovery will be manual)
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
environment:
|
||||
- PORT=8000
|
||||
- SERVER_URL=http://soundtouch.local:8000
|
||||
- HTTPS_SERVER_URL=https://soundtouch.local:8443
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- soundtouch-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
soundtouch-data:
|
||||
```
|
||||
|
||||
And run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Service
|
||||
|
||||
```bash
|
||||
# Start with default settings (port 8000)
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
### 2. Access the Web Interface
|
||||
|
||||
Open your browser to `http://localhost:8000` to access the management interface.
|
||||
|
||||
### 3. Discover Devices
|
||||
|
||||
The service will automatically start discovering SoundTouch devices on your network. You can also trigger manual discovery from the web UI or API.
|
||||
|
||||
### 4. Migrate Devices
|
||||
|
||||
Use the web interface or API to migrate devices from Bose cloud services to your local instance.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Configuration Precedence
|
||||
|
||||
The service supports multiple ways to configure its behavior. When multiple sources provide the same setting, the following precedence rules apply (highest to lowest):
|
||||
|
||||
1. **`settings.json`**: Settings saved via the Web UI (stored in the data directory) take the highest precedence. This ensures that changes made in the browser persist across service restarts even if environment variables or flags change.
|
||||
2. **Environment Variables / CLI Flags**: If a setting is not present in `settings.json`, environment variables and flags are used.
|
||||
3. **Default Values**: If no configuration is provided, the service uses its built-in defaults.
|
||||
|
||||
> **Tip**: If you find that changes to environment variables are not taking effect, check the **Settings** tab in the Web UI or inspect the `settings.json` file in your data directory, as it might be overriding your manual configuration.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
```bash
|
||||
# Custom port and data directory
|
||||
PORT=9000 DATA_DIR=/home/user/soundtouch soundtouch-service
|
||||
|
||||
# External server with custom URL
|
||||
SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
|
||||
|
||||
# Development mode with full logging
|
||||
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
|
||||
```
|
||||
|
||||
## Device Migration
|
||||
|
||||
### Understanding Migration
|
||||
|
||||
Device migration switches your SoundTouch devices from Bose's cloud services to your local service instance. This process:
|
||||
|
||||
1. **Backs up** existing device configuration
|
||||
2. **Updates** device service URLs to point to your local server
|
||||
3. **Maintains** all existing presets and settings
|
||||
4. **Enables** offline operation and advanced debugging
|
||||
|
||||
### Migration Methods
|
||||
|
||||
#### Web Interface (Recommended)
|
||||
|
||||
1. Start the service: `soundtouch-service`
|
||||
2. Open `http://localhost:8000`
|
||||
3. Wait for device discovery to complete
|
||||
4. Click "Migrate" next to each device
|
||||
5. Monitor migration status in real-time
|
||||
|
||||
#### API Migration
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/devices/192.168.1.100/summary
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/devices
|
||||
```
|
||||
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
|
||||
```
|
||||
|
||||
### Post-Migration Verification
|
||||
|
||||
After migration, verify the device is working correctly:
|
||||
|
||||
```bash
|
||||
# Check device status
|
||||
curl http://localhost:8000/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/devices/08DF1F0BA325/events"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
|
||||
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
|
||||
|
||||
> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration.
|
||||
|
||||
**Advantages:**
|
||||
- **Discovery**: Automatically discover all Bose endpoints queried by the device.
|
||||
- **Dynamic Interception**: Intercept new or unknown services without further device modifications.
|
||||
- **Fail-Safe**: Falls back to the standard network DNS (provided by your router) if the Aftertouch service is unavailable.
|
||||
- **DHCP Compatible**: Preserves your router's assigned search domain and secondary DNS servers.
|
||||
- **Wildcard Support**: Seamlessly handles `*.bose.com` redirection via your local DNS server.
|
||||
- **Persistent**: Survives reboots and DHCP renewals.
|
||||
|
||||
**How it works:**
|
||||
1. **Configuration**: A custom file named `/mnt/nv/aftertouch.resolv.conf` is created on the device's persistent partition.
|
||||
2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP scripts (`/etc/udhcpc.d/50default` or `/opt/Bose/udhcpc.script`) have been patched.
|
||||
3. **Surgical Patch**: If not patched, it injects a one-line check into the relevant DHCP scripts.
|
||||
4. **Resolution**: Whenever the device acquires a DHCP lease, the scripts now read your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings.
|
||||
|
||||
**Setup:**
|
||||
1. Enable SSH via the `remote_services` USB trick.
|
||||
2. Create `/mnt/nv/aftertouch.resolv.conf` with your server details:
|
||||
```text
|
||||
# Created by Aftertouch/SoundTouch-Service
|
||||
# Priority nameserver for Bose service redirection
|
||||
nameserver 192.168.1.XXX
|
||||
```
|
||||
3. Update `/mnt/nv/rc.local` with the idempotent patch:
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
|
||||
HOOK_MARKER="/mnt/nv/aftertouch.resolv.conf"
|
||||
if [ -f "$HOOK_MARKER" ]; then
|
||||
# Patch 50default if it exists
|
||||
TARGET_FILE="/etc/udhcpc.d/50default"
|
||||
if [ -f "$TARGET_FILE" ] && ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then
|
||||
sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' && dns=""' "$TARGET_FILE"
|
||||
fi
|
||||
# Patch udhcpc.script if it exists (e.g. SoundTouch 10)
|
||||
TARGET_SCRIPT="/opt/Bose/udhcpc.script"
|
||||
if [ -f "$TARGET_SCRIPT" ] && ! grep -q "$HOOK_MARKER" "$TARGET_SCRIPT"; then
|
||||
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' >> '"\$RESOLV_CONF"' && dns=""' "$TARGET_SCRIPT"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
4. Make the script executable: `chmod +x /mnt/nv/rc.local`.
|
||||
5. Reboot the speaker.
|
||||
|
||||
### DNS Discovery Server
|
||||
|
||||
The SoundTouch service includes a built-in DNS server specifically designed for Bose devices.
|
||||
|
||||
#### How it Works
|
||||
When enabled, the DNS server:
|
||||
1. Receives DNS queries from migrated SoundTouch devices.
|
||||
2. **Intercepts** known Bose domains (e.g., `api.bose.com`, `streaming.bose.com`, `bmx.bose.com`) and resolves them to the AfterTouch service IP.
|
||||
3. **Logs** all other queries for discovery purposes, allowing you to identify new Bose cloud endpoints.
|
||||
4. **Forwards** unknown or non-Bose queries to the configured upstream DNS server (default: `8.8.8.8`).
|
||||
|
||||
#### Configuration
|
||||
You can enable and configure the DNS server via the Web UI or environment variables:
|
||||
- `ENABLE_DNS_DISCOVERY=true`: Turns on the DNS server.
|
||||
- `DNS_BIND_ADDR=:53`: The port to listen on (requires root privileges for port 53).
|
||||
- `DNS_UPSTREAM=1.1.1.1`: Your preferred upstream DNS provider. **Note:** Ensure this is not set to the same address as the DNS server itself (loopback or local IP) to avoid forwarding loops. The server includes built-in loop prevention, but misconfiguration will cause forwarding to fail. DNS Discovery cannot be enabled if this setting is empty.
|
||||
|
||||
#### Manual Discovery via DNS
|
||||
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Discovery & Setup
|
||||
|
||||
#### `GET /devices`
|
||||
Lists all discovered SoundTouch devices with their current status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"device_id": "08DF1F0BA325",
|
||||
"name": "Living Room Speaker",
|
||||
"ip_address": "192.168.1.100",
|
||||
"product_code": "SoundTouch 20",
|
||||
"firmware_version": "19.0.5",
|
||||
"migrated": true,
|
||||
"last_seen": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### `POST /setup/discover`
|
||||
Triggers immediate network device discovery.
|
||||
|
||||
#### `GET /devices/{deviceIP}/info`
|
||||
Gets detailed device information and configuration.
|
||||
|
||||
#### `GET /setup/devices/{deviceIP}/summary`
|
||||
Analyzes device configuration and provides migration preview.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"device_name": "Living Room Speaker",
|
||||
"device_model": "SoundTouch 20",
|
||||
"firmware_version": "19.0.5",
|
||||
"ssh_success": true,
|
||||
"current_config": "<?xml version=\"1.0\"?>...",
|
||||
"planned_config": "<?xml version=\"1.0\"?>...",
|
||||
"remote_services_enabled": false,
|
||||
"migration_required": true
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/migrate`
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url`: Custom service URL (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (optional)
|
||||
- `marge`: Set to "original" to proxy Marge requests (optional)
|
||||
- `stats`: Set to "original" to proxy stats requests (optional)
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/revert`
|
||||
Reverts device to Bose cloud defaults.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/trust-ca`
|
||||
Injects the AfterTouch root CA into the device's trust store.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/sync`
|
||||
Syncs presets and recents from the device to local storage.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/backup`
|
||||
Creates a backup of the current device configuration.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
|
||||
Enables persistent SSH/remote services on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
|
||||
Removes persistent SSH/remote services from the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-connection`
|
||||
Tests HTTPS connection from device to service.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-hosts`
|
||||
Tests /etc/hosts redirection on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-dns`
|
||||
Tests DNS redirection on the device.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
Returns available media services for device registration.
|
||||
|
||||
#### `GET /bmx/tunein/v1/playbook/station/{stationID}`
|
||||
Provides TuneIn station playback information.
|
||||
|
||||
#### `GET /bmx/tunein/v1/podcast/{podcastID}`
|
||||
Returns podcast episode information and playback URLs.
|
||||
|
||||
### Marge Services (Account & Device Management)
|
||||
|
||||
#### `GET /marge/streaming/sourceproviders`
|
||||
Lists available music service providers.
|
||||
|
||||
#### `GET /marge/accounts/{account}/devices/any/presets`
|
||||
Returns user presets for synchronization.
|
||||
|
||||
#### `GET /marge/accounts/{account}/devices/any/recents`
|
||||
Returns recent playback items.
|
||||
|
||||
#### `PUT /marge/accounts/{account}/devices/{device}/presets/{slot}`
|
||||
Updates a specific preset slot.
|
||||
|
||||
#### `POST /marge/streaming/support/addrecent`
|
||||
Adds item to recent playback history.
|
||||
|
||||
#### `GET /marge/updates/soundtouch`
|
||||
Returns software update configuration (disabled by default).
|
||||
|
||||
### Proxy Services
|
||||
|
||||
#### `GET /proxy/{encodedURL}`
|
||||
Proxies requests to external services with logging.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Proxy request to Bose services
|
||||
curl "http://localhost:8000/proxy/aHR0cHM6Ly9hcGkuc291bmR0b3VjaC5ib3NlLmNvbS8="
|
||||
```
|
||||
|
||||
### Health & Monitoring
|
||||
|
||||
#### `GET /health`
|
||||
Returns service health status.
|
||||
|
||||
#### `GET /events/{deviceID}`
|
||||
WebSocket endpoint for real-time device events.
|
||||
|
||||
#### `GET /stats/usage`
|
||||
Returns usage statistics.
|
||||
|
||||
#### `GET /stats/errors`
|
||||
Returns error statistics.
|
||||
|
||||
## Web Interface
|
||||
|
||||
### Overview
|
||||
|
||||
The web management interface provides a comprehensive dashboard for managing your SoundTouch devices:
|
||||
|
||||
**URL:** `http://localhost:8000/`
|
||||
|
||||
### Features
|
||||
|
||||
#### Device Dashboard
|
||||
- **Device Discovery**: Real-time view of discovered devices
|
||||
- **Migration Status**: Visual indicators of migration state
|
||||
- **Device Health**: Connectivity and service status monitoring
|
||||
- **Quick Actions**: One-click migration and configuration
|
||||
|
||||
#### Device Management
|
||||
- **Configuration Viewer**: Inspect current and planned device configs
|
||||
- **Migration Wizard**: Step-by-step device migration process
|
||||
- **Backup Management**: View and restore configuration backups
|
||||
- **Service Testing**: Test connectivity to local services
|
||||
|
||||
#### Monitoring & Debugging
|
||||
- **Traffic Logs**: Real-time proxy request/response logging
|
||||
- **Event Streaming**: Live device event monitoring
|
||||
- **Statistics Dashboard**: Usage and error analytics
|
||||
- **Debug Tools**: Device communication testing utilities
|
||||
|
||||
#### Interactions & Traffic Analysis
|
||||
- **Traffic Overview**: View aggregate request counts for self-handled and proxied traffic.
|
||||
- **Session Browsing**: Browse recorded interactions grouped by session.
|
||||
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
|
||||
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
|
||||
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
|
||||
- **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports.
|
||||
- **DNS Discoveries**: Real-time table of all hostnames discovered via the AfterTouch DNS server, categorized by interception status (Self/Upstream).
|
||||
|
||||
### Usage Tips
|
||||
|
||||
1. **First Time Setup**: The interface will guide you through initial device discovery
|
||||
2. **Migration Monitoring**: Watch migration progress in real-time with detailed status updates
|
||||
3. **Troubleshooting**: Use the debug tools to diagnose device connectivity issues
|
||||
4. **Log Analysis**: Enable detailed logging for development and troubleshooting
|
||||
|
||||
## HTTP Interaction Recording
|
||||
|
||||
The service automatically records all HTTP interactions (both those handled locally and those proxied upstream) as `.http` files. These files are compatible with the [IntelliJ IDEA HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html).
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Session Grouping**: All interactions from a single server session are stored in a dedicated directory named `{timestamp}-{pid}`.
|
||||
- **Chronological Order**: Files are prefixed with a sequential number (e.g., `0001-`, `0002-`) to preserve the exact order of requests across the entire session.
|
||||
- **Path-Based Structure**: Recordings are organized into subdirectories based on their URL path for better discoverability.
|
||||
- **Automatic Sanitization**: Variable path segments like IP addresses, Device IDs, and Account IDs are automatically identified and replaced with placeholders (e.g., `{{ip}}`, `{{deviceId}}`). The original values are preserved as comments at the top of the recorded `.http` files for easy identification.
|
||||
- **Re-playability**: An `http-client.env.json` file is generated for each session, allowing you to re-play the recorded requests immediately in IntelliJ IDEA.
|
||||
- **Management UI**: The **5. Interactions** tab provides a built-in viewer and management tools for all recorded data.
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Redaction
|
||||
|
||||
By default, the service redacts sensitive information from the recorded `.http` files, including:
|
||||
- `Authorization` headers
|
||||
- `Cookie` headers
|
||||
- `X-Bose-Token` headers
|
||||
- `X-Bose-Key` headers
|
||||
- `Proxy-Authorization` headers
|
||||
|
||||
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
|
||||
|
||||
#### Custom Patterns
|
||||
|
||||
The service uses regex patterns to identify variable segments in URL paths. These patterns are loaded from `data/patterns.json`. You can add custom patterns to this file to support additional variable segments:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "MyVariable",
|
||||
"regexp": "^[0-9]{5}$",
|
||||
"replacement": "{myVar}"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Variables found via these patterns will be:
|
||||
1. Used as directory names in the `interactions/` folder.
|
||||
2. Parameterized as `{{myVar}}` within the `.http` files.
|
||||
3. Added to the `http-client.env.json` file with their actual values.
|
||||
|
||||
## Persistent Data
|
||||
|
||||
### Data Directory Structure
|
||||
|
||||
By default, the service creates a `data/` directory in the current working directory:
|
||||
|
||||
```
|
||||
data/
|
||||
├── accounts/
|
||||
│ └── default/
|
||||
│ ├── devices/
|
||||
│ │ ├── {DEVICE_ID}/
|
||||
│ │ │ ├── DeviceInfo.xml
|
||||
│ │ │ └── config_backup_*.xml
|
||||
│ │ └── ...
|
||||
│ ├── Sources.xml
|
||||
│ ├── Presets.xml
|
||||
│ └── Recents.xml
|
||||
├── interactions/
|
||||
│ └── {SESSION_ID}/
|
||||
│ ├── self/
|
||||
│ │ └── {PATH}/
|
||||
│ │ └── {SEQ}-{TIME}-{METHOD}.http
|
||||
│ ├── upstream/
|
||||
│ │ └── {PATH}/
|
||||
│ │ └── {SEQ}-{TIME}-{METHOD}.http
|
||||
│ └── http-client.env.json
|
||||
├── dns/
|
||||
│ └── discoveries.json
|
||||
├── stats/
|
||||
│ ├── usage/
|
||||
│ │ └── *.json
|
||||
│ └── error/
|
||||
│ └── *.json
|
||||
└── events/
|
||||
└── device_events_*.log
|
||||
```
|
||||
|
||||
### Data Components
|
||||
|
||||
#### Device Data (`accounts/default/devices/{DEVICE_ID}/`)
|
||||
- **DeviceInfo.xml**: Device metadata and capabilities
|
||||
- **config_backup_*.xml**: Configuration backups before migration
|
||||
- **presets.xml**: Device-specific preset configurations
|
||||
|
||||
#### Account Data (`accounts/default/`)
|
||||
- **Sources.xml**: Configured music service providers
|
||||
- **Presets.xml**: Cross-device preset synchronization
|
||||
- **Recents.xml**: Recent playback history
|
||||
|
||||
#### DNS Data (`dns/`)
|
||||
- **discoveries.json**: Persisted DNS discovery logs with hostname deduplication
|
||||
|
||||
#### Statistics (`stats/`)
|
||||
- **usage/**: Device usage analytics and patterns
|
||||
- **error/**: Error logs and diagnostic information
|
||||
|
||||
#### Events (`events/`)
|
||||
- **device_events_*.log**: Device event history and debugging logs
|
||||
|
||||
#### HTTP Interactions (`interactions/`)
|
||||
- **{SESSION_ID}/**: A unique directory per server run (format: `YYYYMMDD-HHMMSS-PID`).
|
||||
- **self/**: Requests handled directly by the service.
|
||||
- **upstream/**: Requests proxied to external Bose services.
|
||||
- **{PATH}/**: Nested subdirectories reflecting the URL path (sanitized).
|
||||
- **http-client.env.json**: IntelliJ IDEA HTTP Client environment file with session variables.
|
||||
- **{SEQ}-{TIME}-{METHOD}.http**: Individual interaction recordings in standard HTTP Client format.
|
||||
|
||||
### Data Management
|
||||
|
||||
#### Backup Strategy
|
||||
```bash
|
||||
# Manual backup
|
||||
cp -r data/ backup-$(date +%Y%m%d)/
|
||||
|
||||
# Automated backup (cron example)
|
||||
0 2 * * * cp -r /path/to/data/ /backup/soundtouch-$(date +\%Y\%m\%d)/
|
||||
```
|
||||
|
||||
#### Data Migration
|
||||
```bash
|
||||
# Moving to new server
|
||||
tar czf soundtouch-data.tar.gz data/
|
||||
# Transfer to new server
|
||||
tar xzf soundtouch-data.tar.gz
|
||||
```
|
||||
|
||||
#### Cleanup
|
||||
```bash
|
||||
# Clean old event logs (older than 30 days)
|
||||
find data/events/ -name "*.log" -mtime +30 -delete
|
||||
|
||||
# Clean old statistics (older than 90 days)
|
||||
find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Management UI
|
||||
- **URL**: `http://localhost:8000/` or `http://localhost:8000/web/`
|
||||
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
|
||||
|
||||
### Setup API
|
||||
- `GET /devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /devices`: Manually add a device by IP.
|
||||
- `POST /setup/discover`: Trigger a new network discovery scan.
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
|
||||
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
Lists recorded interactions with optional filtering.
|
||||
|
||||
**Query Parameters:**
|
||||
- `session`: Filter by session ID (optional)
|
||||
- `category`: Filter by category (`self` or `upstream`) (optional)
|
||||
- `since`: Filter by timestamp (e.g., `2026-02-15 15:00:00`) (optional)
|
||||
|
||||
#### `GET /setup/interaction-stats`
|
||||
Returns aggregate statistics about recorded interactions across all sessions.
|
||||
|
||||
#### `GET /setup/interaction-content?file={path}`
|
||||
Returns the raw content of a specific recorded `.http` file.
|
||||
|
||||
#### `DELETE /setup/interactions/sessions/{sessionID}`
|
||||
Deletes all recordings associated with a specific session.
|
||||
|
||||
#### `DELETE /setup/interactions/sessions?keep={N}`
|
||||
Bulk cleanup: deletes all but the most recent `N` sessions.
|
||||
|
||||
### DNS Discovery API
|
||||
|
||||
#### `GET /setup/dns-discoveries`
|
||||
Returns merged in-memory and persisted DNS discoveries, sorted by last seen timestamp.
|
||||
|
||||
#### `DELETE /setup/dns-discoveries`
|
||||
Clears all recorded DNS discovery data from memory and disk.
|
||||
|
||||
### Emulated Services
|
||||
- `/bmx/registry/v1/services`: BMX service registry.
|
||||
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
|
||||
- `/marge/accounts/*`: Account and device management.
|
||||
- `/marge/updates/soundtouch`: Software update emulation.
|
||||
- `/proxy/*`: Logging proxy for original Bose services.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Device Not Discovered
|
||||
```bash
|
||||
# Check network connectivity
|
||||
ping 192.168.1.100
|
||||
|
||||
# Trigger manual discovery
|
||||
curl -X POST http://localhost:8000/setup/discover
|
||||
|
||||
# Check device accessibility
|
||||
curl http://192.168.1.100:8090/info
|
||||
```
|
||||
|
||||
#### Migration Failures
|
||||
```bash
|
||||
# Check SSH connectivity
|
||||
ssh-keyscan 192.168.1.100
|
||||
|
||||
# Get migration summary
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
|
||||
# Verify device configuration
|
||||
curl http://192.168.1.100:8090/info
|
||||
```
|
||||
|
||||
#### Service Connectivity Issues
|
||||
```bash
|
||||
# Test local service endpoints
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8000/bmx/registry/v1/services
|
||||
curl http://localhost:8000/marge/streaming/sourceproviders
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging for detailed troubleshooting:
|
||||
|
||||
```bash
|
||||
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
|
||||
```bash
|
||||
# Monitor service logs
|
||||
tail -f /var/log/soundtouch-service.log
|
||||
|
||||
# Analyze proxy traffic
|
||||
grep "PROXY" /var/log/soundtouch-service.log
|
||||
|
||||
# Check device events
|
||||
ls -la data/events/
|
||||
```
|
||||
|
||||
## Credits & Inspiration
|
||||
|
||||
This service implementation is based on and inspired by several excellent community projects:
|
||||
|
||||
### SoundCork
|
||||
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Gu and contributors
|
||||
- **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies.
|
||||
|
||||
### ÜberBöse API
|
||||
- **Project**: [ÜberBöse API](https://github.com/julius-d/ueberboese-api)
|
||||
- **Author**: Julius D.
|
||||
- **Contribution**: Advanced API endpoint discovery and implementation details that helped make this service more complete and robust.
|
||||
|
||||
We are grateful to these projects for paving the way and providing the research foundation that made this comprehensive service implementation possible.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Service Integration
|
||||
|
||||
```go
|
||||
// Example: Custom BMX service handler
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func customBMXHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Custom BMX service logic
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"custom": "service"}`))
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/bmx/custom/endpoint", customBMXHandler)
|
||||
http.ListenAndServe(":8000", r)
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with Home Assistant
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
soundtouch:
|
||||
- host: 192.168.1.100
|
||||
port: 8090
|
||||
name: "Living Room Speaker"
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/devices"
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "SoundTouch Devices"
|
||||
value_template: "{{ value_json | length }}"
|
||||
```
|
||||
|
||||
### Monitoring & Alerting
|
||||
|
||||
```bash
|
||||
# Health check script
|
||||
#!/bin/bash
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health)
|
||||
if [ $response != "200" ]; then
|
||||
echo "SoundTouch service is down!" | mail -s "Alert" admin@example.com
|
||||
fi
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Network Security**: The service binds to all interfaces by default. Consider using `BIND_ADDR=127.0.0.1` for localhost-only access.
|
||||
- **SSH Access**: Migration requires SSH access to devices. Ensure your network security policies allow this.
|
||||
- **Proxy Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
|
||||
- **Data Protection**: The data directory contains device configurations and usage patterns. Secure appropriately.
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Resource Usage
|
||||
- **Memory**: ~50MB baseline + ~5MB per discovered device
|
||||
- **CPU**: Minimal during steady state, ~10% during discovery/migration
|
||||
- **Disk**: ~1MB per device configuration + logs
|
||||
|
||||
### Scaling Considerations
|
||||
```bash
|
||||
# For many devices, increase discovery interval
|
||||
DISCOVERY_INTERVAL=10m soundtouch-service
|
||||
|
||||
# For high-traffic environments, consider reverse proxy
|
||||
nginx -> soundtouch-service instances
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
### Bose Cloud Shutdown: Survival Guide for SoundTouch
|
||||
|
||||
With Bose's announcement of discontinuing cloud support for SoundTouch devices in May 2026, this project provides the necessary tools to keep your speakers fully functional using a local emulation service.
|
||||
|
||||
This guide explains how to set up the `soundtouch-service` to run your devices independently of Bose's servers.
|
||||
|
||||
---
|
||||
|
||||
### Supported Use Cases
|
||||
|
||||
1. **Local Service Emulation**: The service emulates Bose's BMX (Bose Media eXchange) and Marge services, which handle content registries, presets, recents, and software update checks.
|
||||
2. **Traffic Redirection**: Tools are provided to redirect your speakers to this local service instead of `*.bose.com`.
|
||||
3. **Offline Operation**: Once redirected, the speakers function without needing to reach Bose's servers.
|
||||
4. **Preset & Recent Management**: Captures and stores presets and "recently played" items locally.
|
||||
|
||||
---
|
||||
|
||||
### Setup Steps
|
||||
|
||||
To set up your SoundTouch system for local-only operation, follow these steps:
|
||||
|
||||
#### 1. Install and Start the Service
|
||||
Run the `soundtouch-service` on a machine that is always on (like a Raspberry Pi or a NAS) within your local network.
|
||||
|
||||
```bash
|
||||
# Install the service
|
||||
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
|
||||
# Start the service (defaults to http://localhost:8000)
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
#### 2. Access the Management UI
|
||||
Open your web browser and navigate to the service's web interface:
|
||||
`http://<your-server-ip>:8000/`, e.g. `http://localhost:8000/`
|
||||
|
||||
*Note: The service also supports a `/web/` path for management.*
|
||||
|
||||
#### 3. Enable SSH on Your Speakers
|
||||
To migrate your speakers, the service needs SSH access. You can enable it by:
|
||||
1. Creating an empty file named `remote_services` on a USB stick.
|
||||
2. Inserting the USB stick into the SoundTouch speaker's service port.
|
||||
3. Rebooting the speaker (unplug/replug).
|
||||
|
||||
**Verify SSH Access:**
|
||||
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
|
||||
Once enabled, you can log in as `root` (no password).
|
||||
|
||||
#### 4. Setup Through the Web UI
|
||||
The web interface handles the entire process in a guided flow. Before proceeding, we strongly recommend reviewing the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
* **Step 1: Settings**: Configure your server's IP or domain. This ensures the speakers know where to find the local services.
|
||||
* **Step 2: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
|
||||
* **Step 3: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
|
||||
* **Step 4: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration". After the migration, reboot your speaker to apply the changes.
|
||||
|
||||
#### 5. Verify Your Local Data
|
||||
Once migrated, your speaker will use the data captured during the Sync step.
|
||||
* The service stores data in the `data/` directory, organized by device serial number (e.g., `data/default/devices/<SERIAL>/`).
|
||||
* **Automatic Capture**: As you use the device (changing presets, playing new music), the service continues to "learn" and update your local files.
|
||||
|
||||
---
|
||||
|
||||
### Comparison with other implementations (soundcork)
|
||||
Our implementation (`soundtouch-service`) is largely compatible with the Python-based `soundcork` project but offers several advantages:
|
||||
- **Web UI**: Integrated management interface for discovery and migration.
|
||||
- **Surgical Migration**: Uses XML-based redirection by default, which is less invasive than `/etc/hosts`.
|
||||
- **Automated SSL**: Handles Root CA injection automatically for secure communication.
|
||||
- **Proxy Support**: Can proxy requests to original Bose servers while "learning" your configuration.
|
||||
|
||||
---
|
||||
|
||||
### Alternative: DNS Redirection (No SSH)
|
||||
If you prefer not to modify your speakers via SSH, you can use a local DNS server (like Pi-hole, AdGuard Home, or Unbound) to point the following domains to your local server's IP:
|
||||
|
||||
* `bmx.bose.com`
|
||||
* `streaming.bose.com`
|
||||
* `updates.bose.com`
|
||||
* `stats.bose.com`
|
||||
* `content.api.bose.io`
|
||||
|
||||
*Note: DNS redirection for HTTPS services requires the speakers to trust your local service's SSL certificate. The SSH-based migration handles this automatically by injecting the CA.*
|
||||
|
||||
---
|
||||
@@ -355,6 +355,90 @@ client.SetBalanceSafe(10) // Falls back gracefully
|
||||
|
||||
---
|
||||
|
||||
## 🔔 **Speaker Notification Issues**
|
||||
|
||||
### ❌ "speaker beep" command fails with status 400
|
||||
|
||||
**Symptoms:**
|
||||
```bash
|
||||
$ go run ./cmd/soundtouch-cli --host 192.168.178.35 sp beep
|
||||
Playing notification beep from 192.168.178.35:8090...
|
||||
✗ Failed to play notification beep: API request failed with status 400
|
||||
```
|
||||
|
||||
**Cause:**
|
||||
This was a bug in earlier versions where the Go client incorrectly used POST instead of GET for the `/playNotification` endpoint.
|
||||
|
||||
**Solution:**
|
||||
Update to the latest version. The fix changed the `PlayNotificationBeep()` method to use GET requests:
|
||||
|
||||
```go
|
||||
// Fixed implementation (v2025.02+)
|
||||
func (c *Client) PlayNotificationBeep() error {
|
||||
var status models.StationResponse
|
||||
return c.get("/playNotification", &status)
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
Both commands should now work identically:
|
||||
```bash
|
||||
# CLI command
|
||||
go run ./cmd/soundtouch-cli --host 192.168.178.35 sp beep
|
||||
|
||||
# Direct curl (for comparison)
|
||||
curl http://192.168.178.35:8090/playNotification
|
||||
```
|
||||
|
||||
### ❌ "speaker" commands not supported
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
✗ Failed to play notification: endpoint not supported
|
||||
```
|
||||
|
||||
**Causes & Solutions:**
|
||||
|
||||
#### 1. **Device Model Compatibility**
|
||||
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
|
||||
- ❌ **Not Supported**: SoundTouch 300 (ST-300), older models
|
||||
|
||||
**Solution:** Verify device model with:
|
||||
```bash
|
||||
soundtouch-cli --host <device> info
|
||||
```
|
||||
|
||||
#### 2. **Missing App Key (TTS/URL only)**
|
||||
TTS and URL playback require an app key, but beep does not:
|
||||
```bash
|
||||
# Beep - no app key needed
|
||||
soundtouch-cli --host <device> speaker beep
|
||||
|
||||
# TTS - app key required
|
||||
soundtouch-cli --host <device> speaker tts --text "Hello" --app-key "your-key"
|
||||
```
|
||||
|
||||
### ❌ "Device is busy" during notifications
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
✗ Failed to play notification: device is busy
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
#### 1. **Wait for Current Notification to Complete**
|
||||
Only one notification can play at a time. Wait a few seconds and retry.
|
||||
|
||||
#### 2. **Check Current Playback Status**
|
||||
```go
|
||||
nowPlaying, _ := client.GetNowPlaying()
|
||||
fmt.Printf("Current source: %s, status: %s\n",
|
||||
nowPlaying.Source, nowPlaying.PlayStatus)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 **WebSocket Issues**
|
||||
|
||||
### ❌ "WebSocket connection failed"
|
||||
@@ -686,7 +686,7 @@ Retrieves all supported endpoints for the specific device with comprehensive fea
|
||||
- `/setMusicServiceOAuthAccount` - OAuth account setup
|
||||
- `/removeMusicServiceAccount` - Remove music service account
|
||||
- `/serviceAvailability` ✅ **Implemented** - Check service availability
|
||||
- `/introspect` - Get introspect data for specific sources
|
||||
- `/introspect` ✅ **Implemented** - Get introspect data for specific sources
|
||||
|
||||
**Station Management (Radio/Streaming):**
|
||||
- `/searchStation` - Search for stations (tested with Pandora)
|
||||
@@ -418,10 +418,10 @@ soundtouch-cli -host <discovered-ip> -bass # Verify final state
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
|
||||
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
|
||||
- **[Volume Controls](VOLUME-CONTROLS.md)** - Related audio control documentation
|
||||
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
- **[Models](../pkg/models/bass.go)** - Bass model implementation
|
||||
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
- **[Models](../../pkg/models/bass.go)** - Bass model implementation
|
||||
|
||||
## API Compliance
|
||||
|
||||
@@ -443,4 +443,4 @@ The implementation follows the official SoundTouch API:
|
||||
**Implementation Date**: 2026-01-09
|
||||
**Status**: ✅ Complete and tested
|
||||
**Real Device Validation**: SoundTouch 10, SoundTouch 20
|
||||
**API Compliance**: Full compliance with SoundTouch Web API specification
|
||||
**API Compliance**: Full compliance with SoundTouch Web API specification
|
||||
@@ -0,0 +1,73 @@
|
||||
# Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)
|
||||
|
||||
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
|
||||
|
||||
## Marge API (Account & Configuration)
|
||||
|
||||
Base path: `/marge`
|
||||
|
||||
### GET /streaming/sourceproviders
|
||||
Retrieves a list of available streaming source providers.
|
||||
|
||||
### GET /accounts/{accountId}/full
|
||||
Retrieves the full account configuration including sources, presets, and devices.
|
||||
|
||||
### GET /streaming/account/{accountId}/emailaddress
|
||||
Retrieves the email address associated with the account.
|
||||
|
||||
### GET /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
|
||||
Retrieves settings for a specific device (e.g., clock format).
|
||||
|
||||
### POST /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
|
||||
Updates settings for a specific device.
|
||||
|
||||
### POST /accounts/{accountId}/devices/{deviceId}/presets/{presetNumber}
|
||||
Updates a preset for a device.
|
||||
|
||||
### POST /accounts/{accountId}/devices/{deviceId}/recents
|
||||
Adds an item to the device's recently played history.
|
||||
|
||||
### POST /accounts/{accountId}/devices
|
||||
Adds a device to the account.
|
||||
|
||||
### DELETE /accounts/{accountId}/devices/{deviceId}
|
||||
Removes a device from the account.
|
||||
|
||||
## Customer API (Profile & Password)
|
||||
|
||||
Base path: `/customer`
|
||||
|
||||
### GET /account/{accountId}
|
||||
Retrieves the customer account profile.
|
||||
|
||||
### POST /account/{accountId}
|
||||
Updates the customer account profile.
|
||||
|
||||
### POST /account/{accountId}/password
|
||||
Changes the account password.
|
||||
|
||||
## Analytics & Stats API
|
||||
|
||||
Base path: `/v1` (App Events) or `/streaming/stats` (Device Stats)
|
||||
|
||||
### POST /v1/stapp/{deviceId}
|
||||
Endpoint called by Bose SoundTouch mobile and web applications (Stockholm) to submit event data.
|
||||
|
||||
### POST /v1/scmudc/{deviceId}
|
||||
Endpoint equivalent to `/v1/stapp/{deviceId}` sometimes used by apps or devices.
|
||||
|
||||
### POST /streaming/stats/usage
|
||||
Endpoint used by physical devices to report usage statistics.
|
||||
|
||||
### POST /streaming/stats/error
|
||||
Endpoint used by physical devices to report error statistics.
|
||||
|
||||
## BMX API (Streaming & Registry)
|
||||
|
||||
Base path: `/bmx`
|
||||
|
||||
### GET /registry/v1/services
|
||||
Retrieves the registry of available streaming services.
|
||||
|
||||
### GET /tunein/v1/playback/station/{stationID}
|
||||
Retrieves playback information for a TuneIn station.
|
||||
@@ -366,7 +366,7 @@ This implementation now provides the full preset management lifecycle:
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [API Endpoints Overview](API-Endpoints-Overview.md) - Complete API reference
|
||||
- [API Endpoints Overview](API-ENDPOINTS.md) - Complete API reference
|
||||
- [Volume Controls](VOLUME-CONTROLS.md) - Volume management
|
||||
- [Key Controls](KEY-CONTROLS.md) - Media control commands
|
||||
- [Source Selection](SOURCE-SELECTION.md) - Audio source management
|
||||
@@ -375,4 +375,4 @@ This implementation now provides the full preset management lifecycle:
|
||||
|
||||
Preset management in the Bose SoundTouch API is **intentionally read-only** by design. The API provides excellent capabilities for analyzing and understanding preset configurations, but preset creation must be done through official channels (app or device). This is a deliberate design decision that respects user control over their personal preset configurations.
|
||||
|
||||
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
|
||||
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
|
||||
@@ -38,6 +38,7 @@ The Bose SoundTouch Go client provides comprehensive source selection functional
|
||||
- `IHEARTRADIO` - iHeartRadio streaming
|
||||
- `STORED_MUSIC` - Local/network stored music
|
||||
- `AIRPLAY` - Apple AirPlay (device dependent)
|
||||
- `RADIO_BROWSER` - [RadioBrowser](radio-browser.md) internet radio directory
|
||||
|
||||
## Client Library Usage
|
||||
|
||||
@@ -345,13 +346,13 @@ The implementation follows the official SoundTouch API:
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
|
||||
- **[Sources](../pkg/models/sources.go)** - Source model implementation
|
||||
- **[Now Playing](../pkg/models/nowplaying.go)** - ContentItem model
|
||||
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
|
||||
- **[Sources](../../pkg/models/sources.go)** - Source model implementation
|
||||
- **[Now Playing](../../pkg/models/nowplaying.go)** - ContentItem model
|
||||
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: 2026-01-09
|
||||
**Status**: ✅ Complete and tested
|
||||
**Real Device Validation**: SoundTouch 10, SoundTouch 20
|
||||
**Real Device Validation**: SoundTouch 10, SoundTouch 20
|
||||
@@ -39,6 +39,8 @@ Plays notification content on the speaker.
|
||||
|
||||
Plays a simple notification beep sound.
|
||||
|
||||
**Important**: This endpoint requires a GET request, not POST. Earlier versions of this client library incorrectly used POST and would fail with HTTP 400 status.
|
||||
|
||||
**Response:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
@@ -66,14 +68,14 @@ func main() {
|
||||
|
||||
client := client.NewClient(config)
|
||||
|
||||
// Play TTS at current volume
|
||||
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY")
|
||||
// Play TTS at current volume (language code "EN", "DE", etc.)
|
||||
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Play TTS at specific volume (70)
|
||||
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", 70)
|
||||
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -134,6 +136,7 @@ func main() {
|
||||
func main() {
|
||||
client := client.NewClient(config)
|
||||
|
||||
// Uses GET request (fixed in v2025.02+)
|
||||
err := client.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -274,7 +277,7 @@ You'll need to provide your own application key. The format and generation metho
|
||||
|
||||
```go
|
||||
// Doorbell notification
|
||||
client.PlayTTS("Someone is at the front door", "home-automation-key", 80)
|
||||
client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
|
||||
|
||||
// Security alert
|
||||
client.PlayURL(
|
||||
@@ -308,4 +311,4 @@ soundtouch-cli speaker url --url "https://www.soundjay.com/misc/sounds/bell-ring
|
||||
4. **URL content fails**: Ensure URL is accessible and contains valid audio
|
||||
5. **Volume not restored**: May occur if device is powered off during playback
|
||||
|
||||
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
@@ -0,0 +1,34 @@
|
||||
## radio-browser.info
|
||||
|
||||
- https://www.radio-browser.info is a community driven radio station database.
|
||||
- It provides an API to access the data and allows users to submit new stations or update existing ones.
|
||||
|
||||
### Search for stations
|
||||
|
||||
- Go to https://www.radio-browser.info and find a station you like.
|
||||
- Click on the station and copy the UUID from the URL.
|
||||
- e.g. `https://www.radio-browser.info/history/d28420a4-eccf-47a2-ace1-088c7e7cb7e0`
|
||||
|
||||
### RADIO_BROWSER
|
||||
|
||||
- This project supports source type RADIO_BROWSER to play radio stations.
|
||||
- Set the `location` attribute to `/stations/byuuid/{UUID}`.
|
||||
|
||||
```xml
|
||||
<ContentItem
|
||||
source="RADIO_BROWSER"
|
||||
type="stationurl"
|
||||
isPresetable="true"
|
||||
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
|
||||
<itemName>RADIO_BROWSER</itemName>
|
||||
<containerArt></containerArt>
|
||||
</ContentItem>
|
||||
```
|
||||
|
||||
### Playing the station
|
||||
|
||||
To start the radio stream replace `<uuid>` and `<soundtouch>` and run curl like this:
|
||||
|
||||
```bash
|
||||
curl -d '<ContentItem source="RADIO_BROWSER" type="stationurl" location="/stations/byuuid/<uuid>"/>' <soundtouch>:8090/select
|
||||
```
|
||||
@@ -0,0 +1,152 @@
|
||||
# Music Service Account Management Example
|
||||
|
||||
This example demonstrates how to manage music streaming service accounts and network music library connections on Bose SoundTouch devices.
|
||||
|
||||
## Overview
|
||||
|
||||
The SoundTouch device can store credentials for various music streaming services and network music libraries. This allows you to:
|
||||
|
||||
- Add streaming service accounts (Spotify, Pandora, Amazon Music, Deezer, iHeartRadio)
|
||||
- Configure network music libraries (NAS/UPnP/DLNA servers)
|
||||
- Remove accounts when no longer needed
|
||||
- List currently configured accounts
|
||||
|
||||
## Running the Example
|
||||
|
||||
1. Update the device IP address in `main.go`:
|
||||
```go
|
||||
config := &client.Config{
|
||||
Host: "192.168.1.100", // Replace with your device IP
|
||||
Port: 8090,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
```
|
||||
|
||||
2. Run the example:
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## Supported Music Services
|
||||
|
||||
### Streaming Services (require username/password)
|
||||
- **Spotify Premium**: Personal Spotify accounts
|
||||
- **Pandora**: Pandora Music Service accounts
|
||||
- **Amazon Music**: Amazon Music accounts
|
||||
- **Deezer Premium**: Deezer subscription accounts
|
||||
- **iHeartRadio**: iHeartRadio accounts
|
||||
|
||||
### Network Music Libraries (no password required)
|
||||
- **STORED_MUSIC**: NAS, UPnP, and DLNA media servers
|
||||
- **LOCAL_MUSIC**: Local music servers
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
### 1. Adding Accounts
|
||||
|
||||
```go
|
||||
// Convenience methods for popular services
|
||||
err := client.AddSpotifyAccount("user@spotify.com", "password")
|
||||
err := client.AddPandoraAccount("username", "password")
|
||||
err := client.AddAmazonMusicAccount("username", "password")
|
||||
|
||||
// Generic method for any service
|
||||
credentials := models.NewMusicServiceCredentials("TIDAL", "Tidal HiFi", "user", "pass")
|
||||
err := client.SetMusicServiceAccount(credentials)
|
||||
|
||||
// Network music library (no password needed)
|
||||
err := client.AddStoredMusicAccount("server-guid/0", "My Music Server")
|
||||
```
|
||||
|
||||
### 2. Removing Accounts
|
||||
|
||||
```go
|
||||
// Convenience methods
|
||||
err := client.RemoveSpotifyAccount("user@spotify.com")
|
||||
err := client.RemovePandoraAccount("username")
|
||||
|
||||
// Generic removal method
|
||||
credentials := models.NewSpotifyCredentials("user@spotify.com", "") // Empty password = removal
|
||||
err := client.RemoveMusicServiceAccount(credentials)
|
||||
```
|
||||
|
||||
### 3. Validating Credentials
|
||||
|
||||
```go
|
||||
credentials := models.NewSpotifyCredentials("user", "pass")
|
||||
if err := credentials.Validate(); err != nil {
|
||||
log.Fatal("Invalid credentials:", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Checking Account Status
|
||||
|
||||
```go
|
||||
sources, err := client.GetSources()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Look for sources with accounts configured
|
||||
for _, source := range sources.Sources {
|
||||
if source.SourceAccount != "" {
|
||||
fmt.Printf("Service: %s, Account: %s, Status: %s\n",
|
||||
source.Source, source.SourceAccount, source.Status)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Usage Examples
|
||||
|
||||
After setting up accounts programmatically, you can also manage them via the CLI:
|
||||
|
||||
```bash
|
||||
# List configured accounts
|
||||
soundtouch-cli --host 192.168.1.10 account list
|
||||
|
||||
# Add accounts via CLI
|
||||
soundtouch-cli --host 192.168.1.10 account add-spotify --user user@spotify.com --password mypass
|
||||
soundtouch-cli --host 192.168.1.10 account add-pandora --user pandora_user --password pandora_pass
|
||||
soundtouch-cli --host 192.168.1.10 account add-nas --user "guid/0" --name "My NAS"
|
||||
|
||||
# Remove accounts
|
||||
soundtouch-cli --host 192.168.1.10 account remove-spotify --user user@spotify.com
|
||||
```
|
||||
|
||||
## Network Music Libraries
|
||||
|
||||
For STORED_MUSIC (NAS/UPnP) services:
|
||||
|
||||
1. The `user` field should contain the UPnP server GUID followed by `/0`
|
||||
2. You can find the GUID by discovering UPnP devices on your network
|
||||
3. No password is required
|
||||
4. You can specify a custom display name for the library
|
||||
|
||||
Example GUID format: `d09708a1-5953-44bc-a413-123456789012/0`
|
||||
|
||||
## Error Handling
|
||||
|
||||
The example includes comprehensive error handling for common scenarios:
|
||||
|
||||
- Network connectivity issues
|
||||
- Invalid credentials
|
||||
- Missing required fields
|
||||
- Service-specific authentication failures
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Credentials are sent securely to the SoundTouch device over your local network
|
||||
- The device stores encrypted credentials internally
|
||||
- Passwords are only required during the initial setup
|
||||
- Use the removal methods to completely delete stored credentials
|
||||
|
||||
## Next Steps
|
||||
|
||||
After configuring accounts:
|
||||
|
||||
1. Use `source list` to verify services are available
|
||||
2. Use `source select` to choose a music service
|
||||
3. Use `browse` commands to explore content
|
||||
4. Use `play` commands to start playback
|
||||
|
||||
See the [CLI Reference](../../docs/guides/CLI-REFERENCE.md) for complete documentation.
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package main demonstrates music service account management functionality for Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configure the SoundTouch client
|
||||
config := &client.Config{
|
||||
Host: "192.168.1.100", // Replace with your device IP
|
||||
Port: 8090,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Create client
|
||||
soundtouchClient := client.NewClient(config)
|
||||
|
||||
fmt.Printf("🎵 SoundTouch Music Service Account Management Example\n")
|
||||
fmt.Printf("Device: %s:%d\n\n", config.Host, config.Port)
|
||||
|
||||
// Example 1: Add a Spotify account using convenience method
|
||||
fmt.Println("📱 Adding Spotify Premium account...")
|
||||
|
||||
err := soundtouchClient.AddSpotifyAccount("user@spotify.com", "your_password")
|
||||
if err != nil {
|
||||
log.Printf("Failed to add Spotify account: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Spotify account added successfully")
|
||||
}
|
||||
|
||||
// Example 2: Add a Pandora account
|
||||
fmt.Println("\n📻 Adding Pandora account...")
|
||||
|
||||
err = soundtouchClient.AddPandoraAccount("pandora_username", "pandora_password")
|
||||
if err != nil {
|
||||
log.Printf("Failed to add Pandora account: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Pandora account added successfully")
|
||||
}
|
||||
|
||||
// Example 3: Add Amazon Music account
|
||||
fmt.Println("\n🛒 Adding Amazon Music account...")
|
||||
|
||||
err = soundtouchClient.AddAmazonMusicAccount("amazon_user", "amazon_password")
|
||||
if err != nil {
|
||||
log.Printf("Failed to add Amazon Music account: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Amazon Music account added successfully")
|
||||
}
|
||||
|
||||
// Example 4: Add a network music library (NAS/UPnP)
|
||||
fmt.Println("\n🏠 Adding network music library...")
|
||||
|
||||
nasGUID := "d09708a1-5953-44bc-a413-123456789012/0" // Example UPnP server GUID
|
||||
|
||||
err = soundtouchClient.AddStoredMusicAccount(nasGUID, "My Home Music Server")
|
||||
if err != nil {
|
||||
log.Printf("Failed to add network music library: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Network music library added successfully")
|
||||
}
|
||||
|
||||
// Example 5: Add account using generic method with custom credentials
|
||||
fmt.Println("\n🎧 Adding Deezer account using generic method...")
|
||||
|
||||
deezerCredentials := models.NewDeezerCredentials("deezer_user", "deezer_password")
|
||||
|
||||
err = soundtouchClient.SetMusicServiceAccount(deezerCredentials)
|
||||
if err != nil {
|
||||
log.Printf("Failed to add Deezer account: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Deezer account added successfully")
|
||||
}
|
||||
|
||||
// Example 6: Add a custom/unknown service
|
||||
fmt.Println("\n🎶 Adding custom music service...")
|
||||
|
||||
customCredentials := models.NewMusicServiceCredentials("TIDAL", "Tidal HiFi", "tidal_user", "tidal_password")
|
||||
|
||||
err = soundtouchClient.SetMusicServiceAccount(customCredentials)
|
||||
if err != nil {
|
||||
log.Printf("Failed to add custom music service: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Custom music service added successfully")
|
||||
}
|
||||
|
||||
// Example 7: List current sources to see added accounts
|
||||
fmt.Println("\n📋 Checking available sources...")
|
||||
|
||||
sources, err := soundtouchClient.GetSources()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get sources: %v", err)
|
||||
} else {
|
||||
fmt.Printf("Available sources (%d total):\n", len(sources.SourceItem))
|
||||
|
||||
for _, source := range sources.SourceItem {
|
||||
status := "🔴 Unavailable"
|
||||
if source.Status == models.SourceStatusReady {
|
||||
status = "🟢 Ready"
|
||||
}
|
||||
|
||||
accountInfo := ""
|
||||
if source.SourceAccount != "" && source.SourceAccount != source.Source {
|
||||
accountInfo = fmt.Sprintf(" (%s)", source.SourceAccount)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s%s\n", status, source.GetDisplayName(), accountInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Example 8: Remove accounts
|
||||
fmt.Println("\n🗑️ Removing accounts...")
|
||||
|
||||
// Remove Spotify account
|
||||
err = soundtouchClient.RemoveSpotifyAccount("user@spotify.com")
|
||||
if err != nil {
|
||||
log.Printf("Failed to remove Spotify account: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Spotify account removed successfully")
|
||||
}
|
||||
|
||||
// Remove Deezer account using generic method
|
||||
deezerRemovalCredentials := models.NewDeezerCredentials("deezer_user", "")
|
||||
|
||||
err = soundtouchClient.RemoveMusicServiceAccount(deezerRemovalCredentials)
|
||||
if err != nil {
|
||||
log.Printf("Failed to remove Deezer account: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Deezer account removed successfully")
|
||||
}
|
||||
|
||||
// Remove network music library
|
||||
err = soundtouchClient.RemoveStoredMusicAccount(nasGUID, "My Home Music Server")
|
||||
if err != nil {
|
||||
log.Printf("Failed to remove network music library: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Network music library removed successfully")
|
||||
}
|
||||
|
||||
fmt.Println("\n🎉 Account management example completed!")
|
||||
fmt.Println("\n💡 Tips:")
|
||||
fmt.Println(" • Use 'account list' to see which services are configured")
|
||||
fmt.Println(" • After adding accounts, use 'source list' to verify availability")
|
||||
fmt.Println(" • Network libraries (NAS/UPnP) don't require passwords")
|
||||
fmt.Println(" • Some services may need additional authentication via their mobile apps")
|
||||
fmt.Println(" • Account credentials are stored securely on the SoundTouch device")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
# Content Selection Example
|
||||
|
||||
This example demonstrates the advanced content selection features of the Bose SoundTouch Go client, including support for LOCAL_INTERNET_RADIO with streamUrl format, LOCAL_MUSIC, and STORED_MUSIC content.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
### 1. LOCAL_INTERNET_RADIO with streamUrl Format
|
||||
- Uses proxy server format: `http://contentapi.gmuth.de/station.php?name=StationName&streamUrl=ActualStreamURL`
|
||||
- Supports complex radio station metadata
|
||||
- Artwork and station information
|
||||
|
||||
### 2. LOCAL_INTERNET_RADIO Direct Streams
|
||||
- Direct HTTP/HTTPS stream URLs
|
||||
- Simple internet radio playback
|
||||
- MP3 and other audio format support
|
||||
|
||||
### 3. LOCAL_MUSIC Content
|
||||
- SoundTouch App Media Server content
|
||||
- Albums, tracks, artists, playlists
|
||||
- Requires local SoundTouch Media Server running
|
||||
|
||||
### 4. STORED_MUSIC Content
|
||||
- UPnP/DLNA media server content
|
||||
- NAS libraries and Windows Media Player sharing
|
||||
- Network-attached storage music libraries
|
||||
|
||||
### 5. Generic ContentItem Selection
|
||||
- Direct ContentItem object creation
|
||||
- Maximum flexibility for any content type
|
||||
- All SoundTouch sources supported
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SoundTouch device on your network
|
||||
- Device IP address
|
||||
- Go 1.21+ installed
|
||||
|
||||
### Optional (for specific examples):
|
||||
- **LOCAL_MUSIC**: SoundTouch App Media Server running on a computer
|
||||
- **STORED_MUSIC**: UPnP/DLNA media server (Windows Media Player, NAS, etc.)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
go run main.go <device_ip>
|
||||
|
||||
# Example
|
||||
go run main.go 192.168.1.100
|
||||
```
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
🎵 SoundTouch Content Selection Example
|
||||
📱 Device: 192.168.1.100:8090
|
||||
|
||||
📻 Step 1: Demonstrating LOCAL_INTERNET_RADIO with streamUrl format...
|
||||
📡 Using streamUrl format with proxy server...
|
||||
Station: Antenne Chillout
|
||||
Proxy URL: http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp
|
||||
✅ Successfully selected internet radio with streamUrl format
|
||||
|
||||
🎵 Now Playing:
|
||||
Title: Antenne Chillout
|
||||
Source: LOCAL_INTERNET_RADIO
|
||||
Status: Playing
|
||||
Location: http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp
|
||||
|
||||
📻 Step 2: Demonstrating LOCAL_INTERNET_RADIO with direct stream...
|
||||
📡 Using direct stream URL...
|
||||
Stream: Test Audio Stream
|
||||
URL: https://freetestdata.com/wp-content/uploads/2021/09/Free_Test_Data_1MB_MP3.mp3
|
||||
✅ Successfully selected direct internet radio stream
|
||||
|
||||
💿 Step 3: Demonstrating LOCAL_MUSIC selection...
|
||||
⚠️ LOCAL_MUSIC demo failed (this requires SoundTouch App Media Server): failed to select local music: HTTP 404 Not Found
|
||||
|
||||
💾 Step 4: Demonstrating STORED_MUSIC selection...
|
||||
⚠️ STORED_MUSIC demo failed (this requires UPnP/DLNA media server): failed to select stored music: HTTP 404 Not Found
|
||||
|
||||
🎯 Step 5: Demonstrating generic ContentItem selection...
|
||||
🎯 Using generic ContentItem selection...
|
||||
Content: K-LOVE Radio
|
||||
Source: TUNEIN
|
||||
Location: /v1/playbook/station/s33828
|
||||
✅ Successfully selected content using ContentItem
|
||||
|
||||
✅ Content selection demo completed!
|
||||
```
|
||||
|
||||
## API Methods Demonstrated
|
||||
|
||||
### SelectLocalInternetRadio
|
||||
```go
|
||||
err := client.SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt)
|
||||
```
|
||||
|
||||
### SelectLocalMusic
|
||||
```go
|
||||
err := client.SelectLocalMusic(location, sourceAccount, itemName, containerArt)
|
||||
```
|
||||
|
||||
### SelectStoredMusic
|
||||
```go
|
||||
err := client.SelectStoredMusic(location, sourceAccount, itemName, containerArt)
|
||||
```
|
||||
|
||||
### SelectContentItem (Advanced)
|
||||
```go
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio",
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "My Radio Station",
|
||||
ContainerArt: "https://example.com/art.png",
|
||||
}
|
||||
err := client.SelectContentItem(contentItem)
|
||||
```
|
||||
|
||||
## CLI Usage Examples
|
||||
|
||||
These API methods are also available via the CLI:
|
||||
|
||||
```bash
|
||||
# Internet radio with streamUrl format
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Station" \
|
||||
--artwork "https://example.com/art.png"
|
||||
|
||||
# Local music content
|
||||
soundtouch-cli --host 192.168.1.100 source local-music \
|
||||
--location "album:983" \
|
||||
--account "3f205110-4a57-4e91-810a-123456789012" \
|
||||
--name "Welcome to the New"
|
||||
|
||||
# Stored music content
|
||||
soundtouch-cli --host 192.168.1.100 source stored-music \
|
||||
--location "6_a2874b5d_4f83d999" \
|
||||
--account "d09708a1-5953-44bc-a413-123456789012/0" \
|
||||
--name "Christmas Album"
|
||||
|
||||
# Generic content selection (advanced)
|
||||
soundtouch-cli --host 192.168.1.100 source content \
|
||||
--source LOCAL_INTERNET_RADIO \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "My Stream" \
|
||||
--type stationurl \
|
||||
--presetable
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### streamUrl Format
|
||||
The streamUrl format uses a proxy server that accepts the actual stream URL as a parameter. This allows for:
|
||||
- Complex metadata handling
|
||||
- Stream URL obfuscation
|
||||
- Cross-origin request handling
|
||||
- Additional processing capabilities
|
||||
|
||||
### ContentItem Structure
|
||||
All content selection methods create a `ContentItem` with appropriate defaults:
|
||||
- `Type` is automatically set based on source
|
||||
- `IsPresetable` defaults to true
|
||||
- `ItemName` gets a sensible default if not provided
|
||||
|
||||
### Error Handling
|
||||
The example gracefully handles missing services:
|
||||
- LOCAL_MUSIC requires SoundTouch App Media Server
|
||||
- STORED_MUSIC requires UPnP/DLNA media server
|
||||
- Some internet streams may be geo-restricted
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
|
||||
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md)
|
||||
@@ -0,0 +1,290 @@
|
||||
// Package main demonstrates content selection functionality for Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get device IP from command line
|
||||
deviceIP := os.Args[1]
|
||||
|
||||
// Create client
|
||||
config := &client.Config{
|
||||
Host: deviceIP,
|
||||
Port: 8090,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
c := client.NewClient(config)
|
||||
|
||||
fmt.Printf("🎵 SoundTouch Content Selection Example\n")
|
||||
fmt.Printf("📱 Device: %s:%d\n\n", config.Host, config.Port)
|
||||
|
||||
// Demonstrate various content selection methods
|
||||
if err := demonstrateContentSelection(c); err != nil {
|
||||
log.Fatalf("Demo failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n✅ Content selection demo completed!")
|
||||
}
|
||||
|
||||
func demonstrateContentSelection(c *client.Client) error {
|
||||
// 1. Demonstrate LOCAL_INTERNET_RADIO with streamUrl format
|
||||
fmt.Println("📻 Step 1: Demonstrating LOCAL_INTERNET_RADIO with streamUrl format...")
|
||||
|
||||
if err := demoLocalInternetRadioStreamUrl(c); err != nil {
|
||||
return fmt.Errorf("failed LOCAL_INTERNET_RADIO demo: %w", err)
|
||||
}
|
||||
|
||||
// Wait and show what's playing
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if err := showNowPlaying(c); err != nil {
|
||||
fmt.Printf("⚠️ Could not get now playing: %v\n", err)
|
||||
}
|
||||
|
||||
// 2. Demonstrate LOCAL_INTERNET_RADIO with direct stream
|
||||
fmt.Println("\n📻 Step 2: Demonstrating LOCAL_INTERNET_RADIO with direct stream...")
|
||||
|
||||
if err := demoLocalInternetRadioDirect(c); err != nil {
|
||||
return fmt.Errorf("failed direct stream demo: %w", err)
|
||||
}
|
||||
|
||||
// Wait and show what's playing
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if err := showNowPlaying(c); err != nil {
|
||||
fmt.Printf("⚠️ Could not get now playing: %v\n", err)
|
||||
}
|
||||
|
||||
// 3. Demonstrate LOCAL_MUSIC selection
|
||||
fmt.Println("\n💿 Step 3: Demonstrating LOCAL_MUSIC selection...")
|
||||
|
||||
if err := demoLocalMusic(c); err != nil {
|
||||
fmt.Printf("⚠️ LOCAL_MUSIC demo failed (this requires SoundTouch App Media Server): %v\n", err)
|
||||
} else {
|
||||
// Wait and show what's playing
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if err := showNowPlaying(c); err != nil {
|
||||
fmt.Printf("⚠️ Could not get now playing: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Demonstrate STORED_MUSIC selection
|
||||
fmt.Println("\n💾 Step 4: Demonstrating STORED_MUSIC selection...")
|
||||
|
||||
if err := demoStoredMusic(c); err != nil {
|
||||
fmt.Printf("⚠️ STORED_MUSIC demo failed (this requires UPnP/DLNA media server): %v\n", err)
|
||||
} else {
|
||||
// Wait and show what's playing
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if err := showNowPlaying(c); err != nil {
|
||||
fmt.Printf("⚠️ Could not get now playing: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Demonstrate generic ContentItem selection
|
||||
fmt.Println("\n🎯 Step 5: Demonstrating generic ContentItem selection...")
|
||||
|
||||
if err := demoGenericContentItem(c); err != nil {
|
||||
return fmt.Errorf("failed generic ContentItem demo: %w", err)
|
||||
}
|
||||
|
||||
// Wait and show what's playing
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if err := showNowPlaying(c); err != nil {
|
||||
fmt.Printf("⚠️ Could not get now playing: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func demoLocalInternetRadioStreamUrl(c *client.Client) error {
|
||||
fmt.Printf(" 📡 Using streamUrl format with proxy server...\n")
|
||||
|
||||
// Example using the streamUrl format from the wiki
|
||||
// This uses a proxy server that accepts the actual stream URL as a parameter
|
||||
location := "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp"
|
||||
itemName := "Antenne Chillout"
|
||||
containerArt := "https://www.radio.net/300/antennechillout.png?version=7fddbc7d3f37557ad3291d66fff40f323e1779d6"
|
||||
|
||||
fmt.Printf(" Station: %s\n", itemName)
|
||||
fmt.Printf(" Proxy URL: %s\n", location)
|
||||
|
||||
err := c.SelectLocalInternetRadio(location, "", itemName, containerArt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Successfully selected internet radio with streamUrl format\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func demoLocalInternetRadioDirect(c *client.Client) error {
|
||||
fmt.Printf(" 📡 Using direct stream URL...\n")
|
||||
|
||||
// Example using a direct stream URL
|
||||
location := "https://freetestdata.com/wp-content/uploads/2021/09/Free_Test_Data_1MB_MP3.mp3"
|
||||
itemName := "Test Audio Stream"
|
||||
|
||||
fmt.Printf(" Stream: %s\n", itemName)
|
||||
fmt.Printf(" URL: %s\n", location)
|
||||
|
||||
err := c.SelectLocalInternetRadio(location, "", itemName, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Successfully selected direct internet radio stream\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func demoLocalMusic(c *client.Client) error {
|
||||
fmt.Printf(" 💿 Selecting LOCAL_MUSIC content...\n")
|
||||
|
||||
// Example LOCAL_MUSIC selection (requires SoundTouch App Media Server)
|
||||
// These are example values - in practice, you'd get these from navigation
|
||||
location := "album:983"
|
||||
sourceAccount := "3f205110-4a57-4e91-810a-123456789012" // Example GUID
|
||||
itemName := "Welcome to the New"
|
||||
containerArt := "http://192.168.1.14:8085/v1/albums/983/image"
|
||||
|
||||
fmt.Printf(" Album: %s\n", itemName)
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
|
||||
err := c.SelectLocalMusic(location, sourceAccount, itemName, containerArt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Successfully selected local music content\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func demoStoredMusic(c *client.Client) error {
|
||||
fmt.Printf(" 💾 Selecting STORED_MUSIC content...\n")
|
||||
|
||||
// Example STORED_MUSIC selection (requires UPnP/DLNA media server)
|
||||
// These are example values - in practice, you'd get these from navigation
|
||||
location := "6_a2874b5d_4f83d999"
|
||||
sourceAccount := "d09708a1-5953-44bc-a413-123456789012/0" // Example UPnP server GUID
|
||||
itemName := "Christmas Album"
|
||||
|
||||
fmt.Printf(" Album: %s\n", itemName)
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
|
||||
err := c.SelectStoredMusic(location, sourceAccount, itemName, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Successfully selected stored music content\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func demoGenericContentItem(c *client.Client) error {
|
||||
fmt.Printf(" 🎯 Using generic ContentItem selection...\n")
|
||||
|
||||
// Example using SelectContentItem directly for maximum flexibility
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playbook/station/s33828", // K-LOVE Radio
|
||||
SourceAccount: "",
|
||||
IsPresetable: true,
|
||||
ItemName: "K-LOVE Radio",
|
||||
ContainerArt: "http://cdn-profiles.tunein.com/s33828/images/logog.png",
|
||||
}
|
||||
|
||||
fmt.Printf(" Content: %s\n", contentItem.ItemName)
|
||||
fmt.Printf(" Source: %s\n", contentItem.Source)
|
||||
fmt.Printf(" Location: %s\n", contentItem.Location)
|
||||
|
||||
err := c.SelectContentItem(contentItem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Successfully selected content using ContentItem\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func showNowPlaying(c *client.Client) error {
|
||||
nowPlaying, err := c.GetNowPlaying()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if nowPlaying.IsEmpty() {
|
||||
fmt.Printf(" ⏸️ No content currently playing\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" 🎵 Now Playing:\n")
|
||||
fmt.Printf(" Title: %s\n", nowPlaying.GetDisplayTitle())
|
||||
|
||||
if nowPlaying.GetDisplayArtist() != "" {
|
||||
fmt.Printf(" Artist: %s\n", nowPlaying.GetDisplayArtist())
|
||||
}
|
||||
|
||||
if nowPlaying.Album != "" {
|
||||
fmt.Printf(" Album: %s\n", nowPlaying.Album)
|
||||
}
|
||||
|
||||
fmt.Printf(" Source: %s\n", nowPlaying.Source)
|
||||
fmt.Printf(" Status: %s\n", nowPlaying.PlayStatus.String())
|
||||
|
||||
if nowPlaying.ContentItem != nil && nowPlaying.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", nowPlaying.ContentItem.Location)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("🎵 SoundTouch Content Selection Example")
|
||||
fmt.Println()
|
||||
fmt.Println("This example demonstrates the new content selection features:")
|
||||
fmt.Println("• LOCAL_INTERNET_RADIO with streamUrl format")
|
||||
fmt.Println("• LOCAL_INTERNET_RADIO with direct stream URLs")
|
||||
fmt.Println("• LOCAL_MUSIC content selection")
|
||||
fmt.Println("• STORED_MUSIC content selection")
|
||||
fmt.Println("• Generic ContentItem selection")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage:")
|
||||
fmt.Printf(" %s <device_ip>\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println("Example:")
|
||||
fmt.Printf(" %s 192.168.1.100\n", os.Args[0])
|
||||
fmt.Println()
|
||||
fmt.Println("Prerequisites:")
|
||||
fmt.Println("• SoundTouch device on your network")
|
||||
fmt.Println("• Device IP address")
|
||||
fmt.Println("• Device powered on and connected")
|
||||
fmt.Println()
|
||||
fmt.Println("Note:")
|
||||
fmt.Println("• LOCAL_MUSIC examples require SoundTouch App Media Server")
|
||||
fmt.Println("• STORED_MUSIC examples require UPnP/DLNA media server")
|
||||
fmt.Println("• Some streams may not work depending on your network/location")
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# Introspect Endpoint Example
|
||||
|
||||
This example demonstrates how to use the `/introspect` endpoint to get detailed information about music service states and capabilities on your SoundTouch device.
|
||||
|
||||
## What is the Introspect Endpoint?
|
||||
|
||||
The introspect endpoint provides detailed information about music services (like Spotify, Pandora, TuneIn) including:
|
||||
|
||||
- **Service State**: Active, Inactive, or InactiveUnselected
|
||||
- **User Information**: Associated account names
|
||||
- **Playback Status**: Currently playing content and URIs
|
||||
- **Service Capabilities**: Skip, seek, resume support
|
||||
- **Token Information**: Authentication token status
|
||||
- **Content History**: History size limits
|
||||
- **Subscription Details**: Premium/free account status
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Basic usage - check Spotify status
|
||||
go run main.go -host 192.168.1.100
|
||||
|
||||
# Check specific service with account
|
||||
go run main.go -host 192.168.1.100 -source SPOTIFY -account "your_spotify_username"
|
||||
|
||||
# Check Pandora service
|
||||
go run main.go -host 192.168.1.100 -source PANDORA
|
||||
|
||||
# Check TuneIn radio
|
||||
go run main.go -host 192.168.1.100 -source TUNEIN
|
||||
|
||||
# Custom timeout
|
||||
go run main.go -host 192.168.1.100 -timeout 5s
|
||||
```
|
||||
|
||||
## Command Line Options
|
||||
|
||||
- `-host` - **Required**: SoundTouch device IP address
|
||||
- `-source` - Music service to introspect (default: `SPOTIFY`)
|
||||
- Supported: `SPOTIFY`, `PANDORA`, `TUNEIN`, `AMAZON`, `DEEZER`, etc.
|
||||
- `-account` - Source account name (optional)
|
||||
- `-timeout` - Request timeout (default: `10s`)
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
Getting introspect data for SPOTIFY
|
||||
|
||||
=== SPOTIFY Service Introspect Data ===
|
||||
State: InactiveUnselected
|
||||
User: SpotifyConnectUserName
|
||||
Currently Playing: false
|
||||
Current Content:
|
||||
Shuffle Mode: OFF
|
||||
Subscription Type:
|
||||
|
||||
=== Service State ===
|
||||
❌ Service is INACTIVE
|
||||
|
||||
=== Service Capabilities ===
|
||||
❌ Skip Previous not supported
|
||||
❌ Seek not supported
|
||||
✅ Resume supported
|
||||
✅ Data collection enabled
|
||||
|
||||
=== Content History ===
|
||||
Max History Size: 10 items
|
||||
|
||||
=== Technical Details ===
|
||||
Token Last Changed: 1702566495 seconds
|
||||
Token Microseconds: 427884
|
||||
Play Status State: 2
|
||||
Received Playback Request: false
|
||||
|
||||
=== Service Availability Check ===
|
||||
✅ Spotify is available on this device
|
||||
|
||||
Done!
|
||||
```
|
||||
|
||||
## Understanding the Output
|
||||
|
||||
### Service States
|
||||
- **Active**: Service is currently selected and active
|
||||
- **Inactive**: Service is available but not currently active
|
||||
- **InactiveUnselected**: Service is available but never been used
|
||||
|
||||
### Capabilities
|
||||
- **Skip Previous**: Can skip to previous track
|
||||
- **Seek**: Can seek within tracks (scrub timeline)
|
||||
- **Resume**: Can resume paused playback
|
||||
- **Data Collection**: Service collects usage analytics
|
||||
|
||||
### Technical Fields
|
||||
- **Token Last Changed**: Unix timestamp of last authentication
|
||||
- **Play Status State**: Internal playback state code
|
||||
- **Current URI**: Unique identifier for currently playing content
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Check if Spotify is Logged In
|
||||
```go
|
||||
response, err := client.Introspect("SPOTIFY", "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if response.HasUser() && response.IsActive() {
|
||||
fmt.Println("Spotify is logged in and active")
|
||||
} else {
|
||||
fmt.Println("Spotify needs authentication or activation")
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Verify Service Capabilities Before Playback Control
|
||||
```go
|
||||
response, err := client.IntrospectSpotify("")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if response.SupportsSeek() {
|
||||
// Safe to use seek controls
|
||||
fmt.Println("Seek controls available")
|
||||
}
|
||||
|
||||
if response.SupportsSkipPrevious() {
|
||||
// Safe to use previous track
|
||||
fmt.Println("Previous track control available")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Monitor Service Health
|
||||
```go
|
||||
response, err := client.Introspect("PANDORA", "my_pandora_user")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !response.IsActive() {
|
||||
fmt.Println("Pandora service needs activation")
|
||||
}
|
||||
|
||||
if response.HasSubscription() {
|
||||
fmt.Printf("Premium account: %s\n", response.SubscriptionType)
|
||||
}
|
||||
```
|
||||
|
||||
## Related API Methods
|
||||
|
||||
- `client.GetServiceAvailability()` - Check which services are available
|
||||
- `client.SelectSource(source, account)` - Activate a music service
|
||||
- `client.GetNowPlaying()` - Get current playback information
|
||||
|
||||
## Error Handling
|
||||
|
||||
The introspect endpoint may fail if:
|
||||
- Service is not supported on the device
|
||||
- Invalid source name provided
|
||||
- Network connectivity issues
|
||||
- Device is in standby mode
|
||||
|
||||
Always check for errors and handle gracefully:
|
||||
|
||||
```go
|
||||
response, err := client.Introspect("SPOTIFY", "")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "failed to get introspect data") {
|
||||
fmt.Println("Service may not be configured or available")
|
||||
return
|
||||
}
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Other Examples
|
||||
|
||||
This introspect data is useful before:
|
||||
- [Preset Management](../preset-management/) - Verify service state before storing presets
|
||||
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Check capabilities before switching sources
|
||||
- [Zone Management](../../docs/reference/ZONE-MANAGEMENT.md) - Ensure all devices support the service
|
||||
|
||||
## API Documentation
|
||||
|
||||
For complete API documentation, see:
|
||||
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
|
||||
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
|
||||
@@ -0,0 +1,393 @@
|
||||
# Introspect CLI Commands Demo
|
||||
|
||||
This document demonstrates the usage and output of the new introspect CLI commands added to the soundtouch-cli tool.
|
||||
|
||||
## Available Commands
|
||||
|
||||
The introspect functionality is available through three commands in the `source` command group:
|
||||
|
||||
1. `source introspect` - Get introspect data for any supported service
|
||||
2. `source introspect-spotify` - Convenience command specifically for Spotify
|
||||
3. `source introspect-all` - Get introspect data for all available services
|
||||
|
||||
## Command Examples and Expected Output
|
||||
|
||||
### 1. Basic Spotify Introspect
|
||||
|
||||
```bash
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect --source SPOTIFY
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ SoundTouch CLI v1.0.0
|
||||
🔗 Connecting to SoundTouch device at 192.168.1.100:8090
|
||||
|
||||
Getting introspect data for SPOTIFY
|
||||
|
||||
=== SPOTIFY Service Introspect Data ===
|
||||
State: InactiveUnselected
|
||||
User: SpotifyConnectUserName
|
||||
Currently Playing: ❌ No
|
||||
Current Content:
|
||||
Shuffle Mode: OFF
|
||||
Subscription Type:
|
||||
|
||||
=== Service State ===
|
||||
❌ Service is INACTIVE (Never been used)
|
||||
⏸️ Not currently playing
|
||||
➡️ Shuffle mode is OFF
|
||||
|
||||
=== Service Capabilities ===
|
||||
❌ ⏮️ Skip Previous
|
||||
❌ 🎯 Seek within tracks
|
||||
✅ ▶️ Resume playback
|
||||
✅ 📊 Data collection: ENABLED
|
||||
|
||||
=== Spotify Content History ===
|
||||
Max History Size: 10 items
|
||||
|
||||
=== Technical Details ===
|
||||
Token Last Changed: 2023-12-14 10:48:15 MST
|
||||
Token Timestamp: 1702566495 seconds since Unix epoch
|
||||
Token Microseconds: 427884
|
||||
Play Status State: 2
|
||||
Received Playback Request: ❌ No
|
||||
```
|
||||
|
||||
### 2. Spotify Introspect with Account
|
||||
|
||||
```bash
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect --source SPOTIFY --account my_spotify_user
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ SoundTouch CLI v1.0.0
|
||||
🔗 Connecting to SoundTouch device at 192.168.1.100:8090
|
||||
|
||||
Getting introspect data for SPOTIFY
|
||||
Source Account: my_spotify_user
|
||||
|
||||
=== SPOTIFY Service Introspect Data ===
|
||||
State: Active
|
||||
User: my_spotify_user
|
||||
Currently Playing: ✅ Yes
|
||||
Current Content: spotify://track/4iV5W9uYEdYUVa79Axb7Rh
|
||||
Shuffle Mode: ON
|
||||
Subscription Type: Premium
|
||||
|
||||
=== Service State ===
|
||||
✅ Service is ACTIVE
|
||||
🎵 Currently playing content
|
||||
🔀 Shuffle mode is ON
|
||||
|
||||
=== Service Capabilities ===
|
||||
✅ ⏮️ Skip Previous
|
||||
✅ 🎯 Seek within tracks
|
||||
✅ ▶️ Resume playback
|
||||
🚫 Data collection: DISABLED
|
||||
|
||||
=== Spotify Content History ===
|
||||
Max History Size: 15 items
|
||||
|
||||
=== Technical Details ===
|
||||
Token Last Changed: 2023-12-14 15:30:22 MST
|
||||
Token Timestamp: 1702583422 seconds since Unix epoch
|
||||
Token Microseconds: 123456
|
||||
Play Status State: 1
|
||||
Received Playback Request: ✅ Yes
|
||||
```
|
||||
|
||||
### 3. Spotify Convenience Command
|
||||
|
||||
```bash
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect-spotify
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ SoundTouch CLI v1.0.0
|
||||
🔗 Connecting to SoundTouch device at 192.168.1.100:8090
|
||||
|
||||
Getting Spotify introspect data
|
||||
|
||||
=== Spotify Service Introspect Data ===
|
||||
State: Active
|
||||
User: premium_user
|
||||
Currently Playing: ✅ Yes
|
||||
Current Content: spotify://playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||
Shuffle Mode: ON
|
||||
Subscription Type: Premium
|
||||
|
||||
=== Spotify Service State ===
|
||||
✅ Service is ACTIVE
|
||||
🎵 Currently playing content
|
||||
🔀 Shuffle mode is ON
|
||||
|
||||
=== Spotify Service Capabilities ===
|
||||
✅ ⏮️ Skip Previous
|
||||
✅ 🎯 Seek within tracks
|
||||
✅ ▶️ Resume playback
|
||||
🚫 Data collection: DISABLED
|
||||
|
||||
💡 Spotify Setup Recommendations:
|
||||
(None - service is properly configured and active)
|
||||
|
||||
=== Spotify Content History ===
|
||||
Max History Size: 20 items
|
||||
|
||||
=== Technical Details ===
|
||||
Token Last Changed: 2023-12-14 16:45:10 MST
|
||||
Token Timestamp: 1702587910 seconds since Unix epoch
|
||||
Token Microseconds: 789012
|
||||
Play Status State: 1
|
||||
Received Playback Request: ✅ Yes
|
||||
```
|
||||
|
||||
### 4. Inactive Service Example
|
||||
|
||||
```bash
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect-spotify
|
||||
```
|
||||
|
||||
**Expected Output (when Spotify is not set up):**
|
||||
```
|
||||
⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ SoundTouch CLI v1.0.0
|
||||
🔗 Connecting to SoundTouch device at 192.168.1.100:8090
|
||||
|
||||
Getting Spotify introspect data
|
||||
|
||||
=== Spotify Service Introspect Data ===
|
||||
State: InactiveUnselected
|
||||
User:
|
||||
Currently Playing: ❌ No
|
||||
Current Content:
|
||||
Shuffle Mode: OFF
|
||||
Subscription Type:
|
||||
|
||||
=== Spotify Service State ===
|
||||
❌ Service is INACTIVE (Never been used)
|
||||
⏸️ Not currently playing
|
||||
➡️ Shuffle mode is OFF
|
||||
|
||||
=== Spotify Service Capabilities ===
|
||||
❌ ⏮️ Skip Previous
|
||||
❌ 🎯 Seek within tracks
|
||||
✅ ▶️ Resume playback
|
||||
✅ 📊 Data collection: ENABLED
|
||||
|
||||
💡 Spotify Setup Recommendations:
|
||||
• Sign in to your Spotify account on the device
|
||||
• Use 'soundtouch-cli source select --source SPOTIFY' to activate Spotify
|
||||
• Ensure you have Spotify Premium for full functionality
|
||||
```
|
||||
|
||||
### 5. All Services Introspect
|
||||
|
||||
```bash
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect-all
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ SoundTouch CLI v1.0.0
|
||||
🔗 Connecting to SoundTouch device at 192.168.1.100:8090
|
||||
|
||||
Getting introspect data for all services
|
||||
|
||||
🔍 Getting introspect data for SPOTIFY...
|
||||
✅ SPOTIFY: Successfully retrieved introspect data
|
||||
State: Active (User: spotify_user)
|
||||
Playing: ✅ Yes | Content: spotify://track/4iV5W9uYEdYUVa79Axb7Rh
|
||||
Capabilities: Skip, Seek, Resume
|
||||
|
||||
──────────────────────────────────────────────────
|
||||
🔍 Getting introspect data for PANDORA...
|
||||
❌ PANDORA: Service not available on this device
|
||||
|
||||
──────────────────────────────────────────────────
|
||||
🔍 Getting introspect data for TUNEIN...
|
||||
✅ TUNEIN: Successfully retrieved introspect data
|
||||
State: Inactive
|
||||
Playing: ❌ No
|
||||
Capabilities: Resume
|
||||
|
||||
──────────────────────────────────────────────────
|
||||
🔍 Getting introspect data for AMAZON...
|
||||
❌ AMAZON: Failed to get introspect data - service not configured
|
||||
|
||||
──────────────────────────────────────────────────
|
||||
🔍 Getting introspect data for DEEZER...
|
||||
❌ DEEZER: Service not available on this device
|
||||
|
||||
══════════════════════════════════════════════════
|
||||
📊 Introspect Summary:
|
||||
✅ Successful: 2 services
|
||||
❌ Failed: 3 services
|
||||
📡 Total checked: 5 services
|
||||
|
||||
✅ Successfully retrieved introspect data for 2 services
|
||||
```
|
||||
|
||||
### 6. Error Handling Examples
|
||||
|
||||
#### Missing Source Parameter
|
||||
```bash
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
NAME:
|
||||
soundtouch-cli source introspect - Get introspect data for a music service
|
||||
|
||||
USAGE:
|
||||
soundtouch-cli source introspect [command options]
|
||||
|
||||
OPTIONS:
|
||||
--account value, -a value Source account name (optional)
|
||||
--source value, -s value Music service source (SPOTIFY, PANDORA, TUNEIN, etc.)
|
||||
--help, -h show help
|
||||
|
||||
Required flag "source" not set
|
||||
```
|
||||
|
||||
#### Missing Host Parameter
|
||||
```bash
|
||||
$ soundtouch-cli source introspect --source SPOTIFY
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
host is required. Use --host flag or set SOUNDTOUCH_HOST environment variable
|
||||
```
|
||||
|
||||
#### Invalid Service
|
||||
```bash
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect --source INVALID_SERVICE
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ SoundTouch CLI v1.0.0
|
||||
🔗 Connecting to SoundTouch device at 192.168.1.100:8090
|
||||
|
||||
⚠️ Service INVALID_SERVICE may not be available, but continuing with introspect request...
|
||||
|
||||
Getting introspect data for INVALID_SERVICE
|
||||
|
||||
❌ Error: failed to get introspect data: HTTP 404: endpoint not found or service not supported
|
||||
```
|
||||
|
||||
## Integration with Other Commands
|
||||
|
||||
The introspect commands work well with other CLI commands:
|
||||
|
||||
### 1. Check Availability First
|
||||
```bash
|
||||
# Check what services are available
|
||||
$ soundtouch-cli --host 192.168.1.100 source availability
|
||||
|
||||
# Then introspect specific services
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect --source SPOTIFY
|
||||
```
|
||||
|
||||
### 2. Activate Service After Introspect
|
||||
```bash
|
||||
# Check service status
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect-spotify
|
||||
|
||||
# If inactive, activate it
|
||||
$ soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
|
||||
```
|
||||
|
||||
### 3. Compare Sources and Introspect Data
|
||||
```bash
|
||||
# Compare configured sources vs available services
|
||||
$ soundtouch-cli --host 192.168.1.100 source compare
|
||||
|
||||
# Get detailed introspect data for specific services
|
||||
$ soundtouch-cli --host 192.168.1.100 source introspect-all
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The introspect commands respect the same environment variables as other CLI commands:
|
||||
|
||||
- `SOUNDTOUCH_HOST` - Default device IP address
|
||||
- `SOUNDTOUCH_SKIP_AVAILABILITY_CHECK` - Skip service availability validation
|
||||
- `SOUNDTOUCH_TIMEOUT` - Request timeout duration
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
export SOUNDTOUCH_HOST=192.168.1.100
|
||||
soundtouch-cli source introspect-spotify
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Service Setup Verification
|
||||
Check if streaming services are properly configured and authenticated:
|
||||
```bash
|
||||
soundtouch-cli --host $DEVICE source introspect-spotify
|
||||
soundtouch-cli --host $DEVICE source introspect --source PANDORA
|
||||
```
|
||||
|
||||
### 2. Troubleshooting Playback Issues
|
||||
Understand why certain playback controls aren't working:
|
||||
```bash
|
||||
# Check if seek is supported
|
||||
soundtouch-cli --host $DEVICE source introspect --source SPOTIFY | grep -i seek
|
||||
|
||||
# Check current playback state
|
||||
soundtouch-cli --host $DEVICE source introspect-spotify | grep -i playing
|
||||
```
|
||||
|
||||
### 3. Service Health Monitoring
|
||||
Monitor the health and status of streaming services:
|
||||
```bash
|
||||
# Quick health check for all services
|
||||
soundtouch-cli --host $DEVICE source introspect-all
|
||||
|
||||
# Detailed status for critical service
|
||||
soundtouch-cli --host $DEVICE source introspect-spotify
|
||||
```
|
||||
|
||||
### 4. Account Management
|
||||
Verify which accounts are associated with services:
|
||||
```bash
|
||||
# Check current Spotify account
|
||||
soundtouch-cli --host $DEVICE source introspect-spotify | grep -i user
|
||||
|
||||
# Check with specific account parameter
|
||||
soundtouch-cli --host $DEVICE source introspect --source SPOTIFY --account specific_user
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Use with grep**: Pipe output to `grep` to filter specific information:
|
||||
```bash
|
||||
soundtouch-cli --host $DEVICE source introspect-spotify | grep -E "(State|User|Playing)"
|
||||
```
|
||||
|
||||
2. **JSON output**: While not currently implemented, future versions may support JSON output for scripting:
|
||||
```bash
|
||||
# Future feature
|
||||
soundtouch-cli --host $DEVICE source introspect-spotify --format json
|
||||
```
|
||||
|
||||
3. **Batch operations**: Use shell scripting to check multiple devices:
|
||||
```bash
|
||||
for device in 192.168.1.100 192.168.1.101; do
|
||||
echo "=== Device $device ==="
|
||||
soundtouch-cli --host $device source introspect-spotify
|
||||
done
|
||||
```
|
||||
|
||||
4. **Environment setup**: Set up your environment for easier usage:
|
||||
```bash
|
||||
export SOUNDTOUCH_HOST=192.168.1.100
|
||||
alias st='soundtouch-cli'
|
||||
st source introspect-spotify
|
||||
```
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package main demonstrates introspect functionality for Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// displayBasicInfo prints basic service information
|
||||
func displayBasicInfo(source string, response *models.IntrospectResponse) {
|
||||
fmt.Printf("\n=== %s Service Introspect Data ===\n", source)
|
||||
fmt.Printf("State: %s\n", response.State)
|
||||
|
||||
if response.HasUser() {
|
||||
fmt.Printf("User: %s\n", response.User)
|
||||
}
|
||||
|
||||
fmt.Printf("Currently Playing: %t\n", response.IsPlaying)
|
||||
|
||||
if response.HasCurrentContent() {
|
||||
fmt.Printf("Current Content: %s\n", response.CurrentURI)
|
||||
}
|
||||
|
||||
fmt.Printf("Shuffle Mode: %s\n", response.ShuffleMode)
|
||||
|
||||
if response.HasSubscription() {
|
||||
fmt.Printf("Subscription Type: %s\n", response.SubscriptionType)
|
||||
}
|
||||
}
|
||||
|
||||
// displayServiceState prints service state information
|
||||
func displayServiceState(response *models.IntrospectResponse) {
|
||||
fmt.Printf("\n=== Service State ===\n")
|
||||
|
||||
if response.IsActive() {
|
||||
fmt.Println("✅ Service is ACTIVE")
|
||||
} else if response.IsInactive() {
|
||||
fmt.Println("❌ Service is INACTIVE")
|
||||
}
|
||||
}
|
||||
|
||||
// displayCapabilities prints service capabilities
|
||||
func displayCapabilities(response *models.IntrospectResponse) {
|
||||
fmt.Printf("\n=== Service Capabilities ===\n")
|
||||
|
||||
if response.SupportsSkipPrevious() {
|
||||
fmt.Println("✅ Skip Previous supported")
|
||||
} else {
|
||||
fmt.Println("❌ Skip Previous not supported")
|
||||
}
|
||||
|
||||
if response.SupportsSeek() {
|
||||
fmt.Println("✅ Seek supported")
|
||||
} else {
|
||||
fmt.Println("❌ Seek not supported")
|
||||
}
|
||||
|
||||
if response.SupportsResume() {
|
||||
fmt.Println("✅ Resume supported")
|
||||
} else {
|
||||
fmt.Println("❌ Resume not supported")
|
||||
}
|
||||
|
||||
if response.CollectsData() {
|
||||
fmt.Println("📊 Data collection enabled")
|
||||
} else {
|
||||
fmt.Println("🚫 Data collection disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// displayHistoryInfo prints content history information
|
||||
func displayHistoryInfo(response *models.IntrospectResponse) {
|
||||
historySize := response.GetMaxHistorySize()
|
||||
if historySize > 0 {
|
||||
fmt.Printf("\n=== Content History ===\n")
|
||||
fmt.Printf("Max History Size: %d items\n", historySize)
|
||||
}
|
||||
}
|
||||
|
||||
// displayTechnicalDetails prints technical service details
|
||||
func displayTechnicalDetails(response *models.IntrospectResponse) {
|
||||
if response.TokenLastChangedTimeSeconds > 0 {
|
||||
fmt.Printf("\n=== Technical Details ===\n")
|
||||
fmt.Printf("Token Last Changed: %d seconds\n", response.TokenLastChangedTimeSeconds)
|
||||
|
||||
if response.TokenLastChangedTimeMicroseconds > 0 {
|
||||
fmt.Printf("Token Microseconds: %d\n", response.TokenLastChangedTimeMicroseconds)
|
||||
}
|
||||
|
||||
fmt.Printf("Play Status State: %s\n", response.PlayStatusState)
|
||||
fmt.Printf("Received Playback Request: %t\n", response.ReceivedPlaybackRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// displayServiceAvailability shows service availability for comparison
|
||||
func displayServiceAvailability(soundTouchClient *client.Client, source string) {
|
||||
fmt.Printf("\n=== Service Availability Check ===\n")
|
||||
|
||||
availability, err := soundTouchClient.GetServiceAvailability()
|
||||
if err != nil {
|
||||
fmt.Printf("Could not check service availability: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
if availability.HasSpotify() {
|
||||
fmt.Println("✅ Spotify is available on this device")
|
||||
} else {
|
||||
fmt.Println("❌ Spotify is not available on this device")
|
||||
}
|
||||
case "PANDORA":
|
||||
if availability.HasPandora() {
|
||||
fmt.Println("✅ Pandora is available on this device")
|
||||
} else {
|
||||
fmt.Println("❌ Pandora is not available on this device")
|
||||
}
|
||||
case "TUNEIN":
|
||||
if availability.HasTuneIn() {
|
||||
fmt.Println("✅ TuneIn is available on this device")
|
||||
} else {
|
||||
fmt.Println("❌ TuneIn is not available on this device")
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Service availability check not implemented for %s\n", source)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
host = flag.String("host", "", "SoundTouch device IP address")
|
||||
source = flag.String("source", "SPOTIFY", "Music service source (SPOTIFY, PANDORA, TUNEIN)")
|
||||
sourceAccount = flag.String("account", "", "Source account name (optional)")
|
||||
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *host == "" {
|
||||
log.Fatal("Please provide a SoundTouch device IP address with -host flag")
|
||||
}
|
||||
|
||||
// Create client
|
||||
config := &client.Config{
|
||||
Host: *host,
|
||||
Port: 8090,
|
||||
Timeout: *timeout,
|
||||
}
|
||||
soundTouchClient := client.NewClient(config)
|
||||
|
||||
fmt.Printf("Getting introspect data for %s", *source)
|
||||
|
||||
if *sourceAccount != "" {
|
||||
fmt.Printf(" (account: %s)", *sourceAccount)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Get introspect data
|
||||
response, err := soundTouchClient.Introspect(*source, *sourceAccount)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get introspect data: %v", err)
|
||||
}
|
||||
|
||||
// Display all information using helper functions
|
||||
displayBasicInfo(*source, response)
|
||||
displayServiceState(response)
|
||||
displayCapabilities(response)
|
||||
displayHistoryInfo(response)
|
||||
displayTechnicalDetails(response)
|
||||
displayServiceAvailability(soundTouchClient, *source)
|
||||
|
||||
fmt.Println("\nDone!")
|
||||
}
|
||||
@@ -275,10 +275,10 @@ go run ./cmd/soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Browse and station commands
|
||||
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md) - Comprehensive navigation documentation
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Browse and station commands
|
||||
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md) - Comprehensive navigation documentation
|
||||
- [Navigation API Reference](../../docs/API-NAVIGATION-REFERENCE.md) - Technical API details
|
||||
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
|
||||
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -288,4 +288,4 @@ This example demonstrates patterns for:
|
||||
- **Direct Playback**: Play content without storing as presets first
|
||||
- **Content Exploration**: Browse large music libraries efficiently
|
||||
- **Smart Home Integration**: Programmatically start specific content
|
||||
- **Personalized Experiences**: Access account-specific content from streaming services
|
||||
- **Personalized Experiences**: Access account-specific content from streaming services
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.25.6
|
||||
go 1.25.7
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
|
||||
|
||||
@@ -256,10 +256,10 @@ Error: All preset slots are occupied
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Command-line usage
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Command-line usage
|
||||
- [Preset Implementation Guide](../../docs/preset-store.md) - Technical details
|
||||
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
|
||||
- [API Reference](../../docs/API-Endpoints-Overview.md) - Complete API documentation
|
||||
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
|
||||
- [API Reference](../../docs/reference/API-ENDPOINTS.md) - Complete API documentation
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -269,4 +269,4 @@ This example demonstrates patterns for:
|
||||
- **Music Management**: Organize favorite content into quick-access presets
|
||||
- **Family Scenarios**: Each person gets their own preset slots
|
||||
- **Party Mode**: Pre-configure playlists for different moods
|
||||
- **Radio Favorites**: Save frequently listened radio stations
|
||||
- **Radio Favorites**: Save frequently listened radio stations
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.25.6
|
||||
go 1.25.7
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.0.0
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Recents Endpoint Example
|
||||
|
||||
This example demonstrates how to use the `/recents` endpoint to retrieve and analyze recently played content from your SoundTouch device.
|
||||
|
||||
## What is the Recents Endpoint?
|
||||
|
||||
The recents endpoint provides access to the device's recently played content history, including:
|
||||
|
||||
- **Recently played tracks** from various music services
|
||||
- **Radio stations** that were recently listened to
|
||||
- **Playlists and albums** that were recently accessed
|
||||
- **Local music** files that were recently played
|
||||
- **Metadata** including play timestamps, content types, and source information
|
||||
- **Filtering capabilities** by source type and content type
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Basic usage - show last 10 items
|
||||
go run main.go -host 192.168.1.100
|
||||
|
||||
# Show detailed information for all items
|
||||
go run main.go -host 192.168.1.100 -detailed -limit 0
|
||||
|
||||
# Filter by source (show only Spotify items)
|
||||
go run main.go -host 192.168.1.100 -source SPOTIFY
|
||||
|
||||
# Filter by content type (show only tracks)
|
||||
go run main.go -host 192.168.1.100 -type track
|
||||
|
||||
# Show statistics only
|
||||
go run main.go -host 192.168.1.100 -stats
|
||||
|
||||
# Combined filters with custom limit
|
||||
go run main.go -host 192.168.1.100 -source LOCAL_MUSIC -type track -limit 5 -detailed
|
||||
```
|
||||
|
||||
## Command Line Options
|
||||
|
||||
- `-host` - **Required**: SoundTouch device IP address
|
||||
- `-detailed` - Show detailed information for each item (default: false)
|
||||
- `-limit` - Maximum number of items to display, 0 for all (default: 10)
|
||||
- `-source` - Filter by source (SPOTIFY, LOCAL_MUSIC, TUNEIN, etc.)
|
||||
- `-type` - Filter by content type (track, station, playlist, album, presetable)
|
||||
- `-stats` - Show statistics only (default: false)
|
||||
- `-timeout` - Request timeout duration (default: 10s)
|
||||
|
||||
## Example Output
|
||||
|
||||
### Basic Listing
|
||||
```
|
||||
Getting recent items from 192.168.1.100
|
||||
|
||||
📊 Recent Items Summary:
|
||||
Showing: 5 items (of 15 total)
|
||||
By Source: Spotify: 3, Local: 1, TuneIn: 1
|
||||
|
||||
=== Recent Items ===
|
||||
1. 🎵 Shape of You - Ed Sheeran
|
||||
Source: Spotify | Type: Track
|
||||
Played: 2023-12-14 15:30:22 (2 hours ago)
|
||||
|
||||
2. 📻 BBC Radio 1
|
||||
Source: TuneIn Radio | Type: Stationurl
|
||||
Played: 2023-12-14 13:15:45 (4 hours ago)
|
||||
|
||||
3. 🎵 Local Song.mp3
|
||||
Source: Local Music | Type: Track
|
||||
Played: 2023-12-14 10:45:12 (7 hours ago)
|
||||
|
||||
💡 Showing 3 of 15 total items
|
||||
Use -limit 0 to show all items
|
||||
```
|
||||
|
||||
### Detailed Information
|
||||
```
|
||||
1. 🎵 Shape of You - Ed Sheeran
|
||||
Source: Spotify | Type: Track
|
||||
Played: 2023-12-14 15:30:22 (2 hours ago)
|
||||
ID: spotify123
|
||||
⭐ Can be saved as preset
|
||||
🎨 Has artwork
|
||||
📍 Location: spotify:track:4iV5W9uYEdYUVa79Axb7Rh
|
||||
👤 Account: spotify_user
|
||||
🏷️ Type: Streaming
|
||||
```
|
||||
|
||||
### Statistics View
|
||||
```
|
||||
📊 Recent Items Statistics
|
||||
|
||||
Overall Statistics:
|
||||
Total Items: 25
|
||||
Last Played: 2023-12-14 15:30:22
|
||||
|
||||
📍 By Source:
|
||||
Spotify 15 items ( 60.0%)
|
||||
Local Music 6 items ( 24.0%)
|
||||
TuneIn 3 items ( 12.0%)
|
||||
Pandora 1 items ( 4.0%)
|
||||
|
||||
🎼 By Content Type:
|
||||
Tracks 20 items ( 80.0%)
|
||||
Stations 4 items ( 16.0%)
|
||||
Playlists/Albums 1 items ( 4.0%)
|
||||
|
||||
⭐ Special Categories:
|
||||
Presetable 18 items ( 72.0%)
|
||||
|
||||
📡 Source Analysis:
|
||||
Streaming 19 items ( 76.0%)
|
||||
Local 6 items ( 24.0%)
|
||||
|
||||
🕐 Time Analysis:
|
||||
Today 12 items
|
||||
Yesterday 8 items
|
||||
This Week 3 items
|
||||
Older 2 items
|
||||
```
|
||||
|
||||
## Supported Sources
|
||||
|
||||
- **SPOTIFY** - Spotify streaming service
|
||||
- **LOCAL_MUSIC** - Local music files
|
||||
- **STORED_MUSIC** - Stored music library
|
||||
- **TUNEIN** - TuneIn radio stations
|
||||
- **PANDORA** - Pandora music service
|
||||
- **AMAZON** - Amazon Music
|
||||
- **DEEZER** - Deezer streaming
|
||||
- **IHEART** - iHeartRadio
|
||||
- **BLUETOOTH** - Bluetooth input
|
||||
- **AUX** - AUX input
|
||||
- **AIRPLAY** - AirPlay
|
||||
|
||||
## Content Types
|
||||
|
||||
- **track** - Individual songs/tracks
|
||||
- **station** - Radio stations
|
||||
- **playlist** - Music playlists
|
||||
- **album** - Music albums
|
||||
- **container** - Folders/collections
|
||||
- **presetable** - Items that can be saved as presets
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Recently Played Music Discovery
|
||||
```bash
|
||||
# Find recently played Spotify tracks
|
||||
go run main.go -host 192.168.1.100 -source SPOTIFY -type track -detailed
|
||||
```
|
||||
|
||||
### 2. Radio Station History
|
||||
```bash
|
||||
# See what radio stations were recently played
|
||||
go run main.go -host 192.168.1.100 -type station -detailed
|
||||
```
|
||||
|
||||
### 3. Content Analytics
|
||||
```bash
|
||||
# Get detailed listening statistics
|
||||
go run main.go -host 192.168.1.100 -stats
|
||||
```
|
||||
|
||||
### 4. Preset Candidates
|
||||
```bash
|
||||
# Find content that can be saved as presets
|
||||
go run main.go -host 192.168.1.100 -type presetable -limit 6
|
||||
```
|
||||
|
||||
### 5. Local vs Streaming Analysis
|
||||
```bash
|
||||
# Compare local vs streaming content usage
|
||||
go run main.go -host 192.168.1.100 -stats
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
The example demonstrates several key API patterns:
|
||||
|
||||
### Basic Retrieval
|
||||
```go
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Println("No recent items found")
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### Filtering by Source
|
||||
```go
|
||||
spotifyItems := response.GetSpotifyItems()
|
||||
localItems := response.GetLocalMusicItems()
|
||||
tuneInItems := response.GetTuneInItems()
|
||||
```
|
||||
|
||||
### Filtering by Type
|
||||
```go
|
||||
tracks := response.GetTracks()
|
||||
stations := response.GetStations()
|
||||
presetableItems := response.GetPresetableItems()
|
||||
```
|
||||
|
||||
### Item Analysis
|
||||
```go
|
||||
for _, item := range response.Items {
|
||||
if item.IsSpotifyContent() {
|
||||
fmt.Printf("Spotify track: %s\n", item.GetDisplayName())
|
||||
}
|
||||
|
||||
if item.IsPresetable() {
|
||||
fmt.Printf("Can be saved as preset: %s\n", item.GetDisplayName())
|
||||
}
|
||||
|
||||
if item.HasArtwork() {
|
||||
fmt.Printf("Artwork URL: %s\n", item.GetArtwork())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The example includes comprehensive error handling:
|
||||
|
||||
```bash
|
||||
# Test with invalid host
|
||||
go run main.go -host 192.168.255.255
|
||||
# Output: Failed to get recent items: connection timeout
|
||||
|
||||
# Test with unknown source
|
||||
go run main.go -host 192.168.1.100 -source UNKNOWN
|
||||
# Output: 📭 No items found for source: UNKNOWN
|
||||
# 💡 Available sources: SPOTIFY, LOCAL_MUSIC, TUNEIN
|
||||
|
||||
# Test with unknown type
|
||||
go run main.go -host 192.168.1.100 -type unknown
|
||||
# Output: ❌ Unknown type filter: unknown
|
||||
# 💡 Available types: track, station, playlist, album, presetable
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- The recents endpoint typically returns up to 20-50 items depending on device configuration
|
||||
- Response times are usually under 500ms for typical recent lists
|
||||
- Use filtering to reduce processing time for large recent lists
|
||||
- Consider caching results if calling frequently in applications
|
||||
|
||||
## Integration with Other Examples
|
||||
|
||||
This recents data is useful for:
|
||||
- [Preset Management](../preset-management/) - Finding presetable content to save
|
||||
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Understanding usage patterns
|
||||
- [Navigation](../../docs/guides/SURVIVAL-GUIDE.md) - Quickly accessing recently played content
|
||||
|
||||
## Related CLI Commands
|
||||
|
||||
```bash
|
||||
# List recent items using CLI
|
||||
soundtouch-cli --host 192.168.1.100 recents list
|
||||
|
||||
# Filter recent items by source
|
||||
soundtouch-cli --host 192.168.1.100 recents filter --source SPOTIFY
|
||||
|
||||
# Get recent items statistics
|
||||
soundtouch-cli --host 192.168.1.100 recents stats
|
||||
|
||||
# Show most recent item only
|
||||
soundtouch-cli --host 192.168.1.100 recents latest
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
For complete API documentation, see:
|
||||
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
|
||||
- [Recents Models](../../pkg/models/recents.go)
|
||||
@@ -0,0 +1,544 @@
|
||||
// Package main demonstrates recent content functionality for Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// applyFilters applies source and type filters to the items
|
||||
func applyFilters(response *models.RecentsResponse, source, itemType string) ([]models.RecentsResponseItem, error) {
|
||||
items := response.Items
|
||||
|
||||
// Apply source filter
|
||||
if source != "" {
|
||||
items = response.GetItemsBySource(strings.ToUpper(source))
|
||||
if len(items) == 0 {
|
||||
fmt.Printf("📭 No items found for source: %s\n", source)
|
||||
fmt.Println("💡 Available sources:", getAvailableSources(response))
|
||||
|
||||
return nil, fmt.Errorf("no items found for source")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
if itemType != "" {
|
||||
filteredItems, err := filterItemsByType(items, itemType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items = filteredItems
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Printf("📭 No items found for type: %s\n", itemType)
|
||||
return nil, fmt.Errorf("no items found for type")
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// filterItemsByType filters items by content type
|
||||
func filterItemsByType(items []models.RecentsResponseItem, itemType string) ([]models.RecentsResponseItem, error) {
|
||||
// Define type predicates
|
||||
predicates := map[string]func(*models.RecentsResponseItem) bool{
|
||||
"track": (*models.RecentsResponseItem).IsTrack,
|
||||
"tracks": (*models.RecentsResponseItem).IsTrack,
|
||||
"station": (*models.RecentsResponseItem).IsStation,
|
||||
"stations": (*models.RecentsResponseItem).IsStation,
|
||||
"playlist": (*models.RecentsResponseItem).IsPlaylist,
|
||||
"playlists": (*models.RecentsResponseItem).IsPlaylist,
|
||||
"album": (*models.RecentsResponseItem).IsAlbum,
|
||||
"albums": (*models.RecentsResponseItem).IsAlbum,
|
||||
"presetable": (*models.RecentsResponseItem).IsPresetable,
|
||||
}
|
||||
|
||||
predicate, exists := predicates[strings.ToLower(itemType)]
|
||||
if !exists {
|
||||
fmt.Printf("❌ Unknown type filter: %s\n", itemType)
|
||||
fmt.Println("💡 Available types: track, station, playlist, album, presetable")
|
||||
|
||||
return nil, fmt.Errorf("unknown type filter")
|
||||
}
|
||||
|
||||
var filteredItems []models.RecentsResponseItem
|
||||
|
||||
for _, item := range items {
|
||||
if predicate(&item) {
|
||||
filteredItems = append(filteredItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredItems, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
host = flag.String("host", "", "SoundTouch device IP address")
|
||||
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
|
||||
detailed = flag.Bool("detailed", false, "Show detailed information for each item")
|
||||
limit = flag.Int("limit", 10, "Maximum number of items to display (0 for all)")
|
||||
source = flag.String("source", "", "Filter by source (SPOTIFY, LOCAL_MUSIC, etc.)")
|
||||
itemType = flag.String("type", "", "Filter by type (track, station, playlist, presetable)")
|
||||
stats = flag.Bool("stats", false, "Show statistics only")
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *host == "" {
|
||||
log.Fatal("Please provide a SoundTouch device IP address with -host flag")
|
||||
}
|
||||
|
||||
// Create client
|
||||
config := &client.Config{
|
||||
Host: *host,
|
||||
Port: 8090,
|
||||
Timeout: *timeout,
|
||||
}
|
||||
soundTouchClient := client.NewClient(config)
|
||||
|
||||
fmt.Printf("Getting recent items from %s\n", *host)
|
||||
|
||||
// Get recent items
|
||||
response, err := soundTouchClient.GetRecents()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get recent items: %v", err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Println("\n📭 No recent items found")
|
||||
fmt.Println("💡 Play some content to populate the recent items list")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Show statistics if requested
|
||||
if *stats {
|
||||
showStatistics(response)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
items, err := applyFilters(response, *source, *itemType)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if *limit > 0 && *limit < len(items) {
|
||||
items = items[:*limit]
|
||||
}
|
||||
|
||||
// Display results
|
||||
displayResults(response, items, *detailed, *source, *itemType)
|
||||
|
||||
fmt.Println("\nDone!")
|
||||
}
|
||||
|
||||
// sourceCount represents a count for a named category
|
||||
type sourceCount struct {
|
||||
name string
|
||||
count int
|
||||
}
|
||||
|
||||
// printBasicStatistics prints overall statistics
|
||||
func printBasicStatistics(response *models.RecentsResponse) {
|
||||
fmt.Printf("Overall Statistics:\n")
|
||||
fmt.Printf(" Total Items: %d\n", response.GetItemCount())
|
||||
|
||||
if !response.IsEmpty() {
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent != nil {
|
||||
lastPlayTime := time.Unix(mostRecent.GetUTCTime(), 0)
|
||||
fmt.Printf(" Last Played: %s\n", lastPlayTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printSourceStatistics prints statistics by source
|
||||
func printSourceStatistics(response *models.RecentsResponse) {
|
||||
fmt.Printf("\n📍 By Source:\n")
|
||||
|
||||
sourceStats := map[string]int{
|
||||
"Spotify": len(response.GetSpotifyItems()),
|
||||
"Pandora": len(response.GetPandoraItems()),
|
||||
"TuneIn": len(response.GetTuneInItems()),
|
||||
"Local Music": len(response.GetLocalMusicItems()),
|
||||
"Stored Music": len(response.GetStoredMusicItems()),
|
||||
}
|
||||
|
||||
var sources []sourceCount
|
||||
|
||||
for name, count := range sourceStats {
|
||||
if count > 0 {
|
||||
sources = append(sources, sourceCount{name, count})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(sources, func(i, j int) bool {
|
||||
return sources[i].count > sources[j].count
|
||||
})
|
||||
|
||||
for _, sc := range sources {
|
||||
percentage := float64(sc.count) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", sc.name+":", sc.count, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// printContentTypeStatistics prints statistics by content type
|
||||
func printContentTypeStatistics(response *models.RecentsResponse) {
|
||||
fmt.Printf("\n🎼 By Content Type:\n")
|
||||
|
||||
tracks := len(response.GetTracks())
|
||||
stations := len(response.GetStations())
|
||||
playlists := len(response.GetPlaylistsAndAlbums())
|
||||
|
||||
typeStats := []sourceCount{
|
||||
{"Tracks", tracks},
|
||||
{"Stations", stations},
|
||||
{"Playlists/Albums", playlists},
|
||||
}
|
||||
|
||||
for _, ts := range typeStats {
|
||||
if ts.count > 0 {
|
||||
percentage := float64(ts.count) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", ts.name+":", ts.count, percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printSpecialCategoryStatistics prints special category statistics
|
||||
func printSpecialCategoryStatistics(response *models.RecentsResponse) {
|
||||
presetable := len(response.GetPresetableItems())
|
||||
if presetable > 0 {
|
||||
fmt.Printf("\n⭐ Special Categories:\n")
|
||||
|
||||
percentage := float64(presetable) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Presetable:", presetable, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// printSourceAnalysis prints streaming vs local content analysis
|
||||
func printSourceAnalysis(response *models.RecentsResponse) {
|
||||
streamingCount := 0
|
||||
localCount := 0
|
||||
|
||||
for _, item := range response.Items {
|
||||
if item.IsStreamingContent() {
|
||||
streamingCount++
|
||||
} else if item.IsLocalContent() {
|
||||
localCount++
|
||||
}
|
||||
}
|
||||
|
||||
if streamingCount > 0 || localCount > 0 {
|
||||
fmt.Printf("\n📡 Source Analysis:\n")
|
||||
|
||||
if streamingCount > 0 {
|
||||
percentage := float64(streamingCount) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Streaming:", streamingCount, percentage)
|
||||
}
|
||||
|
||||
if localCount > 0 {
|
||||
percentage := float64(localCount) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Local:", localCount, percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printTimeAnalysis prints when items were played
|
||||
func printTimeAnalysis(response *models.RecentsResponse) {
|
||||
fmt.Printf("\n🕐 Time Analysis:\n")
|
||||
|
||||
now := time.Now()
|
||||
today := 0
|
||||
yesterday := 0
|
||||
thisWeek := 0
|
||||
older := 0
|
||||
|
||||
for _, item := range response.Items {
|
||||
if item.GetUTCTime() > 0 {
|
||||
playTime := time.Unix(item.GetUTCTime(), 0)
|
||||
diff := now.Sub(playTime)
|
||||
|
||||
switch {
|
||||
case diff < 24*time.Hour:
|
||||
today++
|
||||
case diff < 48*time.Hour:
|
||||
yesterday++
|
||||
case diff < 7*24*time.Hour:
|
||||
thisWeek++
|
||||
default:
|
||||
older++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if today > 0 {
|
||||
fmt.Printf(" %-15s %3d items\n", "Today:", today)
|
||||
}
|
||||
|
||||
if yesterday > 0 {
|
||||
fmt.Printf(" %-15s %3d items\n", "Yesterday:", yesterday)
|
||||
}
|
||||
|
||||
if thisWeek > 0 {
|
||||
fmt.Printf(" %-15s %3d items\n", "This Week:", thisWeek)
|
||||
}
|
||||
|
||||
if older > 0 {
|
||||
fmt.Printf(" %-15s %3d items\n", "Older:", older)
|
||||
}
|
||||
}
|
||||
|
||||
func showStatistics(response *models.RecentsResponse) {
|
||||
fmt.Printf("\n📊 Recent Items Statistics\n\n")
|
||||
|
||||
printBasicStatistics(response)
|
||||
printSourceStatistics(response)
|
||||
printContentTypeStatistics(response)
|
||||
printSpecialCategoryStatistics(response)
|
||||
printSourceAnalysis(response)
|
||||
printTimeAnalysis(response)
|
||||
}
|
||||
|
||||
func displayResults(response *models.RecentsResponse, items []models.RecentsResponseItem, detailed bool, sourceFilter, typeFilter string) {
|
||||
// Build filter description
|
||||
var filters []string
|
||||
if sourceFilter != "" {
|
||||
filters = append(filters, fmt.Sprintf("source: %s", sourceFilter))
|
||||
}
|
||||
|
||||
if typeFilter != "" {
|
||||
filters = append(filters, fmt.Sprintf("type: %s", typeFilter))
|
||||
}
|
||||
|
||||
filterDesc := ""
|
||||
if len(filters) > 0 {
|
||||
filterDesc = fmt.Sprintf(" (filtered by %s)", strings.Join(filters, ", "))
|
||||
}
|
||||
|
||||
// Display header
|
||||
fmt.Printf("\n📊 Recent Items Summary%s:\n", filterDesc)
|
||||
fmt.Printf(" Showing: %d items", len(items))
|
||||
|
||||
if len(items) < response.GetItemCount() {
|
||||
fmt.Printf(" (of %d total)", response.GetItemCount())
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if len(filters) == 0 {
|
||||
// Show source breakdown for unfiltered results
|
||||
sources := []string{}
|
||||
sourceCounts := map[string]int{
|
||||
"Spotify": len(response.GetSpotifyItems()),
|
||||
"Local": len(response.GetLocalMusicItems()) + len(response.GetStoredMusicItems()),
|
||||
"TuneIn": len(response.GetTuneInItems()),
|
||||
"Pandora": len(response.GetPandoraItems()),
|
||||
}
|
||||
|
||||
for source, count := range sourceCounts {
|
||||
if count > 0 {
|
||||
sources = append(sources, fmt.Sprintf("%s: %d", source, count))
|
||||
}
|
||||
}
|
||||
|
||||
if len(sources) > 0 {
|
||||
fmt.Printf(" By Source: %s\n", strings.Join(sources, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Recent Items ===\n")
|
||||
|
||||
// Display items
|
||||
for i, item := range items {
|
||||
displayItem(i+1, &item, detailed)
|
||||
}
|
||||
|
||||
if len(items) < response.GetItemCount() {
|
||||
fmt.Printf("\n💡 Showing %d of %d total items\n", len(items), response.GetItemCount())
|
||||
fmt.Printf(" Use -limit 0 to show all items\n")
|
||||
}
|
||||
}
|
||||
|
||||
func displayItem(index int, item *models.RecentsResponseItem, detailed bool) {
|
||||
// Basic information
|
||||
displayName := item.GetDisplayName()
|
||||
source := formatSource(item.GetSource())
|
||||
contentType := item.GetContentType()
|
||||
|
||||
// Content type icon
|
||||
icon := getIcon(item)
|
||||
|
||||
fmt.Printf("%d. %s %s\n", index, icon, displayName)
|
||||
fmt.Printf(" Source: %s", source)
|
||||
|
||||
if contentType != "" {
|
||||
fmt.Printf(" | Type: %s", contentType)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Time information
|
||||
if item.GetUTCTime() > 0 {
|
||||
playTime := time.Unix(item.GetUTCTime(), 0)
|
||||
timeAgo := time.Since(playTime)
|
||||
fmt.Printf(" Played: %s", playTime.Format("2006-01-02 15:04:05"))
|
||||
fmt.Printf(" (%s ago)\n", formatDuration(timeAgo))
|
||||
}
|
||||
|
||||
// Additional details if requested
|
||||
if detailed {
|
||||
if item.HasID() {
|
||||
fmt.Printf(" ID: %s\n", item.GetID())
|
||||
}
|
||||
|
||||
if item.IsPresetable() {
|
||||
fmt.Printf(" ⭐ Can be saved as preset\n")
|
||||
}
|
||||
|
||||
if item.HasArtwork() {
|
||||
fmt.Printf(" 🎨 Has artwork\n")
|
||||
}
|
||||
|
||||
location := item.GetLocation()
|
||||
if location != "" {
|
||||
fmt.Printf(" 📍 Location: %s\n", truncateString(location, 60))
|
||||
}
|
||||
|
||||
sourceAccount := item.GetSourceAccount()
|
||||
if sourceAccount != "" && sourceAccount != item.GetSource() {
|
||||
fmt.Printf(" 👤 Account: %s\n", truncateString(sourceAccount, 40))
|
||||
}
|
||||
|
||||
// Content classification
|
||||
var classifications []string
|
||||
if item.IsStreamingContent() {
|
||||
classifications = append(classifications, "Streaming")
|
||||
}
|
||||
|
||||
if item.IsLocalContent() {
|
||||
classifications = append(classifications, "Local")
|
||||
}
|
||||
|
||||
if len(classifications) > 0 {
|
||||
fmt.Printf(" 🏷️ Type: %s\n", strings.Join(classifications, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func getIcon(item *models.RecentsResponseItem) string {
|
||||
switch {
|
||||
case item.IsTrack():
|
||||
return "🎵"
|
||||
case item.IsStation():
|
||||
return "📻"
|
||||
case item.IsPlaylist():
|
||||
return "📋"
|
||||
case item.IsAlbum():
|
||||
return "💿"
|
||||
case item.IsContainer():
|
||||
return "📁"
|
||||
default:
|
||||
return "🎶"
|
||||
}
|
||||
}
|
||||
|
||||
func formatSource(source string) string {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
return "Spotify"
|
||||
case "LOCAL_MUSIC":
|
||||
return "Local Music"
|
||||
case "STORED_MUSIC":
|
||||
return "Stored Music"
|
||||
case "TUNEIN":
|
||||
return "TuneIn Radio"
|
||||
case "PANDORA":
|
||||
return "Pandora"
|
||||
case "AMAZON":
|
||||
return "Amazon Music"
|
||||
case "DEEZER":
|
||||
return "Deezer"
|
||||
case "IHEART":
|
||||
return "iHeartRadio"
|
||||
case "BLUETOOTH":
|
||||
return "Bluetooth"
|
||||
case "AUX":
|
||||
return "AUX Input"
|
||||
case "AIRPLAY":
|
||||
return "AirPlay"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "< 1 minute"
|
||||
case d < time.Hour:
|
||||
minutes := int(d.Minutes())
|
||||
return fmt.Sprintf("%d minute%s", minutes, pluralize(minutes))
|
||||
case d < 24*time.Hour:
|
||||
hours := int(d.Hours())
|
||||
return fmt.Sprintf("%d hour%s", hours, pluralize(hours))
|
||||
default:
|
||||
days := int(d.Hours() / 24)
|
||||
return fmt.Sprintf("%d day%s", days, pluralize(days))
|
||||
}
|
||||
}
|
||||
|
||||
func pluralize(count int) string {
|
||||
if count == 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "s"
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLength int) string {
|
||||
if len(s) <= maxLength {
|
||||
return s
|
||||
}
|
||||
|
||||
if maxLength <= 3 {
|
||||
return "..."
|
||||
}
|
||||
|
||||
return s[:maxLength-3] + "..."
|
||||
}
|
||||
|
||||
func getAvailableSources(response *models.RecentsResponse) string {
|
||||
sourceMap := make(map[string]bool)
|
||||
|
||||
for _, item := range response.Items {
|
||||
if source := item.GetSource(); source != "" {
|
||||
sourceMap[source] = true
|
||||
}
|
||||
}
|
||||
|
||||
var sources []string
|
||||
for source := range sourceMap {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
|
||||
sort.Strings(sources)
|
||||
|
||||
if len(sources) == 0 {
|
||||
return "none"
|
||||
}
|
||||
|
||||
return strings.Join(sources, ", ")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package main provides a demo client for the SoundTouch service API,
|
||||
// demonstrating how to interact with devices and retrieve media information.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This example demonstrates how to interact with the soundtouch-service API
|
||||
// to list discovered devices.
|
||||
|
||||
func main() {
|
||||
// 1. Trigger a discovery scan
|
||||
fmt.Println("Triggering discovery scan...")
|
||||
|
||||
resp, err := http.Post("http://localhost:8000/setup/discover", "application/json", nil)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to trigger discovery: %v\nMake sure soundtouch-service is running on localhost:8000", err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Wait a bit for discovery to find some devices
|
||||
fmt.Println("Waiting 5 seconds for discovery...")
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// 2. List discovered devices
|
||||
fmt.Println("Fetching discovered devices...")
|
||||
|
||||
resp, err = http.Get("http://localhost:8000/setup/devices")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch devices: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
log.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var devices []map[string]interface{}
|
||||
if err := json.Unmarshal(body, &devices); err != nil {
|
||||
log.Fatalf("Failed to unmarshal JSON: %v", err)
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
fmt.Println("No devices discovered yet.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Discovered %d devices:\n", len(devices))
|
||||
|
||||
for _, d := range devices {
|
||||
fmt.Printf("- %s (IP: %s, Model: %s)\n", d["name"], d["ip_address"], d["product_code"])
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.25.6
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.6
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -22,14 +24,16 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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=
|
||||
@@ -40,8 +44,8 @@ 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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
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=
|
||||
@@ -63,8 +67,8 @@ 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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
@@ -75,6 +79,8 @@ 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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
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=
|
||||
@@ -92,6 +98,6 @@ 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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_SetMusicServiceAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials *models.MusicServiceCredentials
|
||||
serverStatus int
|
||||
serverBody string
|
||||
wantError bool
|
||||
errorMessage string
|
||||
}{
|
||||
{
|
||||
name: "Valid Spotify credentials",
|
||||
credentials: models.NewSpotifyCredentials("user@spotify.com", "mypassword"),
|
||||
serverStatus: http.StatusOK,
|
||||
serverBody: `<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid Pandora credentials",
|
||||
credentials: models.NewPandoraCredentials("pandora_user", "pandora_pass"),
|
||||
serverStatus: http.StatusOK,
|
||||
serverBody: `<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid STORED_MUSIC credentials",
|
||||
credentials: models.NewStoredMusicCredentials("d09708a1-5953-44bc-a413-123456789012/0", "My NAS Library"),
|
||||
serverStatus: http.StatusOK,
|
||||
serverBody: `<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Nil credentials",
|
||||
credentials: nil,
|
||||
wantError: true,
|
||||
errorMessage: "credentials cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "Invalid credentials - empty source",
|
||||
credentials: &models.MusicServiceCredentials{
|
||||
Source: "",
|
||||
DisplayName: "Test Service",
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
},
|
||||
wantError: true,
|
||||
errorMessage: "invalid credentials: source cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Invalid credentials - empty user",
|
||||
credentials: &models.MusicServiceCredentials{
|
||||
Source: "SPOTIFY",
|
||||
DisplayName: "Spotify",
|
||||
User: "",
|
||||
Pass: "testpass",
|
||||
},
|
||||
wantError: true,
|
||||
errorMessage: "invalid credentials: user cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Invalid credentials - empty password for non-STORED_MUSIC",
|
||||
credentials: &models.MusicServiceCredentials{
|
||||
Source: "SPOTIFY",
|
||||
DisplayName: "Spotify",
|
||||
User: "testuser",
|
||||
Pass: "",
|
||||
},
|
||||
wantError: true,
|
||||
errorMessage: "invalid credentials: password cannot be empty for SPOTIFY",
|
||||
},
|
||||
{
|
||||
name: "Server error",
|
||||
credentials: models.NewSpotifyCredentials("user@spotify.com", "mypassword"),
|
||||
serverStatus: http.StatusInternalServerError,
|
||||
serverBody: "Internal Server Error",
|
||||
wantError: true,
|
||||
errorMessage: "failed to set music service account for SPOTIFY",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var receivedRequest *models.MusicServiceCredentials
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setMusicServiceAccount" {
|
||||
t.Errorf("Expected path /setMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Parse request body to verify credentials
|
||||
if tt.credentials != nil {
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err == nil {
|
||||
receivedRequest = &req
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.serverStatus)
|
||||
|
||||
if tt.serverBody != "" {
|
||||
_, _ = w.Write([]byte(tt.serverBody))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.SetMusicServiceAccount(tt.credentials)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if tt.errorMessage != "" && !strings.Contains(err.Error(), tt.errorMessage) {
|
||||
t.Errorf("Expected error message to contain %q, got %q", tt.errorMessage, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify request was sent correctly
|
||||
if receivedRequest != nil {
|
||||
if receivedRequest.Source != tt.credentials.Source {
|
||||
t.Errorf("Expected source %s, got %s", tt.credentials.Source, receivedRequest.Source)
|
||||
}
|
||||
|
||||
if receivedRequest.User != tt.credentials.User {
|
||||
t.Errorf("Expected user %s, got %s", tt.credentials.User, receivedRequest.User)
|
||||
}
|
||||
|
||||
if receivedRequest.Pass != tt.credentials.Pass {
|
||||
t.Errorf("Expected pass %s, got %s", tt.credentials.Pass, receivedRequest.Pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveMusicServiceAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials *models.MusicServiceCredentials
|
||||
serverStatus int
|
||||
serverBody string
|
||||
wantError bool
|
||||
errorMessage string
|
||||
}{
|
||||
{
|
||||
name: "Valid Spotify removal",
|
||||
credentials: models.NewSpotifyCredentials("user@spotify.com", "mypassword"),
|
||||
serverStatus: http.StatusOK,
|
||||
serverBody: `<?xml version="1.0" encoding="UTF-8" ?><status>/removeMusicServiceAccount</status>`,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid Pandora removal",
|
||||
credentials: models.NewPandoraCredentials("pandora_user", "pandora_pass"),
|
||||
serverStatus: http.StatusOK,
|
||||
serverBody: `<?xml version="1.0" encoding="UTF-8" ?><status>/removeMusicServiceAccount</status>`,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Nil credentials",
|
||||
credentials: nil,
|
||||
wantError: true,
|
||||
errorMessage: "credentials cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "Empty source",
|
||||
credentials: &models.MusicServiceCredentials{
|
||||
Source: "",
|
||||
User: "testuser",
|
||||
},
|
||||
wantError: true,
|
||||
errorMessage: "source cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty user",
|
||||
credentials: &models.MusicServiceCredentials{
|
||||
Source: "SPOTIFY",
|
||||
User: "",
|
||||
},
|
||||
wantError: true,
|
||||
errorMessage: "user cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var receivedRequest *models.MusicServiceCredentials
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeMusicServiceAccount" {
|
||||
t.Errorf("Expected path /removeMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Parse request body to verify credentials have empty password
|
||||
if tt.credentials != nil {
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err == nil {
|
||||
receivedRequest = &req
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.serverStatus)
|
||||
|
||||
if tt.serverBody != "" {
|
||||
_, _ = w.Write([]byte(tt.serverBody))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.RemoveMusicServiceAccount(tt.credentials)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if tt.errorMessage != "" && !strings.Contains(err.Error(), tt.errorMessage) {
|
||||
t.Errorf("Expected error message to contain %q, got %q", tt.errorMessage, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify password was cleared for removal
|
||||
if receivedRequest != nil && receivedRequest.Pass != "" {
|
||||
t.Errorf("Expected empty password for removal, got %s", receivedRequest.Pass)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddSpotifyAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setMusicServiceAccount" {
|
||||
t.Errorf("Expected path /setMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "SPOTIFY" {
|
||||
t.Errorf("Expected source SPOTIFY, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "test@spotify.com" {
|
||||
t.Errorf("Expected user test@spotify.com, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "mypassword" {
|
||||
t.Errorf("Expected password mypassword, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.AddSpotifyAccount("test@spotify.com", "mypassword")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveSpotifyAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeMusicServiceAccount" {
|
||||
t.Errorf("Expected path /removeMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "SPOTIFY" {
|
||||
t.Errorf("Expected source SPOTIFY, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "test@spotify.com" {
|
||||
t.Errorf("Expected user test@spotify.com, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "" {
|
||||
t.Errorf("Expected empty password for removal, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/removeMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.RemoveSpotifyAccount("test@spotify.com")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddStoredMusicAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "STORED_MUSIC" {
|
||||
t.Errorf("Expected source STORED_MUSIC, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "d09708a1-5953-44bc-a413-123456789012/0" {
|
||||
t.Errorf("Expected NAS user ID, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.DisplayName != "My NAS Library" {
|
||||
t.Errorf("Expected display name 'My NAS Library', got %s", req.DisplayName)
|
||||
}
|
||||
|
||||
// STORED_MUSIC should have empty password
|
||||
if req.Pass != "" {
|
||||
t.Errorf("Expected empty password for STORED_MUSIC, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.AddStoredMusicAccount("d09708a1-5953-44bc-a413-123456789012/0", "My NAS Library")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AccountManagementErrors(t *testing.T) {
|
||||
// Test network error
|
||||
client := NewClient(&Config{
|
||||
Host: "non-existent-host.invalid",
|
||||
Port: 8090,
|
||||
Timeout: 1 * time.Second,
|
||||
})
|
||||
|
||||
credentials := models.NewSpotifyCredentials("user@spotify.com", "password")
|
||||
|
||||
err := client.SetMusicServiceAccount(credentials)
|
||||
if err == nil {
|
||||
t.Error("Expected error for network error")
|
||||
}
|
||||
|
||||
err = client.RemoveMusicServiceAccount(credentials)
|
||||
if err == nil {
|
||||
t.Error("Expected error for network error")
|
||||
}
|
||||
}
|
||||
|
||||
// Test convenience methods for all supported services
|
||||
func TestClient_AddAmazonMusicAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setMusicServiceAccount" {
|
||||
t.Errorf("Expected path /setMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "AMAZON" {
|
||||
t.Errorf("Expected source AMAZON, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "test@amazon.com" {
|
||||
t.Errorf("Expected user test@amazon.com, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "mypassword" {
|
||||
t.Errorf("Expected password mypassword, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.AddAmazonMusicAccount("test@amazon.com", "mypassword")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveAmazonMusicAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeMusicServiceAccount" {
|
||||
t.Errorf("Expected path /removeMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "AMAZON" {
|
||||
t.Errorf("Expected source AMAZON, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "test@amazon.com" {
|
||||
t.Errorf("Expected user test@amazon.com, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "" {
|
||||
t.Errorf("Expected empty password for removal, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/removeMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.RemoveAmazonMusicAccount("test@amazon.com")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddDeezerAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setMusicServiceAccount" {
|
||||
t.Errorf("Expected path /setMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "DEEZER" {
|
||||
t.Errorf("Expected source DEEZER, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "deezer_user" {
|
||||
t.Errorf("Expected user deezer_user, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "deezer_pass" {
|
||||
t.Errorf("Expected password deezer_pass, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.AddDeezerAccount("deezer_user", "deezer_pass")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveDeezerAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeMusicServiceAccount" {
|
||||
t.Errorf("Expected path /removeMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "DEEZER" {
|
||||
t.Errorf("Expected source DEEZER, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "deezer_user" {
|
||||
t.Errorf("Expected user deezer_user, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "" {
|
||||
t.Errorf("Expected empty password for removal, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/removeMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.RemoveDeezerAccount("deezer_user")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddIHeartRadioAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setMusicServiceAccount" {
|
||||
t.Errorf("Expected path /setMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "IHEART" {
|
||||
t.Errorf("Expected source IHEART, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "iheart_user" {
|
||||
t.Errorf("Expected user iheart_user, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "iheart_pass" {
|
||||
t.Errorf("Expected password iheart_pass, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.AddIHeartRadioAccount("iheart_user", "iheart_pass")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveIHeartRadioAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeMusicServiceAccount" {
|
||||
t.Errorf("Expected path /removeMusicServiceAccount, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var req models.MusicServiceCredentials
|
||||
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("Failed to decode request: %v", err)
|
||||
}
|
||||
|
||||
if req.Source != "IHEART" {
|
||||
t.Errorf("Expected source IHEART, got %s", req.Source)
|
||||
}
|
||||
|
||||
if req.User != "iheart_user" {
|
||||
t.Errorf("Expected user iheart_user, got %s", req.User)
|
||||
}
|
||||
|
||||
if req.Pass != "" {
|
||||
t.Errorf("Expected empty password for removal, got %s", req.Pass)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/removeMusicServiceAccount</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.RemoveIHeartRadioAccount("iheart_user")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ConvenienceMethodsExist(_ *testing.T) {
|
||||
client := NewClient(&Config{
|
||||
Host: "localhost",
|
||||
Port: 8090,
|
||||
Timeout: testTimeout,
|
||||
})
|
||||
|
||||
// Test that convenience methods exist (compilation test)
|
||||
var err error
|
||||
|
||||
// Spotify
|
||||
err = client.AddSpotifyAccount("user", "pass")
|
||||
_ = err // Expect network error, but method should exist
|
||||
|
||||
err = client.RemoveSpotifyAccount("user")
|
||||
_ = err
|
||||
|
||||
// Pandora
|
||||
err = client.AddPandoraAccount("user", "pass")
|
||||
_ = err
|
||||
|
||||
err = client.RemovePandoraAccount("user")
|
||||
_ = err
|
||||
|
||||
// Amazon Music
|
||||
err = client.AddAmazonMusicAccount("user", "pass")
|
||||
_ = err
|
||||
|
||||
err = client.RemoveAmazonMusicAccount("user")
|
||||
_ = err
|
||||
|
||||
// Deezer
|
||||
err = client.AddDeezerAccount("user", "pass")
|
||||
_ = err
|
||||
|
||||
err = client.RemoveDeezerAccount("user")
|
||||
_ = err
|
||||
|
||||
// iHeartRadio
|
||||
err = client.AddIHeartRadioAccount("user", "pass")
|
||||
_ = err
|
||||
|
||||
err = client.RemoveIHeartRadioAccount("user")
|
||||
_ = err
|
||||
|
||||
// STORED_MUSIC
|
||||
err = client.AddStoredMusicAccount("guid/0", "Display Name")
|
||||
_ = err
|
||||
|
||||
err = client.RemoveStoredMusicAccount("guid/0", "Display Name")
|
||||
_ = err
|
||||
}
|
||||
+316
-3
@@ -792,6 +792,130 @@ func (c *Client) SelectPandora(sourceAccount string) error {
|
||||
return c.SelectSource("PANDORA", sourceAccount)
|
||||
}
|
||||
|
||||
// SelectContentItem selects content using a ContentItem directly.
|
||||
// This method allows full control over all ContentItem properties including
|
||||
// complex location parameters for LOCAL_INTERNET_RADIO streamUrl format.
|
||||
//
|
||||
// Example usage for LOCAL_INTERNET_RADIO with streamUrl:
|
||||
//
|
||||
// contentItem := &models.ContentItem{
|
||||
// Source: "LOCAL_INTERNET_RADIO",
|
||||
// Type: "stationurl",
|
||||
// Location: "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio",
|
||||
// IsPresetable: true,
|
||||
// ItemName: "My Radio Station",
|
||||
// ContainerArt: "https://example.com/art.png",
|
||||
// }
|
||||
// err := client.SelectContentItem(contentItem)
|
||||
func (c *Client) SelectContentItem(contentItem *models.ContentItem) error {
|
||||
if contentItem == nil {
|
||||
return fmt.Errorf("contentItem cannot be nil")
|
||||
}
|
||||
|
||||
if contentItem.Source == "" {
|
||||
return fmt.Errorf("contentItem source cannot be empty")
|
||||
}
|
||||
|
||||
return c.post("/select", contentItem)
|
||||
}
|
||||
|
||||
// SelectLocalInternetRadio is a convenience method to select LOCAL_INTERNET_RADIO content.
|
||||
// For simple direct stream URLs, use streamURL parameter.
|
||||
// For complex streamUrl format (with proxy), use the location parameter with full URL.
|
||||
//
|
||||
// Example 1 - Direct stream:
|
||||
//
|
||||
// err := client.SelectLocalInternetRadio("https://stream.example.com/radio", "", "My Radio", "")
|
||||
//
|
||||
// Example 2 - StreamUrl format with proxy:
|
||||
//
|
||||
// location := "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio"
|
||||
// err := client.SelectLocalInternetRadio(location, "", "My Radio", "https://example.com/art.png")
|
||||
func (c *Client) SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt string) error {
|
||||
if location == "" {
|
||||
return fmt.Errorf("location cannot be empty")
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: location,
|
||||
SourceAccount: sourceAccount,
|
||||
IsPresetable: true,
|
||||
ItemName: itemName,
|
||||
ContainerArt: containerArt,
|
||||
}
|
||||
|
||||
if itemName == "" {
|
||||
contentItem.ItemName = "Internet Radio"
|
||||
}
|
||||
|
||||
return c.SelectContentItem(contentItem)
|
||||
}
|
||||
|
||||
// SelectLocalMusic is a convenience method to select LOCAL_MUSIC content.
|
||||
// This is used for SoundTouch App Media Server content on local computers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := client.SelectLocalMusic("album:983", "3f205110-4a57-4e91-810a-123456789012", "Welcome to the New", "http://192.168.1.14:8085/v1/albums/983/image")
|
||||
func (c *Client) SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error {
|
||||
if location == "" {
|
||||
return fmt.Errorf("location cannot be empty")
|
||||
}
|
||||
|
||||
if sourceAccount == "" {
|
||||
return fmt.Errorf("sourceAccount cannot be empty for LOCAL_MUSIC")
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_MUSIC",
|
||||
Type: "album", // Default type, could be "track", "artist", etc.
|
||||
Location: location,
|
||||
SourceAccount: sourceAccount,
|
||||
IsPresetable: true,
|
||||
ItemName: itemName,
|
||||
ContainerArt: containerArt,
|
||||
}
|
||||
|
||||
if itemName == "" {
|
||||
contentItem.ItemName = "Local Music"
|
||||
}
|
||||
|
||||
return c.SelectContentItem(contentItem)
|
||||
}
|
||||
|
||||
// SelectStoredMusic is a convenience method to select STORED_MUSIC content.
|
||||
// This is used for UPnP/DLNA media servers and NAS libraries.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := client.SelectStoredMusic("6_a2874b5d_4f83d999", "d09708a1-5953-44bc-a413-123456789012/0", "Christmas Album", "")
|
||||
func (c *Client) SelectStoredMusic(location, sourceAccount, itemName, containerArt string) error {
|
||||
if location == "" {
|
||||
return fmt.Errorf("location cannot be empty")
|
||||
}
|
||||
|
||||
if sourceAccount == "" {
|
||||
return fmt.Errorf("sourceAccount cannot be empty for STORED_MUSIC")
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "STORED_MUSIC",
|
||||
Location: location,
|
||||
SourceAccount: sourceAccount,
|
||||
IsPresetable: true,
|
||||
ItemName: itemName,
|
||||
ContainerArt: containerArt,
|
||||
}
|
||||
|
||||
if itemName == "" {
|
||||
contentItem.ItemName = "Stored Music"
|
||||
}
|
||||
|
||||
return c.SelectContentItem(contentItem)
|
||||
}
|
||||
|
||||
// GetClockTime retrieves the device's current time from the /clockTime endpoint
|
||||
func (c *Client) GetClockTime() (*models.ClockTime, error) {
|
||||
var clockTime models.ClockTime
|
||||
@@ -1645,8 +1769,8 @@ func (c *Client) hasCapability(capabilities *models.Capabilities, capability str
|
||||
}
|
||||
|
||||
// PlayTTS plays a Text-To-Speech message using Google TTS on the speaker
|
||||
func (c *Client) PlayTTS(text, appKey string, volume ...int) error {
|
||||
playInfo := models.NewTTSPlayInfo(text, appKey, volume...)
|
||||
func (c *Client) PlayTTS(text, appKey, language string, volume ...int) error {
|
||||
playInfo := models.NewTTSPlayInfo(text, appKey, language, volume...)
|
||||
|
||||
if err := playInfo.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid TTS request: %w", err)
|
||||
@@ -1677,10 +1801,199 @@ func (c *Client) PlayCustom(playInfo *models.PlayInfo) error {
|
||||
|
||||
// PlayNotificationBeep plays a notification beep on the device
|
||||
func (c *Client) PlayNotificationBeep() error {
|
||||
return c.post("/playNotification", nil)
|
||||
return c.PlayNotification("")
|
||||
}
|
||||
|
||||
// PlayNotification plays a notification. If a non-empty local path is provided,
|
||||
// it will be sent as XML body to play that specific device-local PCM file.
|
||||
// When path is empty, the device's default beep is triggered.
|
||||
func (c *Client) PlayNotification(path string) error {
|
||||
// Empty path -> trigger default beep via GET
|
||||
if strings.TrimSpace(path) == "" {
|
||||
var status models.StationResponse
|
||||
return c.get("/playNotification", &status)
|
||||
}
|
||||
|
||||
// Non-empty path -> POST minimal XML payload as required by the device
|
||||
payload := struct {
|
||||
XMLName xml.Name `xml:"audioSource"`
|
||||
PathToFile string `xml:"pathToFile,attr"`
|
||||
}{
|
||||
XMLName: xml.Name{Local: "audioSource"},
|
||||
PathToFile: path,
|
||||
}
|
||||
|
||||
return c.post("/playNotification", payload)
|
||||
}
|
||||
|
||||
// Introspect retrieves introspect data for a specified music service
|
||||
func (c *Client) Introspect(source, sourceAccount string) (*models.IntrospectResponse, error) {
|
||||
if source == "" {
|
||||
return nil, fmt.Errorf("source cannot be empty")
|
||||
}
|
||||
|
||||
request := models.NewIntrospectRequest(source, sourceAccount)
|
||||
|
||||
var response models.IntrospectResponse
|
||||
|
||||
err := c.postWithResponse("/introspect", request, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get introspect data for %s: %w", source, err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// IntrospectSpotify is a convenience method to get introspect data for Spotify
|
||||
func (c *Client) IntrospectSpotify(sourceAccount string) (*models.IntrospectResponse, error) {
|
||||
return c.Introspect("SPOTIFY", sourceAccount)
|
||||
}
|
||||
|
||||
// GetRecents retrieves recently played content from the device
|
||||
func (c *Client) GetRecents() (*models.RecentsResponse, error) {
|
||||
var response models.RecentsResponse
|
||||
|
||||
err := c.get("/recents", &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// postPlayInfo sends a PlayInfo request to the /speaker endpoint
|
||||
func (c *Client) postPlayInfo(playInfo *models.PlayInfo) error {
|
||||
return c.post("/speaker", playInfo)
|
||||
}
|
||||
|
||||
// SetMusicServiceAccount adds or updates a music service account
|
||||
func (c *Client) SetMusicServiceAccount(credentials *models.MusicServiceCredentials) error {
|
||||
if credentials == nil {
|
||||
return fmt.Errorf("credentials cannot be nil")
|
||||
}
|
||||
|
||||
if err := credentials.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid credentials: %w", err)
|
||||
}
|
||||
|
||||
var response models.MusicServiceAccountResponse
|
||||
|
||||
err := c.postWithResponse("/setMusicServiceAccount", credentials, &response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set music service account for %s: %w", credentials.Source, err)
|
||||
}
|
||||
|
||||
if !response.IsSuccess() {
|
||||
return fmt.Errorf("music service account operation failed: unexpected response %s", response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMusicServiceAccount removes an existing music service account
|
||||
func (c *Client) RemoveMusicServiceAccount(credentials *models.MusicServiceCredentials) error {
|
||||
if credentials == nil {
|
||||
return fmt.Errorf("credentials cannot be nil")
|
||||
}
|
||||
|
||||
if credentials.Source == "" {
|
||||
return fmt.Errorf("source cannot be empty")
|
||||
}
|
||||
|
||||
if credentials.User == "" {
|
||||
return fmt.Errorf("user cannot be empty")
|
||||
}
|
||||
|
||||
// For removal, ensure password is empty
|
||||
removalCredentials := &models.MusicServiceCredentials{
|
||||
Source: credentials.Source,
|
||||
DisplayName: credentials.DisplayName,
|
||||
User: credentials.User,
|
||||
Pass: "", // Empty password indicates removal
|
||||
}
|
||||
|
||||
var response models.MusicServiceAccountResponse
|
||||
|
||||
err := c.postWithResponse("/removeMusicServiceAccount", removalCredentials, &response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove music service account for %s: %w", credentials.Source, err)
|
||||
}
|
||||
|
||||
if !response.IsSuccess() {
|
||||
return fmt.Errorf("music service account removal failed: unexpected response %s", response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSpotifyAccount adds a Spotify Premium account
|
||||
func (c *Client) AddSpotifyAccount(user, password string) error {
|
||||
credentials := models.NewSpotifyCredentials(user, password)
|
||||
return c.SetMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// RemoveSpotifyAccount removes a Spotify account
|
||||
func (c *Client) RemoveSpotifyAccount(user string) error {
|
||||
credentials := models.NewSpotifyCredentials(user, "")
|
||||
return c.RemoveMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// AddPandoraAccount adds a Pandora account
|
||||
func (c *Client) AddPandoraAccount(user, password string) error {
|
||||
credentials := models.NewPandoraCredentials(user, password)
|
||||
return c.SetMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// RemovePandoraAccount removes a Pandora account
|
||||
func (c *Client) RemovePandoraAccount(user string) error {
|
||||
credentials := models.NewPandoraCredentials(user, "")
|
||||
return c.RemoveMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// AddStoredMusicAccount adds a STORED_MUSIC (NAS/UPnP) account
|
||||
func (c *Client) AddStoredMusicAccount(user, displayName string) error {
|
||||
credentials := models.NewStoredMusicCredentials(user, displayName)
|
||||
return c.SetMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// RemoveStoredMusicAccount removes a STORED_MUSIC account
|
||||
func (c *Client) RemoveStoredMusicAccount(user, displayName string) error {
|
||||
credentials := models.NewStoredMusicCredentials(user, displayName)
|
||||
return c.RemoveMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// AddAmazonMusicAccount adds an Amazon Music account
|
||||
func (c *Client) AddAmazonMusicAccount(user, password string) error {
|
||||
credentials := models.NewAmazonMusicCredentials(user, password)
|
||||
return c.SetMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// RemoveAmazonMusicAccount removes an Amazon Music account
|
||||
func (c *Client) RemoveAmazonMusicAccount(user string) error {
|
||||
credentials := models.NewAmazonMusicCredentials(user, "")
|
||||
return c.RemoveMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// AddDeezerAccount adds a Deezer Premium account
|
||||
func (c *Client) AddDeezerAccount(user, password string) error {
|
||||
credentials := models.NewDeezerCredentials(user, password)
|
||||
return c.SetMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// RemoveDeezerAccount removes a Deezer account
|
||||
func (c *Client) RemoveDeezerAccount(user string) error {
|
||||
credentials := models.NewDeezerCredentials(user, "")
|
||||
return c.RemoveMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// AddIHeartRadioAccount adds an iHeartRadio account
|
||||
func (c *Client) AddIHeartRadioAccount(user, password string) error {
|
||||
credentials := models.NewIHeartRadioCredentials(user, password)
|
||||
return c.SetMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
// RemoveIHeartRadioAccount removes an iHeartRadio account
|
||||
func (c *Client) RemoveIHeartRadioAccount(user string) error {
|
||||
credentials := models.NewIHeartRadioCredentials(user, "")
|
||||
return c.RemoveMusicServiceAccount(credentials)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -1160,3 +1161,71 @@ func TestClient_RequestToken_Error(t *testing.T) {
|
||||
t.Errorf("Error should mention 'failed to request token', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PlayNotificationBeep(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/playNotification" {
|
||||
t.Errorf("Expected path '/playNotification', got '%s'", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("Expected GET method, got %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>success</status>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create test client
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
// Test PlayNotificationBeep
|
||||
err := client.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
t.Fatalf("PlayNotificationBeep() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PlayNotification_Path(t *testing.T) {
|
||||
testPath := "/opt/Bose/chimes/grouped.pcm"
|
||||
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/playNotification" {
|
||||
t.Errorf("Expected path '/playNotification', got '%s'", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
expectedXML := `<audioSource pathToFile="` + testPath + `"></audioSource>`
|
||||
if string(body) != expectedXML {
|
||||
t.Errorf("Expected body '%s', got '%s'", expectedXML, string(body))
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create test client
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
// Test PlayNotification with path
|
||||
err := client.PlayNotification(testPath)
|
||||
if err != nil {
|
||||
t.Fatalf("PlayNotification() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_Introspect_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Test getting Spotify introspect data
|
||||
t.Run("spotify introspect", func(t *testing.T) {
|
||||
// First check if Spotify is available
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get service availability: %v", err)
|
||||
}
|
||||
|
||||
if !serviceAvailability.HasSpotify() {
|
||||
t.Skip("Spotify not available on this device")
|
||||
}
|
||||
|
||||
// Test introspect with empty source account (should still work)
|
||||
response, err := client.Introspect("SPOTIFY", "")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get Spotify introspect data: %v", err)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Spotify introspect state: %s", response.State)
|
||||
t.Logf("Spotify user: %s", response.User)
|
||||
t.Logf("Spotify is playing: %t", response.IsPlaying)
|
||||
t.Logf("Spotify shuffle mode: %s", response.ShuffleMode)
|
||||
t.Logf("Spotify current URI: %s", response.CurrentURI)
|
||||
t.Logf("Spotify subscription type: %s", response.SubscriptionType)
|
||||
|
||||
// Test state methods
|
||||
if response.IsActive() {
|
||||
t.Log("Spotify service is active")
|
||||
} else if response.IsInactive() {
|
||||
t.Log("Spotify service is inactive")
|
||||
}
|
||||
|
||||
// Test capabilities
|
||||
if response.SupportsSkipPrevious() {
|
||||
t.Log("Spotify supports skip previous")
|
||||
}
|
||||
|
||||
if response.SupportsSeek() {
|
||||
t.Log("Spotify supports seek")
|
||||
}
|
||||
|
||||
if response.SupportsResume() {
|
||||
t.Log("Spotify supports resume")
|
||||
}
|
||||
|
||||
// Test history
|
||||
historySize := response.GetMaxHistorySize()
|
||||
if historySize > 0 {
|
||||
t.Logf("Spotify content history max size: %d", historySize)
|
||||
}
|
||||
})
|
||||
|
||||
// Test the convenience method
|
||||
t.Run("spotify introspect convenience method", func(t *testing.T) {
|
||||
// First check if Spotify is available
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get service availability: %v", err)
|
||||
}
|
||||
|
||||
if !serviceAvailability.HasSpotify() {
|
||||
t.Skip("Spotify not available on this device")
|
||||
}
|
||||
|
||||
response, err := client.IntrospectSpotify("")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get Spotify introspect data using convenience method: %v", err)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response from convenience method, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Convenience method - Spotify state: %s", response.State)
|
||||
})
|
||||
|
||||
// Test introspect with other services if available
|
||||
t.Run("other services introspect", func(t *testing.T) {
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get service availability: %v", err)
|
||||
}
|
||||
|
||||
// Test Pandora if available
|
||||
if serviceAvailability.HasPandora() {
|
||||
t.Log("Testing Pandora introspect...")
|
||||
|
||||
response, err := client.Introspect("PANDORA", "")
|
||||
if err != nil {
|
||||
t.Logf("Pandora introspect failed (expected for some configurations): %v", err)
|
||||
} else {
|
||||
t.Logf("Pandora introspect state: %s", response.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Test TuneIn if available
|
||||
if serviceAvailability.HasTuneIn() {
|
||||
t.Log("Testing TuneIn introspect...")
|
||||
|
||||
response, err := client.Introspect("TUNEIN", "")
|
||||
if err != nil {
|
||||
t.Logf("TuneIn introspect failed (expected for some configurations): %v", err)
|
||||
} else {
|
||||
t.Logf("TuneIn introspect state: %s", response.State)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_Introspect_ErrorCases_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Test with invalid source
|
||||
t.Run("invalid source", func(t *testing.T) {
|
||||
response, err := client.Introspect("INVALID_SOURCE", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid source, got nil")
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
t.Error("expected nil response for invalid source, got non-nil")
|
||||
}
|
||||
|
||||
t.Logf("Expected error for invalid source: %v", err)
|
||||
})
|
||||
|
||||
// Test with empty source
|
||||
t.Run("empty source", func(t *testing.T) {
|
||||
response, err := client.Introspect("", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty source, got nil")
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
t.Error("expected nil response for empty source, got non-nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ExampleClient_Introspect demonstrates how to use the Introspect method
|
||||
func ExampleClient_Introspect() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Get introspect data for Spotify
|
||||
response, err := client.Introspect("SPOTIFY", "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Check service state
|
||||
if response.IsActive() {
|
||||
println("Spotify service is active")
|
||||
|
||||
if response.IsPlaying {
|
||||
println("Currently playing:", response.CurrentURI)
|
||||
}
|
||||
} else {
|
||||
println("Spotify service is inactive")
|
||||
}
|
||||
|
||||
// Check capabilities
|
||||
if response.SupportsSeek() {
|
||||
println("Seek is supported")
|
||||
}
|
||||
|
||||
if response.SupportsSkipPrevious() {
|
||||
println("Skip previous is supported")
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleClient_IntrospectSpotify demonstrates the Spotify convenience method
|
||||
func ExampleClient_IntrospectSpotify() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Get Spotify introspect data using convenience method
|
||||
response, err := client.IntrospectSpotify("")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Display user and subscription info
|
||||
if response.HasUser() {
|
||||
println("Spotify user:", response.User)
|
||||
}
|
||||
|
||||
if response.HasSubscription() {
|
||||
println("Subscription type:", response.SubscriptionType)
|
||||
}
|
||||
|
||||
// Check shuffle state
|
||||
if response.IsShuffleEnabled() {
|
||||
println("Shuffle is enabled")
|
||||
} else {
|
||||
println("Shuffle is disabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_Introspect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
sourceAccount string
|
||||
responseXML string
|
||||
expectedError string
|
||||
wantResponse *models.IntrospectResponse
|
||||
}{
|
||||
{
|
||||
name: "successful spotify introspect",
|
||||
source: "SPOTIFY",
|
||||
sourceAccount: "SpotifyConnectUserName",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<spotifyAccountIntrospectResponse state="InactiveUnselected" user="SpotifyConnectUserName" isPlaying="false" tokenLastChangedTimeSeconds="1702566495" tokenLastChangedTimeMicroseconds="427884" shuffleMode="OFF" playStatusState="2" currentUri="" receivedPlaybackRequest="false" subscriptionType="">
|
||||
<cachedPlaybackRequest />
|
||||
<nowPlaying skipPreviousSupported="false" seekSupported="false" resumeSupported="true" collectData="true" />
|
||||
<contentItemHistory maxSize="10" />
|
||||
</spotifyAccountIntrospectResponse>`,
|
||||
wantResponse: &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
User: "SpotifyConnectUserName",
|
||||
IsPlaying: false,
|
||||
TokenLastChangedTimeSeconds: 1702566495,
|
||||
TokenLastChangedTimeMicroseconds: 427884,
|
||||
ShuffleMode: "OFF",
|
||||
PlayStatusState: "2",
|
||||
CurrentURI: "",
|
||||
ReceivedPlaybackRequest: false,
|
||||
SubscriptionType: "",
|
||||
CachedPlaybackRequest: &models.CachedPlaybackRequest{},
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: false,
|
||||
SeekSupported: false,
|
||||
ResumeSupported: true,
|
||||
CollectData: true,
|
||||
},
|
||||
ContentItemHistory: &models.ContentItemHistory{
|
||||
MaxSize: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful pandora introspect",
|
||||
source: "PANDORA",
|
||||
sourceAccount: "pandora_user",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<pandoraAccountIntrospectResponse state="Active" user="pandora_user" isPlaying="true" shuffleMode="ON" currentUri="pandora://track/123" subscriptionType="Premium">
|
||||
<nowPlaying skipPreviousSupported="true" seekSupported="false" resumeSupported="true" collectData="false" />
|
||||
<contentItemHistory maxSize="20" />
|
||||
</pandoraAccountIntrospectResponse>`,
|
||||
wantResponse: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "pandora_user",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
CurrentURI: "pandora://track/123",
|
||||
SubscriptionType: "Premium",
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: false,
|
||||
ResumeSupported: true,
|
||||
CollectData: false,
|
||||
},
|
||||
ContentItemHistory: &models.ContentItemHistory{
|
||||
MaxSize: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty source error",
|
||||
source: "",
|
||||
sourceAccount: "test_user",
|
||||
expectedError: "source cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "http error",
|
||||
source: "SPOTIFY",
|
||||
sourceAccount: "test_user",
|
||||
responseXML: "",
|
||||
expectedError: "failed to get introspect data for SPOTIFY:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request method and path
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/introspect" {
|
||||
t.Errorf("expected /introspect path, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Verify request body
|
||||
var requestBody models.IntrospectRequest
|
||||
if err := xml.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
}
|
||||
|
||||
if requestBody.Source != tt.source {
|
||||
t.Errorf("expected source %s, got %s", tt.source, requestBody.Source)
|
||||
}
|
||||
|
||||
if requestBody.SourceAccount != tt.sourceAccount {
|
||||
t.Errorf("expected sourceAccount %s, got %s", tt.sourceAccount, requestBody.SourceAccount)
|
||||
}
|
||||
|
||||
if tt.responseXML == "" {
|
||||
// Simulate server error
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:], // Remove "http://" prefix
|
||||
Port: 80,
|
||||
}
|
||||
client := NewClient(config)
|
||||
// Override the base URL to use test server
|
||||
client.baseURL = server.URL
|
||||
|
||||
response, err := client.Introspect(tt.source, tt.sourceAccount)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.expectedError)
|
||||
return
|
||||
}
|
||||
|
||||
if !containsString(err.Error(), tt.expectedError) {
|
||||
t.Errorf("expected error containing %q, got %q", tt.expectedError, err.Error())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Error("expected response, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify response fields
|
||||
if response.State != tt.wantResponse.State {
|
||||
t.Errorf("expected state %s, got %s", tt.wantResponse.State, response.State)
|
||||
}
|
||||
|
||||
if response.User != tt.wantResponse.User {
|
||||
t.Errorf("expected user %s, got %s", tt.wantResponse.User, response.User)
|
||||
}
|
||||
|
||||
if response.IsPlaying != tt.wantResponse.IsPlaying {
|
||||
t.Errorf("expected isPlaying %t, got %t", tt.wantResponse.IsPlaying, response.IsPlaying)
|
||||
}
|
||||
|
||||
if response.ShuffleMode != tt.wantResponse.ShuffleMode {
|
||||
t.Errorf("expected shuffleMode %s, got %s", tt.wantResponse.ShuffleMode, response.ShuffleMode)
|
||||
}
|
||||
|
||||
if response.CurrentURI != tt.wantResponse.CurrentURI {
|
||||
t.Errorf("expected currentUri %s, got %s", tt.wantResponse.CurrentURI, response.CurrentURI)
|
||||
}
|
||||
|
||||
if response.SubscriptionType != tt.wantResponse.SubscriptionType {
|
||||
t.Errorf("expected subscriptionType %s, got %s", tt.wantResponse.SubscriptionType, response.SubscriptionType)
|
||||
}
|
||||
|
||||
// Verify nested structures
|
||||
if tt.wantResponse.NowPlaying != nil {
|
||||
if response.NowPlaying == nil {
|
||||
t.Error("expected nowPlaying, got nil")
|
||||
} else {
|
||||
if response.NowPlaying.SkipPreviousSupported != tt.wantResponse.NowPlaying.SkipPreviousSupported {
|
||||
t.Errorf("expected skipPreviousSupported %t, got %t",
|
||||
tt.wantResponse.NowPlaying.SkipPreviousSupported,
|
||||
response.NowPlaying.SkipPreviousSupported)
|
||||
}
|
||||
|
||||
if response.NowPlaying.SeekSupported != tt.wantResponse.NowPlaying.SeekSupported {
|
||||
t.Errorf("expected seekSupported %t, got %t",
|
||||
tt.wantResponse.NowPlaying.SeekSupported,
|
||||
response.NowPlaying.SeekSupported)
|
||||
}
|
||||
|
||||
if response.NowPlaying.ResumeSupported != tt.wantResponse.NowPlaying.ResumeSupported {
|
||||
t.Errorf("expected resumeSupported %t, got %t",
|
||||
tt.wantResponse.NowPlaying.ResumeSupported,
|
||||
response.NowPlaying.ResumeSupported)
|
||||
}
|
||||
|
||||
if response.NowPlaying.CollectData != tt.wantResponse.NowPlaying.CollectData {
|
||||
t.Errorf("expected collectData %t, got %t",
|
||||
tt.wantResponse.NowPlaying.CollectData,
|
||||
response.NowPlaying.CollectData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tt.wantResponse.ContentItemHistory != nil {
|
||||
if response.ContentItemHistory == nil {
|
||||
t.Error("expected contentItemHistory, got nil")
|
||||
} else {
|
||||
if response.ContentItemHistory.MaxSize != tt.wantResponse.ContentItemHistory.MaxSize {
|
||||
t.Errorf("expected maxSize %d, got %d",
|
||||
tt.wantResponse.ContentItemHistory.MaxSize,
|
||||
response.ContentItemHistory.MaxSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospectResponse_Methods(t *testing.T) {
|
||||
response := &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "test_user",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
CurrentURI: "spotify://track/123",
|
||||
SubscriptionType: "Premium",
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: true,
|
||||
ResumeSupported: true,
|
||||
CollectData: false,
|
||||
},
|
||||
ContentItemHistory: &models.ContentItemHistory{
|
||||
MaxSize: 15,
|
||||
},
|
||||
}
|
||||
|
||||
// Test state methods
|
||||
if !response.IsActive() {
|
||||
t.Error("expected IsActive() to return true")
|
||||
}
|
||||
|
||||
if response.IsInactive() {
|
||||
t.Error("expected IsInactive() to return false")
|
||||
}
|
||||
|
||||
// Test user methods
|
||||
if !response.HasUser() {
|
||||
t.Error("expected HasUser() to return true")
|
||||
}
|
||||
|
||||
// Test shuffle methods
|
||||
if !response.IsShuffleEnabled() {
|
||||
t.Error("expected IsShuffleEnabled() to return true")
|
||||
}
|
||||
|
||||
// Test content methods
|
||||
if !response.HasCurrentContent() {
|
||||
t.Error("expected HasCurrentContent() to return true")
|
||||
}
|
||||
|
||||
// Test capability methods
|
||||
if !response.SupportsSkipPrevious() {
|
||||
t.Error("expected SupportsSkipPrevious() to return true")
|
||||
}
|
||||
|
||||
if !response.SupportsSeek() {
|
||||
t.Error("expected SupportsSeek() to return true")
|
||||
}
|
||||
|
||||
if !response.SupportsResume() {
|
||||
t.Error("expected SupportsResume() to return true")
|
||||
}
|
||||
|
||||
if response.CollectsData() {
|
||||
t.Error("expected CollectsData() to return false")
|
||||
}
|
||||
|
||||
// Test history methods
|
||||
if response.GetMaxHistorySize() != 15 {
|
||||
t.Errorf("expected GetMaxHistorySize() to return 15, got %d", response.GetMaxHistorySize())
|
||||
}
|
||||
|
||||
// Test subscription methods
|
||||
if !response.HasSubscription() {
|
||||
t.Error("expected HasSubscription() to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospectResponse_InactiveState(t *testing.T) {
|
||||
response := &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
User: "",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
CurrentURI: "",
|
||||
SubscriptionType: "",
|
||||
}
|
||||
|
||||
// Test inactive state
|
||||
if response.IsActive() {
|
||||
t.Error("expected IsActive() to return false")
|
||||
}
|
||||
|
||||
if !response.IsInactive() {
|
||||
t.Error("expected IsInactive() to return true")
|
||||
}
|
||||
|
||||
// Test empty values
|
||||
if response.HasUser() {
|
||||
t.Error("expected HasUser() to return false")
|
||||
}
|
||||
|
||||
if response.IsShuffleEnabled() {
|
||||
t.Error("expected IsShuffleEnabled() to return false")
|
||||
}
|
||||
|
||||
if response.HasCurrentContent() {
|
||||
t.Error("expected HasCurrentContent() to return false")
|
||||
}
|
||||
|
||||
if response.HasSubscription() {
|
||||
t.Error("expected HasSubscription() to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIntrospectRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
sourceAccount string
|
||||
}{
|
||||
{
|
||||
name: "with source account",
|
||||
source: "SPOTIFY",
|
||||
sourceAccount: "test_user",
|
||||
},
|
||||
{
|
||||
name: "without source account",
|
||||
source: "BLUETOOTH",
|
||||
sourceAccount: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := models.NewIntrospectRequest(tt.source, tt.sourceAccount)
|
||||
|
||||
if request == nil {
|
||||
t.Error("expected request, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
if request.Source != tt.source {
|
||||
t.Errorf("expected source %s, got %s", tt.source, request.Source)
|
||||
}
|
||||
|
||||
if request.SourceAccount != tt.sourceAccount {
|
||||
t.Errorf("expected sourceAccount %s, got %s", tt.sourceAccount, request.SourceAccount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_GetRecents_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
t.Run("get recents", func(t *testing.T) {
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get recents: %v", err)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Recent items count: %d", response.GetItemCount())
|
||||
|
||||
if response.IsEmpty() {
|
||||
t.Log("No recent items found - this is normal if device hasn't played anything recently")
|
||||
return
|
||||
}
|
||||
|
||||
// Test basic functionality
|
||||
t.Logf("Recent items found: %d", response.GetItemCount())
|
||||
|
||||
// Get most recent item
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent != nil {
|
||||
t.Logf("Most recent item: %s (Source: %s, Time: %d)",
|
||||
mostRecent.GetDisplayName(),
|
||||
mostRecent.GetSource(),
|
||||
mostRecent.GetUTCTime())
|
||||
|
||||
if mostRecent.HasArtwork() {
|
||||
t.Logf(" Has artwork: %s", mostRecent.GetArtwork())
|
||||
}
|
||||
|
||||
if mostRecent.IsPresetable() {
|
||||
t.Log(" Can be saved as preset")
|
||||
}
|
||||
|
||||
// Test content type detection
|
||||
if mostRecent.IsTrack() {
|
||||
t.Log(" Content type: Track")
|
||||
} else if mostRecent.IsStation() {
|
||||
t.Log(" Content type: Radio Station")
|
||||
} else if mostRecent.IsPlaylist() {
|
||||
t.Log(" Content type: Playlist")
|
||||
} else if mostRecent.IsAlbum() {
|
||||
t.Log(" Content type: Album")
|
||||
} else if mostRecent.IsContainer() {
|
||||
t.Log(" Content type: Container")
|
||||
}
|
||||
|
||||
// Test source type detection
|
||||
if mostRecent.IsSpotifyContent() {
|
||||
t.Log(" Source type: Spotify")
|
||||
} else if mostRecent.IsLocalContent() {
|
||||
t.Log(" Source type: Local")
|
||||
} else if mostRecent.IsStreamingContent() {
|
||||
t.Log(" Source type: Streaming service")
|
||||
}
|
||||
}
|
||||
|
||||
// Test filtering methods
|
||||
spotifyItems := response.GetSpotifyItems()
|
||||
if len(spotifyItems) > 0 {
|
||||
t.Logf("Spotify items: %d", len(spotifyItems))
|
||||
|
||||
for i, item := range spotifyItems {
|
||||
if i < 3 { // Show first 3
|
||||
t.Logf(" - %s", item.GetDisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localItems := response.GetLocalMusicItems()
|
||||
if len(localItems) > 0 {
|
||||
t.Logf("Local music items: %d", len(localItems))
|
||||
}
|
||||
|
||||
storedItems := response.GetStoredMusicItems()
|
||||
if len(storedItems) > 0 {
|
||||
t.Logf("Stored music items: %d", len(storedItems))
|
||||
}
|
||||
|
||||
tuneInItems := response.GetTuneInItems()
|
||||
if len(tuneInItems) > 0 {
|
||||
t.Logf("TuneIn items: %d", len(tuneInItems))
|
||||
}
|
||||
|
||||
pandoraItems := response.GetPandoraItems()
|
||||
if len(pandoraItems) > 0 {
|
||||
t.Logf("Pandora items: %d", len(pandoraItems))
|
||||
}
|
||||
|
||||
// Test content type filters
|
||||
tracks := response.GetTracks()
|
||||
if len(tracks) > 0 {
|
||||
t.Logf("Track items: %d", len(tracks))
|
||||
}
|
||||
|
||||
stations := response.GetStations()
|
||||
if len(stations) > 0 {
|
||||
t.Logf("Station items: %d", len(stations))
|
||||
}
|
||||
|
||||
playlistsAndAlbums := response.GetPlaylistsAndAlbums()
|
||||
if len(playlistsAndAlbums) > 0 {
|
||||
t.Logf("Playlist/Album items: %d", len(playlistsAndAlbums))
|
||||
}
|
||||
|
||||
presetableItems := response.GetPresetableItems()
|
||||
if len(presetableItems) > 0 {
|
||||
t.Logf("Presetable items: %d", len(presetableItems))
|
||||
}
|
||||
|
||||
// Show all items with details
|
||||
t.Log("\nAll recent items:")
|
||||
|
||||
for i, item := range response.Items {
|
||||
if i >= 10 { // Limit to first 10 items to avoid spam
|
||||
t.Logf(" ... and %d more items", len(response.Items)-i)
|
||||
break
|
||||
}
|
||||
|
||||
displayName := item.GetDisplayName()
|
||||
source := item.GetSource()
|
||||
contentType := item.GetContentType()
|
||||
utcTime := item.GetUTCTime()
|
||||
|
||||
timeStr := ""
|
||||
|
||||
if utcTime > 0 {
|
||||
playTime := time.Unix(utcTime, 0)
|
||||
timeStr = playTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
t.Logf(" %d. %s (%s/%s) - %s", i+1, displayName, source, contentType, timeStr)
|
||||
|
||||
if item.HasID() {
|
||||
t.Logf(" ID: %s", item.GetID())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_GetRecents_Performance(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping performance test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Measure response time
|
||||
start := time.Now()
|
||||
response, err := client.GetRecents()
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get recents: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("GetRecents() took %v", duration)
|
||||
|
||||
if duration > 2*time.Second {
|
||||
t.Logf("Warning: GetRecents() took longer than expected: %v", duration)
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
t.Logf("Retrieved %d recent items", response.GetItemCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetRecents_ErrorConditions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
// Test with invalid host
|
||||
t.Run("invalid host", func(t *testing.T) {
|
||||
config := &Config{
|
||||
Host: "192.168.255.255", // Non-existent IP
|
||||
Timeout: 2 * time.Second, // Short timeout
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid host, got nil")
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
t.Error("expected nil response for invalid host, got non-nil")
|
||||
}
|
||||
|
||||
t.Logf("Expected error for invalid host: %v", err)
|
||||
})
|
||||
|
||||
// Test with very short timeout
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 1 * time.Nanosecond, // Impossibly short timeout
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err == nil {
|
||||
t.Log("Warning: expected timeout error, but request succeeded")
|
||||
}
|
||||
|
||||
if response != nil && err != nil {
|
||||
t.Error("got both response and error")
|
||||
}
|
||||
|
||||
t.Logf("Timeout test result - error: %v, response nil: %t", err, response == nil)
|
||||
})
|
||||
}
|
||||
|
||||
// ExampleClient_GetRecents demonstrates how to use the GetRecents method
|
||||
func ExampleClient_GetRecents() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Get recent items
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
println("No recent items found")
|
||||
return
|
||||
}
|
||||
|
||||
// Show most recent item
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent != nil {
|
||||
println("Most recent:", mostRecent.GetDisplayName())
|
||||
println("Source:", mostRecent.GetSource())
|
||||
|
||||
if mostRecent.IsPresetable() {
|
||||
println("Can be saved as preset")
|
||||
}
|
||||
}
|
||||
|
||||
// Show Spotify items
|
||||
spotifyItems := response.GetSpotifyItems()
|
||||
if len(spotifyItems) > 0 {
|
||||
println("Recent Spotify tracks:")
|
||||
|
||||
for _, item := range spotifyItems {
|
||||
println("-", item.GetDisplayName())
|
||||
}
|
||||
}
|
||||
|
||||
// Show only tracks (no stations or playlists)
|
||||
tracks := response.GetTracks()
|
||||
println("Total tracks in recent items:", len(tracks))
|
||||
}
|
||||
|
||||
// ExampleRecentsResponse_filtering demonstrates filtering recent items
|
||||
func ExampleRecentsResponse_filtering() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Filter by source
|
||||
println("Spotify items:", len(response.GetSpotifyItems()))
|
||||
println("Local music items:", len(response.GetLocalMusicItems()))
|
||||
println("TuneIn items:", len(response.GetTuneInItems()))
|
||||
|
||||
// Filter by type
|
||||
println("Tracks:", len(response.GetTracks()))
|
||||
println("Stations:", len(response.GetStations()))
|
||||
println("Playlists/Albums:", len(response.GetPlaylistsAndAlbums()))
|
||||
|
||||
// Filter by capability
|
||||
println("Presetable items:", len(response.GetPresetableItems()))
|
||||
|
||||
// Get items from streaming services only
|
||||
streamingItems := 0
|
||||
|
||||
for _, item := range response.Items {
|
||||
if item.IsStreamingContent() {
|
||||
streamingItems++
|
||||
}
|
||||
}
|
||||
|
||||
println("Streaming service items:", streamingItems)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_GetRecents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
statusCode int
|
||||
expectedError string
|
||||
wantResponse *models.RecentsResponse
|
||||
}{
|
||||
{
|
||||
name: "successful recents response",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701202831">
|
||||
<contentItem source="STORED_MUSIC" location="6_a2874b5d_4f83d999" sourceAccount="d09708a1-5953-44bc-a413-123456789012/0" isPresetable="true">
|
||||
<itemName>MercyMe, It's Christmas!</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1700232917" id="2487503626">
|
||||
<contentItem source="LOCAL_MUSIC" type="track" location="track:2590" sourceAccount="3f205110-4a57-4e91-810a-123456789012" isPresetable="true">
|
||||
<itemName>Baby It's Cold Outside - ANNE MURRAY</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701202831,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "STORED_MUSIC",
|
||||
Location: "6_a2874b5d_4f83d999",
|
||||
SourceAccount: "d09708a1-5953-44bc-a413-123456789012/0",
|
||||
IsPresetable: true,
|
||||
ItemName: "MercyMe, It's Christmas!",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1700232917,
|
||||
ID: "2487503626",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "LOCAL_MUSIC",
|
||||
Type: "track",
|
||||
Location: "track:2590",
|
||||
SourceAccount: "3f205110-4a57-4e91-810a-123456789012",
|
||||
IsPresetable: true,
|
||||
ItemName: "Baby It's Cold Outside - ANNE MURRAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty recents response",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spotify recents with artwork",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701300000" id="spotify123">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:4iV5W9uYEdYUVa79Axb7Rh" sourceAccount="spotify_user" isPresetable="true">
|
||||
<itemName>Shape of You - Ed Sheeran</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96</containerArt>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701250000" id="spotify124">
|
||||
<contentItem source="SPOTIFY" type="playlist" location="spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" sourceAccount="spotify_user" isPresetable="true">
|
||||
<itemName>Today's Top Hits</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67706f00000002ca5a7517156021292e5663a6</containerArt>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701300000,
|
||||
ID: "spotify123",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "track",
|
||||
Location: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh",
|
||||
SourceAccount: "spotify_user",
|
||||
IsPresetable: true,
|
||||
ItemName: "Shape of You - Ed Sheeran",
|
||||
ContainerArt: "https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701250000,
|
||||
ID: "spotify124",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "playlist",
|
||||
Location: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
SourceAccount: "spotify_user",
|
||||
IsPresetable: true,
|
||||
ItemName: "Today's Top Hits",
|
||||
ContainerArt: "https://i.scdn.co/image/ab67706f00000002ca5a7517156021292e5663a6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tunein radio station",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701400000">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:s24939" sourceAccount="tunein" isPresetable="true">
|
||||
<itemName>BBC Radio 1</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701400000,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "tunein:station:s24939",
|
||||
SourceAccount: "tunein",
|
||||
IsPresetable: true,
|
||||
ItemName: "BBC Radio 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http error",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
responseXML: "",
|
||||
expectedError: "failed to get recent items:",
|
||||
},
|
||||
{
|
||||
name: "malformed xml",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<invalid>xml</malformed>`,
|
||||
expectedError: "failed to get recent items:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request method and path
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("expected GET request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/recents" {
|
||||
t.Errorf("expected /recents path, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
if tt.statusCode != http.StatusOK {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:], // Remove "http://" prefix
|
||||
Port: 80,
|
||||
}
|
||||
client := NewClient(config)
|
||||
// Override the base URL to use test server
|
||||
client.baseURL = server.URL
|
||||
|
||||
response, err := client.GetRecents()
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.expectedError)
|
||||
return
|
||||
}
|
||||
|
||||
if !containsString(err.Error(), tt.expectedError) {
|
||||
t.Errorf("expected error containing %q, got %q", tt.expectedError, err.Error())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Error("expected response, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify response structure
|
||||
if len(response.Items) != len(tt.wantResponse.Items) {
|
||||
t.Errorf("expected %d items, got %d", len(tt.wantResponse.Items), len(response.Items))
|
||||
}
|
||||
|
||||
// Verify each item
|
||||
for i, expectedItem := range tt.wantResponse.Items {
|
||||
if i >= len(response.Items) {
|
||||
break
|
||||
}
|
||||
|
||||
actualItem := response.Items[i]
|
||||
|
||||
if actualItem.DeviceID != expectedItem.DeviceID {
|
||||
t.Errorf("item %d: expected deviceID %s, got %s", i, expectedItem.DeviceID, actualItem.DeviceID)
|
||||
}
|
||||
|
||||
if actualItem.UTCTime != expectedItem.UTCTime {
|
||||
t.Errorf("item %d: expected utcTime %d, got %d", i, expectedItem.UTCTime, actualItem.UTCTime)
|
||||
}
|
||||
|
||||
if actualItem.ID != expectedItem.ID {
|
||||
t.Errorf("item %d: expected id %s, got %s", i, expectedItem.ID, actualItem.ID)
|
||||
}
|
||||
|
||||
// Verify ContentItem
|
||||
if expectedItem.ContentItem != nil {
|
||||
if actualItem.ContentItem == nil {
|
||||
t.Errorf("item %d: expected contentItem, got nil", i)
|
||||
continue
|
||||
}
|
||||
|
||||
if actualItem.ContentItem.Source != expectedItem.ContentItem.Source {
|
||||
t.Errorf("item %d: expected source %s, got %s", i, expectedItem.ContentItem.Source, actualItem.ContentItem.Source)
|
||||
}
|
||||
|
||||
if actualItem.ContentItem.Type != expectedItem.ContentItem.Type {
|
||||
t.Errorf("item %d: expected type %s, got %s", i, expectedItem.ContentItem.Type, actualItem.ContentItem.Type)
|
||||
}
|
||||
|
||||
if actualItem.ContentItem.Location != expectedItem.ContentItem.Location {
|
||||
t.Errorf("item %d: expected location %s, got %s", i, expectedItem.ContentItem.Location, actualItem.ContentItem.Location)
|
||||
}
|
||||
|
||||
if actualItem.ContentItem.ItemName != expectedItem.ContentItem.ItemName {
|
||||
t.Errorf("item %d: expected itemName %s, got %s", i, expectedItem.ContentItem.ItemName, actualItem.ContentItem.ItemName)
|
||||
}
|
||||
|
||||
if actualItem.ContentItem.IsPresetable != expectedItem.ContentItem.IsPresetable {
|
||||
t.Errorf("item %d: expected isPresetable %t, got %t", i, expectedItem.ContentItem.IsPresetable, actualItem.ContentItem.IsPresetable)
|
||||
}
|
||||
|
||||
if actualItem.ContentItem.ContainerArt != expectedItem.ContentItem.ContainerArt {
|
||||
t.Errorf("item %d: expected containerArt %s, got %s", i, expectedItem.ContentItem.ContainerArt, actualItem.ContentItem.ContainerArt)
|
||||
}
|
||||
} else if actualItem.ContentItem != nil {
|
||||
t.Errorf("item %d: expected nil contentItem, got non-nil", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentsResponse_MethodsIntegration(t *testing.T) {
|
||||
// Test the response methods with a realistic response
|
||||
xmlData := `<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701300000" id="1">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:123" isPresetable="true">
|
||||
<itemName>Spotify Track</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701200000" id="2">
|
||||
<contentItem source="LOCAL_MUSIC" type="track" location="/music/local.mp3" isPresetable="false">
|
||||
<itemName>Local Track</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701100000" id="3">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:123" isPresetable="true">
|
||||
<itemName>Radio Station</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701000000" id="4">
|
||||
<contentItem source="PANDORA" type="track" location="pandora:track:456" isPresetable="true">
|
||||
<itemName>Pandora Track</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
|
||||
var response models.RecentsResponse
|
||||
|
||||
err := xml.Unmarshal([]byte(xmlData), &response)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal test data: %v", err)
|
||||
}
|
||||
|
||||
// Test various filtering methods
|
||||
tests := []struct {
|
||||
name string
|
||||
method func() interface{}
|
||||
expected interface{}
|
||||
}{
|
||||
{"GetItemCount", func() interface{} { return response.GetItemCount() }, 4},
|
||||
{"IsEmpty", func() interface{} { return response.IsEmpty() }, false},
|
||||
{"GetSpotifyItems count", func() interface{} { return len(response.GetSpotifyItems()) }, 1},
|
||||
{"GetLocalMusicItems count", func() interface{} { return len(response.GetLocalMusicItems()) }, 1},
|
||||
{"GetTuneInItems count", func() interface{} { return len(response.GetTuneInItems()) }, 1},
|
||||
{"GetPandoraItems count", func() interface{} { return len(response.GetPandoraItems()) }, 1},
|
||||
{"GetTracks count", func() interface{} { return len(response.GetTracks()) }, 3},
|
||||
{"GetStations count", func() interface{} { return len(response.GetStations()) }, 1},
|
||||
{"GetPresetableItems count", func() interface{} { return len(response.GetPresetableItems()) }, 3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.method()
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test most recent item
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent == nil {
|
||||
t.Error("expected most recent item, got nil")
|
||||
} else {
|
||||
if mostRecent.GetDisplayName() != "Spotify Track" {
|
||||
t.Errorf("expected most recent to be 'Spotify Track', got %s", mostRecent.GetDisplayName())
|
||||
}
|
||||
|
||||
if mostRecent.GetUTCTime() != 1701300000 {
|
||||
t.Errorf("expected most recent UTC time 1701300000, got %d", mostRecent.GetUTCTime())
|
||||
}
|
||||
}
|
||||
|
||||
// Test individual item methods
|
||||
for i, item := range response.Items {
|
||||
t.Run(t.Name()+"/item_"+item.GetID(), func(t *testing.T) {
|
||||
if !item.HasContent() {
|
||||
t.Error("expected item to have content")
|
||||
}
|
||||
|
||||
if item.GetDisplayName() == "" {
|
||||
t.Error("expected item to have display name")
|
||||
}
|
||||
|
||||
if item.GetSource() == "" {
|
||||
t.Error("expected item to have source")
|
||||
}
|
||||
|
||||
if item.GetUTCTime() == 0 {
|
||||
t.Error("expected item to have UTC time")
|
||||
}
|
||||
|
||||
// Test specific item properties
|
||||
switch i {
|
||||
case 0: // Spotify track
|
||||
if !item.IsSpotifyContent() {
|
||||
t.Error("expected first item to be Spotify content")
|
||||
}
|
||||
|
||||
if !item.IsTrack() {
|
||||
t.Error("expected first item to be a track")
|
||||
}
|
||||
|
||||
if !item.IsStreamingContent() {
|
||||
t.Error("expected first item to be streaming content")
|
||||
}
|
||||
case 1: // Local music
|
||||
if !item.IsLocalContent() {
|
||||
t.Error("expected second item to be local content")
|
||||
}
|
||||
|
||||
if item.IsStreamingContent() {
|
||||
t.Error("expected second item to not be streaming content")
|
||||
}
|
||||
case 2: // TuneIn station
|
||||
if !item.IsStation() {
|
||||
t.Error("expected third item to be a station")
|
||||
}
|
||||
|
||||
if item.IsTrack() {
|
||||
t.Error("expected third item to not be a track")
|
||||
}
|
||||
case 3: // Pandora track
|
||||
if !item.IsStreamingContent() {
|
||||
t.Error("expected fourth item to be streaming content")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -565,3 +565,341 @@ func containsMiddleSubstring(s, substr string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func TestClient_SelectContentItem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentItem *models.ContentItem
|
||||
wantError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid LOCAL_INTERNET_RADIO with streamUrl format",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp",
|
||||
IsPresetable: false,
|
||||
ItemName: "Antenne Chillout",
|
||||
ContainerArt: "https://www.radio.net/300/antennechillout.png",
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid LOCAL_MUSIC content",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "LOCAL_MUSIC",
|
||||
Type: "album",
|
||||
Location: "album:983",
|
||||
SourceAccount: "3f205110-4a57-4e91-810a-123456789012",
|
||||
IsPresetable: true,
|
||||
ItemName: "Welcome to the New",
|
||||
ContainerArt: "http://192.168.1.14:8085/v1/albums/983/image",
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid STORED_MUSIC content",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "STORED_MUSIC",
|
||||
Location: "6_a2874b5d_4f83d999",
|
||||
SourceAccount: "d09708a1-5953-44bc-a413-123456789012/0",
|
||||
IsPresetable: true,
|
||||
ItemName: "Christmas Album",
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Nil ContentItem",
|
||||
contentItem: nil,
|
||||
wantError: true,
|
||||
errorMsg: "contentItem cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "Empty source",
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "",
|
||||
Location: "test",
|
||||
},
|
||||
wantError: true,
|
||||
errorMsg: "contentItem source cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/select" {
|
||||
t.Errorf("Expected path /select, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
UserAgent: testUserAgent,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.SelectContentItem(tt.contentItem)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("Expected error message %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SelectLocalInternetRadio(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
location string
|
||||
sourceAccount string
|
||||
itemName string
|
||||
containerArt string
|
||||
wantError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Direct stream URL",
|
||||
location: "https://stream.example.com/radio",
|
||||
sourceAccount: "",
|
||||
itemName: "My Radio",
|
||||
containerArt: "",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "StreamUrl format with proxy",
|
||||
location: "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio",
|
||||
sourceAccount: "",
|
||||
itemName: "My Station",
|
||||
containerArt: "https://example.com/art.png",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty itemName gets default",
|
||||
location: "https://stream.example.com/radio",
|
||||
sourceAccount: "",
|
||||
itemName: "",
|
||||
containerArt: "",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty location",
|
||||
location: "",
|
||||
wantError: true,
|
||||
errorMsg: "location cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/select" {
|
||||
t.Errorf("Expected path /select, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
UserAgent: testUserAgent,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.SelectLocalInternetRadio(tt.location, tt.sourceAccount, tt.itemName, tt.containerArt)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("Expected error message %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SelectLocalMusic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
location string
|
||||
sourceAccount string
|
||||
itemName string
|
||||
containerArt string
|
||||
wantError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid album selection",
|
||||
location: "album:983",
|
||||
sourceAccount: "3f205110-4a57-4e91-810a-123456789012",
|
||||
itemName: "Welcome to the New",
|
||||
containerArt: "http://192.168.1.14:8085/v1/albums/983/image",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid track selection",
|
||||
location: "track:2579",
|
||||
sourceAccount: "3f205110-4a57-4e91-810a-123456789012",
|
||||
itemName: "Finish What He Started",
|
||||
containerArt: "",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty location",
|
||||
location: "",
|
||||
sourceAccount: "test",
|
||||
wantError: true,
|
||||
errorMsg: "location cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty sourceAccount",
|
||||
location: "album:983",
|
||||
sourceAccount: "",
|
||||
wantError: true,
|
||||
errorMsg: "sourceAccount cannot be empty for LOCAL_MUSIC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/select" {
|
||||
t.Errorf("Expected path /select, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
UserAgent: testUserAgent,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.SelectLocalMusic(tt.location, tt.sourceAccount, tt.itemName, tt.containerArt)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("Expected error message %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SelectStoredMusic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
location string
|
||||
sourceAccount string
|
||||
itemName string
|
||||
containerArt string
|
||||
wantError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid NAS album selection",
|
||||
location: "6_a2874b5d_4f83d999",
|
||||
sourceAccount: "d09708a1-5953-44bc-a413-123456789012/0",
|
||||
itemName: "Christmas Album",
|
||||
containerArt: "",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid track selection",
|
||||
location: "7_114e8de9-8115 TRACK",
|
||||
sourceAccount: "d09708a1-5953-44bc-a413-123456789012/0",
|
||||
itemName: "Burn Baby Burn",
|
||||
containerArt: "",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty location",
|
||||
location: "",
|
||||
sourceAccount: "test",
|
||||
wantError: true,
|
||||
errorMsg: "location cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty sourceAccount",
|
||||
location: "6_a2874b5d_4f83d999",
|
||||
sourceAccount: "",
|
||||
wantError: true,
|
||||
errorMsg: "sourceAccount cannot be empty for STORED_MUSIC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/select" {
|
||||
t.Errorf("Expected path /select, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:],
|
||||
Port: 80,
|
||||
Timeout: testTimeout,
|
||||
UserAgent: testUserAgent,
|
||||
}
|
||||
client := NewClient(config)
|
||||
client.baseURL = server.URL
|
||||
|
||||
err := client.SelectStoredMusic(tt.location, tt.sourceAccount, tt.itemName, tt.containerArt)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("Expected error message %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user