mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee44526d25 | ||
|
|
50c40be763 | ||
|
|
147a1a8490 | ||
|
|
ca7f8d5453 | ||
|
|
2ab98f2b6f | ||
|
|
70d05554a0 | ||
|
|
cb037df831 | ||
|
|
f1c2b7a53f | ||
|
|
466e9eca97 | ||
|
|
406180e4ce | ||
|
|
3e96e95a7d | ||
|
|
5fbad7d315 | ||
|
|
c8f280f9d4 | ||
|
|
1bf083ae0d | ||
|
|
4f76c82f9b | ||
|
|
c6fbc45be5 | ||
|
|
376c85a641 | ||
|
|
968312aa39 | ||
|
|
9412b5ffa0 | ||
|
|
ff6edc5383 | ||
|
|
a2f952495e | ||
|
|
29c904b7e4 | ||
|
|
4a46df1167 | ||
|
|
cdaf9f0c0a | ||
|
|
174d087b8e | ||
|
|
066e381737 | ||
|
|
522492177d | ||
|
|
885967aafc | ||
|
|
c6748eda41 | ||
|
|
469a91ad80 | ||
|
|
ceb08cd6bf | ||
|
|
7a3eef110b | ||
|
|
5e6885cfe8 | ||
|
|
747a9cec97 | ||
|
|
88c83b6131 | ||
|
|
5943abfddd | ||
|
|
56e82d5a01 | ||
|
|
56256de47b | ||
|
|
5b99d7f46b | ||
|
|
d0ce48ef03 | ||
|
|
9704e2d8ac | ||
|
|
aa5a25b382 | ||
|
|
f14cb45680 | ||
|
|
1fecb3948e | ||
|
|
0e2f05e6e5 | ||
|
|
ffe61dd7a6 | ||
|
|
76bb19ebcb | ||
|
|
13b8e7be82 | ||
|
|
57d020c407 | ||
|
|
4348d22c5c | ||
|
|
82fd77c8e2 | ||
|
|
0b59e66f70 | ||
|
|
3678719627 | ||
|
|
ccfd49778e | ||
|
|
bdc1f71ece | ||
|
|
68f8efce4e | ||
|
|
276d01fe42 | ||
|
|
4de7911817 | ||
|
|
5d933f7ebc | ||
|
|
153d387aaf | ||
|
|
fea6df32f3 | ||
|
|
3b1c639892 | ||
|
|
740cf54b9d | ||
|
|
e5b94158e6 | ||
|
|
c54ee79320 | ||
|
|
c4cf078d2a | ||
|
|
382567d67d | ||
|
|
de96b1f119 | ||
|
|
d22dc99c9e | ||
|
|
bd0e3d64a3 | ||
|
|
379ac758f6 | ||
|
|
6d0b5f2c78 | ||
|
|
f354c63bac | ||
|
|
50e45ab5f2 | ||
|
|
c1e7d513b4 | ||
|
|
cc92430e69 | ||
|
|
181cd550e3 | ||
|
|
7c92a785a4 | ||
|
|
b79a168084 | ||
|
|
21ce44fa2e | ||
|
|
65f1a2565c | ||
|
|
aa7b2c28ab | ||
|
|
8ef8d71121 | ||
|
|
c5c88f32c3 | ||
|
|
0b8f561077 | ||
|
|
71e3260823 | ||
|
|
bc1b70b8a5 | ||
|
|
a8140ad4fd | ||
|
|
766671f02b | ||
|
|
a06657f3f5 | ||
|
|
505a189ce5 | ||
|
|
509f613e34 | ||
|
|
1478d97886 | ||
|
|
a40fd8cdac | ||
|
|
e0a84d5904 | ||
|
|
5d080cf35f | ||
|
|
bcd383bdff | ||
|
|
7a09a2ddc0 | ||
|
|
b04b0bcc32 | ||
|
|
61b5c71097 | ||
|
|
9f7cb81b45 | ||
|
|
d5d6585517 | ||
|
|
50b694aa08 | ||
|
|
717693e01f | ||
|
|
a0833c113c |
@@ -0,0 +1,5 @@
|
||||
# Files intentionally not linked in docs/SUMMARY.md.
|
||||
# Paths are relative to the docs/ directory.
|
||||
# Lines starting with # and blank lines are ignored.
|
||||
|
||||
#analysis/bose-soundtouch-community-tools.md
|
||||
@@ -28,6 +28,21 @@
|
||||
},
|
||||
{
|
||||
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
|
||||
},
|
||||
{
|
||||
"pattern": "https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://bose\\.fandom\\.com/"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://www\\.reddit\\.com/"
|
||||
}
|
||||
],
|
||||
"replacementPatterns": [
|
||||
|
||||
+47
-12
@@ -34,6 +34,9 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
@@ -43,8 +46,14 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Build service
|
||||
run: make build-service
|
||||
|
||||
- name: Run HTTP client integration tests
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -64,6 +73,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
@@ -121,6 +133,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run basic vulnerability check
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -141,11 +156,9 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check documentation links
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: "yes"
|
||||
use-verbose-mode: "yes"
|
||||
config-file: ".github/markdown-link-check.json"
|
||||
run: |
|
||||
npm install -g markdown-link-check
|
||||
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
|
||||
|
||||
- name: Warn on pending images
|
||||
run: |
|
||||
@@ -266,8 +279,8 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
@@ -275,14 +288,36 @@ jobs:
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -320,7 +355,7 @@ jobs:
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
|
||||
@@ -22,16 +22,16 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -68,6 +68,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run tests before release
|
||||
run: |
|
||||
echo "Running final tests before release..."
|
||||
@@ -165,12 +168,16 @@ jobs:
|
||||
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
|
||||
# Build Web
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-web"
|
||||
id: build
|
||||
|
||||
- name: Generate individual checksums
|
||||
run: |
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
|
||||
|
||||
# Use atomic operations to avoid conflicts
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
@@ -186,6 +193,7 @@ jobs:
|
||||
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TEMP_DIR"
|
||||
@@ -198,6 +206,7 @@ jobs:
|
||||
path: |
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-web-v*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
@@ -224,7 +233,7 @@ jobs:
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" \) -exec mv {} release-files/ \;
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
@@ -246,7 +255,7 @@ jobs:
|
||||
cat checksums.sha256
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=14 # 7 platforms * 2 binaries
|
||||
EXPECTED_COUNT=21 # 7 platforms * 3 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
@@ -377,6 +386,12 @@ jobs:
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Web
|
||||
\`\`\`bash
|
||||
# Start the web app
|
||||
./soundtouch-web
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
|
||||
- Bose SoundTouch 10
|
||||
@@ -395,7 +410,7 @@ jobs:
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
Both `soundtouch-cli` and `soundtouch-service` are included.
|
||||
`soundtouch-cli`, `soundtouch-service`, and `soundtouch-web` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
@@ -440,7 +455,7 @@ jobs:
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
@@ -450,6 +465,7 @@ jobs:
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -470,12 +486,13 @@ jobs:
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -501,8 +518,8 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
@@ -511,14 +528,37 @@ jobs:
|
||||
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
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -532,7 +572,7 @@ jobs:
|
||||
- name: Notify success
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
|
||||
echo "📦 Binaries built for 7 platforms (CLI and Service)"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, and Web)"
|
||||
echo "🐳 Docker image published to ghcr.io"
|
||||
echo "🔐 Checksums generated and verified"
|
||||
echo "📋 Release notes automatically generated"
|
||||
|
||||
@@ -26,6 +26,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install security scanning tools
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -67,6 +70,9 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install static analysis tools
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
@@ -106,6 +112,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
|
||||
@@ -14,6 +14,7 @@ dist/
|
||||
# Root-level binary executables (exclude built binaries in root)
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
|
||||
+25
-9
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
@@ -24,31 +24,47 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
fi
|
||||
|
||||
# Final stage
|
||||
FROM alpine:3.23
|
||||
# Build the soundtouch-web
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
FROM alpine:3.23 AS soundtouch-service
|
||||
|
||||
# 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
|
||||
|
||||
# Verify the binary works on the target platform
|
||||
RUN /app/soundtouch-service version || echo "Binary verification complete"
|
||||
|
||||
# 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"]
|
||||
|
||||
# soundtouch-web image
|
||||
FROM alpine:3.23 AS soundtouch-web
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-web /app/soundtouch-web
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
|
||||
@@ -14,12 +14,16 @@ BINARY_NAME=soundtouch-cli
|
||||
BINARY_PATH=./cmd/$(BINARY_NAME)
|
||||
SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
WEB_NAME=soundtouch-web
|
||||
WEB_PATH=./cmd/$(WEB_NAME)
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
|
||||
SCANNER_NAME=mdns-scanner
|
||||
SCANNER_PATH=./cmd/$(SCANNER_NAME)
|
||||
FAVICON_GEN_NAME=favicon-gen
|
||||
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Version info
|
||||
@@ -27,7 +31,7 @@ BUILD_DIR=./build
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-examples
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@@ -39,6 +43,11 @@ build-service:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@@ -48,6 +57,11 @@ build-examples:
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@@ -96,7 +110,56 @@ test-coverage:
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
check: fmt vet test
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
test-http-client:
|
||||
@echo "Starting services with docker compose..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
|
||||
@echo "Waiting for services to start..."
|
||||
@sleep 10
|
||||
@echo "Running .http tests..."
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
-v "$(PWD)/tests/integration/http-client:/workdir" \
|
||||
jetbrains/intellij-http-client:2026.1 \
|
||||
--env-file /workdir/http-client.env.json \
|
||||
--env ci \
|
||||
/workdir/spotify_registration.http \
|
||||
/workdir/amazon_registration.http \
|
||||
/workdir/create_account.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/spotify_full_flow.http \
|
||||
/workdir/customer_support.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_bmx_services.http \
|
||||
/workdir/get_sourceproviders.http \
|
||||
/workdir/get_software_update.http \
|
||||
/workdir/get_soundtouch_updates.http \
|
||||
/workdir/get_streaming_token.http \
|
||||
/workdir/post_oauth_token.http \
|
||||
/workdir/post_oauth_token_amazon.http \
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/tunein_playback_station.http \
|
||||
/workdir/set_preset_6.http \
|
||||
/workdir/get_presets.http \
|
||||
/workdir/delete_preset_6.http \
|
||||
/workdir/set_preset_5.http \
|
||||
/workdir/post_recent.http \
|
||||
/workdir/get_recents.http \
|
||||
/workdir/get_account_presets.http \
|
||||
/workdir/get_account_devices.http \
|
||||
/workdir/get_account_sources.http \
|
||||
/workdir/get_api_versions.http \
|
||||
/workdir/post_musicprovider_is_eligible.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs amazon-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
@@ -187,10 +250,31 @@ dev-scan-http: build-examples
|
||||
@echo "Scanning for HTTP mDNS services..."
|
||||
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
|
||||
|
||||
install: build-cli build-service
|
||||
dev-web: build-web
|
||||
@echo "Starting web UI (default port 8080)..."
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
|
||||
|
||||
dev-web-port: build-web
|
||||
@echo "Starting web UI on custom port..."
|
||||
@if [ -z "$(PORT)" ]; then \
|
||||
echo "Usage: make dev-web-port PORT=8888"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
|
||||
|
||||
dev-web-host: build-web
|
||||
@echo "Starting web UI with specific host..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-web-host HOST=192.168.1.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-web
|
||||
@echo "Installing binaries to $(GOPATH)/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@@ -210,7 +294,7 @@ release: clean check build-all
|
||||
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build -t soundtouch-service .
|
||||
docker build --target soundtouch-service -t soundtouch-service .
|
||||
|
||||
docker-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@@ -226,6 +310,7 @@ help:
|
||||
@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-favicon-gen - Build the favicon generator"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " test - Run tests"
|
||||
@@ -249,6 +334,9 @@ 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 " dev-web - Build and run web UI (default port 8080)"
|
||||
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
|
||||
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " release - Create release binaries"
|
||||
@@ -270,5 +358,8 @@ help:
|
||||
@echo " make dev-upnp-timeout TIMEOUT=10s"
|
||||
@echo " make dev-scan-all"
|
||||
@echo " make dev-scan-soundtouch"
|
||||
@echo " make dev-web"
|
||||
@echo " make dev-web-port PORT=8888"
|
||||
@echo " make dev-web-host HOST=192.168.1.10"
|
||||
@echo " make test"
|
||||
@echo " make build-all"
|
||||
|
||||
@@ -480,7 +480,7 @@ SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
This Go library will continue to work as it uses the local Web API for direct device control, which is unaffected by the cloud service discontinuation. The local preset management functionality implemented in this library (discovered through the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)) provides an alternative to the cloud-based preset features that will be discontinued.
|
||||
|
||||
**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.
|
||||
**Community Alternatives**: See the [Related Projects & Credits](#related-projects--credits) 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 & Credits
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/srwiley/oksvg"
|
||||
"github.com/srwiley/rasterx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mediaDir := "pkg/service/handlers/web/img"
|
||||
files := []string{"favicon-braille", "favicon-morse"}
|
||||
|
||||
for _, name := range files {
|
||||
svgPath := filepath.Join(mediaDir, name+".svg")
|
||||
pngPath := filepath.Join(mediaDir, name+".png")
|
||||
icoPath := filepath.Join(mediaDir, name+".ico")
|
||||
|
||||
fmt.Printf("Processing %s...\n", name)
|
||||
|
||||
// 1. Render SVG to PNG
|
||||
img, err := renderSVG(svgPath, 32, 32)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s: %v", svgPath, err)
|
||||
}
|
||||
|
||||
f, err := os.Create(pngPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
fmt.Printf("Created %s\n", pngPath)
|
||||
|
||||
// 2. Create ICO (containing multiple sizes)
|
||||
sizes := []int{16, 32, 48}
|
||||
|
||||
var images []image.Image
|
||||
|
||||
for _, s := range sizes {
|
||||
m, err := renderSVG(svgPath, s, s)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
|
||||
}
|
||||
|
||||
images = append(images, m)
|
||||
}
|
||||
|
||||
if err := writeICO(icoPath, images); err != nil {
|
||||
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s\n", icoPath)
|
||||
}
|
||||
}
|
||||
|
||||
func renderSVG(path string, w, h int) (image.Image, error) {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
icon, err := oksvg.ReadIconStream(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
icon.SetTarget(0, 0, float64(w), float64(h))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
|
||||
dasher := rasterx.NewDasher(w, h, gv)
|
||||
icon.Draw(dasher, 1.0)
|
||||
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
// Simple ICO encoder that wraps PNGs
|
||||
func writeICO(path string, images []image.Image) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
bw := bufio.NewWriter(f)
|
||||
defer bw.Flush()
|
||||
|
||||
// ICONDIR header
|
||||
// Reserved (2), Type (2), Count (2)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(0))
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
|
||||
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
|
||||
|
||||
var pngData [][]byte
|
||||
|
||||
for _, img := range images {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pngData = append(pngData, buf.Bytes())
|
||||
}
|
||||
|
||||
offset := uint32(6 + len(images)*16)
|
||||
for i, img := range images {
|
||||
b := img.Bounds()
|
||||
|
||||
width := uint8(b.Dx())
|
||||
if b.Dx() >= 256 {
|
||||
width = 0
|
||||
}
|
||||
|
||||
height := uint8(b.Dy())
|
||||
if b.Dy() >= 256 {
|
||||
height = 0
|
||||
}
|
||||
|
||||
// ICONDIRENTRY
|
||||
bw.WriteByte(width)
|
||||
bw.WriteByte(height)
|
||||
bw.WriteByte(0) // Color count
|
||||
bw.WriteByte(0) // Reserved
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
|
||||
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
|
||||
binary.Write(bw, binary.LittleEndian, offset)
|
||||
|
||||
offset += uint32(len(pngData[i]))
|
||||
}
|
||||
|
||||
for _, data := range pngData {
|
||||
bw.Write(data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package main provides a mock Amazon LWA server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/amazon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Amazon LWA server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package main provides a mock Spotify server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Spotify server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,73 @@ func listMusicServiceAccounts(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pairDevice triggers the Stockholm registration flow via WebSocket
|
||||
func pairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID := c.String("id")
|
||||
token := c.String("token")
|
||||
|
||||
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" Account ID: %s\n", accountID)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.PairWithAccount(accountID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send pairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pairing request sent successfully")
|
||||
fmt.Println("💡 The device will now register itself with the cloud service.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
|
||||
func unpairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.UnPairFromAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send unpairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Unpairing request sent successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceDisplayName returns a user-friendly display name for a service
|
||||
func getServiceDisplayName(source string) string {
|
||||
switch source {
|
||||
|
||||
@@ -196,7 +196,7 @@ var httpClient = &http.Client{
|
||||
}
|
||||
|
||||
func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "tunein.com/radio/") {
|
||||
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a TuneIn radio URL")
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
}
|
||||
|
||||
func fetchSpotifyMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "open.spotify.com/") {
|
||||
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a Spotify URL")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestFetchTuneInMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -30,23 +30,23 @@ func TestFetchTuneInMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTuneInMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchTuneInMetadata() returned nil metadata")
|
||||
}
|
||||
} else {
|
||||
expectedName := "WDR 2 Rheinland"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedName := "WDR 2 Rheinland"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -185,22 +185,22 @@ func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
|
||||
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
|
||||
}
|
||||
} else {
|
||||
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2038,6 +2038,30 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pair",
|
||||
Usage: "Pair the device with a Marge cloud account (Stockholm registration)",
|
||||
Action: pairDevice,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "Marge account ID (e.g., 1234567)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Usage: "User authorization token",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unpair",
|
||||
Usage: "Unpair the device from its Marge cloud account",
|
||||
Action: unpairDevice,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Token commands
|
||||
|
||||
+363
-89
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
@@ -34,10 +35,15 @@ var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
repoURL = "https://github.com/gesellix/bose-soundtouch"
|
||||
)
|
||||
|
||||
func updateBuildInfo() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Path != "" {
|
||||
repoURL = "https://" + info.Main.Path
|
||||
}
|
||||
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
@@ -48,13 +54,124 @@ func updateBuildInfo() {
|
||||
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")
|
||||
date = t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
// Ensure default sources exist for all known devices on startup
|
||||
allDevices, _ := ds.ListAllDevices()
|
||||
for i := range allDevices {
|
||||
dev := &allDevices[i]
|
||||
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
|
||||
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
|
||||
|
||||
// Find default sources and merge them if missing or outdated tokens.
|
||||
// claimed tracks which stored sources have already been matched by a default,
|
||||
// so two defaults with the same SourceKeyType but different SourceProviderIDs
|
||||
// (e.g. INTERNET_RADIO/2 and INTERNET_RADIO/39) are treated as distinct entries.
|
||||
defaults := ds.GetDefaultSources()
|
||||
modified := false
|
||||
claimed := make(map[int]bool)
|
||||
|
||||
for i := range defaults {
|
||||
def := defaults[i]
|
||||
foundIdx := -1
|
||||
|
||||
for j := range sources {
|
||||
if claimed[j] || sources[j].SourceKeyType != def.SourceKeyType {
|
||||
continue
|
||||
}
|
||||
// When both sides have a providerID, require it to match.
|
||||
if def.SourceProviderID != "" && sources[j].SourceProviderID != "" && sources[j].SourceProviderID != def.SourceProviderID {
|
||||
continue
|
||||
}
|
||||
|
||||
foundIdx = j
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if foundIdx >= 0 {
|
||||
claimed[foundIdx] = true
|
||||
|
||||
if sources[foundIdx].Secret == "" && def.Secret != "" {
|
||||
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
|
||||
sources[foundIdx].Secret = def.Secret
|
||||
sources[foundIdx].SecretType = def.SecretType
|
||||
modified = true
|
||||
}
|
||||
} else {
|
||||
log.Printf("Adding missing default source %s (providerID=%s) to device %s", def.SourceKeyType, def.SourceProviderID, dev.DeviceID)
|
||||
sources = append(sources, def)
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
|
||||
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
if config.spotifyClientID != "" {
|
||||
spotifyService := spotify.NewSpotifyService(
|
||||
config.spotifyClientID,
|
||||
config.spotifyClientSecret,
|
||||
config.spotifyRedirectURI,
|
||||
config.dataDir,
|
||||
)
|
||||
if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" {
|
||||
spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase)
|
||||
}
|
||||
|
||||
if err := spotifyService.Load(); err != nil {
|
||||
log.Printf("[Spotify] Failed to load accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(spotifyService)
|
||||
|
||||
clientIDPrefix := config.spotifyClientID
|
||||
if len(clientIDPrefix) > 8 {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
|
||||
}
|
||||
|
||||
if config.amazonClientID != "" {
|
||||
amazonService := amazon.NewAmazonService(
|
||||
config.amazonClientID,
|
||||
config.amazonClientSecret,
|
||||
config.amazonRedirectURI,
|
||||
config.dataDir,
|
||||
)
|
||||
if config.amazonTokenURL != "" || config.amazonProfileURL != "" {
|
||||
amazonService.SetEndpoints(config.amazonTokenURL, config.amazonProfileURL)
|
||||
}
|
||||
|
||||
if err := amazonService.Load(); err != nil {
|
||||
log.Printf("[Amazon] Failed to load accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetAmazonService(amazonService)
|
||||
|
||||
clientIDPrefix := config.amazonClientID
|
||||
if len(clientIDPrefix) > 8 {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Amazon Music service initialized (client ID: %s...)", clientIDPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
@@ -158,10 +275,44 @@ func main() {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI",
|
||||
Value: "ueberboese-login://spotify",
|
||||
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-id",
|
||||
Usage: "Amazon LWA OAuth client ID",
|
||||
EnvVars: []string{"AMAZON_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-secret",
|
||||
Usage: "Amazon LWA OAuth client secret",
|
||||
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-redirect-uri",
|
||||
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
|
||||
EnvVars: []string{"AMAZON_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-token-url",
|
||||
Usage: "Amazon LWA token URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-profile-url",
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
@@ -189,6 +340,11 @@ func main() {
|
||||
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "skip-mirror-endpoints",
|
||||
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
@@ -238,30 +394,16 @@ func main() {
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
|
||||
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)
|
||||
}
|
||||
initMusicServices(config, server)
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
@@ -316,6 +458,8 @@ func main() {
|
||||
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
initializeDefaultSources(ds)
|
||||
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
@@ -374,12 +518,20 @@ type serviceConfig struct {
|
||||
dnsBind string
|
||||
mirrorEnabled bool
|
||||
mirrorEndpoints []string
|
||||
skipMirrorEndpoints []string
|
||||
internalPaths []string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyTokenURL string
|
||||
spotifyAPIBase string
|
||||
amazonClientID string
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
migrationEnabled bool
|
||||
@@ -444,10 +596,18 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
spotifyClientID := c.String("spotify-client-id")
|
||||
spotifyClientSecret := c.String("spotify-client-secret")
|
||||
spotifyRedirectURI := c.String("spotify-redirect-uri")
|
||||
spotifyTokenURL := c.String("spotify-token-url")
|
||||
spotifyAPIBase := c.String("spotify-api-base")
|
||||
amazonClientID := c.String("amazon-client-id")
|
||||
amazonClientSecret := c.String("amazon-client-secret")
|
||||
amazonRedirectURI := c.String("amazon-redirect-uri")
|
||||
amazonTokenURL := c.String("amazon-token-url")
|
||||
amazonProfileURL := c.String("amazon-profile-url")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
mirrorEnabled := c.Bool("mirror-enabled")
|
||||
mirrorEndpoints := c.StringSlice("mirror-endpoints")
|
||||
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
|
||||
internalPaths := c.StringSlice("internal-paths")
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
@@ -469,12 +629,20 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsBind: dnsBind,
|
||||
mirrorEnabled: mirrorEnabled,
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
skipMirrorEndpoints: skipMirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
spotifyTokenURL: spotifyTokenURL,
|
||||
spotifyAPIBase: spotifyAPIBase,
|
||||
amazonClientID: amazonClientID,
|
||||
amazonClientSecret: amazonClientSecret,
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
migrationEnabled: migrationEnabled,
|
||||
@@ -565,28 +733,61 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
|
||||
config.mirrorEnabled = persisted.MirrorEnabled
|
||||
config.mirrorEndpoints = persisted.MirrorEndpoints
|
||||
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
|
||||
config.preferredSource = persisted.PreferredSource
|
||||
config.internalPaths = persisted.InternalPaths
|
||||
|
||||
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
|
||||
applyPersistedMusicServiceCredentials(config, persisted)
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
// applyPersistedMusicServiceCredentials fills in music service credentials from persisted
|
||||
// settings when they have not been supplied via CLI flags or environment variables.
|
||||
func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) {
|
||||
if config.spotifyClientID == "" {
|
||||
config.spotifyClientID = persisted.SpotifyClientID
|
||||
}
|
||||
|
||||
if config.spotifyClientSecret == "" {
|
||||
config.spotifyClientSecret = persisted.SpotifyClientSecret
|
||||
}
|
||||
|
||||
if config.spotifyRedirectURI == "" {
|
||||
config.spotifyRedirectURI = persisted.SpotifyRedirectURI
|
||||
}
|
||||
|
||||
if config.amazonClientID == "" {
|
||||
config.amazonClientID = persisted.AmazonClientID
|
||||
}
|
||||
|
||||
if config.amazonClientSecret == "" {
|
||||
config.amazonClientSecret = persisted.AmazonClientSecret
|
||||
}
|
||||
|
||||
if config.amazonRedirectURI == "" {
|
||||
config.amazonRedirectURI = persisted.AmazonRedirectURI
|
||||
}
|
||||
}
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
MirrorEnabled: config.mirrorEnabled,
|
||||
MirrorEndpoints: config.mirrorEndpoints,
|
||||
PreferredSource: config.preferredSource,
|
||||
InternalPaths: config.internalPaths,
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
MirrorEnabled: config.mirrorEnabled,
|
||||
MirrorEndpoints: config.mirrorEndpoints,
|
||||
SkipMirrorEndpoints: config.skipMirrorEndpoints,
|
||||
PreferredSource: config.preferredSource,
|
||||
InternalPaths: config.internalPaths,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
@@ -651,70 +852,117 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
|
||||
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.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
|
||||
|
||||
r.Route("/tunein", func(r chi.Router) {
|
||||
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/v1/token", server.HandleTuneInToken)
|
||||
r.Post("/v1/report", server.HandleTuneInReport)
|
||||
r.Get("/v1/navigate", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/search", server.HandleTuneInSearch)
|
||||
})
|
||||
|
||||
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.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Route("/streaming", func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Post("/account", server.HandleMargeCreateAccount)
|
||||
r.Post("/account/login", server.HandleMargeLogin)
|
||||
r.Post("/account/{account}/source", server.HandleMargeAddSource)
|
||||
|
||||
r.Route("/account/{account}", func(r chi.Router) {
|
||||
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
r.Get("/presets", server.HandleMargeAccountPresets)
|
||||
r.Get("/presets/all", server.HandleMargeAccountPresets)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
r.Post("/", server.HandleMargeAddDevice)
|
||||
r.Post("/{device}", server.HandleMargeAddDevice)
|
||||
})
|
||||
|
||||
r.Route("/device/{device}", func(r chi.Router) {
|
||||
r.Get("/presets", server.HandleMargePresets)
|
||||
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
|
||||
r.Get("/recent", server.HandleMargeRecents)
|
||||
r.Get("/recents", server.HandleMargeRecents)
|
||||
r.Post("/recent", server.HandleMargeAddRecent)
|
||||
|
||||
r.Get("/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
|
||||
})
|
||||
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
|
||||
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
|
||||
r.Route("/support", func(r chi.Router) {
|
||||
r.Post("/power_on", server.HandleMargePowerOn)
|
||||
r.Post("/customersupport", server.HandleMargeCustomerSupport)
|
||||
})
|
||||
|
||||
r.Route("/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
}
|
||||
|
||||
accountsRoutes := func(r chi.Router) {
|
||||
r.Get("/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
}
|
||||
r.Route("/music", func(r chi.Router) {
|
||||
r.Route("/musicprovider/{providerID}", func(r chi.Router) {
|
||||
r.Post("/is_eligible", server.HandleMusicProviderIsEligible)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
|
||||
})
|
||||
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Route("/{account}", func(r chi.Router) {
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
|
||||
r.Post("/devices", server.HandleMargeAddDevice)
|
||||
|
||||
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
|
||||
|
||||
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
})
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
@@ -724,33 +972,59 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
})
|
||||
|
||||
r.Route("/oauth", func(r chi.Router) {
|
||||
r.Post("/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
|
||||
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
|
||||
r.HandleFunc("/*", server.HandleBoseProxy)
|
||||
})
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
|
||||
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
|
||||
// Return 405 Method Not Allowed as the upstream behavior also returns 405
|
||||
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callback — no auth required (Spotify redirects the
|
||||
// Browser OAuth callbacks — no auth required (provider redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
// short-lived, and useless without the client_secret.
|
||||
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
|
||||
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
|
||||
|
||||
// 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.Route("/accounts", func(r chi.Router) {
|
||||
r.Get("/", server.HandleMgmtListAccounts)
|
||||
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
|
||||
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
})
|
||||
|
||||
r.Route("/spotify", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
|
||||
r.Route("/amazon", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtAmazonInit)
|
||||
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
|
||||
r.Get("/token", server.HandleMgmtAmazonToken)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
|
||||
})
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.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.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server)
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
route = strings.ReplaceAll(route, "/*/", "/")
|
||||
handlerName := runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()
|
||||
// Clean up the handler name (remove package path)
|
||||
// For example, "github.com/gesellix/bose-soundtouch/cmd/soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
parts := strings.Split(handlerName, "/")
|
||||
if len(parts) > 0 {
|
||||
handlerName = parts[len(parts)-1]
|
||||
}
|
||||
// Now we might have "soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
// Let's remove the first part if it's a known varying package name
|
||||
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
|
||||
handlerName = handlerName[idx:]
|
||||
}
|
||||
// In case it's not setupRouter but still has a package prefix
|
||||
for {
|
||||
dotIdx := strings.Index(handlerName, ".")
|
||||
if dotIdx == -1 {
|
||||
break
|
||||
}
|
||||
prefix := handlerName[:dotIdx]
|
||||
if prefix == "main" || prefix == "command-line-arguments" || strings.Contains(prefix, "soundtouch-service") {
|
||||
handlerName = handlerName[dotIdx+1:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove any ".funcN" suffix if it's an anonymous function
|
||||
if idx := strings.Index(handlerName, ".func"); idx != -1 {
|
||||
handlerName = handlerName[:idx]
|
||||
}
|
||||
|
||||
routes = append(routes, fmt.Sprintf("%-8s %-60s %s", method, route, handlerName))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(r, walkFunc); err != nil {
|
||||
t.Fatalf("Failed to walk routes: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(routes)
|
||||
|
||||
output := strings.Join(routes, "\n") + "\n"
|
||||
|
||||
// Define snapshot path
|
||||
snapshotPath := "testdata/router_routes.txt"
|
||||
actualPath := "testdata/router_routes.actual.txt"
|
||||
|
||||
// Always write the current (actual) routes to a file
|
||||
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write actual routes: %v", err)
|
||||
}
|
||||
|
||||
// Check if snapshot exists
|
||||
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
||||
// Create testdata directory if it doesn't exist
|
||||
if err := os.MkdirAll("testdata", 0755); err != nil {
|
||||
t.Fatalf("Failed to create testdata directory: %v", err)
|
||||
}
|
||||
// Initial snapshot creation
|
||||
if err := os.WriteFile(snapshotPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write snapshot: %v", err)
|
||||
}
|
||||
t.Logf("Initial snapshot created at %s", snapshotPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing snapshot
|
||||
existingOutput, err := os.ReadFile(snapshotPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read snapshot: %v", err)
|
||||
}
|
||||
|
||||
if string(existingOutput) != output {
|
||||
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
*.actual.txt
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
|
||||
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
|
||||
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
GET /favicon.ico setupRouter
|
||||
GET /health handlers.(*Server).HandleHealth-fm
|
||||
GET /media/* handlers.(*Server).HandleMedia
|
||||
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
GET /mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
|
||||
GET /mgmt/amazon/callback handlers.(*Server).HandleMgmtAmazonCallback-fm
|
||||
GET /mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
|
||||
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
|
||||
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
|
||||
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
|
||||
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
|
||||
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
|
||||
GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm
|
||||
GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-fm
|
||||
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
|
||||
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
|
||||
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
|
||||
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
|
||||
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
|
||||
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
|
||||
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
|
||||
POST /mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
|
||||
POST /mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
|
||||
POST /mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
|
||||
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
|
||||
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
|
||||
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
|
||||
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
|
||||
POST /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
|
||||
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
|
||||
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
|
||||
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
|
||||
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
|
||||
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
|
||||
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
|
||||
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
|
||||
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
|
||||
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
|
||||
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
|
||||
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
|
||||
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
|
||||
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
|
||||
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
|
||||
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
|
||||
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
@@ -0,0 +1,2 @@
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -0,0 +1,276 @@
|
||||
# SoundTouch Web Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Design
|
||||
|
||||
The architecture eliminates Go template dependencies and provides:
|
||||
- **JSON API Backend**: Pure Go server returning only JSON responses
|
||||
- **Client-Side Rendering**: JavaScript handles all HTML generation
|
||||
- **WebSocket Real-time**: Bi-directional communication for live updates
|
||||
- **Better Performance**: No server-side template processing
|
||||
- **Easier Development**: Clear separation of frontend/backend concerns
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. Main Application (`main.go`)
|
||||
- **Entry Point**: Handles command-line arguments and application initialization
|
||||
- **SPA Routing**: Serves static HTML file for all non-API routes
|
||||
- **Device Discovery**: Automatic discovery of SoundTouch devices using unified discovery service
|
||||
- **JSON API Server**: Configures API routes and serves the SPA
|
||||
- **Context Management**: Proper context handling for timeouts and cancellation
|
||||
|
||||
#### 2. HTTP Handlers (`handlers/handlers.go`)
|
||||
- **WebApp Structure**: Central application state management
|
||||
- **JSON API Endpoints**: RESTful API returning only JSON responses
|
||||
- **Device Control**: Device control with proper validation and error handling
|
||||
- **Modular Design**: Separated control actions into focused functions
|
||||
|
||||
#### 3. WebSocket Support (`handlers/websocket.go`)
|
||||
- **Real-time Updates**: Live device status streaming to web clients
|
||||
- **Device WebSocket Connections**: Maintains persistent connections to SoundTouch devices
|
||||
- **Event Handling**: Processes nowPlaying, volume, and connection state updates
|
||||
- **Status Synchronization**: Keeps device status current across all connected clients
|
||||
|
||||
#### 4. Type Definitions (`webtypes/types.go`)
|
||||
- **Device Management**: Structures for device connections and status
|
||||
- **API Responses**: Standardized JSON response format
|
||||
- **WebSocket Messages**: Real-time message types
|
||||
- **Template Data**: HTML template data structures
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
#### Device Discovery & Management
|
||||
- **Auto-discovery**: Finds SoundTouch devices on local network using mDNS/UPnP
|
||||
- **Multi-device Support**: Manages multiple devices simultaneously
|
||||
- **Connection Tracking**: Monitors device availability and connection status
|
||||
- **Device Information**: Displays device details (name, type, IP address)
|
||||
|
||||
#### Real-time Control Interface
|
||||
- **Now Playing**: Live track information with artwork display
|
||||
- **Playback Controls**: Play/pause/stop/next/previous with visual feedback
|
||||
- **Volume Control**: Real-time volume slider with mute functionality
|
||||
- **Bass Adjustment**: Bass level control for supported devices
|
||||
- **Preset Management**: Quick access to saved presets (1-6)
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
#### Web Interface
|
||||
- **Single-Page Application**: Self-contained HTML file with embedded CSS and JavaScript
|
||||
- **Responsive Design**: Bootstrap 5-based UI optimized for desktop and mobile
|
||||
- **Client-Side Routing**: JavaScript handles page navigation without page reloads
|
||||
- **Dynamic Rendering**: All HTML generated client-side from JSON data
|
||||
- **Real-time Updates**: WebSocket-powered live status updates
|
||||
- **Performance Optimized**: Fast loading and no template rendering delays
|
||||
|
||||
#### API Endpoints
|
||||
```
|
||||
GET / # SPA - serves static/index.html
|
||||
GET /api/devices # List all devices (JSON)
|
||||
GET /api/device/{id} # Get device info (JSON)
|
||||
POST /api/discover # Trigger device discovery
|
||||
GET /api/control/{id}/play # Playback control
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (JSON body)
|
||||
GET /api/control/{id}/mute # Toggle mute
|
||||
POST /api/control/{id}/bass # Set bass level (JSON body)
|
||||
GET /api/control/{id}/preset?id=N # Select preset
|
||||
GET /api/control/{id}/source?name=X # Select source
|
||||
```
|
||||
|
||||
#### WebSocket Events
|
||||
- **Connection**: `ws://localhost:8080/ws`
|
||||
- **Device Updates**: Real-time device list changes
|
||||
- **Status Updates**: Live playback and volume changes
|
||||
- **Connection Monitoring**: Device availability status
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Frontend Architecture
|
||||
- **Single HTML File**: Complete application in `static/index.html`
|
||||
- **Embedded CSS**: Bootstrap 5 with custom Bose-inspired styling
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast performance
|
||||
- **Client-Side Routing**: JavaScript manages page state without reloads
|
||||
- **Dynamic Components**: HTML elements generated from JSON API responses
|
||||
|
||||
### Error Handling & Validation
|
||||
- **Input Validation**: Proper bounds checking for volume (0-100) and bass (-9 to 9)
|
||||
- **HTTP Status Codes**: Appropriate response codes for different error conditions
|
||||
- **JSON Error Responses**: Structured error messages for API consumers
|
||||
- **Client-Side Error Display**: JavaScript toast notifications for user feedback
|
||||
|
||||
### Code Quality
|
||||
- **golangci-lint Compliance**: Passes all configured lint checks
|
||||
- **Context Handling**: Proper context propagation and timeout management
|
||||
- **Error Checking**: All JSON encoding/decoding operations checked
|
||||
- **Type Safety**: Strong typing with dedicated type package
|
||||
- **Test Coverage**: Comprehensive unit tests for handlers and types
|
||||
|
||||
### WebSocket Integration
|
||||
- **Gabbo Protocol**: Native SoundTouch WebSocket protocol implementation
|
||||
- **Event Processing**: Handles all documented SoundTouch WebSocket events
|
||||
- **Connection Management**: Automatic reconnection and health monitoring
|
||||
- **Bi-directional Communication**: Both status monitoring and device control
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Libraries
|
||||
- **chi v5**: HTTP router (inherited from existing codebase)
|
||||
- **gorilla/websocket**: WebSocket implementation
|
||||
- **Go standard library**: html/template, net/http, encoding/json
|
||||
|
||||
### Project Dependencies
|
||||
- **pkg/client**: SoundTouch HTTP and WebSocket client library
|
||||
- **pkg/discovery**: Device discovery service (mDNS/UPnP)
|
||||
- **pkg/models**: XML/JSON data structures for SoundTouch API
|
||||
- **pkg/config**: Configuration management
|
||||
|
||||
### Frontend Dependencies
|
||||
- **Bootstrap 5**: CSS framework for responsive design
|
||||
- **Bootstrap Icons**: Icon library for UI elements
|
||||
- **Vanilla JavaScript**: No external JS frameworks, pure WebSocket implementation
|
||||
|
||||
## Build & Testing
|
||||
|
||||
### Build Commands
|
||||
```bash
|
||||
# Build the web application
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
|
||||
# Build all project components (includes soundtouch-web)
|
||||
make build
|
||||
|
||||
# Cross-platform builds
|
||||
make build-all
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run unit tests
|
||||
go test ./cmd/soundtouch-web/...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./cmd/soundtouch-web/...
|
||||
|
||||
# Lint checking
|
||||
golangci-lint run cmd/soundtouch-web/...
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
# Run development server
|
||||
cd cmd/soundtouch-web
|
||||
go run main.go -port 8080
|
||||
|
||||
# Access the web interface
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Command Line Options
|
||||
```bash
|
||||
soundtouch-web [options]
|
||||
|
||||
Options:
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific device host for single-device mode (optional)
|
||||
```
|
||||
|
||||
### File Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point
|
||||
├── soundtouch-web # Built binary
|
||||
├── handlers/
|
||||
│ ├── handlers.go # HTTP request handlers
|
||||
│ ├── handlers_test.go # Handler tests
|
||||
│ └── websocket.go # WebSocket functionality
|
||||
├── webtypes/
|
||||
│ ├── types.go # Type definitions
|
||||
│ └── types_test.go # Type tests
|
||||
├── templates/
|
||||
│ ├── layout.html # Base HTML layout
|
||||
│ ├── index.html # Device list page
|
||||
│ └── device.html # Device control page
|
||||
├── static/
|
||||
│ └── style.css # Additional CSS styles
|
||||
└── README.md # User documentation
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- JSON API support
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Design Principles
|
||||
- **Local Network Only**: Designed for trusted local network environments
|
||||
- **No Authentication**: Assumes local network security
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **Input Validation**: All user inputs validated on server side
|
||||
|
||||
### Network Security
|
||||
- **Port Usage**: Uses standard HTTP port (configurable)
|
||||
- **WebSocket Security**: Same-origin WebSocket connections only
|
||||
- **No External Dependencies**: All resources served locally
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Resource Usage
|
||||
- **Memory**: Minimal footprint, scales with number of discovered devices
|
||||
- **CPU**: Low usage, event-driven architecture
|
||||
- **Network**: Efficient WebSocket connections, HTTP REST for control
|
||||
|
||||
### Scalability
|
||||
- **Device Limits**: Designed for typical home networks (5-20 devices)
|
||||
- **Concurrent Users**: Multiple browser sessions supported
|
||||
- **Update Frequency**: Real-time updates without polling
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Features
|
||||
- **Zone Management**: Multi-room audio control
|
||||
- **Preset Programming**: Advanced preset configuration
|
||||
- **Mobile PWA**: Progressive Web App for mobile installation
|
||||
- **Theme Support**: Additional UI themes
|
||||
- **Device Grouping**: Logical device organization
|
||||
|
||||
### Technical Improvements
|
||||
- **Caching**: Enhanced device status caching
|
||||
- **Compression**: WebSocket message compression
|
||||
- **Persistence**: Device settings persistence
|
||||
- **Metrics**: Usage analytics and performance monitoring
|
||||
|
||||
## Integration with Main Project
|
||||
|
||||
### Project Alignment
|
||||
- **Consistent Architecture**: Follows established project patterns
|
||||
- **Shared Libraries**: Leverages existing pkg/ modules
|
||||
- **Build Integration**: Included in main Makefile targets
|
||||
- **Documentation**: Consistent with project documentation standards
|
||||
|
||||
### Migration Path
|
||||
- **Cloud Replacement**: Serves as local alternative to Bose cloud services
|
||||
- **API Compatibility**: Maintains compatibility with existing SoundTouch APIs
|
||||
- **User Experience**: Familiar interface for existing SoundTouch app users
|
||||
- **Long-term Support**: Designed for continued operation post-2026
|
||||
|
||||
This implementation provides a robust, feature-complete web interface for SoundTouch device control, ensuring continued functionality beyond the official app's lifecycle while maintaining high code quality and user experience standards.
|
||||
@@ -0,0 +1,330 @@
|
||||
# SoundTouch Web UI
|
||||
|
||||
A modern single-page web application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering for superior performance and maintainability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → Static HTML → JavaScript → JSON API → Go Server
|
||||
↓
|
||||
Client-Side Rendering
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
- **Better Performance**: No server-side template processing overhead
|
||||
- **Improved Maintainability**: Clear separation between frontend (JavaScript) and backend (Go)
|
||||
- **Real-time Experience**: Smooth client-side updates without page reloads
|
||||
- **Mobile Ready**: The JSON API can power both this web interface and mobile applications
|
||||
|
||||
## Features
|
||||
|
||||
Based on captured WebSocket interactions and device API capabilities, this web UI provides:
|
||||
|
||||
### Device Management
|
||||
- **Auto-discovery** of SoundTouch devices on the network
|
||||
- **Real-time status monitoring** via WebSocket connections
|
||||
- **Multi-device support** with centralized control
|
||||
- **Connection status** indicators and health monitoring
|
||||
|
||||
### Playback Control
|
||||
- **Play/Pause/Stop/Next/Previous** controls
|
||||
- **Now playing information** with artwork, track details, and progress
|
||||
- **Real-time updates** of playback state changes
|
||||
- **Source selection** from available inputs (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
### Audio Controls
|
||||
- **Volume control** with real-time slider updates
|
||||
- **Mute/Unmute** functionality
|
||||
- **Bass adjustment** (on supported models)
|
||||
- **Audio level monitoring** and statistics
|
||||
|
||||
### Preset Management
|
||||
- **6 preset buttons** with visual feedback
|
||||
- **Preset content display** showing station/playlist names
|
||||
- **One-click preset selection**
|
||||
|
||||
### Advanced Features
|
||||
- **WebSocket real-time updates** for instant state synchronization
|
||||
- **Responsive design** optimized for desktop and mobile
|
||||
- **Dark mode support** (auto-detects system preference)
|
||||
- **Accessibility features** (keyboard navigation, screen reader support)
|
||||
- **Network statistics** and device health monitoring
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main Device Overview
|
||||
The main page shows all discovered devices with their current status, now-playing information, and quick controls.
|
||||
|
||||
### Detailed Device Control
|
||||
Individual device pages provide full control over:
|
||||
- Detailed now-playing information with artwork
|
||||
- Comprehensive audio controls (volume, bass)
|
||||
- Full preset and source selection
|
||||
- Real-time status updates
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or later
|
||||
- Access to SoundTouch devices on the same network
|
||||
- Modern web browser with WebSocket support
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# From project root
|
||||
make build
|
||||
|
||||
# Or manually
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
# Run with default settings (port 8080)
|
||||
./soundtouch-web
|
||||
|
||||
# Specify custom port
|
||||
./soundtouch-web -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-web -host 192.168.1.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
```
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific SoundTouch device host (optional, enables single-device mode)
|
||||
-help Show help information
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Interface
|
||||
1. Start the application
|
||||
2. Open your web browser and navigate to `http://localhost:8080`
|
||||
3. Click "Discover Devices" to find SoundTouch devices on your network
|
||||
4. Click on any device for detailed control, or use quick controls from the main page
|
||||
|
||||
### Device Discovery
|
||||
The application automatically discovers SoundTouch devices using:
|
||||
- **mDNS discovery** for local network devices
|
||||
- **UPnP/SSDP discovery** as fallback
|
||||
- **Manual device addition** via IP address
|
||||
|
||||
### Real-time Updates
|
||||
The interface maintains WebSocket connections to each device for instant updates of:
|
||||
- Now playing information and artwork
|
||||
- Volume and audio settings changes
|
||||
- Playback status (play/pause/stop)
|
||||
- Connection status and device health
|
||||
|
||||
### Responsive Design
|
||||
- **Desktop**: Full-featured interface with side-by-side panels
|
||||
- **Tablet**: Optimized layout with touch-friendly controls
|
||||
- **Mobile**: Stacked interface with gesture support
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The web UI exposes a REST API for programmatic control:
|
||||
|
||||
### Device Management
|
||||
```
|
||||
GET /api/devices # List all discovered devices
|
||||
GET /api/device/{id} # Get specific device info
|
||||
POST /api/discover # Trigger device discovery
|
||||
```
|
||||
|
||||
### Device Control
|
||||
```
|
||||
GET /api/control/{id}/play # Start playback
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (body: {"level": 50})
|
||||
GET /api/control/{id}/mute # Mute audio
|
||||
GET /api/control/{id}/unmute # Unmute audio
|
||||
POST /api/control/{id}/bass # Set bass (body: {"level": 0})
|
||||
GET /api/control/{id}/preset?id=1 # Select preset
|
||||
GET /api/control/{id}/source?name=SPOTIFY # Select source
|
||||
```
|
||||
|
||||
### WebSocket Events
|
||||
Connect to `/ws` for real-time updates:
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8080/ws');
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
// Handle device updates, status changes, etc.
|
||||
};
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Architecture
|
||||
- **JSON API Backend**: Go server providing RESTful endpoints
|
||||
- **Client-Side Rendering**: JavaScript handles all UI rendering
|
||||
- **WebSocket Real-time**: Bi-directional real-time communication
|
||||
- **No Template Dependencies**: Eliminates server-side template issues
|
||||
|
||||
### Backend Components
|
||||
- **Discovery Service**: Finds and manages SoundTouch devices
|
||||
- **WebSocket Manager**: Maintains real-time connections to devices
|
||||
- **JSON API Server**: RESTful interface returning only JSON
|
||||
- **Device Manager**: Tracks device state and health
|
||||
|
||||
### Frontend Components
|
||||
- **Bootstrap 5**: Modern responsive UI framework
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast loading
|
||||
- **WebSocket Client**: Real-time bidirectional communication
|
||||
- **Dynamic Rendering**: Client-side HTML generation from JSON
|
||||
|
||||
### Communication Flow
|
||||
1. **SPA Loading**: Single HTML file with embedded CSS and JavaScript
|
||||
2. **JSON API**: Device discovery and control via REST endpoints
|
||||
3. **WebSocket (Device)**: Real-time status updates from SoundTouch devices
|
||||
4. **WebSocket (Browser)**: Real-time UI updates to web clients
|
||||
5. **Client Rendering**: JavaScript dynamically creates all UI elements
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point and SPA routing
|
||||
├── handlers/ # HTTP and WebSocket handlers
|
||||
│ ├── handlers.go # JSON API endpoints
|
||||
│ └── websocket.go # WebSocket management
|
||||
├── webtypes/ # Type definitions
|
||||
│ └── types.go # Request/response types
|
||||
├── static/ # Static assets
|
||||
│ ├── index.html # Single-page application
|
||||
│ └── js/ # Legacy JS files (reference)
|
||||
├── templates/ # Legacy templates (unused in SPA)
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
1. **API Endpoints**: Add new JSON routes in `setupRoutes()` and `handlers.go`
|
||||
2. **WebSocket Events**: Extend event handlers in WebSocket client
|
||||
3. **UI Components**: Add JavaScript rendering functions in `static/index.html`
|
||||
4. **Device Controls**: Implement new control commands and update client-side handlers
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Unit tests
|
||||
go test ./...
|
||||
|
||||
# Manual testing with multiple devices
|
||||
./soundtouch-web -port 8080
|
||||
|
||||
# API testing
|
||||
curl http://localhost:8080/api/devices
|
||||
```
|
||||
|
||||
## WebSocket Protocol Analysis
|
||||
|
||||
This UI is based on extensive analysis of captured SoundTouch WebSocket interactions, including:
|
||||
|
||||
### Message Types Implemented
|
||||
- **SoundTouchSdkInfo**: Initial handshake and version info
|
||||
- **nowPlayingUpdated**: Real-time track information
|
||||
- **volumeUpdated**: Audio level changes
|
||||
- **recentsUpdated**: Recently played items
|
||||
- **userActivityUpdate**: User interaction notifications
|
||||
|
||||
### Request/Response Patterns
|
||||
- **Device Information**: System details and capabilities
|
||||
- **Audio Controls**: Volume, bass, mute controls
|
||||
- **Playback Control**: Play/pause/stop/skip commands
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, etc.)
|
||||
- **Preset Management**: Saved station/playlist access
|
||||
|
||||
### Gabbo Protocol Features
|
||||
- **Persistent Connections**: Maintains long-lived WebSocket connections
|
||||
- **Request Correlation**: Uses request IDs for response matching
|
||||
- **Real-time Events**: Instant updates for all device state changes
|
||||
- **Bi-directional Control**: Both status monitoring and device control
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- Responsive CSS media queries
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Local Network Only**: Designed for local network device control
|
||||
- **No Authentication**: Assumes trusted local network environment
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **WebSocket Security**: Uses same-origin WebSocket connections
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Devices Not Found**
|
||||
- Ensure devices are on the same network
|
||||
- Check firewall settings (ports 8090, 8080)
|
||||
- Click "Discover Devices" button to trigger discovery
|
||||
|
||||
**WebSocket Connection Failed**
|
||||
- Verify device supports WebSocket connections
|
||||
- Check browser console for connection errors
|
||||
- Refresh the page to reconnect WebSocket
|
||||
|
||||
**Control Commands Not Working**
|
||||
- Check device is powered on and connected
|
||||
- Verify device is not in exclusive mode (e.g., Spotify Connect active)
|
||||
- Look for error notifications in the UI
|
||||
|
||||
**Page Shows Template Errors**
|
||||
- This has been fixed in the SPA implementation
|
||||
- Ensure you're accessing the correct URL (localhost:8080)
|
||||
- Clear browser cache if you see old template-based content
|
||||
|
||||
### Debug Mode
|
||||
Add verbose logging by setting environment variable:
|
||||
```bash
|
||||
export DEBUG=true
|
||||
./soundtouch-web
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This web UI is part of the larger SoundTouch Go library project. See the main project README for contribution guidelines.
|
||||
|
||||
### Architecture Benefits
|
||||
The new SPA approach provides:
|
||||
- **Better Performance**: No server-side template rendering
|
||||
- **Easier Development**: Clear separation of frontend/backend
|
||||
- **Mobile Ready**: Same JSON API can power mobile apps
|
||||
- **Scalable**: Single-page app architecture
|
||||
|
||||
### Feature Requests
|
||||
Based on WebSocket interaction analysis, potential future features:
|
||||
- Zone/multi-room management
|
||||
- Clock display control
|
||||
- Software update management
|
||||
- Advanced preset programming
|
||||
- Progressive Web App (PWA) features
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project - see main repository LICENSE file.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built on the comprehensive SoundTouch Go library
|
||||
- UI design inspired by modern audio control interfaces
|
||||
- WebSocket protocol reverse-engineered from captured device interactions
|
||||
- Bootstrap and Bootstrap Icons for responsive design components
|
||||
@@ -0,0 +1,648 @@
|
||||
// Package handlers contains HTTP handlers for the SoundTouch web UI.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevice returns a specific device as JSON
|
||||
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update device status to get fresh power state
|
||||
app.UpdateDeviceStatus(deviceID, device)
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIControl handles device control commands
|
||||
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
action := chi.URLParam(r, "action")
|
||||
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
app.handleControlAction(w, r, action, device)
|
||||
}
|
||||
|
||||
// handleControlAction processes different control actions
|
||||
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
|
||||
switch action {
|
||||
case "play":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Play()
|
||||
app.sendControlResponse(w, err, "Started playback")
|
||||
case "pause":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Pause()
|
||||
app.sendControlResponse(w, err, "Paused playback")
|
||||
case "stop":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Stop()
|
||||
app.sendControlResponse(w, err, "Stopped playback")
|
||||
case "next":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.NextTrack()
|
||||
app.sendControlResponse(w, err, "Next track")
|
||||
case "previous":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.PrevTrack()
|
||||
app.sendControlResponse(w, err, "Previous track")
|
||||
case "volume":
|
||||
app.handleVolumeControl(w, r, device)
|
||||
case "mute":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SendKey(models.KeyMute)
|
||||
app.sendControlResponse(w, err, "Toggled mute")
|
||||
case "preset":
|
||||
app.handlePresetControl(w, r, device)
|
||||
case "bass":
|
||||
app.handleBassControl(w, r, device)
|
||||
case "source":
|
||||
app.handleSourceControl(w, r, device)
|
||||
default:
|
||||
app.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVolumeControl processes volume control requests
|
||||
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var volumeReq webtypes.VolumeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
|
||||
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if volumeReq.Level < 0 || volumeReq.Level > 100 {
|
||||
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetVolume(volumeReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
||||
}
|
||||
|
||||
// handlePresetControl processes preset control requests
|
||||
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
presetParam := r.URL.Query().Get("id")
|
||||
if presetParam == "" {
|
||||
app.sendError(w, "Preset ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
presetID, err := strconv.Atoi(presetParam)
|
||||
if err != nil {
|
||||
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = device.Client.SelectPreset(presetID)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
|
||||
}
|
||||
|
||||
// handleBassControl processes bass control requests
|
||||
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var bassReq webtypes.BassRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
|
||||
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bassReq.Level < -9 || bassReq.Level > 9 {
|
||||
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetBass(bassReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
|
||||
}
|
||||
|
||||
// handleSourceControl processes source control requests
|
||||
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
sourceParam := r.URL.Query().Get("name")
|
||||
if sourceParam == "" {
|
||||
app.sendError(w, "Source name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SelectSource(sourceParam, "")
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
|
||||
}
|
||||
|
||||
// sendControlResponse sends a control command response
|
||||
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": successMessage},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an error response
|
||||
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey handles sending key commands to devices
|
||||
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
key := chi.URLParam(r, "key")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := device.Client.SendKey(key)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
|
||||
}
|
||||
|
||||
// HandleDirectVolumeControl handles direct volume setting via URL parameter
|
||||
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
|
||||
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
|
||||
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = device.Client.SetVolume(volumeLevel)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
|
||||
}
|
||||
|
||||
// HandleDevicePower handles power toggle commands for devices
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Send POWER key command to toggle device power
|
||||
err := device.Client.SendKey("POWER")
|
||||
app.sendControlResponse(w, err, "Power toggle command sent")
|
||||
}
|
||||
|
||||
// HandleDevicePowerStatus handles lightweight power status check
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Quick power status check by getting now playing
|
||||
nowPlaying, err := device.Client.GetNowPlaying()
|
||||
if err != nil {
|
||||
app.sendControlResponse(w, err, "Failed to get power status")
|
||||
return
|
||||
}
|
||||
|
||||
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"isPoweredOn": isPoweredOn,
|
||||
"source": nowPlaying.Source,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{
|
||||
"status": status,
|
||||
"deviceCount": deviceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
|
||||
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearch(query)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
// - /{encodedURI} → browse the given TuneIn URI
|
||||
// - /sub/{n}/{encodedURI} → single subsection
|
||||
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
|
||||
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
var (
|
||||
resp interface{}
|
||||
err error
|
||||
)
|
||||
|
||||
if wildcard == "" {
|
||||
resp, err = bmxpkg.TuneInNavigate("", nil)
|
||||
} else {
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
pfx := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch pfx {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
||||
} else {
|
||||
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
||||
if parseErr != nil {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
}
|
||||
}
|
||||
case "profiles":
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
||||
}
|
||||
default:
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
|
||||
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ContainerArt string `json:"containerArt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
app.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Location == "" {
|
||||
app.sendError(w, "location is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
itemType := req.Type
|
||||
if itemType == "" {
|
||||
itemType = "stationurl"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: itemType,
|
||||
Location: req.Location,
|
||||
ItemName: req.Name,
|
||||
IsPresetable: true,
|
||||
ContainerArt: req.ContainerArt,
|
||||
}
|
||||
|
||||
if err := device.Client.SelectContentItem(contentItem); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func createTestApp() *WebApp {
|
||||
app := NewWebApp()
|
||||
|
||||
// Add test device with minimal data
|
||||
deviceInfo := &models.DeviceInfo{
|
||||
Name: "Test Speaker",
|
||||
Type: "SoundTouch 30",
|
||||
NetworkInfo: []models.NetworkInfo{
|
||||
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
device := &webtypes.DeviceConnection{
|
||||
Client: nil, // No real client for unit tests
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
|
||||
Bass: &models.Bass{ActualBass: 0},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
app.Devices["test-device"] = device
|
||||
return app
|
||||
}
|
||||
|
||||
func withChiParams(r *http.Request, params map[string]string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestNewWebApp(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
// Use require-style checks that satisfy static analyzer
|
||||
if app == nil {
|
||||
t.Fatal("NewWebApp returned nil")
|
||||
}
|
||||
if app.Devices == nil {
|
||||
t.Fatal("Devices map not initialized")
|
||||
}
|
||||
|
||||
// At this point we know app and app.Devices are not nil
|
||||
if len(app.Devices) != 0 {
|
||||
t.Errorf("Expected empty devices map, got %d devices", len(app.Devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDevices(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got false")
|
||||
}
|
||||
|
||||
// Check that devices data is present
|
||||
data, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected data to be map[string]interface{}")
|
||||
}
|
||||
|
||||
if _, exists := data["test-device"]; !exists {
|
||||
t.Errorf("Expected 'test-device' in response data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDevice(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
chiID string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid device",
|
||||
path: "/api/device/test-device",
|
||||
chiID: "test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/device/",
|
||||
chiID: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "unknown device",
|
||||
path: "/api/device/unknown",
|
||||
chiID: "unknown",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
if tt.chiID != "" {
|
||||
req = withChiParams(req, map[string]string{"id": tt.chiID})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevice(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_InvalidDevice(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "unknown-device", "action": "play"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Device not found" {
|
||||
t.Errorf("Expected 'Device not found' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_InvalidPath(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"missing action", "/api/control/test-device"},
|
||||
{"missing device and action", "/api/control/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_VolumeValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "invalid method",
|
||||
method: "GET",
|
||||
body: "",
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
method: "POST",
|
||||
body: `invalid json`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "volume too low",
|
||||
method: "POST",
|
||||
body: `{"level": -1}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "volume too high",
|
||||
method: "POST",
|
||||
body: `{"level": 101}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.body != "" {
|
||||
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", strings.NewReader(tt.body))
|
||||
} else {
|
||||
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil)
|
||||
}
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "volume"})
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_BassValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "bass too low",
|
||||
method: "POST",
|
||||
body: `{"level": -10}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "bass too high",
|
||||
method: "POST",
|
||||
body: `{"level": 10}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body))
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "bass"})
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_PresetValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "missing preset ID",
|
||||
query: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid preset ID",
|
||||
query: "?id=abc",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "preset"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_SourceValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/source", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "source"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Source name required" {
|
||||
t.Errorf("Expected 'Source name required' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDiscover(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid POST request",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "invalid GET request",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, "/api/discover", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDiscover(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.sendError(w, "Test error", http.StatusBadRequest)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Test error" {
|
||||
t.Errorf("Expected 'Test error', got '%s'", response.Error)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebSocket_InvalidUpgrade(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
// Test without proper WebSocket headers (should fail gracefully)
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// This will fail because it's not a real WebSocket upgrade, but should not panic
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// We're just checking that the handler doesn't panic
|
||||
// The actual upgrade will fail in test environment without proper headers
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "unsupported"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Unknown action" {
|
||||
t.Errorf("Expected 'Unknown action' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkHandleAPIDevices(b *testing.B) {
|
||||
app := createTestApp()
|
||||
|
||||
// Add more devices for realistic benchmarking
|
||||
for i := 0; i < 10; i++ {
|
||||
deviceID := "device-" + string(rune('0'+i))
|
||||
app.Devices[deviceID] = &webtypes.DeviceConnection{
|
||||
Client: &client.Client{},
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device " + deviceID},
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIDevices(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHandleAPIDevice(b *testing.B) {
|
||||
app := createTestApp()
|
||||
req := httptest.NewRequest("GET", "/api/device/test-device", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device"})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSendError(b *testing.B) {
|
||||
app := createTestApp()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.sendError(w, "Test error", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unregister client
|
||||
app.WSMutex.Lock()
|
||||
delete(app.WSClients, conn)
|
||||
app.WSMutex.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Register client
|
||||
app.WSMutex.Lock()
|
||||
app.WSClients[conn] = true
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep connection alive and send updates
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
log.Printf("WebSocket read error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop for sending periodic updates
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery will be triggered by the main app
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
|
||||
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
|
||||
// Skip WebSocket connection if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
|
||||
conn.Status.Volume = &event.Volume
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
|
||||
conn.Status.IsConnected = event.ConnectionState.IsConnected()
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
|
||||
conn.Status.Presets = &event.Presets
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn.WebSocket = wsClient
|
||||
conn.Status.IsConnected = true
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
|
||||
// Skip status update if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdated := false
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Update last activity if any status was updated
|
||||
if statusUpdated {
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
// Get sources
|
||||
if sources, err := conn.Client.GetSources(); err == nil {
|
||||
conn.Status.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Device WebSocket upgrade failed for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial device status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
log.Printf("Device WebSocket read error for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Send periodic device status updates
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && device.Status.IsConnected {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"nowPlaying": device.Status.NowPlaying,
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
Usage: "Web UI for controlling Bose SoundTouch devices",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to listen on",
|
||||
Value: "8080",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
port := c.String("port")
|
||||
bindAddr := c.String("bind")
|
||||
|
||||
addr := ":" + port
|
||||
if bindAddr != "" {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := handlers.NewWebApp()
|
||||
|
||||
// Initialize discovery service
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
||||
|
||||
discoverDevices(ctx, webApp, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
r := setupRoutes(webApp, discoveryService)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
return http.ListenAndServe(addr, r)
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Static assets (embedded in binary)
|
||||
subFS, _ := fs.Sub(staticFS, "static")
|
||||
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
r.Get("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
r.Get("/api/devices", app.HandleAPIDevices)
|
||||
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
||||
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
||||
app.HandleAPIDiscover(w, r)
|
||||
// Trigger discovery
|
||||
//nolint:contextcheck // Context is created within goroutine
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
|
||||
// Device control endpoints (GET for most actions, POST for volume/bass)
|
||||
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
||||
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
||||
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints
|
||||
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
||||
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
||||
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
||||
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
||||
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for client-side routing
|
||||
r.Get("/", serveIndex)
|
||||
r.Get("/devices", serveIndex)
|
||||
r.Get("/device/*", serveIndex)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
log.Println("Starting device discovery...")
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
deviceID := device.Host // Use host as unique ID for now
|
||||
|
||||
// Skip if we already have this device
|
||||
if _, exists := app.Devices[deviceID]; exists {
|
||||
app.Devices[deviceID].LastSeen = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new device connection
|
||||
clientConfig := &client.Config{
|
||||
Host: device.Host,
|
||||
Port: device.Port,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
soundTouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Get device info
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create device connection
|
||||
conn := &webtypes.DeviceConnection{
|
||||
Client: soundTouchClient,
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Initial status fetch asynchronously to avoid blocking discovery
|
||||
go app.UpdateDeviceStatus(deviceID, conn)
|
||||
|
||||
app.Devices[deviceID] = conn
|
||||
|
||||
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func withChiParams(r *http.Request, params map[string]string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestSPARouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedHTML bool
|
||||
}{
|
||||
{
|
||||
name: "root path serves HTML",
|
||||
path: "/",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "device path serves HTML",
|
||||
path: "/device/test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "arbitrary path serves HTML",
|
||||
path: "/some/random/path",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
</body>
|
||||
</html>`))
|
||||
}
|
||||
|
||||
spaHandler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedHTML {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
t.Errorf("Expected HTML content type, got %s", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "<!doctype html>") {
|
||||
t.Errorf("Expected HTML content, got: %s", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectedJSON bool
|
||||
}{
|
||||
{
|
||||
name: "devices API returns JSON",
|
||||
path: "/api/devices",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "discover API accepts POST",
|
||||
path: "/api/discover",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch tt.path {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
case "/api/discover":
|
||||
app.HandleAPIDiscover(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(tt.path, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(tt.path, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedJSON {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
|
||||
if response.Data == nil {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
}
|
||||
|
||||
if len(dataMap) != 0 {
|
||||
t.Errorf("Expected empty device map, got %d devices", len(dataMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
chiParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/control//play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid control path",
|
||||
path: "/api/control/device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
path: "/api/control/nonexistent/invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "invalid"},
|
||||
},
|
||||
{
|
||||
name: "nonexistent device",
|
||||
path: "/api/control/nonexistent/play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "play"},
|
||||
},
|
||||
{
|
||||
name: "unknown action with valid device",
|
||||
path: "/api/control/testdevice/unknownaction",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
app.Devices["testdevice"] = mockDevice
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.body != "" {
|
||||
req = httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(tt.method, tt.path, nil)
|
||||
}
|
||||
if tt.chiParams != nil {
|
||||
req = withChiParams(req, tt.chiParams)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false for error case, got success=true")
|
||||
}
|
||||
|
||||
if response.Error == "" {
|
||||
t.Errorf("Expected error message, got empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
"/api/device/test",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run("JSON consistency for "+endpoint, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", endpoint, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch endpoint {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(endpoint, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(endpoint, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
if !response.Success && response.Error == "" {
|
||||
t.Errorf("Endpoint %s: error response should have error message", endpoint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,200 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="/static/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('devices')">
|
||||
<i class="bi bi-speaker"></i>
|
||||
SoundTouch Control
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="showPage('devices')"
|
||||
title="Home"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link tunein-nav-link"
|
||||
href="#"
|
||||
onclick="showPage('tunein')"
|
||||
title="TuneIn Browse"
|
||||
>
|
||||
<img
|
||||
src="/static/img/tunein-mono.svg"
|
||||
alt="TuneIn"
|
||||
class="tunein-nav-icon"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="discoverDevices()"
|
||||
title="Discover Devices"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
<button
|
||||
class="theme-toggle nav-link"
|
||||
onclick="toggleTheme()"
|
||||
title="Toggle Dark Mode"
|
||||
>
|
||||
<i id="theme-icon" class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Device List Page -->
|
||||
<div id="devices-page" class="page active">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center mb-4"
|
||||
>
|
||||
<h2>Your SoundTouch Devices</h2>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Discover Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading-spinner"></div>
|
||||
|
||||
<div id="devices-list" class="row">
|
||||
<!-- Device cards will be inserted here by JavaScript -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="no-devices"
|
||||
style="display: none"
|
||||
class="text-center py-5"
|
||||
>
|
||||
<i class="bi bi-speaker display-1 text-muted"></i>
|
||||
<h4 class="mt-3">No Devices Found</h4>
|
||||
<p class="text-muted">
|
||||
Click "Discover Devices" to search for SoundTouch
|
||||
speakers on your network.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TuneIn Browse Page -->
|
||||
<div id="tunein-page" class="page">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
|
||||
</div>
|
||||
|
||||
<div class="tunein-search-bar mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="tunein-search-input"
|
||||
class="form-control"
|
||||
placeholder="Search stations, podcasts..."
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="tuneInBrowse()"
|
||||
title="Browse top level"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
|
||||
<!-- filled by JavaScript -->
|
||||
</nav>
|
||||
|
||||
<div id="tunein-results">
|
||||
<!-- filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Page -->
|
||||
<div id="device-page" class="page">
|
||||
<div class="back-button">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="showPage('devices')"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="device-content">
|
||||
<!-- Device control content will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container text-center">
|
||||
<small>
|
||||
SoundTouch Web Control Interface -
|
||||
<a
|
||||
href="https://github.com/gesellix/Bose-SoundTouch"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
Open Source Project
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container"></div>
|
||||
|
||||
<!-- Device picker for TuneIn playback -->
|
||||
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="devicePickerLabel">
|
||||
<i class="bi bi-speaker me-2"></i>Play on device
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2" id="devicePickerList">
|
||||
<!-- device buttons filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
// Package webtypes contains type definitions for the SoundTouch web UI.
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// SoundTouchClient defines the interface for SoundTouch client operations
|
||||
type SoundTouchClient interface {
|
||||
Play() error
|
||||
Pause() error
|
||||
Stop() error
|
||||
NextTrack() error
|
||||
PrevTrack() error
|
||||
SetVolume(level int) error
|
||||
SetBass(level int) error
|
||||
SelectPreset(id int) error
|
||||
SelectSource(source, account string) error
|
||||
SendKey(key string) error
|
||||
GetDeviceInfo() (*models.DeviceInfo, error)
|
||||
GetNowPlaying() (*models.NowPlaying, error)
|
||||
GetVolume() (*models.Volume, error)
|
||||
GetPresets() (*models.Presets, error)
|
||||
GetSources() (*models.Sources, error)
|
||||
GetBass() (*models.Bass, error)
|
||||
NewWebSocketClient(config interface{}) *client.WebSocketClient
|
||||
}
|
||||
|
||||
// DeviceConnection wraps a SoundTouch client with WebSocket connection
|
||||
type DeviceConnection struct {
|
||||
Client *client.Client
|
||||
WebSocket *client.WebSocketClient
|
||||
DeviceInfo *models.DeviceInfo
|
||||
LastSeen time.Time
|
||||
Status DeviceStatus
|
||||
}
|
||||
|
||||
// DeviceStatus represents the current device state
|
||||
type DeviceStatus struct {
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
Presets *models.Presets `json:"presets,omitempty"`
|
||||
Sources *models.Sources `json:"sources,omitempty"`
|
||||
Bass *models.Bass `json:"bass,omitempty"`
|
||||
IsConnected bool `json:"isConnected"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
}
|
||||
|
||||
// APIResponse is a standard JSON response wrapper
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// VolumeRequest represents a volume control request
|
||||
type VolumeRequest struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// BassRequest represents a bass control request
|
||||
type BassRequest struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// WebSocketMessage represents messages sent over WebSocket
|
||||
type WebSocketMessage struct {
|
||||
Type string `json:"type"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package types contains tests for type definitions.
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestAPIResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response APIResponse
|
||||
wantJSON string
|
||||
}{
|
||||
{
|
||||
name: "success response",
|
||||
response: APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "OK"},
|
||||
},
|
||||
wantJSON: `{"success":true,"data":{"message":"OK"}}`,
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
response: APIResponse{
|
||||
Success: false,
|
||||
Error: "Something went wrong",
|
||||
},
|
||||
wantJSON: `{"success":false,"error":"Something went wrong"}`,
|
||||
},
|
||||
{
|
||||
name: "success with nil data",
|
||||
response: APIResponse{
|
||||
Success: true,
|
||||
},
|
||||
wantJSON: `{"success":true}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test that the struct fields are correctly set
|
||||
if tt.response.Success != (tt.name == "success response" || tt.name == "success with nil data") {
|
||||
t.Errorf("Expected success to match test case")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req VolumeRequest
|
||||
level int
|
||||
}{
|
||||
{"zero volume", VolumeRequest{Level: 0}, 0},
|
||||
{"mid volume", VolumeRequest{Level: 50}, 50},
|
||||
{"max volume", VolumeRequest{Level: 100}, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req.Level != tt.level {
|
||||
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBassRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req BassRequest
|
||||
level int
|
||||
}{
|
||||
{"min bass", BassRequest{Level: -9}, -9},
|
||||
{"neutral bass", BassRequest{Level: 0}, 0},
|
||||
{"max bass", BassRequest{Level: 9}, 9},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req.Level != tt.level {
|
||||
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg WebSocketMessage
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "devices message",
|
||||
msg: WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: map[string]interface{}{"device1": "data"},
|
||||
},
|
||||
wantType: "devices",
|
||||
},
|
||||
{
|
||||
name: "status update message",
|
||||
msg: WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: "device1",
|
||||
Data: DeviceStatus{IsConnected: true},
|
||||
},
|
||||
wantType: "status_update",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.msg.Type != tt.wantType {
|
||||
t.Errorf("Expected type %s, got %s", tt.wantType, tt.msg.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceConnection(t *testing.T) {
|
||||
deviceInfo := &models.DeviceInfo{
|
||||
Name: "Test Speaker",
|
||||
Type: "SoundTouch 30",
|
||||
NetworkInfo: []models.NetworkInfo{
|
||||
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
nowPlaying := &models.NowPlaying{
|
||||
Track: "Test Track",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
PlayStatus: models.PlayStatusPlaying,
|
||||
Source: "SPOTIFY",
|
||||
}
|
||||
|
||||
volume := &models.Volume{
|
||||
ActualVolume: 50,
|
||||
MuteEnabled: false,
|
||||
}
|
||||
|
||||
conn := &DeviceConnection{
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: DeviceStatus{
|
||||
NowPlaying: nowPlaying,
|
||||
Volume: volume,
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("device connection fields", func(t *testing.T) {
|
||||
if conn.DeviceInfo.Name != "Test Speaker" {
|
||||
t.Errorf("Expected device name 'Test Speaker', got '%s'", conn.DeviceInfo.Name)
|
||||
}
|
||||
|
||||
if conn.Status.NowPlaying.Track != "Test Track" {
|
||||
t.Errorf("Expected track 'Test Track', got '%s'", conn.Status.NowPlaying.Track)
|
||||
}
|
||||
|
||||
if conn.Status.Volume.ActualVolume != 50 {
|
||||
t.Errorf("Expected volume 50, got %d", conn.Status.Volume.ActualVolume)
|
||||
}
|
||||
|
||||
if !conn.Status.IsConnected {
|
||||
t.Error("Expected device to be connected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceStatus(t *testing.T) {
|
||||
status := DeviceStatus{
|
||||
NowPlaying: &models.NowPlaying{
|
||||
Track: "Test Track",
|
||||
PlayStatus: models.PlayStatusPlaying,
|
||||
},
|
||||
Volume: &models.Volume{
|
||||
ActualVolume: 75,
|
||||
MuteEnabled: false,
|
||||
},
|
||||
Bass: &models.Bass{
|
||||
ActualBass: 3,
|
||||
},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
t.Run("device status fields", func(t *testing.T) {
|
||||
if status.NowPlaying == nil {
|
||||
t.Error("Expected now playing to be set")
|
||||
}
|
||||
|
||||
if status.Volume == nil {
|
||||
t.Error("Expected volume to be set")
|
||||
}
|
||||
|
||||
if status.Bass == nil {
|
||||
t.Error("Expected bass to be set")
|
||||
}
|
||||
|
||||
if !status.IsConnected {
|
||||
t.Error("Expected device to be connected")
|
||||
}
|
||||
|
||||
if status.LastActivity.IsZero() {
|
||||
t.Error("Expected last activity to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil fields", func(t *testing.T) {
|
||||
emptyStatus := DeviceStatus{}
|
||||
|
||||
if emptyStatus.NowPlaying != nil {
|
||||
t.Error("Expected now playing to be nil")
|
||||
}
|
||||
|
||||
if emptyStatus.Volume != nil {
|
||||
t.Error("Expected volume to be nil")
|
||||
}
|
||||
|
||||
if emptyStatus.IsConnected {
|
||||
t.Error("Expected device to be disconnected by default")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkAPIResponse(b *testing.B) {
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "OK"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = response.Success
|
||||
_ = response.Data
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDeviceStatus(b *testing.B) {
|
||||
status := DeviceStatus{
|
||||
NowPlaying: &models.NowPlaying{Track: "Test Track"},
|
||||
Volume: &models.Volume{ActualVolume: 50},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = status.IsConnected
|
||||
_ = status.NowPlaying.Track
|
||||
_ = status.Volume.ActualVolume
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWebSocketMessage(b *testing.B) {
|
||||
msg := WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: "device1",
|
||||
Data: DeviceStatus{
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = msg.Type
|
||||
_ = msg.DeviceID
|
||||
_ = msg.Data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
build:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
volumes:
|
||||
- ./tests/integration/testdata:/app/data
|
||||
environment:
|
||||
- SPOTIFY_CLIENT_ID=mock-id
|
||||
- SPOTIFY_CLIENT_SECRET=mock-secret
|
||||
- SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
|
||||
- SPOTIFY_API_BASE=http://spotify-mock:8080
|
||||
- AMAZON_CLIENT_ID=mock-amazon-id
|
||||
- AMAZON_CLIENT_SECRET=mock-amazon-secret
|
||||
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
environment:
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
@@ -35,6 +37,34 @@ services:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-spotify/main.go -port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-amazon/main.go -port 8080
|
||||
ports:
|
||||
- "8082:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
|
||||
volumes:
|
||||
soundtouch-data:
|
||||
# Named volumes are preferred in Swarm. For multi-node persistence,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,114 @@
|
||||
# Bose SoundTouch Device Setup Flow
|
||||
|
||||
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
|
||||
|
||||
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
|
||||
|
||||
## 1. Local Coordination Stage (WebSocket)
|
||||
|
||||
Before a device can be controlled, it must be configured on the local network and named. These actions occur via a WebSocket connection to the device on port 8080.
|
||||
|
||||
### 1.1 Language Configuration (Optional)
|
||||
If the device is in a factory-reset state, the UI typically ensures the device language matches the user's choice.
|
||||
- **WebSocket Action**: `set_language`
|
||||
- **Internal Logic**: `SetupWizard.js` handles this via `set_device_language`.
|
||||
|
||||
### 1.2 Network Configuration (WiFi)
|
||||
Configures the device to connect to a specific wireless access point.
|
||||
- **File Reference**: `setup/js/workflow_wifi_setup.js`
|
||||
- **Logic**: Triggers a site survey, then sends SSID and credentials.
|
||||
- **WebSocket Command**: `set_WIFI_OLED` or similar internal method calls to configure the network profile.
|
||||
|
||||
### 1.3 Device Naming (Rename Step)
|
||||
Assigns a user-friendly name (e.g., "Living Room") to the device.
|
||||
- **File Reference**: `setup/js/workflow_rename.js`
|
||||
- **WebSocket Action**: `name`
|
||||
- **XML Payload**:
|
||||
```xml
|
||||
<name>Living Room</name>
|
||||
```
|
||||
- **Implementation**: The `RenameDevices.do_rename_devices()` function sends this to the device. The device then updates its local name and mDNS/SSDP broadcasts.
|
||||
|
||||
## 2. Cloud Interaction Stage (HTTP)
|
||||
|
||||
The device needs to be linked to a Bose "Marge" account to enable cloud-based features and music services.
|
||||
|
||||
### 2.1 Account Creation (Registration)
|
||||
If a user doesn't have an account, the setup client creates one.
|
||||
- **File Reference**: `setup/js/workflow_marge.js`
|
||||
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account`
|
||||
- **Payload**: XML containing name, email, password, and country.
|
||||
- **Content-Type**: `application/vnd.bose.customer-v1.0+xml`
|
||||
|
||||
### 2.2 Cloud Authentication (Login)
|
||||
The setup client must obtain a valid `accountId` and `userAuthToken` to pair the device.
|
||||
- **File Reference**: `setup/js/workflow_marge.js`
|
||||
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account/login`
|
||||
- **Payload**: XML containing username and password.
|
||||
- **Content-Type**: `application/vnd.bose.streaming-v1.2+xml`
|
||||
- **Result**: Returns a session token in the `Credentials` response header and the user's `account ID` in the XML body.
|
||||
|
||||
## 3. Registration Bridge (WebSocket to Cloud)
|
||||
|
||||
This is the final "pairing" step where the client tells the device which account it belongs to.
|
||||
|
||||
### 3.1 Device Registration (The "Pair" Step)
|
||||
The client sends the user's credentials to the device, which then registers itself with the cloud.
|
||||
- **File Reference**: `setup/js/workflow_add_devices.js`
|
||||
- **WebSocket Action**: `setMargeAccount`
|
||||
- **XML Payload**:
|
||||
```xml
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>12345</accountId>
|
||||
<userAuthToken>jGwE... (truncated)</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
```
|
||||
- **Device Reaction**: Upon receiving this, the device makes its own outbound HTTP POST to the Marge service:
|
||||
`POST https://streaming.bose.com/{accountId}/devices`
|
||||
|
||||
## 4. Finalization
|
||||
|
||||
Once the registration is complete, the setup application (Stockholm) performs final cleanup. It's important to distinguish between **App State** (the Stockholm UI's persistent settings) and **Device State** (the physical speaker's configuration).
|
||||
|
||||
### 4.1 Exiting Setup Mode (App Settings)
|
||||
The Stockholm app communicates with its "native container" (the WebView bridge on iOS/Android/Windows/macOS) using a `setData` command in **JSON format**. This is an internal message to the application's persistent storage, **not a network command sent to the physical speaker**.
|
||||
|
||||
This command tells the Stockholm app which page to load on startup, effectively marking the setup as complete in the UI.
|
||||
|
||||
- **Internal Command**: `setData`
|
||||
- **Parameter**: `startupPage`
|
||||
- **Normal Value**: `index.html` (Normal mode)
|
||||
- **Setup Value**: `setup/index.html` (Setup mode)
|
||||
|
||||
**JSON Payload (Internal to Stockholm App)**:
|
||||
```json
|
||||
{
|
||||
"method": "setData",
|
||||
"params": {
|
||||
"name": "startupPage",
|
||||
"value": "index.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Other Common Internal Parameters**:
|
||||
- `changeStartupPage`: Set to `false` after a successful setup or update.
|
||||
- `tipsEnabled`: Set to `false` to suppress the "Getting Started" tutorials.
|
||||
- `promptUpdate`: Set to `true` if a firmware update was deferred during setup.
|
||||
|
||||
### 4.2 Device Finalization
|
||||
The physical speaker considers the setup "done" once it successfully processes the `<PairDeviceWithAccount>` XML message and completes its own handshake with the Marge cloud. There is no specific "Finalize" XML command sent to the speaker; the successful registration is the signal.
|
||||
|
||||
The `SetupWizard.js` calls `single_device_setup_done()` to trigger the internal `setData` updates described above. If these are not saved in the app's local storage, the Stockholm UI may return to the setup flow on next launch, even if the speaker is already paired.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Scriptable Requirements
|
||||
|
||||
To automate a device setup using a custom tool (like `soundtouch-cli`), you must perform the following:
|
||||
1. **Configure WiFi**: (Assumed if device is reachable over IP).
|
||||
2. **Set Name**: Send the `<name>` WebSocket message (XML) to update the device identity.
|
||||
3. **Obtain Token**: Authenticate against the cloud service (Marge) via HTTP.
|
||||
4. **Pair Device**: Send the `<PairDeviceWithAccount>` WebSocket message (XML) with the account ID and token.
|
||||
|
||||
**Note**: The JSON `setData` commands are only necessary if you are building/controlling a version of the Stockholm UI itself. They are not required to configure the physical hardware.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Technical Proposal: External Service Provider Abstraction
|
||||
|
||||
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
|
||||
|
||||
## 1. Problem Statement
|
||||
Currently, content handling for BMX (Bose Media Exchange) services like TuneIn or RadioBrowser is deeply intertwined with the HTTP handlers and XML models. Adding a new content provider (e.g., Local Media, Podcast RSS) requires modifying several files and duplicating boilerplate code for HTTP requests and error handling.
|
||||
|
||||
## 2. Proposed Architecture
|
||||
|
||||
### 2.1 The Provider Interface
|
||||
We define a generic `ContentProvider` interface that abstracts away the source-specific logic (API calls, data parsing).
|
||||
|
||||
```go
|
||||
package provider
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
|
||||
type ContentProvider interface {
|
||||
// ID returns the unique identifier for this provider (e.g. "RADIO_BROWSER")
|
||||
ID() string
|
||||
|
||||
// Resolve returns playback details for a given content identifier
|
||||
Resolve(id string) (*models.BmxPlaybackResponse, error)
|
||||
|
||||
// Search allows finding content within this provider
|
||||
Search(query string) ([]models.ContentItem, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Provider Registry
|
||||
A central registry in `soundtouch-service` manages the lifecycle and selection of providers.
|
||||
|
||||
```go
|
||||
type Registry struct {
|
||||
providers map[string]ContentProvider
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p ContentProvider) { ... }
|
||||
func (r *Registry) Get(id string) ContentProvider { ... }
|
||||
```
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### 3.1 Phase 1: Modularize RadioBrowser
|
||||
1. **Extract Logic**: Move current RadioBrowser logic from `bmx.go` into a new package `pkg/service/providers/radiobrowser`.
|
||||
2. **Add Failover**: Implement the **API Failover** logic inspired by OpenCloudTouch.
|
||||
- Maintain a list of active RadioBrowser mirrors (e.g., `de1.api.radio-browser.info`, `nl1.api.radio-browser.info`).
|
||||
- Implement a round-robin or health-based selection strategy.
|
||||
3. **Implements Interface**: Ensure the new package satisfies the `ContentProvider` interface.
|
||||
|
||||
### 3.2 Phase 2: Refactor BMX Handlers
|
||||
- Update `HandleTuneInPlayback` and `HandleOrionPlayback` to use the registry.
|
||||
- The handlers will look up the provider based on the request context or URL parameters and delegate the resolution.
|
||||
|
||||
### 3.3 Phase 3: Dynamic Service Advertising
|
||||
- Modify `HandleBMXRegistry` to dynamically generate the `bmx_services.json` content based on the currently registered and enabled providers.
|
||||
|
||||
## 4. Benefits
|
||||
- **Resilience**: Centralized error handling and failover strategies for all external APIs.
|
||||
- **Extensibility**: New services can be added by simply implementing the interface and registering them at startup.
|
||||
- **Testability**: Providers can be unit-tested in isolation without mocking the entire HTTP server stack.
|
||||
- **Unified UI**: A future Web UI can query the registry to show available content sources and their statuses.
|
||||
|
||||
## 5. Next Steps
|
||||
1. Refine the `ContentProvider` interface to include metadata (icons, user-friendly names).
|
||||
2. Create a prototype for the `radiobrowser` provider with failover support.
|
||||
+25
-23
@@ -3,10 +3,19 @@
|
||||
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
|
||||
|
||||
#### ✅ Completed Improvements (Marge Service)
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
|
||||
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
|
||||
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
|
||||
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
|
||||
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
|
||||
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
|
||||
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
|
||||
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
|
||||
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
|
||||
* **Attribute-level Parity**:
|
||||
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
|
||||
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
|
||||
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
|
||||
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
|
||||
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
|
||||
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
|
||||
@@ -37,19 +46,21 @@ This document summarizes the improvements made to the **Marge service** to impro
|
||||
|
||||
#### 🛠️ Open Issues and Next Steps
|
||||
|
||||
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
|
||||
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
|
||||
|
||||
#### 1. BMX / TuneIn Playback Parity (Medium)
|
||||
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=3432432423`, while upstream uses a different session-based ID.
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
|
||||
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
|
||||
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
|
||||
|
||||
#### 2. Presets and Recents Parity (Medium)
|
||||
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
|
||||
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
|
||||
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
|
||||
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
|
||||
#### 2. `/full` Account Response Data Gaps (Medium)
|
||||
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
|
||||
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
|
||||
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
|
||||
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
|
||||
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
|
||||
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
|
||||
|
||||
#### 3. OAuth / Spotify Token Noise (Low/Medium)
|
||||
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
|
||||
@@ -68,19 +79,10 @@ Analysis of device reboot logs revealed several data requirements:
|
||||
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
|
||||
#### 7. Account Full Response (/full) Structural & Value Parity (In Progress)
|
||||
Based on `_/diffs/diff7/`, several structural and value gaps remain in the `/full` account response:
|
||||
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
|
||||
Structural and value gaps in the `/full` account response have been addressed:
|
||||
|
||||
**Remaining Findings:**
|
||||
* **Nested Source Inconsistency in Recents**: The `<source>` element within `<recent>` entries still frequently points to a generic fallback (ID `9330201`) instead of the specific source (e.g., Spotify ID `10863533`).
|
||||
* Missing/empty `<username>` at the `<preset>` level.
|
||||
* **Values**:
|
||||
* **Empty Device `<name>`**: Locally, the device `<name>` is empty in the response even when available in the datastore or upstream.
|
||||
* **Empty `<contentItemType>`**: Local responses have empty `<contentItemType>` in presets and recents, whereas upstream has `tracklisturl` or `stationurl`.
|
||||
* `preferredLanguage` mismatch (`en` vs `de`).
|
||||
|
||||
**Next Implementation Steps (Proposals):**
|
||||
1. **Fix Device `<name>` Population**: Investigate why `CreateAccountDevice` or `AccountFullToXML` is not correctly returning the device name even if it's synchronized.
|
||||
2. **Refine Source Association in Recents**: Improve the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
|
||||
3. **Populate `contentItemType`**: Update the internal models and `SyncFromAccountFull` to correctly extract, persist, and echo back `contentItemType` (e.g., `tracklisturl`).
|
||||
4. **Handle Account Metadata**: Synchronize `preferredLanguage` from the upstream `/full` response to the local account state.
|
||||
**Key Fixes:**
|
||||
* **Structural**:
|
||||
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
|
||||
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
- **Bose-SoundTouch (Go)**: A high-performance, strongly typed backend with a CLI and background service. Focuses on full API coverage, parity testing, and robust hardware control (DSP, zones).
|
||||
- **OpenCloudTouch (OCT)**: A modern full-stack application (FastAPI + React/TypeScript). Prioritizes user experience with a web-based setup wizard and a clean abstraction for internet radio.
|
||||
|
||||
## 2. Functional Comparison
|
||||
|
||||
| Feature | Bose-SoundTouch (Go) | OpenCloudTouch (Python) |
|
||||
|:------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------|
|
||||
| **Setup Experience** | CLI-driven or manual API calls for migration (SSH, XML). | Web-based **Setup Wizard** guides through SSH, backup, and redirection. |
|
||||
| **Radio Support** | Static integration of **RadioBrowser** and TuneIn. | Dynamic **RadioBrowserAdapter** with automatic **API Failover**. |
|
||||
| **Commercial Services** | Deep integration (Spotify priming, Pandora, Deezer, etc.). | Basic support, focus is on local content and radio. |
|
||||
| **Hardware Control** | Extensive (Bass, Treble, Soundbar levels, Clock display). | Basic playback and zone controls. |
|
||||
| **Cloud Emulation** | High-fidelity parity (mirroring, discrepancy logging). | Functional emulation for local preset/recent persistence. |
|
||||
| **Notifications** | Built-in **TTS** and custom URL audio alerts. | Not a primary focus. |
|
||||
|
||||
## 3. Key Strengths of OpenCloudTouch
|
||||
- **Guided Onboarding**: The setup wizard reduces the entry barrier for non-technical users significantly.
|
||||
- **Resilient Radio**: The API failover for RadioBrowser ensures continuous service even if specific community-hosted API instances go offline.
|
||||
- **Modern API Stack**: Uses OpenAPI and generated TypeScript types for a seamless frontend integration.
|
||||
- **Provider Abstraction**: A cleaner internal separation between the "Bose World" (XML/BMX) and external content providers (RadioBrowser).
|
||||
|
||||
## 4. Suggested Improvements for Bose-SoundTouch
|
||||
|
||||
### A. Web-based Setup Wizard (High Priority)
|
||||
- Implement a state-driven wizard in the `soundtouch-service` to handle:
|
||||
- SSH activation (checking `/remote_services` via USB).
|
||||
- Automated backup of speaker configuration.
|
||||
- Verification of DNS/Hosts redirection.
|
||||
- Expose this via a simple embedded Web UI (using Go's `embed` package).
|
||||
|
||||
### B. RadioBrowser Failover (Medium Priority)
|
||||
- Adapt the failover logic from OCT:
|
||||
- Periodically refresh the list of available RadioBrowser API servers.
|
||||
- Implement a retry mechanism that switches servers on 5xx errors or timeouts.
|
||||
|
||||
### C. External Service Abstraction (Medium Priority)
|
||||
- Refactor the hardcoded BMX logic into a more modular **Provider System** (see `EXTERNAL-SERVICES-ABSTRACTION.md`).
|
||||
- This will allow easier addition of new sources (e.g., local DLNA, generic M3U playlists) without touching the core BMX handlers.
|
||||
|
||||
## 5. Summary
|
||||
While our Go project provides the most complete technical coverage of SoundTouch hardware and commercial services, OpenCloudTouch sets a higher standard for **user onboarding** and **service resilience** for community-driven content. Integrating a setup wizard and a more robust radio backend would make our project significantly more accessible and reliable.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
|
||||
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
|
||||
|
||||
## 2. Functional Comparison
|
||||
|
||||
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|
||||
|:---------------------|:----------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------|
|
||||
| **Group Management** | Full CRUD: `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` with XML datastore persistence (`Group_{id}.xml`). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
|
||||
| **ZeroConf Priming** | Full DH key exchange + encrypted blob; fallback to `tokenType=accesstoken` for older firmware. | Simple `tokenType=accesstoken` push only; token expires after ~60 minutes. |
|
||||
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
|
||||
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
|
||||
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
|
||||
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
|
||||
|
||||
## 3. Key Strengths of SoundCork
|
||||
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
|
||||
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
|
||||
- ~~**Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).~~ **Addressed**: AfterTouch's `HandleNotFound` (registered via `r.NotFound`) logs every unimplemented endpoint as `[UNHANDLED]` and forwards the request to the Bose upstream via `HandleBoseProxy`. This provides at least the same coverage as static dummy responses, while also aiding discovery of new endpoints.
|
||||
|
||||
## 4. Suggested Implementation Steps for Bose-SoundTouch
|
||||
|
||||
### ✅ A. Implement Full Group Support (Completed)
|
||||
- `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` implemented in `pkg/service/handlers/handlers_marge.go`.
|
||||
- Group CRUD persisted in XML datastore (`Group_{id}.xml`) via `pkg/service/datastore/datastore.go`.
|
||||
- `GET /group` on device registration reads the group the device belongs to.
|
||||
|
||||
### ✅ B. Proper ZeroConf Spotify Blob (Completed)
|
||||
- Full Spotify Connect ZeroConf protocol implemented in `pkg/service/spotify/zeroconf.go`.
|
||||
- Flow: `getInfo` (fetch speaker DH public key) → 768-bit DH key exchange → AES-128-CTR encrypted `LoginCredentials` protobuf blob → `addUser`.
|
||||
- Speaker can self-refresh credentials independently; no periodic re-priming needed for token expiry.
|
||||
- Automatic fallback to `tokenType=accesstoken` if `getInfo` fails (older firmware without DH support).
|
||||
- See `docs/concepts/spotify-priming-strategy.md` for full protocol details.
|
||||
- **Remaining gap**: Background watchdog to re-prime devices that lose their session (reboot / power loss). Not required for token expiry on modern firmware; only needed for the "speaker rebooted and lost state" recovery path and for older firmware on the fallback path (~45 min token expiry).
|
||||
|
||||
### C. Modularize BMX Registry (Medium Priority)
|
||||
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
|
||||
- Allow users to customize which mocked services are advertised to the speaker.
|
||||
|
||||
### D. Enhanced Source Management (Medium Priority)
|
||||
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
|
||||
|
||||
### E. Basic Admin Web UI (Low Priority)
|
||||
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
|
||||
|
||||
## 5. Summary
|
||||
|
||||
Group support and ZeroConf Spotify priming are now feature-complete in AfterTouch. The Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`). SoundCork's remaining functional advantages are:
|
||||
|
||||
- **BMX service extensibility**: the `bmx_services.json` registry makes it trivial to add or mock new streaming providers without code changes (step C above).
|
||||
- **Group pairing logic**: master/slave relationship management for SoundTouch 10 stereo pairs goes beyond the CRUD AfterTouch implements.
|
||||
|
||||
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](analysis/bose-soundtouch-community-tools.md).
|
||||
+44
-44
@@ -21,7 +21,7 @@ This document describes the most important patterns for the Bose SoundTouch API
|
||||
|
||||
**Key Aspects:**
|
||||
- **Native Builds**: Full API functionality for CLI and server
|
||||
- **WASM Builds**: Browser-compatible subset functionality
|
||||
- **WASM Builds**: Browser-compatible subset functionality
|
||||
- **Cross-Platform**: Linux, macOS, Windows support
|
||||
- **Embedded Assets**: Web UI directly embedded in binary
|
||||
|
||||
@@ -66,7 +66,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
var nowPlaying models.NowPlaying
|
||||
err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
|
||||
return &nowPlaying, err
|
||||
@@ -77,7 +77,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
```go
|
||||
func (c *Client) SendKey(key models.Key) error {
|
||||
keyXML := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, key)
|
||||
|
||||
|
||||
resp, err := c.httpClient.Post(
|
||||
c.baseURL+"/key",
|
||||
"application/xml",
|
||||
@@ -117,14 +117,14 @@ func (d *DiscoveryService) DiscoverDevices() ([]Device, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
|
||||
// Send M-SEARCH request
|
||||
searchRequest := "M-SEARCH * HTTP/1.1\r\n" +
|
||||
"HOST: 239.255.255.250:1900\r\n" +
|
||||
"MAN: \"ssdp:discover\"\r\n" +
|
||||
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" +
|
||||
"MX: 3\r\n\r\n"
|
||||
|
||||
|
||||
// Implementation details...
|
||||
return devices, nil
|
||||
}
|
||||
@@ -158,13 +158,13 @@ func (e *EventClient) Subscribe(eventType string, handler EventHandler) {
|
||||
|
||||
func (e *EventClient) Start() error {
|
||||
u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"}
|
||||
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.conn = conn
|
||||
|
||||
|
||||
go e.eventLoop()
|
||||
return nil
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (e *EventClient) eventLoop() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if handler, exists := e.handlers[event.Type]; exists {
|
||||
go handler(event)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
go func() {
|
||||
devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices()
|
||||
|
||||
|
||||
result := make(map[string]interface{})
|
||||
if err != nil {
|
||||
result["error"] = err.Error()
|
||||
@@ -228,13 +228,13 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
|
||||
devicesJSON, _ := json.Marshal(devices)
|
||||
result["devices"] = string(devicesJSON)
|
||||
}
|
||||
|
||||
|
||||
// Call JavaScript callback
|
||||
args[0].Invoke(js.ValueOf(result))
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
return handler
|
||||
}
|
||||
```
|
||||
@@ -280,7 +280,7 @@ func main() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
for i, device := range devices {
|
||||
fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host)
|
||||
}
|
||||
@@ -300,7 +300,7 @@ func main() {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.Run(os.Args)
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func getClientFromContext(c *cli.Context) *client.Client {
|
||||
devices, _ := discovery.DiscoverDevices()
|
||||
deviceHost = selectDeviceInteractive(devices)
|
||||
}
|
||||
|
||||
|
||||
return client.NewClient(deviceHost, 8090)
|
||||
}
|
||||
```
|
||||
@@ -327,34 +327,34 @@ var webAssets embed.FS
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
|
||||
// Embedded web assets
|
||||
webFS, err := fs.Sub(webAssets, "web")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// SPA routing
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
data, err := webAssets.ReadFile("web/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(data)
|
||||
})
|
||||
|
||||
|
||||
// API endpoints
|
||||
mux.HandleFunc("/api/devices", handleDeviceDiscovery)
|
||||
mux.HandleFunc("/api/client/", handleClientProxy)
|
||||
|
||||
|
||||
log.Println("SoundTouch Web UI starting on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", mux))
|
||||
}
|
||||
@@ -370,36 +370,36 @@ func handleClientProxy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
deviceIP := pathParts[3]
|
||||
apiPath := "/" + strings.Join(pathParts[4:], "/")
|
||||
|
||||
|
||||
// Proxy request to SoundTouch device
|
||||
targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath)
|
||||
|
||||
|
||||
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Copy headers
|
||||
for k, v := range r.Header {
|
||||
proxyReq.Header[k] = v
|
||||
}
|
||||
|
||||
|
||||
resp, err := http.DefaultClient.Do(proxyReq)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
// Enable CORS
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
|
||||
// Copy response
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
@@ -448,7 +448,7 @@ func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
switch s {
|
||||
case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped):
|
||||
*p = PlayStatus(s)
|
||||
@@ -469,41 +469,41 @@ type Config struct {
|
||||
// Server configuration
|
||||
WebPort int `env:"WEB_PORT" default:"8080"`
|
||||
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
|
||||
|
||||
|
||||
// Discovery configuration
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
|
||||
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
|
||||
|
||||
|
||||
// CORS configuration (for web proxy)
|
||||
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
var cfg Config
|
||||
|
||||
|
||||
// Load from .env file
|
||||
loadDotEnv()
|
||||
|
||||
|
||||
// Parse environment variables with reflection
|
||||
parseEnvVars(&cfg)
|
||||
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseEnvVars(cfg interface{}) {
|
||||
v := reflect.ValueOf(cfg).Elem()
|
||||
t := v.Type()
|
||||
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := t.Field(i)
|
||||
|
||||
|
||||
envTag := fieldType.Tag.Get("env")
|
||||
defaultTag := fieldType.Tag.Get("default")
|
||||
|
||||
|
||||
if envTag != "" {
|
||||
if envValue := os.Getenv(envTag); envValue != "" {
|
||||
setFieldValue(field, envValue)
|
||||
@@ -545,11 +545,11 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
if err, exists := m.errors["now_playing"]; exists {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
if resp, exists := m.responses["now_playing"]; exists {
|
||||
return resp.(*models.NowPlaying), nil
|
||||
}
|
||||
|
||||
|
||||
return &models.NowPlaying{
|
||||
Track: "Mock Track",
|
||||
Artist: "Mock Artist",
|
||||
@@ -577,8 +577,8 @@ CMD ["go", "test", "-v", "./..."]
|
||||
```bash
|
||||
# Makefile test target
|
||||
test-integration:
|
||||
docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit
|
||||
docker-compose -f test/docker-compose.yml down
|
||||
docker compose -f test/docker-compose.yml up --build --abort-on-container-exit
|
||||
docker compose -f test/docker-compose.yml down
|
||||
```
|
||||
|
||||
## Recommended Project Structure
|
||||
@@ -741,7 +741,7 @@ type APIError struct {
|
||||
Message string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
// pkg/models/device.go
|
||||
// pkg/models/device.go
|
||||
type DeviceInfo struct {
|
||||
XMLResponse
|
||||
Name string `xml:"name"`
|
||||
@@ -773,7 +773,7 @@ func main() {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.Run(os.Args)
|
||||
}
|
||||
```
|
||||
@@ -802,4 +802,4 @@ func main() {
|
||||
|
||||
## Conclusion
|
||||
|
||||
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
|
||||
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
|
||||
|
||||
+3
-2
@@ -8,7 +8,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
|
||||
- **[Complete Migration Guide](guides/MIGRATION-GUIDE.md)** - Step-by-step guide from Bose Cloud to local control
|
||||
- **[Getting Started](guides/GETTING-STARTED.md)** - Quick introduction to the toolkit
|
||||
|
||||
### For Existing Users
|
||||
### For Existing Users
|
||||
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
|
||||
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
|
||||
|
||||
@@ -17,7 +17,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
|
||||
The documentation is organized into three main categories:
|
||||
|
||||
### 1. **User Guides** - For everyday users migrating and managing devices
|
||||
### 2. **Technical Reference** - For developers and advanced configuration
|
||||
### 2. **Technical Reference** - For developers and advanced configuration
|
||||
### 3. **Concept Documentation** - For contributors and system architects
|
||||
|
||||
## 🗂 Documentation Structure
|
||||
@@ -47,6 +47,7 @@ The documentation is organized into three main categories:
|
||||
|
||||
### API Documentation
|
||||
- [API Endpoints](reference/API-ENDPOINTS.md) - REST API reference
|
||||
- [Spotify Account Addition](reference/spotify-account-addition.md) - Technical requests for Spotify
|
||||
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md) - Real-time events
|
||||
- [Zone Management](reference/ZONE-MANAGEMENT.md) - Multi-room control
|
||||
- [Preset Management](reference/PRESET-MANAGEMENT.md) - Preset operations
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
|
||||
## User Guides
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
|
||||
* [Connecting Music Services](guides/MUSIC-SERVICES.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)
|
||||
* [Device Setup Flow](DEVICE-SETUP.md)
|
||||
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
|
||||
* [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
* [Deployment](guides/DEPLOYMENT.md)
|
||||
@@ -28,6 +31,7 @@
|
||||
## Technical Reference
|
||||
* [API Cookbook](reference/API-COOKBOOK.md)
|
||||
* [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
* [Spotify Account Addition](reference/spotify-account-addition.md)
|
||||
* [Cloud API Emulation](reference/CLOUD-API.md)
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
@@ -56,8 +60,18 @@
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
|
||||
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
|
||||
* [Bose App ADB Emulator](analysis/BOSE-APP-ADB-Emulator.md)
|
||||
* [Community Tools](analysis/bose-soundtouch-community-tools.md)
|
||||
|
||||
## Parity Analysis
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
|
||||
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
|
||||
* [Claude Instructions](CLAUDE.md)
|
||||
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
|
||||
@@ -81,3 +95,4 @@
|
||||
* [Power On Implementation Guide](power-on-implementation-guide.md)
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
# Bose SoundTouch Traffic Interception Runbook
|
||||
|
||||
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android Studio installed (for SDK tools and emulator)
|
||||
- Docker installed
|
||||
- mitmproxy installed (`pip install mitmproxy` or via your preferred method)
|
||||
- The Bose SoundTouch APK (extracted from a real device, see below)
|
||||
|
||||
Add Android SDK tools to your PATH (add to `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export PATH=$PATH:~/Library/Android/sdk/emulator
|
||||
export PATH=$PATH:~/Library/Android/sdk/platform-tools
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Extract APK from Real Device
|
||||
|
||||
Connect your Android device via USB with USB debugging enabled.
|
||||
|
||||
```bash
|
||||
adb devices
|
||||
# note your device ID, e.g. "ABC123"
|
||||
|
||||
adb -s ABC123 shell pm path com.bose.soundtouch
|
||||
# output e.g.: package:/data/app/~~xyz/com.bose.soundtouch-abc/base.apk
|
||||
|
||||
adb -s ABC123 pull /data/app/~~xyz/com.bose.soundtouch-abc/base.apk bose.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Create Android Emulator (ARM64, API 33)
|
||||
|
||||
On Apple Silicon you need an ARM64 image. Use the `avdmanager` and `sdkmanager` CLI tools.
|
||||
|
||||
```bash
|
||||
# Install the system image
|
||||
~/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager \
|
||||
"system-images;android-33;google_apis;arm64-v8a"
|
||||
|
||||
# Create the AVD
|
||||
~/Library/Android/sdk/cmdline-tools/latest/bin/avdmanager create avd \
|
||||
-n Pixel_6_API33 \
|
||||
-k "system-images;android-33;google_apis;arm64-v8a" \
|
||||
-d "pixel_6"
|
||||
```
|
||||
|
||||
Alternatively create the AVD via Android Studio Device Manager (choose "Google APIs", arm64-v8a, API 33).
|
||||
|
||||
---
|
||||
|
||||
## 3. Start Emulator with Writable System
|
||||
|
||||
```bash
|
||||
# List available AVDs
|
||||
~/Library/Android/sdk/emulator/emulator -list-avds
|
||||
|
||||
# Start with writable system partition
|
||||
~/Library/Android/sdk/emulator/emulator -avd Pixel_6_API33 -writable-system
|
||||
```
|
||||
|
||||
Wait until the emulator has fully booted, then:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 root
|
||||
adb -s emulator-5554 shell avbctl disable-verification
|
||||
adb -s emulator-5554 reboot
|
||||
|
||||
# After reboot:
|
||||
adb -s emulator-5554 root
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Install Bose APK
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 install bose.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Set Up mitmproxy
|
||||
|
||||
```bash
|
||||
# Start mitmproxy (generates CA cert on first run)
|
||||
mitmweb --port 8080 --mode regular -w bose_traffic.mitm
|
||||
```
|
||||
|
||||
Extract the CA certificate (without private key):
|
||||
|
||||
```bash
|
||||
openssl x509 -in ~/.mitmproxy/mitmproxy-ca.pem -out ~/.mitmproxy/mitmproxy-ca-cert.pem
|
||||
|
||||
# Verify it's the mitmproxy cert, not another cert:
|
||||
openssl x509 -in ~/.mitmproxy/mitmproxy-ca-cert.pem -noout -issuer
|
||||
# should show: issuer= /CN=mitmproxy/O=mitmproxy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Install mitmproxy CA Certificate in Emulator
|
||||
|
||||
```bash
|
||||
HASH=$(openssl x509 -inform PEM -subject_hash_old \
|
||||
-in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1)
|
||||
|
||||
adb -s emulator-5554 push ~/.mitmproxy/mitmproxy-ca-cert.pem /data/local/tmp/mitmproxy.pem
|
||||
|
||||
adb -s emulator-5554 shell su 0 mkdir -p /data/misc/user/0/cacerts-added
|
||||
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
cp /data/local/tmp/mitmproxy.pem /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
|
||||
adb -s emulator-5554 shell su 0 \
|
||||
chmod 644 /data/misc/user/0/cacerts-added/${HASH}.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Set System Proxy in Emulator
|
||||
|
||||
Find your Mac's local IP:
|
||||
|
||||
```bash
|
||||
ipconfig getifaddr en0
|
||||
# e.g. 192.168.1.123
|
||||
```
|
||||
|
||||
Set the proxy:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 shell settings put global http_proxy 192.168.1.123:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Set Up Frida (via Python venv)
|
||||
|
||||
```bash
|
||||
python3 -m venv /tmp/frida-venv
|
||||
/tmp/frida-venv/bin/pip install frida==17.9.1 frida-tools==14.8.1
|
||||
```
|
||||
|
||||
Download the frida-server binary for ARM64 Android:
|
||||
|
||||
```bash
|
||||
FRIDA_VERSION=17.9.1
|
||||
|
||||
curl -L "https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-arm64.xz" \
|
||||
-o /tmp/frida-server.xz
|
||||
|
||||
unxz /tmp/frida-server.xz
|
||||
mv /tmp/frida-server-${FRIDA_VERSION}-android-arm64 /tmp/frida-server
|
||||
```
|
||||
|
||||
Push to emulator and start:
|
||||
|
||||
```bash
|
||||
adb -s emulator-5554 push /tmp/frida-server /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 chmod 755 /data/local/tmp/frida-server
|
||||
adb -s emulator-5554 shell su 0 /data/local/tmp/frida-server &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Download SSL Bypass Scripts
|
||||
|
||||
```bash
|
||||
BASE=https://raw.githubusercontent.com/httptoolkit/frida-interception-and-unpinning/main
|
||||
|
||||
curl -L "${BASE}/config.js" -o /tmp/config.js
|
||||
curl -L "${BASE}/android/android-system-certificate-injection.js" \
|
||||
-o /tmp/android-system-certificate-injection.js
|
||||
curl -L "${BASE}/android/android-proxy-override.js" \
|
||||
-o /tmp/android-proxy-override.js
|
||||
curl -L "${BASE}/android/android-certificate-unpinning.js" \
|
||||
-o /tmp/android-certificate-unpinning.js
|
||||
curl -L "${BASE}/android/android-certificate-unpinning-fallback.js" \
|
||||
-o /tmp/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Configure config.js
|
||||
|
||||
Edit `/tmp/config.js` and set:
|
||||
|
||||
```javascript
|
||||
const CERT_PEM = `<contents of ~/.mitmproxy/mitmproxy-ca-cert.pem>`;
|
||||
|
||||
const PROXY_HOST = '192.168.1.123'; // your Mac IP
|
||||
const PROXY_PORT = 8080;
|
||||
```
|
||||
|
||||
Insert the full PEM content (from `-----BEGIN CERTIFICATE-----` to `-----END CERTIFICATE-----`) between the backticks.
|
||||
|
||||
Quick check that the right cert is in place:
|
||||
|
||||
```bash
|
||||
# The issuer inside config.js should be mitmproxy, not SoundTouch
|
||||
grep -A3 "CERT_PEM" /tmp/config.js | head -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Start Interception
|
||||
|
||||
Make sure mitmweb is running, then:
|
||||
|
||||
```bash
|
||||
/tmp/frida-venv/bin/frida \
|
||||
-U \
|
||||
-f com.bose.soundtouch \
|
||||
-l /tmp/config.js \
|
||||
-l /tmp/android-system-certificate-injection.js \
|
||||
-l /tmp/android-proxy-override.js \
|
||||
-l /tmp/android-certificate-unpinning.js \
|
||||
-l /tmp/android-certificate-unpinning-fallback.js
|
||||
```
|
||||
|
||||
Expected output in the Frida REPL:
|
||||
|
||||
```
|
||||
== System certificate trust injected ==
|
||||
== Proxy system configuration overridden to 192.168.1.123:8080 ==
|
||||
== Proxy configuration overridden to 192.168.1.123:8080 ==
|
||||
== Certificate unpinning completed ==
|
||||
== Unpinning fallback auto-patcher installed ==
|
||||
```
|
||||
|
||||
Open mitmweb at `http://127.0.0.1:8081` to observe traffic live.
|
||||
|
||||
---
|
||||
|
||||
## 12. Save & Replay Recordings
|
||||
|
||||
Traffic is saved to `bose_traffic.mitm` (set via `-w` flag in step 5).
|
||||
|
||||
```bash
|
||||
# Replay/analyse a saved recording:
|
||||
mitmweb -r bose_traffic.mitm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Remove proxy setting from emulator
|
||||
adb -s emulator-5554 shell settings delete global http_proxy
|
||||
|
||||
# Remove venv
|
||||
rm -rf /tmp/frida-venv /tmp/frida-server /tmp/frida-server.xz
|
||||
rm /tmp/config.js /tmp/android-*.js
|
||||
|
||||
# Stop emulator
|
||||
adb -s emulator-5554 emu kill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|-----------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `remount failed` | ARM64 emulator doesn't support overlayfs remount | Use `/data/misc/user/0/cacerts-added/` method instead |
|
||||
| `TLS: Trust anchor not found` | Wrong certificate in config.js | Check issuer: must be mitmproxy, not SoundTouch |
|
||||
| `Chain validation failed` | Private key included in cert | Re-extract with `openssl x509 -in mitmproxy-ca.pem -out mitmproxy-ca-cert.pem` |
|
||||
| `frida-server: connection refused` | frida-server not running | Re-run `adb shell su 0 /data/local/tmp/frida-server &` |
|
||||
| frida and frida-server version mismatch | Versions must be identical | Pin both to same version (e.g. `17.9.1`) |
|
||||
| `emulator: multiple AVDs` error | Emulator already running | Kill first: `adb emu kill`, then restart with `-writable-system` |
|
||||
|
||||
---
|
||||
|
||||
## App Automation Options
|
||||
|
||||
For most traffic-recording purposes, manually operating the app while mitmproxy captures is sufficient. If you need to automate specific interactions (e.g. to repeatably capture the requests triggered by startup or a particular action), the following tools are available.
|
||||
|
||||
### Starting the App
|
||||
|
||||
```bash
|
||||
# Via app drawer: swipe up on the home screen and tap "Bose SoundTouch"
|
||||
|
||||
# Via adb monkey (simplest)
|
||||
adb -s emulator-5554 shell monkey -p com.bose.soundtouch 1
|
||||
|
||||
# Via explicit intent (if the activity name is known)
|
||||
adb -s emulator-5554 shell am start -n com.bose.soundtouch/.MainActivity
|
||||
|
||||
# Look up all activities if the name is unknown
|
||||
adb -s emulator-5554 shell dumpsys package com.bose.soundtouch | grep Activity
|
||||
```
|
||||
|
||||
### adb — sufficient for simple cases
|
||||
|
||||
```bash
|
||||
# Tap at screen coordinates
|
||||
adb shell input tap 540 960
|
||||
|
||||
# Swipe
|
||||
adb shell input swipe 540 1500 540 500
|
||||
|
||||
# Type text
|
||||
adb shell input text "mytext"
|
||||
|
||||
# Take a screenshot
|
||||
adb shell screencap /sdcard/screen.png && adb pull /sdcard/screen.png
|
||||
```
|
||||
|
||||
### UIAutomator2 — inspect UI elements
|
||||
|
||||
```bash
|
||||
# Dump the current UI hierarchy to find element IDs
|
||||
adb shell uiautomator dump /sdcard/ui.xml
|
||||
adb pull /sdcard/ui.xml
|
||||
```
|
||||
|
||||
Open `ui.xml` to find element resource IDs, then target them precisely in scripts.
|
||||
|
||||
### Appium — full scripted automation
|
||||
|
||||
```python
|
||||
from appium import webdriver
|
||||
|
||||
driver = webdriver.Remote('http://localhost:4723/wd/hub', {
|
||||
'platformName': 'Android',
|
||||
'appPackage': 'com.bose.soundtouch',
|
||||
'appActivity': '.MainActivity',
|
||||
})
|
||||
|
||||
# Find an element by resource ID and tap it
|
||||
driver.find_element('id', 'com.bose.soundtouch:id/play_button').click()
|
||||
```
|
||||
|
||||
> **Note:** `monkey` is a stress-test tool that sends random events — use it only to launch the app, not to drive specific interactions.
|
||||
@@ -0,0 +1,892 @@
|
||||
# Bose SoundTouch – Traffic Analysis Runbook
|
||||
|
||||
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app – specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Component | Details |
|
||||
|--------------------|------------------------------------------------------------------|
|
||||
| Raspberry Pi | Pi 3 or newer, Raspberry Pi OS (Bullseye, Bookworm, Trixie) |
|
||||
| Network interfaces | `eth0` → LAN cable to FritzBox, `wlan0` → own Access Point |
|
||||
| FritzBox | Unchanged, assigns an IP to the Pi via DHCP on eth0 |
|
||||
| Custom DNS Server | Already present (or see Appendix A), incl. custom CA certificate |
|
||||
| Phone | Android, connects to the Pi's Wi-Fi |
|
||||
|
||||
### Network Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
↓
|
||||
FritzBox (existing, unchanged)
|
||||
↓ LAN cable (eth0)
|
||||
Raspberry Pi
|
||||
├── DNS Server → selective logging / redirection
|
||||
├── hostapd → custom Wi-Fi Access Point ("Bose-Lab")
|
||||
├── dnsmasq → DHCP for clients, DNS to custom server
|
||||
├── iptables → NAT, Forwarding eth0 ↔ wlan0
|
||||
├── tcpdump → full traffic capture
|
||||
└── (optional) mitmproxy → HTTPS decryption
|
||||
↓ Wi-Fi ("Bose-Lab")
|
||||
Android Phone
|
||||
└── Bose SoundTouch App
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 – Install Packages
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y \
|
||||
hostapd \ # Wi-Fi Access Point daemon
|
||||
dnsmasq \ # DHCP + DNS forwarding
|
||||
nftables \ # Modern NAT / firewall / forwarding
|
||||
tcpdump \ # Packet capture at all levels
|
||||
wireshark-common # tshark CLI (optional, for live analysis)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 – Enable IP Forwarding
|
||||
|
||||
The Pi must forward packets between `wlan0` (phone) and `eth0` (FritzBox).
|
||||
|
||||
```bash
|
||||
# Active immediately (no reboot required)
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
# Permanent (survives reboots)
|
||||
# On modern Debian, using a dedicated file in sysctl.d/ is more reliable:
|
||||
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-ip-forward.conf
|
||||
|
||||
# Apply changes immediately
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
# After a reboot, ensure it is still '1'
|
||||
cat /proc/sys/net/ipv4/ip_forward
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 – Static IP on wlan0 (systemd-networkd)
|
||||
|
||||
On modern Debian (Bookworm/Trixie), `dhcpcd` is replaced by `systemd-networkd`.
|
||||
|
||||
```bash
|
||||
# Create network configuration
|
||||
sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
|
||||
[Match]
|
||||
Name=wlan0
|
||||
|
||||
[Network]
|
||||
Address=192.168.10.1/24
|
||||
IPForward=yes
|
||||
ConfigureWithoutCarrier=yes
|
||||
DHCP=no
|
||||
IPv6AcceptRA=no
|
||||
EOF
|
||||
|
||||
# Restart service
|
||||
sudo systemctl enable systemd-networkd
|
||||
sudo systemctl restart systemd-networkd
|
||||
|
||||
# Ensure wpa_supplicant and NetworkManager don't interfere
|
||||
sudo nmcli device set wlan0 managed no
|
||||
sudo systemctl stop wpa_supplicant@wlan0
|
||||
sudo systemctl mask wpa_supplicant@wlan0
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 – hostapd (Access Point)
|
||||
|
||||
```bash
|
||||
sudo tee /etc/hostapd/hostapd.conf << 'EOF'
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
ssid=Bose-Lab
|
||||
hw_mode=b
|
||||
#hw_mode=g
|
||||
channel=1
|
||||
#channel=6
|
||||
wmm_enabled=0
|
||||
auth_algs=1
|
||||
wpa=2
|
||||
wpa_passphrase=secret123
|
||||
wpa_key_mgmt=WPA-PSK
|
||||
wpa_pairwise=CCMP
|
||||
EOF
|
||||
|
||||
# The modern way is to just use hostapd.service which defaults to /etc/hostapd/hostapd.conf
|
||||
sudo systemctl unmask hostapd
|
||||
sudo systemctl enable --now hostapd
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
sudo systemctl status hostapd
|
||||
# Expected: active (running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 – dnsmasq (DHCP + DNS)
|
||||
|
||||
dnsmasq gives the phone an IP and forwards DNS queries to the custom DNS server.
|
||||
|
||||
```bash
|
||||
# Back up original config
|
||||
sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
|
||||
|
||||
sudo tee /etc/dnsmasq.conf << 'EOF'
|
||||
interface=wlan0
|
||||
dhcp-range=192.168.10.100,192.168.10.200,24h
|
||||
dhcp-option=3,192.168.10.1
|
||||
dhcp-option=6,192.168.10.1
|
||||
|
||||
# DNS Upstream: custom server on localhost (adjust port if necessary)
|
||||
server=127.0.0.1#5353 # Example: custom server on port 5353
|
||||
# Alternatively: server=1.1.1.1 if DNS server runs directly on port 53
|
||||
|
||||
# Log all DNS queries (for initial analysis)
|
||||
log-queries
|
||||
log-facility=/var/log/dnsmasq.log
|
||||
EOF
|
||||
|
||||
sudo systemctl restart dnsmasq
|
||||
```
|
||||
|
||||
**Observe DNS log live:**
|
||||
```bash
|
||||
sudo tail -f /var/log/dnsmasq.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 – NAT and Forwarding (nftables)
|
||||
|
||||
On modern Debian (Bookworm/Trixie), `nftables` is the default and recommended way to manage NAT and traffic forwarding.
|
||||
|
||||
```bash
|
||||
# Define the NAT and Forwarding rules
|
||||
sudo tee /etc/nftables.conf << 'EOF'
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
|
||||
table inet filter {
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy drop;
|
||||
|
||||
# Allow traffic from phone (wlan0) to internet (eth0)
|
||||
iifname "wlan0" oifname "eth0" accept
|
||||
|
||||
# Allow established/related traffic back to the phone
|
||||
iifname "eth0" oifname "wlan0" ct state established,related accept
|
||||
}
|
||||
}
|
||||
|
||||
table ip nat {
|
||||
chain posterouting {
|
||||
type nat hook postrouting priority 100; policy accept;
|
||||
|
||||
# MASQUERADE outgoing packets on eth0
|
||||
oifname "eth0" masquerade
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Enable and start nftables
|
||||
sudo systemctl enable nftables
|
||||
sudo systemctl restart nftables
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
sudo nft list ruleset
|
||||
# Expected: ruleset showing the forward and nat chains
|
||||
```
|
||||
|
||||
### WiFi "Bose-Lab" not visible?
|
||||
|
||||
If you cannot see the `Bose-Lab` SSID on your phone:
|
||||
|
||||
1. **Check hostapd status:** `sudo systemctl status hostapd`. If it failed with "nl80211: Driver does not support configured mode", try changing `hw_mode=g` to `hw_mode=b`.
|
||||
2. **Interface blocking:** Ensure `rfkill` hasn't blocked WiFi: `sudo rfkill unblock wlan`.
|
||||
3. **Country Code:** Some systems require a country code in `hostapd.conf` to enable the radio. Add `country_code=DE` (or your country) to the top of `/etc/hostapd/hostapd.conf` and restart hostapd: `sudo systemctl restart hostapd`.
|
||||
4. **Local Radio Check:** You can verify that the radio is actually configured as an AP: `iw dev wlan0 info`. Look for `type AP` and your SSID.
|
||||
> **Note:** Do NOT rely on `iw dev wlan0 scan` for your own SSID; many WiFi drivers cannot "scan" and "broadcast" simultaneously.
|
||||
5. **Debug Mode:** If the scan still returns nothing, stop the service and run hostapd in the foreground to see real-time errors:
|
||||
```bash
|
||||
sudo systemctl stop hostapd
|
||||
sudo hostapd -dd /etc/hostapd/hostapd.conf
|
||||
```
|
||||
Look for messages like `nl80211: Failed to set interface wlan0 into AP mode`. This usually means the hardware is busy or doesn't support the current `hw_mode` / `channel` combination.
|
||||
6. **Conflicting Services:** Ensure nothing else is managing `wlan0`. NetworkManager is common on modern Debian:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.168.178.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
|
||||
```bash
|
||||
sudo nmcli device set wlan0 managed no
|
||||
# If the ghost IP is still there, remove it manually:
|
||||
sudo ip addr del 192.168.178.X/24 dev wlan0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 – Install Custom CA Certificate on the Phone
|
||||
|
||||
Since a custom DNS server with a custom CA certificate is used, it must be trusted on the phone – otherwise, the app will block HTTPS connections to redirected domains.
|
||||
|
||||
### Copy CA Certificate to the Pi (if not already there)
|
||||
|
||||
If you haven't created a CA yet, follow **Appendix A** first.
|
||||
|
||||
```bash
|
||||
# Certificate is located e.g. at /etc/my-dns-ca/ca.crt
|
||||
# Temporarily make reachable via HTTP for easy download:
|
||||
cd /etc/my-dns-ca/
|
||||
python3 -m http.server 8080
|
||||
# → Reachable at http://192.168.10.1:8080/ca.crt
|
||||
```
|
||||
|
||||
### Install on Android
|
||||
|
||||
1. Connect phone to `Bose-Lab`
|
||||
2. Open browser → `http://192.168.10.1:8080/ca.crt`
|
||||
3. Download certificate
|
||||
4. **Settings → Security → Credentials → Install CA Certificate**
|
||||
5. Select certificate and confirm
|
||||
|
||||
> **Note:** Android distinguishes between system CAs and user CAs. User-installed CAs are accepted by many apps, but apps with certificate pinning (hardcoded certificate hashes) ignore them. Whether Bose uses pinning will be visible in the capture (Connection Reset after TLS ClientHello).
|
||||
|
||||
### Android 14+ Special Case
|
||||
|
||||
From Android 14 onwards, apps do not trust user CAs by default unless explicitly declared in the manifest. If the Bose app rejects the CA certificate:
|
||||
|
||||
```bash
|
||||
# Option A: Root + Magisk module "MagiskTrustUserCerts"
|
||||
# → moves user CAs to the system store
|
||||
|
||||
# Option B: Root + manually copy to system CA directory
|
||||
adb push ca.crt /system/etc/security/cacerts/
|
||||
adb shell chmod 644 /system/etc/security/cacerts/ca.crt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8 – Capture Traffic
|
||||
|
||||
### All at once (recommended)
|
||||
|
||||
```bash
|
||||
# Full capture of all protocols on wlan0
|
||||
# Filename with timestamp for multiple sessions
|
||||
sudo tcpdump -i wlan0 \
|
||||
-w /tmp/bose-$(date +%Y%m%d-%H%M%S).pcap \
|
||||
-s 0 # full packet length (no truncation)
|
||||
|
||||
# End session: Ctrl+C
|
||||
```
|
||||
|
||||
### Targeted by protocol
|
||||
|
||||
```bash
|
||||
# DNS only (Port 53) – shows if app uses standard DNS
|
||||
sudo tcpdump -i wlan0 -n port 53
|
||||
|
||||
# HTTPS only – TLS connections to Bose Cloud
|
||||
sudo tcpdump -i wlan0 -n 'tcp port 443'
|
||||
|
||||
# mDNS (ZeroConf) – device discovery in LAN
|
||||
# Multicast group 224.0.0.1, Port 5353
|
||||
sudo tcpdump -i wlan0 -n 'udp port 5353'
|
||||
|
||||
# SSDP/UPnP – alternative device discovery
|
||||
sudo tcpdump -i wlan0 -n 'udp port 1900'
|
||||
|
||||
# Everything except DNS (reduces noise)
|
||||
sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
|
||||
|
||||
# Traffic of a specific host only (filter by phone IP)
|
||||
# Read phone IP from dnsmasq.leases beforehand (see below)
|
||||
sudo tcpdump -i wlan0 -n host 192.168.10.101
|
||||
```
|
||||
|
||||
### Read SNI from TLS Traffic (without decryption)
|
||||
|
||||
```bash
|
||||
# Extract domains from TLS ClientHello (SNI is unencrypted)
|
||||
sudo tcpdump -i wlan0 -n 'tcp port 443' -A 2>/dev/null \
|
||||
| grep -oP '(?<=\x00)([a-zA-Z0-9.-]+\.(?:com|net|io|cloud|bose\.com))'
|
||||
```
|
||||
|
||||
### Readable mDNS Announcements output
|
||||
|
||||
```bash
|
||||
# tshark decodes mDNS directly
|
||||
sudo tshark -i wlan0 -f 'udp port 5353' -T fields \
|
||||
-e dns.qry.name \
|
||||
-e dns.resp.name \
|
||||
-e dns.a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9 – Analysis with Wireshark (on PC)
|
||||
|
||||
Transfer `.pcap` files from the Pi to the PC:
|
||||
|
||||
```bash
|
||||
# From the PC (scp)
|
||||
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
|
||||
```
|
||||
|
||||
**Important Wireshark Filters:**
|
||||
|
||||
```
|
||||
# DNS only
|
||||
dns
|
||||
|
||||
# HTTPS only
|
||||
tcp.port == 443
|
||||
|
||||
# WebSocket connections (HTTP Upgrade)
|
||||
websocket
|
||||
|
||||
# mDNS
|
||||
mdns
|
||||
|
||||
# TLS Handshakes (SNI visible)
|
||||
tls.handshake.extensions_server_name
|
||||
|
||||
# Traffic of a specific domain (resolve by IP)
|
||||
http.host contains "bose"
|
||||
|
||||
# WebSocket frames
|
||||
websocket.payload
|
||||
```
|
||||
|
||||
> **Tip:** Wireshark decodes WebSocket frames automatically if it sees the HTTP Upgrade handshake in the same capture. For the pairing flow: filtering for `tls.handshake.extensions_server_name` shows all domains the app contacts, even without decryption.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 – mitmproxy (optional, for HTTPS content)
|
||||
|
||||
Only useful if the CA certificate on the phone is trusted and no certificate pinning is active. `mitmproxy` acts as a Man-in-the-Middle by generating fake, on-the-fly certificates for any domain (e.g., `global.api.bose.io`) using your custom CA.
|
||||
|
||||
### 1. Configure mitmproxy to use your Custom CA
|
||||
|
||||
By default, `mitmproxy` creates its own CA in `~/.mitmproxy/`. To ensure the phone (which already trusts your `ca.crt`) accepts the traffic, you must tell `mitmproxy` to use your existing CA:
|
||||
|
||||
```bash
|
||||
# mitmproxy expects the CA in a specific PEM format (cert + key in one file)
|
||||
sudo mkdir -p ~/.mitmproxy
|
||||
sudo cat /etc/my-dns-ca/ca.crt /etc/my-dns-ca/ca.key | sudo tee ~/.mitmproxy/mitmproxy-ca.pem > /dev/null
|
||||
```
|
||||
|
||||
### 2. Install and Start mitmproxy
|
||||
|
||||
```bash
|
||||
# Install mitmproxy binary (stable version for aarch64)
|
||||
cd /tmp
|
||||
wget https://downloads.mitmproxy.org/12.2.1/mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
tar -xzf mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
sudo mv mitmproxy mitmdump mitmweb /usr/local/bin/
|
||||
rm mitmproxy-12.2.1-linux-aarch64.tar.gz
|
||||
|
||||
mitmproxy --version
|
||||
|
||||
# Transparent proxy on port 8080
|
||||
# It will now use the CA from ~/.mitmproxy/mitmproxy-ca.pem
|
||||
mitmproxy --mode transparent --listen-port 8080
|
||||
|
||||
# Alternatively: mitmdump for automatic logging to file
|
||||
# mitmdump --mode transparent --listen-port 8080 -w /tmp/bose-https.mitm
|
||||
```
|
||||
|
||||
### 3. Troubleshooting: TLS Handshake Failures
|
||||
|
||||
If you see `Client TLS handshake failed. The client does not trust the proxy's certificate for www.google.com` (or other domains) in the `mitmproxy` logs:
|
||||
|
||||
1. **HSTS and Pre-installed Pinning:** High-security sites like `www.google.com` use **HSTS (HTTP Strict Transport Security)** and have their certificates hardcoded (pinned) into browsers like Chrome and the Android system. **These will always fail with a User-installed CA.**
|
||||
2. **User vs. System CA Store:** On Android 7.0+, apps **do not trust User-installed CAs by default**. They only trust the "System" store.
|
||||
* **The Bose app:** If it fails, it's because it only trusts the System store or uses its own certificate pinning.
|
||||
* **The Fix (Rooted Phone):** Use a Magisk module like `AlwaysTrustUserCerts` or manually move your `ca.crt` to `/system/etc/security/cacerts/` (see Step 7).
|
||||
3. **The "Golden Rule" - Verify the Proxy is Working:**
|
||||
To confirm your CA and `mitmproxy` are correctly configured, test with a non-HSTS site on the phone's browser (e.g., `http://neverssl.com`). Once redirected to HTTPS, **inspect the certificate**. It should say it was issued by your "Bose-Lab Root CA" (or "SoundTouch Root CA").
|
||||
|
||||
* **If this works:** Your "factory" (mitmproxy + CA) is 100% correct. Any failure in the Bose app is due to its own security policy (ignore User Store or Pinning).
|
||||
* **If this fails:** Your CA is not trusted by the browser or `mitmproxy` is not using your PEM file.
|
||||
|
||||
Alternatively, use `curl` from a terminal emulator on the phone:
|
||||
```bash
|
||||
# This should work if the CA is in the user store and curl is told to use it
|
||||
curl -v --cacert /path/to/ca.crt https://example.com
|
||||
```
|
||||
4. **Check mitmproxy CA:** Ensure `mitmproxy` is actually using your CA. When it starts, it should NOT generate a new CA in `~/.mitmproxy/mitmproxy-ca.pem` if you've already placed yours there.
|
||||
|
||||
---
|
||||
|
||||
**nftables rule: redirect HTTPS traffic to mitmproxy**
|
||||
|
||||
```bash
|
||||
# Create a temporary file for the redirection rule
|
||||
sudo nft add table ip mitm
|
||||
sudo nft add chain ip mitm prerouting { type nat hook prerouting priority -100 \; }
|
||||
sudo nft add rule ip mitm prerouting iifname "wlan0" tcp dport 443 redirect to :8080
|
||||
```
|
||||
|
||||
**Remove rule when no longer needed:**
|
||||
|
||||
```bash
|
||||
sudo nft delete table ip mitm
|
||||
```
|
||||
|
||||
> **Detecting Certificate Pinning:** If the app immediately disconnects after mitmproxy redirection (connection reset directly after TLS ClientHello), pinning is active. In this case, Frida + root is needed to patch the pinning.
|
||||
|
||||
---
|
||||
|
||||
## Step 11 – Bypassing Android Trust Restrictions
|
||||
|
||||
If `neverssl.com` works in the browser but the Bose app shows `TLS handshake failed` in `mitmproxy`, the app is either ignoring the **User CA store** (common on Android 7+) or using **Certificate Pinning**.
|
||||
|
||||
### Option A: Move CA to System Store (Requires Root/Magisk)
|
||||
|
||||
This is the most reliable way to make apps trust your CA without modifying the app itself.
|
||||
|
||||
1. **Using Magisk (Recommended):**
|
||||
Install the **"AlwaysTrustUserCerts"** or **"Move Certificates"** module in Magisk. It automatically mirrors all certificates from the User store to the System store on every boot.
|
||||
|
||||
2. **Manual Move (via ADB):**
|
||||
Android system certificates are stored in `/system/etc/security/cacerts/` and must be named using the hash of the certificate.
|
||||
|
||||
```bash
|
||||
# 1. Get the hash of your certificate
|
||||
hash=$(openssl x509 -inform PEM -subject_hash_old -in ca.crt | head -1)
|
||||
|
||||
# 2. Rename the certificate locally
|
||||
cp ca.crt ${hash}.0
|
||||
|
||||
# 3. Push to the phone (requires remounting /system as read-write)
|
||||
adb push ${hash}.0 /sdcard/
|
||||
adb shell
|
||||
su
|
||||
mount -o rw,remount /
|
||||
cp /sdcard/${hash}.0 /system/etc/security/cacerts/
|
||||
chmod 644 /system/etc/security/cacerts/${hash}.0
|
||||
chown root:root /system/etc/security/cacerts/${hash}.0
|
||||
reboot
|
||||
```
|
||||
|
||||
### Option B: Patching the App (No Root Required)
|
||||
|
||||
If you cannot root your phone, you can modify the app's APK to trust user-installed certificates. This involves obtaining the APK, decompiling it, adding a network security configuration, and then repackaging and signing it.
|
||||
|
||||
#### 0. How to get the .apk file?
|
||||
|
||||
You have two main ways to get the official Bose SoundTouch APK:
|
||||
|
||||
**Method 1: Extract from your phone (Safest)**
|
||||
If the app is already installed on your phone, you can pull it using `adb`:
|
||||
```bash
|
||||
# 1. Find the package name (usually com.bose.soundtouch)
|
||||
adb shell pm list packages | grep bose
|
||||
|
||||
# 2. Get the full path to the APK on the phone
|
||||
adb shell pm path com.bose.soundtouch
|
||||
# Output: package:/data/app/~~...==/com.bose.soundtouch-.../base.apk
|
||||
|
||||
# 3. Pull the file to your computer
|
||||
adb pull /data/app/~~...==/com.bose.soundtouch-.../base.apk Bose-SoundTouch.apk
|
||||
```
|
||||
|
||||
**Method 2: Download from a Mirror (Easiest)**
|
||||
You can download the APK from reputable third-party sites.
|
||||
> **Warning:** Always verify the site's reputation.
|
||||
* [APKMirror](https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/)
|
||||
* [APKPure](https://apkpure.com/bose-soundtouch/com.bose.soundtouch)
|
||||
|
||||
#### 1. Automated Method: apk-mitm (Recommended)
|
||||
The easiest way is to use `apk-mitm`, which automates the entire process including fixing common certificate pinning libraries.
|
||||
|
||||
```bash
|
||||
# Requires Node.js installed on your PC
|
||||
npx apk-mitm Bose-SoundTouch.apk
|
||||
```
|
||||
This will produce a `Bose-SoundTouch-patched.apk` which you can install on your phone.
|
||||
|
||||
#### 2. Manual Method: Network Security Config
|
||||
If you prefer to do it manually:
|
||||
|
||||
1. **Decompile the APK:**
|
||||
```bash
|
||||
apktool d Bose-SoundTouch.apk
|
||||
```
|
||||
2. **Create/Modify `res/xml/network_security_config.xml`:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
```
|
||||
3. **Update `AndroidManifest.xml`:**
|
||||
Ensure the `<application>` tag includes: `android:networkSecurityConfig="@xml/network_security_config"`.
|
||||
4. **Repackage and Sign:**
|
||||
```bash
|
||||
apktool b Bose-SoundTouch -o Bose-SoundTouch-patched.apk
|
||||
# Sign with your own key
|
||||
# 1. Generate a keystore (if you don't have one)
|
||||
# Note: You can use ANY name/values here. The phone does not need to "know" or "trust" this key beforehand.
|
||||
# It only needs the APK to be digitally signed so the Android installer accepts it.
|
||||
keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
|
||||
|
||||
# 2. Sign the APK
|
||||
apksigner sign --ks my-release-key.keystore --out Bose-SoundTouch-patched-signed.apk Bose-SoundTouch-patched.apk
|
||||
|
||||
# Alternatively, use uber-apk-signer (recommended for simplicity)
|
||||
# It handles zipalign and signing automatically.
|
||||
java -jar uber-apk-signer.jar --apk Bose-SoundTouch-patched.apk
|
||||
```
|
||||
|
||||
#### 3. Install the Patched APK
|
||||
|
||||
Once you have your `Bose-SoundTouch-patched.apk` (and it is signed), you need to install it on your phone.
|
||||
|
||||
**Important:** You must **uninstall the original Bose app first**. Android will not allow you to "update" the official app with your patched version because the digital signatures won't match.
|
||||
|
||||
**Method 1: via ADB (Recommended)**
|
||||
```bash
|
||||
# 1. Uninstall the original app
|
||||
adb uninstall com.bose.soundtouch
|
||||
|
||||
# 2. Install your patched version
|
||||
adb install Bose-SoundTouch-patched.apk
|
||||
```
|
||||
|
||||
**Method 2: Manual Transfer**
|
||||
1. Copy the `Bose-SoundTouch-patched.apk` to your phone's storage (via USB, Google Drive, or the Pi's HTTP server).
|
||||
2. On the phone, use a File Manager to open the APK.
|
||||
3. If prompted, allow "Install from Unknown Sources" for your File Manager.
|
||||
|
||||
### Option C: Using the macOS Bose SoundTouch App (No Root/Patching Required)
|
||||
|
||||
If you have a Mac, using the macOS version of the Bose SoundTouch app is often a good alternative. However, because the app is built on an **older version of Qt (5.7.0)**, it has specific trust and TLS compatibility issues that require extra steps.
|
||||
|
||||
#### 1. Install the Custom CA in macOS Keychain
|
||||
|
||||
1. Open **Keychain Access** on your Mac.
|
||||
2. Select the **System** keychain (or **login** if System is locked).
|
||||
3. Drag and drop your `ca.crt` file into the list.
|
||||
4. Double-click the newly added certificate (e.g., "Bose-Lab Root CA").
|
||||
5. Expand the **Trust** section.
|
||||
6. Set "When using this certificate" to **Always Trust**.
|
||||
7. Close the window and authenticate with your Mac password.
|
||||
|
||||
#### 2. Configure the Proxy
|
||||
|
||||
You can either configure the macOS system proxy manually or use `mitmproxy`'s automatic interception.
|
||||
|
||||
**Method 1: System Proxy (Manual)**
|
||||
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
|
||||
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
|
||||
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
|
||||
4. Click **OK** and **Apply**.
|
||||
|
||||
**Method 2: mitmproxy Local Redirect (Automatic)**
|
||||
If you are running `mitmproxy` directly on your Mac (instead of the Pi), you can use the modern "Local Redirect" mode which doesn't require proxy settings:
|
||||
```bash
|
||||
# Install mitmproxy via Homebrew
|
||||
brew install mitmproxy
|
||||
|
||||
# Start mitmproxy in local redirect mode
|
||||
# This uses a macOS Network Extension to intercept traffic from specific apps
|
||||
mitmproxy --mode local
|
||||
```
|
||||
|
||||
#### 3. Special Troubleshooting: Legacy Qt 5.7.0 SSL Failures
|
||||
|
||||
If you see `SSL handshake failed` in the `mitmproxy` logs or the app's internal log (`log.txt`), the app's older networking stack is rejecting the connection. This is common because Qt 5.7.0 (2016) lacks support for **TLS 1.3** and many modern root certificates (like Let's Encrypt's **ISRG Root X1**).
|
||||
|
||||
**The Solution: Launch with SSL Bypass Flags**
|
||||
|
||||
Since the Bose macOS app is a hybrid of **Qt/Chromium** and **Node.js**, you must bypass the trust checks for both engines by launching the app from the terminal:
|
||||
|
||||
```bash
|
||||
# 1. Bypass QtWebEngine/Chromium (Qt 5.7) trust
|
||||
export QTWEBENGINE_CHROMIUM_FLAGS="--ignore-certificate-errors"
|
||||
|
||||
# 2. Bypass Node.js (SoundTouch Music Server) trust
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
# 3. (Optional) Provide your custom CA directly to Node.js
|
||||
export NODE_EXTRA_CA_CERTS="/path/to/your/ca.crt"
|
||||
|
||||
# 4. Launch the application
|
||||
"/Applications/SoundTouch/SoundTouch.app/Contents/MacOS/SoundTouch"
|
||||
```
|
||||
|
||||
#### 4. Verify and Capture
|
||||
|
||||
1. Open Safari and visit `https://neverssl.com`. Verify the certificate is issued by your custom CA.
|
||||
2. Launch the Bose app using the terminal command above.
|
||||
3. Watch the traffic flow in `mitmproxy`.
|
||||
|
||||
> **Note:** Even on macOS, **Certificate Pinning** is still possible if Bose implemented it specifically in the desktop app code. However, it is much less common on desktop apps than on mobile apps. If it works, you've saved yourself hours of Android patching!
|
||||
|
||||
### Option D: Patching the App with Frida (Requires Root)
|
||||
|
||||
If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA to the System store won't work. You must disable the pinning check in the app's code.
|
||||
|
||||
1. **Install Frida** on your PC and `frida-server` on the rooted phone.
|
||||
2. **Use a universal bypass script:**
|
||||
```bash
|
||||
frida -U -f com.bose.soundtouch -l https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/ --no-pause
|
||||
```
|
||||
*(Replace `com.bose.soundtouch` with the actual package name if different).*
|
||||
|
||||
## Step 12 – Alternative: Regular HTTP Proxy Mode
|
||||
|
||||
If the **Transparent AP** setup (Steps 1–6) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
|
||||
|
||||
### 1. How it works
|
||||
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
|
||||
|
||||
* **Pros:** No complex `nftables` or NAT rules required.
|
||||
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
|
||||
|
||||
### 2. Start mitmproxy in Regular Mode
|
||||
```bash
|
||||
# Stop transparent mode first if it's running
|
||||
# No special flags needed for regular mode
|
||||
mitmproxy --listen-port 8080
|
||||
```
|
||||
|
||||
### 3. Configure the Phone
|
||||
1. Go to **Settings → Wi-Fi → Bose-Lab**.
|
||||
2. Select **Modify Network** (or the "i" icon).
|
||||
3. Set **Proxy** to **Manual**.
|
||||
4. **Proxy hostname:** `192.168.10.1`
|
||||
5. **Proxy port:** `8080`
|
||||
6. Save and try to browse a site.
|
||||
|
||||
---
|
||||
|
||||
## Step 13 – Extracting for soundtouch-service
|
||||
|
||||
You can extract interactions (especially unencrypted WebSockets on port 8090) from a `.pcap` and format them for use in `soundtouch-service`.
|
||||
|
||||
### 1. Extract Traffic using Go
|
||||
|
||||
A helper script is provided in `scripts/extract-ws.go`. It automatically detects, unmasks, and decompresses (GZIP) WebSocket frames, and also extracts DNS, MDNS, and SSDP traffic.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
go get github.com/google/gopacket
|
||||
|
||||
# Run extraction (outputs multiple files: .ws.http, .dns.txt, .mdns.txt, .ssdp.txt)
|
||||
# The results will be saved beside your .pcap file
|
||||
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
|
||||
|
||||
# Example: Filter for a specific speaker's IP in WebSocket messages
|
||||
go run scripts/extract-ws.go capture.pcap 192.168.100.1
|
||||
```
|
||||
|
||||
### 2. Manual Extraction with tshark
|
||||
|
||||
If you only need a quick look at the payloads:
|
||||
|
||||
```bash
|
||||
# Extract all WebSocket text payloads
|
||||
tshark -r your_capture.pcap -Y "websocket.payload.text" -T fields -e websocket.payload.text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 14 – Extracting from Internal App Logs (macOS)
|
||||
|
||||
If you are using the macOS app and cannot decrypt the cloud traffic due to pinning, you can still extract the JSON/XML messages from the app's internal communication log.
|
||||
|
||||
A helper script is provided in `scripts/extract-log-interactions.go`. It parses the interleaved "Native" and "Network" calls to reconstruct the application's internal state and cloud requests.
|
||||
|
||||
```bash
|
||||
# Run extraction from the log file
|
||||
# Outputs a chronological record of internal events and network URLs
|
||||
go run scripts/extract-log-interactions.go path/to/log.txt > extracted-interactions.http
|
||||
```
|
||||
|
||||
**What this shows:**
|
||||
- **TO NETWORK:** The URLs the app is about to call (intercepted before encryption).
|
||||
- **FROM NATIVE:** Data being returned from the OS or Cloud to the UI.
|
||||
- **TO NATIVE:** Commands being sent from the UI to the underlying engines.
|
||||
|
||||
This is a powerful "Plan B" when HTTPS decryption is blocked, as the app essentially logs its own decrypted data for you.
|
||||
|
||||
---
|
||||
|
||||
## Helper Commands / Troubleshooting
|
||||
|
||||
After a Pi reboot, everything should come up automatically. If not:
|
||||
|
||||
```bash
|
||||
# Restart and enable all core services
|
||||
sudo systemctl restart systemd-networkd
|
||||
sudo systemctl enable --now hostapd
|
||||
sudo systemctl enable --now dnsmasq
|
||||
sudo systemctl restart nftables
|
||||
|
||||
# Verify the unmanaged state of wlan0 (nmcli)
|
||||
sudo nmcli device set wlan0 managed no
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Expect
|
||||
|
||||
| Protocol | Port | Tool | Visibility |
|
||||
|----------------------|------------|--------------------------|------------------------------------------------|
|
||||
| DNS (Standard) | UDP 53 | tcpdump, dnsmasq log | Full, plaintext |
|
||||
| HTTPS / REST | TCP 443 | tcpdump (SNI), mitmproxy | SNI without decryption, content with mitmproxy |
|
||||
| WebSockets | TCP 443/80 | Wireshark | Frames decoded if TLS is broken |
|
||||
| mDNS / ZeroConf | UDP 5353 | tcpdump, tshark | Full, plaintext |
|
||||
| SSDP / UPnP | UDP 1900 | tcpdump | Full, plaintext |
|
||||
| SoundTouch local API | TCP 8090 | tcpdump | Full, plaintext (no TLS) |
|
||||
|
||||
> **Expectation for Bose SoundTouch:** The app likely uses standard DNS (older app generation), REST/HTTPS for the pairing flow with the cloud, WebSockets for push events from the device, and mDNS for local device discovery. The local device API on port 8090 is HTTP without TLS – this traffic is always readable.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Analysis
|
||||
|
||||
1. Extract domains from DNS log and SNI → List of all Bose endpoints
|
||||
2. HTTP methods and paths from mitmproxy log → Reconstruct API structure
|
||||
3. Document auth flow (OAuth2? Proprietary? Token format?)
|
||||
4. Build a minimal mock server simulating the critical endpoints
|
||||
5. Testing: App against mock server → does pairing work offline?
|
||||
|
||||
---
|
||||
|
||||
## Appendix A – Generating a Custom CA Certificate
|
||||
|
||||
If you don't have a custom DNS server with a CA yet, you can create one directly on the Pi. Alternatively, if you are already using the `soundtouch-service` from this repository, you can reuse its CA certificate located in the `data/certs/` directory.
|
||||
|
||||
### 0. (Optional) Copy an Existing CA from another host
|
||||
|
||||
If you are already using the `soundtouch-service` on another machine (e.g., your notebook), you can copy the existing CA to the Pi instead of generating a new one:
|
||||
|
||||
```bash
|
||||
# On your Pi:
|
||||
sudo mkdir -p /etc/my-dns-ca
|
||||
sudo chown $USER:$USER /etc/my-dns-ca
|
||||
|
||||
# Run this on your notebook (replace hostnames and paths):
|
||||
# Note: This is easiest if your SSH key is added to the Pi and soundtouch-service host.
|
||||
# If you run into permission issues with sudo, ensure the source user has passwordless sudo for 'cat'.
|
||||
|
||||
# Step A: Download from source to your notebook
|
||||
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.crt" > ca.crt
|
||||
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.key" > ca.key
|
||||
|
||||
# Step B: Upload from notebook to the Pi
|
||||
scp ca.crt ca.key soundtouch-access-point:/tmp/
|
||||
ssh soundtouch-access-point "sudo mv /tmp/ca.crt /tmp/ca.key /etc/my-dns-ca/ && sudo chown root:root /etc/my-dns-ca/ca.*"
|
||||
rm ca.crt ca.key
|
||||
```
|
||||
|
||||
### 1. Create CA Key and Certificate
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/my-dns-ca
|
||||
cd /etc/my-dns-ca
|
||||
|
||||
# Generate CA private key
|
||||
sudo openssl genrsa -out ca.key 4096
|
||||
|
||||
# Generate Root CA certificate
|
||||
# Note: we explicitly add basicConstraints=CA:TRUE for modern TLS clients
|
||||
sudo openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
|
||||
-out ca.crt \
|
||||
-subj "/C=DE/O=Bose-Lab/CN=Bose-Lab Root CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||
```
|
||||
|
||||
### 2. Generate a Certificate for Interception (Example)
|
||||
|
||||
To intercept `global.api.bose.io`, you need a certificate for it, signed by your CA:
|
||||
|
||||
```bash
|
||||
# Generate server key
|
||||
sudo openssl genrsa -out bose.key 2048
|
||||
|
||||
# Create CSR (Certificate Signing Request) configuration
|
||||
sudo tee bose.ext << 'EOF'
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = global.api.bose.io
|
||||
DNS.2 = *.bose.io
|
||||
EOF
|
||||
|
||||
# Generate CSR
|
||||
sudo openssl req -new -key bose.key -out bose.csr \
|
||||
-subj "/C=DE/O=Bose-Lab/CN=global.api.bose.io"
|
||||
|
||||
# Sign the certificate with your CA
|
||||
sudo openssl x509 -req -in bose.csr -CA ca.crt -CAkey ca.key \
|
||||
-CAcreateserial -out bose.crt -days 365 -sha256 -extfile bose.ext
|
||||
```
|
||||
|
||||
### 3. Usage in your DNS/HTTPS Server
|
||||
|
||||
Your custom server (e.g., a small Go or Python script) would then use `bose.crt` and `bose.key` to serve HTTPS traffic for those domains.
|
||||
|
||||
## Appendix B – Helpful Commands
|
||||
|
||||
```bash
|
||||
# Which IPs did the phone receive?
|
||||
cat /var/lib/misc/dnsmasq.leases
|
||||
|
||||
# Is the access point active?
|
||||
sudo systemctl status hostapd
|
||||
|
||||
# Is dnsmasq active?
|
||||
sudo systemctl status dnsmasq
|
||||
|
||||
# Check interfaces and IPs
|
||||
ip addr show
|
||||
|
||||
# Check routing table
|
||||
ip route show
|
||||
|
||||
# Show active nftables rules
|
||||
sudo nft list ruleset
|
||||
|
||||
# All running tcpdump processes
|
||||
pgrep -a tcpdump
|
||||
|
||||
# Test the Pi's own DNS resolution
|
||||
dig @127.0.0.1 -p 5353 global.api.bose.io
|
||||
|
||||
# Check network connectivity from the phone (from the Pi)
|
||||
ping 192.168.10.101 # Phone IP from dnsmasq.leases
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
# Spotify Account Addition Implementation Status
|
||||
|
||||
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
|
||||
|
||||
## 1. OAuth Token Exchange (Bose Cloud)
|
||||
|
||||
The Stockholm background worker (in `worker_common.js` and `spotify_worker.js`) performs a token exchange using an authorization code.
|
||||
|
||||
* **Route**: `POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs`
|
||||
* **Purpose**: To exchange the Spotify authorization code for a Bose-mediated token.
|
||||
* **Implementation**: `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go`.
|
||||
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/oauth` route group.
|
||||
|
||||
## 2. Cloud Source Registration (Marge Service)
|
||||
|
||||
The SoundTouch application registers a new music source (e.g., Spotify) with the Bose cloud profile.
|
||||
|
||||
* **Route**: `POST /streaming/account/{account}/source`
|
||||
* **Purpose**: To add the new source (username, credentials, display name) to the user's emulated cloud profile.
|
||||
* **Implementation**: `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go`.
|
||||
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/streaming` route group.
|
||||
* **Payload Format**: XML `application/vnd.bose.streaming-v1.1+xml` containing `<source>` with `<username>`, `<sourceproviderid>`, and `<credential type="token_version_3">`.
|
||||
|
||||
## 3. Redirect Handling (Browser to App)
|
||||
|
||||
The `soundtouch://` deep link redirect URI is handled by the management interface which provides the OAuth callback.
|
||||
|
||||
* **Callback Route**: `GET /mgmt/spotify/callback`
|
||||
* **Implementation**: `HandleMgmtSpotifyCallback` in `pkg/service/handlers/handlers_mgmt.go`.
|
||||
* **Confirmation Route**: `POST /mgmt/spotify/confirm` (used by mobile apps for deep-link codes).
|
||||
* **Implementation**: `HandleMgmtSpotifyConfirm` in `pkg/service/handlers/handlers_mgmt.go`.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
1. **Marge Add Source**:
|
||||
* `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go` parses the incoming XML and persists the new source to the `DataStore` for the corresponding account.
|
||||
|
||||
2. **OAuth Account Token Exchange**:
|
||||
* `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go` supports the `/oauth/account/.../token/cs` path.
|
||||
* It responds with a JSON payload including `access_token` and `token_type` "Bearer" after exchanging the code via `ExchangeCodeAndStore`.
|
||||
|
||||
3. **Router Registration**:
|
||||
* These paths are registered in `cmd/soundtouch-service/main.go` within the `/streaming`, `/oauth`, and `/mgmt` route blocks.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Bose SoundTouch — Community Tools for Post-EOL Preservation
|
||||
|
||||
> **Context:** Bose announced the shutdown of SoundTouch cloud services, extended to **May 6, 2026**. On that date the official SoundTouch app will update to a local-only version. Bose has released the [SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf) as open-source to enable community-driven development. This document surveys the active community projects, their feature coverage, and open development opportunities.
|
||||
|
||||
---
|
||||
|
||||
## What Bose Is Doing
|
||||
|
||||
After the May 6, 2026 shutdown, the following will **continue to work**:
|
||||
|
||||
- Streaming via Bluetooth, AirPlay, Spotify Connect, and AUX
|
||||
- Local device control and grouping via an updated SoundTouch app
|
||||
- Remote control features (play, pause, skip, volume)
|
||||
- HDMI/optical connections on soundbars
|
||||
|
||||
The following will **stop working**:
|
||||
|
||||
- Physical and app-based presets
|
||||
- In-app music service browsing (TuneIn, Pandora, etc.)
|
||||
- Stereo pairing for SoundTouch 10
|
||||
- Security and firmware updates
|
||||
|
||||
---
|
||||
|
||||
## Community Projects
|
||||
|
||||
### 1. soundcork
|
||||
**[github.com/deborahgu/soundcork](https://github.com/deborahgu/soundcork)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Python |
|
||||
| License | MIT |
|
||||
| Stars | 111 |
|
||||
| Contributors | 8 |
|
||||
| Commits | 287 |
|
||||
| Status | Pre-alpha, actively developed |
|
||||
|
||||
A reverse-engineered intercept API that replaces the Bose cloud servers locally. Works by redirecting the speaker's internal `SoundTouchSdkPrivateCfg.xml` to a self-hosted FastAPI server, emulating the `marge` server (required for basic network functionality) and the `bmx` server (required for TuneIn). Deployable as a Docker container or systemd daemon. The most community-engaged project, with a dedicated discussion thread tracking Bose cloud service status.
|
||||
|
||||
---
|
||||
|
||||
### 2. Überböse API
|
||||
**[github.com/julius-d/ueberboese-api](https://github.com/julius-d/ueberboese-api)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Java (Spring Boot) |
|
||||
| License | MIT |
|
||||
| Stars | 10 |
|
||||
| Contributors | 1 |
|
||||
| Commits | 224 |
|
||||
| Tags/Releases | 163 |
|
||||
| Documentation | [julius-d.github.io/ueberboese-api](https://julius-d.github.io/ueberboese-api/) |
|
||||
|
||||
Reverse-engineers and rebuilds the Bose streaming HTTP API. Unique in publishing a machine-readable OpenAPI specification (`ueberboese-api.yaml`) and comprehensive request logging — making it the best research instrument for understanding what speakers actually call upstream. Implements Spotify OAuth integration and TuneIn. Companion to the Überböse App.
|
||||
|
||||
---
|
||||
|
||||
### 3. Überböse App
|
||||
**[github.com/julius-d/ueberboese-app](https://github.com/julius-d/ueberboese-app)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Flutter (Dart) |
|
||||
| License | MIT |
|
||||
| Latest version | 0.26.0 (March 2026) |
|
||||
| Distribution | [F-Droid](https://f-droid.org/en/packages/io.github.juliusd.ueberboese.app/) |
|
||||
| Platform | Android |
|
||||
|
||||
The only native installable phone app in the ecosystem. Pairs with the Überböse API server. Features: preset view/play/reprogram, multi-room zone management, volume control, now-playing display, Spotify authentication setup. Controls speakers directly via the local SoundTouch WebServices API (no server required for basic control).
|
||||
|
||||
---
|
||||
|
||||
### 4. SoundTouch Hybrid 2026
|
||||
**[github.com/TJGigs/Bose-SoundTouch-Hybrid-2026](https://github.com/TJGigs/Bose-SoundTouch-Hybrid-2026)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Node.js (JavaScript) |
|
||||
| License | — |
|
||||
| Stars | 1 |
|
||||
| Commits | 3 (V1) / 12 (V3) |
|
||||
| Status | Experimental / testing |
|
||||
|
||||
A self-hosted private cloud that emulates and replaces the Bose Cloud Service. Runs locally on a NAS or PC, intercepts the complex server handshakes needed to keep the SoundTouch infrastructure functional. Relies on **Music Assistant** for backend audio routing and provider aggregation. Features a setup wizard including USB config generation (`OverrideSdkPrivateCfg.xml`) and an on-screen Bose Cloud Emulation Setup guide. Targets users who want the broadest streaming provider support via Music Assistant's ecosystem.
|
||||
|
||||
---
|
||||
|
||||
### 5. OpenCloudTouch (OCT)
|
||||
**[github.com/scheilch/opencloudtouch](https://github.com/scheilch/opencloudtouch)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Python (FastAPI) + TypeScript (React) |
|
||||
| License | Apache 2.0 |
|
||||
| Stars | 9 |
|
||||
| Commits | 313 |
|
||||
| Latest release | v1.1.1 (April 12, 2026) |
|
||||
| Documentation | GitHub Wiki (EN/DE) |
|
||||
|
||||
A single Docker container combining a FastAPI backend and React frontend. The most production-ready project in the ecosystem in terms of release discipline and deployment accessibility. Features: internet radio with full hardware preset support (buttons 1–6), responsive web UI, device discovery via SSDP/UPnP, multi-room zone management, BMX-compatible endpoints, TuneIn stream resolver, RadioBrowser as a built-in first-class search provider, and pre-built Raspberry Pi SD card images for Pi 3/4/5. Deployable on amd64, arm64, and arm/v7. Documented in English and German. Spotify and Music Assistant integration are on the roadmap.
|
||||
|
||||
---
|
||||
|
||||
### 6. AfterTouch
|
||||
**[github.com/gesellix/Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch)** by gesellix
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Go |
|
||||
| License | MIT |
|
||||
| Stars | 16 |
|
||||
| Contributors | 2 |
|
||||
| Commits | 217 |
|
||||
| Releases | 51 (latest: v0.28.0, Feb 15, 2026) |
|
||||
| Documentation | [gesellix.github.io/Bose-SoundTouch](https://gesellix.github.io/Bose-SoundTouch/) |
|
||||
|
||||
The most comprehensive single toolkit in the ecosystem. Comprises three components: a Go library (importable package), a CLI (`soundtouch-cli`), and a local cloud emulation service (`soundtouch-service`). Covers the widest range of dimensions of any single project. Implements the complete Bose Spotify OAuth relay including surrogate secret generation and token refresh proxy. Includes a built-in DNS server for device redirection without SSH, HTTPS/custom CA injection, HTTP session recording, traffic proxy/logging, and a web management UI. Tested on real SoundTouch 10 and 20 hardware. Has a Patreon for ongoing support.
|
||||
|
||||
---
|
||||
|
||||
### 7. soundcork-stockholm-app
|
||||
**[github.com/krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app)**
|
||||
| | |
|
||||
|---|---|
|
||||
| Language | Java |
|
||||
| License | — |
|
||||
| Stars | 2 |
|
||||
| Commits | 21 |
|
||||
| Status | Active development, bugs expected |
|
||||
|
||||
A Java-based middleware that hosts the original Bose Stockholm frontend (extracted from the APK) in a local web browser at `http://127.0.0.1:8088/`. Bridges the Stockholm UI to local speakers via an HTTP proxy that resolves cross-origin issues, with SSDP-based device discovery and JSON state persistence. Unlike every other tool in the ecosystem, it runs the **official Bose UI** rather than a custom replacement — preserving the familiar Bose UX at the cost of requiring the Stockholm APK. Notable limitations: OAuth flows are unreliable, and WebSocket connections to speakers over HTTPS have blocking issues. Works alongside soundcork's backend for full cloud emulation.
|
||||
|
||||
---
|
||||
|
||||
### 8. jaas666/bose-soundtouch-web-api (Reference)
|
||||
**[github.com/jaas666/bose-soundtouch-web-api](https://github.com/jaas666/bose-soundtouch-web-api)**
|
||||
|
||||
Community-maintained Markdown conversion of the official Bose SoundTouch Web API PDF (v1.0, January 7, 2026). Useful as a developer reference. Not a deployable tool.
|
||||
|
||||
---
|
||||
|
||||
## Feature Coverage Matrix
|
||||
|
||||
Legend: ● Yes/complete · ◑ Partial/planned · ○ No
|
||||
|
||||
| Dimension | soundcork | Überböse API | Überböse App | ST Hybrid 2026 | OpenCloudTouch | AfterTouch | Stockholm App |
|
||||
|----------------------------------------------------|:---------:|:------------:|:------------:|:--------------:|:--------------:|:----------:|:-------------:|
|
||||
| **① App layer — local HTTP/WS control** | | | | | | | |
|
||||
| Playback control (play/pause/vol) | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Preset view & trigger | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Preset write / reprogram | ○ | ○ | ● | ● | ◑ | ● | ● |
|
||||
| Multi-room zone management | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Now playing / status display | ○ | ○ | ● | ● | ● | ● | ● |
|
||||
| Device discovery (SSDP/mDNS) | ○ | ○ | ● | ○ | ● | ● | ● |
|
||||
| WebSocket real-time events | ○ | ○ | ◑ | ● | ◑ | ● | ◑ |
|
||||
| **② Cloud/service layer — replaces Bose upstream** | | | | | | | |
|
||||
| Marge server emulation | ● | ● | ○ | ● | ○ | ● | ○ |
|
||||
| BMX / content registry | ◑ | ◑ | ○ | ● | ● | ● | ○ |
|
||||
| Account / OAuth token relay | ○ | ● | ○ | ◑ | ○ | ● | ◑ |
|
||||
| Preset sync (cloud-side) | ● | ● | ○ | ● | ○ | ● | ○ |
|
||||
| Recents sync | ● | ◑ | ○ | ◑ | ○ | ● | ○ |
|
||||
| Sources / device info persistence | ● | ● | ○ | ● | ○ | ● | ○ |
|
||||
| Stereo group CRUD (ST10 pairs) | ● | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| **③ Device redirection — USB/SSH setup** | | | | | | | |
|
||||
| Setup wizard / guided redirect | ◑ | ◑ | ○ | ● | ● | ● | ○ |
|
||||
| USB image / config generation | ○ | ○ | ○ | ● | ○ | ◑ | ○ |
|
||||
| HTTPS / custom CA support | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| **④ Streaming provider integration** | | | | | | | |
|
||||
| Internet radio (RadioBrowser) | ○ | ◑ | ◑ | ◑ | ● | ◑ | ○ |
|
||||
| TuneIn stream resolver | ● | ● | ● | ● | ● | ● | ● |
|
||||
| Spotify OAuth / Connect | ○ | ● | ● | ◑ | ◑ | ● | ◑ |
|
||||
| Pandora | ○ | ○ | ○ | ○ | ○ | ● | ◑ |
|
||||
| Music Assistant backend | ○ | ○ | ○ | ● | ◑ | ○ | ○ |
|
||||
| **⑤ Mobile / native app** | | | | | | | |
|
||||
| Android app (installable) | ○ | ○ | ● | ○ | ○ | ○ | ○ |
|
||||
| iOS app | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
|
||||
| Mobile-responsive web UI | ○ | ○ | ○ | ● | ● | ● | ● |
|
||||
| **⑥ Smart home / ecosystem integration** | | | | | | | |
|
||||
| Home Assistant integration | ○ | ○ | ○ | ○ | ○ | ◑ | ○ |
|
||||
| Music Assistant integration | ○ | ○ | ○ | ● | ◑ | ○ | ○ |
|
||||
| **⑦ CLI / automation tools** | | | | | | | |
|
||||
| CLI for scripting / automation | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| Traffic proxy / API logging | ◑ | ● | ○ | ○ | ○ | ● | ◑ |
|
||||
| HTTP session recording | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| **⑧ Library / SDK** | | | | | | | |
|
||||
| Importable library / package | ○ | ○ | ○ | ○ | ○ | ● | ○ |
|
||||
| Published API spec / docs | ○ | ● | ○ | ○ | ○ | ● | ○ |
|
||||
| Docker deployment | ● | ● | ○ | ● | ● | ● | ● |
|
||||
| Raspberry Pi SD card image | ○ | ○ | ○ | ○ | ● | ○ | ○ |
|
||||
|
||||
---
|
||||
|
||||
## Making AfterTouch the One-Stop Solution — Open Tasks
|
||||
|
||||
AfterTouch is the strongest single project across the service and developer layers. Its remaining gaps are on the consumer-facing and ecosystem-integration sides.
|
||||
|
||||
### Priority 1 — PWA installability
|
||||
|
||||
The web UI is already fully responsive — it has Bootstrap grid columns, `@media (max-width: 768px)` and `@media (max-width: 576px)` breakpoints, and a proper viewport meta tag. It works on iPhone and Android browsers today. What's missing is **installability**: no `manifest.json` and no service worker, so it cannot be added to the home screen as a standalone app. Adding these would close the iOS app gap ecosystem-wide (no project has an iOS app) at minimal effort.
|
||||
|
||||
### Priority 2 — RadioBrowser as a first-class provider
|
||||
|
||||
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
|
||||
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
|
||||
- Make discovered stations directly presetable to hardware buttons.
|
||||
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
|
||||
|
||||
### Priority 3 — Raspberry Pi SD card image
|
||||
|
||||
OpenCloudTouch ships a flashable Pi image and it dramatically lowers the barrier for the most common "always-on local server" deployment. AfterTouch already has Docker and a web management UI; this is largely a CI/packaging task:
|
||||
- Build a Pi image (using e.g. `pi-gen` or `rpi-imager`-compatible tooling) that boots directly into `soundtouch-service`.
|
||||
- Auto-starts on boot, auto-discovers devices, opens the web UI on a known port.
|
||||
- Target Pi 3/4/5 with amd64/arm64/arm/v7 variants (mirroring OCT's approach).
|
||||
|
||||
### Priority 4 — USB config generation in the web UI
|
||||
|
||||
AfterTouch modifies `SoundTouchSdkPrivateCfg.xml` via SSH (`pkg/service/setup/setup.go`) and documents the redirect process thoroughly, but does not yet generate the USB stick content for users without SSH access. A "prepare USB stick" button in the web UI would remove the last manual step:
|
||||
- Generate `OverrideSdkPrivateCfg.xml` pre-populated with the running server's URL.
|
||||
- Optionally include the custom CA certificate for HTTPS-capable devices.
|
||||
- Surface alongside the existing guided migration wizard.
|
||||
|
||||
### Priority 5 — Music Assistant integration
|
||||
|
||||
SoundTouch Hybrid 2026 uses Music Assistant as its streaming backend, giving access to Apple Music, Deezer, local libraries, and many other providers. A formal Music Assistant **player provider** for AfterTouch would give power users a path to sources beyond Spotify, TuneIn, Pandora, and RadioBrowser. The Music Assistant community has an open discussion thread on this ([#4766](https://github.com/orgs/music-assistant/discussions/4766)).
|
||||
|
||||
### Priority 6 — DNS-based migration documentation
|
||||
|
||||
AfterTouch includes a built-in DNS server (`ENABLE_DNS_DISCOVERY`, `DNS_BIND_ADDR`, `DNS_UPSTREAM`) that intercepts `*.bose.com` queries and forwards everything else upstream — no Pi-hole, AdGuard, or any other external tool required. The ResolvConf migration path already treats DNS as a first-class option. The remaining gap is awareness: users unfamiliar with the project may not realise no external DNS infrastructure is needed. Tasks:
|
||||
- Surface the built-in DNS server more prominently in the getting-started documentation.
|
||||
- Document Pi-hole / AdGuard Home as an *alternative* for users who already run those, not a requirement.
|
||||
|
||||
### Priority 7 — MQTT integration
|
||||
|
||||
A design document exists (`docs/guides/MQTT-INTEGRATION-DESIGN.md`) but no code has been written. Implementing it would unlock home automation use cases without requiring the full Home Assistant stack — enabling triggers like "play preset 1 when front door opens" via any MQTT-capable automation platform.
|
||||
|
||||
---
|
||||
|
||||
## soundcork ↔ AfterTouch
|
||||
|
||||
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../PARITY-SOUNDCORK.md).
|
||||
|
||||
### Architectural differences (not gaps)
|
||||
|
||||
These exist in soundcork but are deliberate architectural choices in AfterTouch, not missing features:
|
||||
|
||||
| Area | soundcork | AfterTouch |
|
||||
|--------------------------|---------------------------------------|-----------------------------------------------------------|
|
||||
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-web` component (Go + plain HTML/JS) |
|
||||
| Direct device management | SSH/SCP access into speakers | HTTP API only; no SSH |
|
||||
| Device discovery client | Python `upnpclient` library | mDNS + UPnP in Go, with dedicated DNS interception server |
|
||||
| Token delivery | Push (ZeroConf priming to port 8200) | Pull (device calls back to fetch) |
|
||||
| Persistence format | Flat files | XML flat files + atomic writes |
|
||||
|
||||
### AfterTouch capabilities soundcork lacks
|
||||
|
||||
| Feature | Notes |
|
||||
|-------------------------------------------|-----------------------------------------------------------------|
|
||||
| DNS server for device redirect | Intercepts Bose domain queries; no Pi-hole required |
|
||||
| HTTPS / custom CA injection | Full TLS with certificate generation and trust workflow |
|
||||
| HTTP interaction recording & replay | Captures real device traffic for debugging and regression tests |
|
||||
| Device migration (serial → MAC path) | Handles legacy device ID formats automatically |
|
||||
| Transparent proxy mode with upstream sync | Can mirror to real Bose cloud while running locally |
|
||||
| CLI (`soundtouch-cli`) | Scriptable control of speakers |
|
||||
| Importable Go library | `github.com/gesellix/bose-soundtouch/pkg/client` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Ecosystem fragmentation vs. convergence
|
||||
|
||||
The community is currently covering different parts of the problem in parallel rather than converging. AfterTouch explicitly credits soundcork, Überböse, and SoundTouch Plus in its README and describes its `soundtouch-service` as "heavily inspired by SoundCork". There is an opportunity — and arguably a need — for these projects to formally coordinate: shared test fixtures, a common compatibility matrix against specific firmware versions, and agreed-on API contracts would all reduce duplicated effort.
|
||||
|
||||
### Firmware version sensitivity
|
||||
|
||||
The SoundTouch 10 is most dependent on Marge for basic network functionality; the 20 and 30 are somewhat more tolerant. Compatibility across firmware versions is not systematically documented anywhere. A community firmware compatibility matrix (model × firmware version × which emulation features work) would be high value and is currently missing.
|
||||
|
||||
### Security posture
|
||||
|
||||
All projects warn that speakers should only be used on a private, firewalled network after cloud shutdown. AfterTouch is the only project to implement HTTPS/custom CA, which matters if devices are ever on a network where traffic could be inspected. soundcork's SECURITY.md explicitly warns against running on open networks.
|
||||
|
||||
### No iOS app — a structural gap
|
||||
|
||||
The original SoundTouch app was iOS-first. Every community replacement is Android-only (Überböse App) or browser-based. This is the largest unaddressed user segment in the ecosystem.
|
||||
|
||||
### Bose's open-source move as a precedent
|
||||
|
||||
Bose's decision to release API documentation rather than simply shutting down is notable — it mirrors what Pebble users did themselves with Rebble after that shutdown, but here the manufacturer initiated it. This sets a useful precedent and gives the community a solid legal and technical foundation to build on.
|
||||
|
||||
### Related community resources
|
||||
|
||||
- [Bose SoundTouch Plus (Home Assistant component)](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus) — comprehensive HA integration by Todd Lucas, extensive API wiki
|
||||
- [Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) — `LD_PRELOAD`-based reverse engineering framework used by AfterTouch for protocol research
|
||||
- [Bose SoundTouch Web API (community Markdown)](https://github.com/jaas666/bose-soundtouch-web-api) — official API PDF converted to Markdown
|
||||
- [Bose Wiki — SoundTouch App Alternatives](https://bose.fandom.com/wiki/SoundTouch_app_alternatives) — community-maintained living list of workarounds and projects
|
||||
- [Reddit megathread — Bose alternatives](https://www.reddit.com/r/bose) — ongoing community discussion
|
||||
- [Radio Browser](https://www.radio-browser.info/) — the free, community-maintained internet radio directory used as a TuneIn replacement
|
||||
|
||||
---
|
||||
|
||||
*Document compiled April 2026. Project details sourced directly from GitHub repositories and official documentation. Star counts, commit counts, and release dates reflect the state at time of writing and will change as projects evolve. soundcork-stockholm-app added April 2026.*
|
||||
+19
-19
@@ -185,7 +185,7 @@ type NowPlaying struct {
|
||||
type PlayStatus string
|
||||
const (
|
||||
PlayStatusPlaying PlayStatus = "PLAY_STATE"
|
||||
PlayStatusPaused PlayStatus = "PAUSE_STATE"
|
||||
PlayStatusPaused PlayStatus = "PAUSE_STATE"
|
||||
PlayStatusStopped PlayStatus = "STOP_STATE"
|
||||
)
|
||||
|
||||
@@ -277,19 +277,19 @@ type Config struct {
|
||||
// Server configuration
|
||||
WebPort int `env:"WEB_PORT" default:"8080"`
|
||||
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
|
||||
|
||||
// Discovery configuration
|
||||
|
||||
// Discovery configuration
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
|
||||
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
|
||||
CacheTTL time.Duration `env:"CACHE_TTL" default:"5m"`
|
||||
|
||||
|
||||
// CORS configuration (for web proxy)
|
||||
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
LogFormat string `env:"LOG_FORMAT" default:"json"`
|
||||
|
||||
|
||||
// Development
|
||||
DevMode bool `env:"DEV_MODE" default:"false"`
|
||||
}
|
||||
@@ -537,7 +537,7 @@ build-all: build-linux build-darwin build-windows
|
||||
dev-cli:
|
||||
air -c .air-cli.toml
|
||||
|
||||
dev-webapp:
|
||||
dev-webapp:
|
||||
air -c .air-webapp.toml
|
||||
|
||||
dev-wasm:
|
||||
@@ -556,7 +556,7 @@ check: fmt vet lint test
|
||||
|
||||
# Docker development environment
|
||||
docker-dev:
|
||||
docker-compose up --build
|
||||
docker compose up --build
|
||||
|
||||
# Release packaging
|
||||
release: build-all
|
||||
@@ -596,7 +596,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -609,36 +609,36 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
if len(devices) == 0 {
|
||||
log.Fatal("No SoundTouch devices found")
|
||||
}
|
||||
|
||||
|
||||
// Create client for first device
|
||||
client := client.NewClient(client.ClientConfig{
|
||||
Host: devices[0].Host,
|
||||
Port: 8090,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
|
||||
// Get device info
|
||||
info, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Connected to: %s\n", info.Name)
|
||||
|
||||
|
||||
// Get current playback
|
||||
nowPlaying, err := client.GetNowPlaying()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
|
||||
fmt.Printf("Playing: %s - %s (%s)\n",
|
||||
fmt.Printf("Playing: %s - %s (%s)\n",
|
||||
nowPlaying.Artist, nowPlaying.Track, nowPlaying.Album)
|
||||
}
|
||||
|
||||
|
||||
// Control playback
|
||||
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
|
||||
client.SendKey(models.KeyPause)
|
||||
@@ -737,11 +737,11 @@ docker run -p 8080:8080 soundtouch-webapp
|
||||
```bash
|
||||
# Local development with hot reload
|
||||
make dev-webapp # Web app development
|
||||
make dev-wasm # WASM development
|
||||
make dev-wasm # WASM development
|
||||
make dev-cli # CLI development
|
||||
|
||||
# Full development environment
|
||||
docker-compose up # Mock devices + web app
|
||||
docker compose up # Mock devices + web app
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
@@ -781,7 +781,7 @@ docker-compose up # Mock devices + web app
|
||||
|
||||
- [Bose SoundTouch Web API Documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
|
||||
- [Go WebAssembly](https://github.com/golang/go/wiki/WebAssembly)
|
||||
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
|
||||
- [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
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# Amazon Music OAuth Integration
|
||||
|
||||
This document describes the plan and specification for adding Amazon Music OAuth support to the SoundTouch service, enabling continued Amazon Music playback after the Bose cloud shutdown (May 2026).
|
||||
|
||||
The implementation mirrors the [Spotify OAuth integration](spotify-oauth.md) closely. Read that document first — this one calls out only the differences.
|
||||
|
||||
## Status
|
||||
|
||||
**Infrastructure complete — streaming blocked by API access.**
|
||||
|
||||
All eight implementation steps are done. The OAuth flow (account linking, token storage, token refresh) works end-to-end with a standard Login with Amazon app. Token exchange (`/oauth/device/.../token/cs1`) succeeds and the speaker receives a valid `Atza|` access token.
|
||||
|
||||
However, real-world testing shows that the speaker then calls `https://music-api.amazon.com/` with that token and receives a `401 Unauthorized` (no redirect to a regional endpoint). This means the token does not carry the scopes required to access the Amazon Music streaming API.
|
||||
|
||||
**Root cause (confirmed):** Amazon Music streaming requires the `amazon_music:access` scope, which is only available to **device client IDs** — a separate credential type obtained through Amazon's Music partner programme. Standard Login with Amazon application client IDs (`amzn1.application-oa2-client.*`) cannot request this scope: attempting to include it in the authorization URL returns `lwa-invalid-parameter-bad-scope` (HTTP 400) from the LWA authorization endpoint. Bose would have held a device client ID as a registered Amazon Music partner.
|
||||
|
||||
**What still works:**
|
||||
- Account linking and token storage
|
||||
- Token refresh (the service correctly exchanges the refresh token for a fresh access token)
|
||||
- Marge source registration (the speaker sees Amazon Music as a configured source)
|
||||
|
||||
**What does not work:**
|
||||
- Actual music playback — the speaker's `AmazonClient` cannot authenticate to `music-api.amazon.com` with a standard LWA token
|
||||
|
||||
**Path forward:** Obtaining a device client ID requires registering with Amazon's Music partner programme. If such a credential is obtained, the only code change needed is swapping the `client_id`/`client_secret` for the device credentials and adding `amazon_music:access` to `AmazonScopes` in `pkg/service/amazon/service.go` — everything else is already in place. The `site_id` field is a secondary open question that may also affect regional routing once the scope issue is resolved.
|
||||
|
||||
---
|
||||
|
||||
## Secret Format (confirmed from a live Bose system)
|
||||
|
||||
A real Amazon source entry from a migrated device's `Sources.xml`:
|
||||
|
||||
```xml
|
||||
<source secretType="token">
|
||||
<credential type="token">{"AmazonSecret":{"refresh_token":"Atzr|...","site_id":"1464855981"}}</credential>
|
||||
<sourceKey type="AMAZON" account="user@example.com"/>
|
||||
</source>
|
||||
```
|
||||
|
||||
Key observations:
|
||||
|
||||
- **Secret envelope**: `{"AmazonSecret":{"refresh_token":"...","site_id":"..."}}` — JSON-encoded, HTML-entity-escaped in XML attributes, stored as the credential value.
|
||||
- **`Atzr|` prefix**: This is the standard Amazon LWA (Login with Amazon) refresh token prefix from the **authorization code grant** — confirming that Web OAuth is the correct flow, not CBL.
|
||||
- **`site_id`**: A numeric string (`"1464855981"`). Origin is not yet fully confirmed; candidates are:
|
||||
- A static Bose partner identifier baked into the Bose app/firmware (same value for all users), or
|
||||
- A per-user Amazon Music identifier returned by a Music API device registration call.
|
||||
- Needs verification — possibly obtained by calling the Amazon Music API after initial authentication.
|
||||
- **`account` field**: The user's Amazon email address (obtained from the LWA `/user/profile` endpoint).
|
||||
|
||||
When `HandleBoseAmazonToken` receives a refresh request from the speaker, it must:
|
||||
1. Parse the `AmazonSecret` JSON from the stored credential to extract `refresh_token`.
|
||||
2. Call the LWA token endpoint with a `refresh_token` grant.
|
||||
3. Return the fresh `access_token` to the speaker.
|
||||
4. Persist the rotated `refresh_token` back into the `AmazonSecret` envelope.
|
||||
|
||||
---
|
||||
|
||||
## How the Speaker Uses This
|
||||
|
||||
When the SoundTouch firmware tries to play Amazon Music after migration, it sends a token refresh request to the local service:
|
||||
|
||||
```
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
|
||||
```
|
||||
|
||||
The service must respond with a fresh Amazon access token. The speaker then uses that token directly with Amazon's playback infrastructure.
|
||||
|
||||
The `cs1` suffix (credential schema 1) is Amazon-specific; Spotify uses `cs3`. This route is already registered.
|
||||
|
||||
> **DNS note:** The speaker constructs the OAuth hostname by appending `oauth` to the streaming service subdomain. If the service is reachable at `myhost.local`, the speaker will call `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP as the service is required.
|
||||
|
||||
---
|
||||
|
||||
## OAuth Flows
|
||||
|
||||
### 1. Browser-based Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client (curl/app)
|
||||
participant Service as Service
|
||||
participant Amazon as Amazon Auth Server (LWA)
|
||||
participant Browser as User's Browser
|
||||
|
||||
Client->>Service: POST /mgmt/amazon/init [Basic Auth]
|
||||
Service-->>Client: {"redirectUrl": "https://www.amazon.com/ap/oa?..."}
|
||||
|
||||
Client->>Browser: User opens URL
|
||||
Browser->>Amazon: User logs in & grants access
|
||||
Amazon-->>Browser: Redirect to /mgmt/amazon/callback?code=abc
|
||||
|
||||
Browser->>Service: GET /mgmt/amazon/callback?code=abc
|
||||
Note over Service: No auth needed for callback
|
||||
|
||||
Service->>Amazon: POST /auth/o2/token (exchange code)
|
||||
Amazon-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Amazon: GET /user/profile (fetch profile)
|
||||
Amazon-->>Service: {user_id, name, email}
|
||||
|
||||
Note over Service: Store account to disk
|
||||
|
||||
Service-->>Browser: HTML: "Amazon Music Connected. You can close this window."
|
||||
```
|
||||
|
||||
### 2. Mobile App Flow (ueberboese)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as ueberboese Flutter App
|
||||
participant Service as Service
|
||||
participant Amazon as Amazon Auth Server (LWA)
|
||||
|
||||
App->>Service: POST /mgmt/amazon/init [Basic Auth]
|
||||
Service-->>App: {"redirectUrl": "https://www.amazon.com/ap/oa?..."}
|
||||
|
||||
App->>Amazon: Open in-app browser (User authorizes)
|
||||
Amazon-->>App: Deep link redirect: ueberboese-login://amazon?code=abc
|
||||
|
||||
App->>Service: POST /mgmt/amazon/confirm?code=abc [Basic Auth]
|
||||
|
||||
Service->>Amazon: POST /auth/o2/token (exchange code)
|
||||
Amazon-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Amazon: GET /user/profile (fetch profile)
|
||||
Amazon-->>Service: {profile}
|
||||
|
||||
Service-->>App: {"ok": true}
|
||||
```
|
||||
|
||||
### 3. Token Retrieval (Speaker Token Refresh)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Speaker as SoundTouch Speaker
|
||||
participant Service as Service
|
||||
participant Amazon as Amazon Token API (LWA)
|
||||
|
||||
Speaker->>Service: POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
|
||||
Note over Service: Body contains stored AmazonSecret JSON;<br/>extract refresh_token from {"AmazonSecret":{...}}
|
||||
|
||||
alt Token expired or near expiry
|
||||
Service->>Amazon: POST /auth/o2/token (refresh_token grant, body credentials)
|
||||
Amazon-->>Service: {access_token, refresh_token, expires_in}
|
||||
Note over Service: Persist rotated refresh_token back into AmazonSecret envelope
|
||||
end
|
||||
|
||||
Service-->>Speaker: {"access_token": "...", "token_type": "Bearer", "expires_in": 3600}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1 — Extract ZeroConf into a shared package
|
||||
|
||||
**Why first:** The DH-blob encryption in `pkg/service/spotify/zeroconf.go` is entirely provider-agnostic. Extracting it to `pkg/service/zeroconf/` before adding Amazon avoids duplicating ~200 lines of crypto code.
|
||||
|
||||
**What changes:**
|
||||
- Create `pkg/service/zeroconf/zeroconf.go` — move `generateDHKeyPair`, `computeSharedSecret`, `deriveKeys`, `buildCredentialsBlob`, `encryptBlob` and helpers. Expose `authType` as a parameter (Spotify and Amazon both use `AuthTypeOAuthToken = 4`, but this makes it explicit).
|
||||
- Update `pkg/service/spotify/zeroconf.go` — delete moved code; `PushSpotifyCredentials` becomes a one-line wrapper calling `zeroconf.PushCredentials(...)`.
|
||||
|
||||
### Step 2 — Create `pkg/service/amazon/service.go`
|
||||
|
||||
Mirror `pkg/service/spotify/service.go`. The `Account` struct is identical; copy it unchanged.
|
||||
|
||||
**Amazon-specific differences:**
|
||||
|
||||
| Item | Spotify | Amazon |
|
||||
|---------------------------|------------------------------------------|-------------------------------------------------|
|
||||
| Authorization URL | `https://accounts.spotify.com/authorize` | `https://www.amazon.com/ap/oa` |
|
||||
| Token endpoint | `https://accounts.spotify.com/api/token` | `https://api.amazon.com/auth/o2/token` |
|
||||
| Profile endpoint | `https://api.spotify.com/v1/me` | `https://api.amazon.com/user/profile` |
|
||||
| Token request credentials | HTTP Basic Auth (clientID:clientSecret) | POST body fields `client_id` / `client_secret` |
|
||||
| Profile fields | `id`, `display_name`, `email` | `user_id`, `name`, `email` |
|
||||
| Scopes | `streaming user-read-private ...` | `profile` (expand to `music::*` when available) |
|
||||
| Entity resolution | `ResolveEntity()` via Spotify API | Not implemented (API in closed beta) |
|
||||
|
||||
Accounts persist to `{dataDir}/amazon/accounts.json`.
|
||||
|
||||
The token request credential difference (body vs. Basic Auth) is the most important implementation detail.
|
||||
|
||||
### Step 3 — Create `pkg/service/amazon/zeroconf.go`
|
||||
|
||||
A single exported function `PushAmazonCredentials(zcBaseURL, username, accessToken string) error` delegating to the shared `zeroconf.PushCredentials(...)`.
|
||||
|
||||
### Step 4 — Implement `HandleBoseAmazonToken`
|
||||
|
||||
Replace the 501 stub in `pkg/service/handlers/handlers_oauth.go` with the full mirror of `HandleBoseSpotifyToken`:
|
||||
- Parse body for `refresh_token` / `code`
|
||||
- Look up account by BoseSecret; refresh and return token
|
||||
- Fall back to first account via `GetFreshToken()` if no matching account
|
||||
- Fall back to `HandleBoseProxy` if no Amazon service is configured
|
||||
- **Omit `scope` from the response** — Amazon Music scopes are undocumented; sending invented values risks firmware rejection
|
||||
|
||||
### Step 5 — Add Amazon fields to `Server`
|
||||
|
||||
In `pkg/service/handlers/server.go`, add alongside the Spotify fields:
|
||||
|
||||
```go
|
||||
amazonClientID string
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonService *amazon.Service
|
||||
```
|
||||
|
||||
Add methods: `SetAmazonConfig`, `SetAmazonService`, `IsAmazonConfigured`, `PrimeDeviceWithAmazon`.
|
||||
|
||||
### Step 6 — Add management handlers
|
||||
|
||||
In `pkg/service/handlers/handlers_mgmt.go`, add six handlers mirroring Spotify:
|
||||
|
||||
| Handler | Notes |
|
||||
|-------------------------------|-----------------------------------------|
|
||||
| `HandleMgmtAmazonInit` | Returns LWA authorize URL |
|
||||
| `HandleMgmtAmazonCallback` | No auth; calls `bridgeAmazonToMarge` |
|
||||
| `HandleMgmtAmazonConfirm` | Basic Auth; calls `bridgeAmazonToMarge` |
|
||||
| `HandleMgmtAmazonAccounts` | Returns account list (tokens stripped) |
|
||||
| `HandleMgmtAmazonToken` | Returns fresh access token |
|
||||
| `HandleMgmtPrimeDeviceAmazon` | Pushes token to speaker via ZeroConf |
|
||||
|
||||
`bridgeAmazonToMarge` must encode the stored secret as `{"AmazonSecret":{"refresh_token":"<token>","site_id":"<id>"}}` and use `CredentialTypeToken` ("token") — **not** `CredentialTypeTokenV3`. Amazon uses `cs1` semantics.
|
||||
|
||||
### Step 7 — Wire CLI flags and router
|
||||
|
||||
**`main.go` flags** (env vars in parentheses):
|
||||
- `--amazon-client-id` (`AMAZON_CLIENT_ID`)
|
||||
- `--amazon-client-secret` (`AMAZON_CLIENT_SECRET`)
|
||||
- `--amazon-redirect-uri` (`AMAZON_REDIRECT_URI`, default: `ueberboese-login://amazon`)
|
||||
- `--amazon-token-url` (`AMAZON_TOKEN_URL`, for testing overrides)
|
||||
- `--amazon-profile-url` (`AMAZON_PROFILE_URL`, for testing overrides)
|
||||
|
||||
**Router** (`setupRouter`): Add `/mgmt/amazon/*` sub-routes next to the Spotify block. The `/oauth/.../token/cs1` route is already registered and dispatches to `HandleBoseAmazonToken`.
|
||||
|
||||
**`pkg/service/marge/marge.go`**: Extend the `AddSource` provider-label branch to map `AmazonProviderID (20) → "AMAZON"` so stored sources carry the correct type string rather than the raw numeric ID.
|
||||
|
||||
**`pkg/models/account.go`**: Add `NewAmazonOAuthCredentials` with `Source: "AMAZON"`, `Version: "token"`.
|
||||
|
||||
### Step 8 — Tests
|
||||
|
||||
Mirror the Spotify test suite for the Amazon package:
|
||||
|
||||
- `TestBuildAuthorizeURL` — verify LWA URL structure
|
||||
- `TestExchangeCodeAndStore` — mock token + profile servers; assert POST body credentials (not Basic Auth)
|
||||
- `TestRefreshAccessToken` — verify body credentials, token rotation
|
||||
- `TestGetFreshToken*` — copy Spotify variants verbatim
|
||||
- `TestSaveAndLoad` — verify persistence under `amazon/accounts.json`
|
||||
|
||||
Add `pkg/testutils/amazon/handlers.go` and `tests/integration/mocks/amazon.go` mock servers mirroring the Spotify equivalents.
|
||||
|
||||
Update `cmd/soundtouch-service/testdata/router_routes.txt` snapshot after wiring.
|
||||
|
||||
---
|
||||
|
||||
## Trying It Out
|
||||
|
||||
### 1. Create a Login with Amazon (LWA) app
|
||||
|
||||
Go to [developer.amazon.com](https://developer.amazon.com) → **Login with Amazon** → **Create a New Security Profile**.
|
||||
|
||||
You will receive a **Client ID** and **Client Secret**. Under *Web Settings*, add an **Allowed Return URL** that matches `--amazon-redirect-uri`:
|
||||
|
||||
- **Browser flow** (easiest to test): `http://<your-host>:8000/mgmt/amazon/callback`
|
||||
- **Mobile deep-link flow**: `ueberboese-login://amazon` (the default)
|
||||
|
||||
The `profile` scope is sufficient — `music::` scopes are in closed beta and not required. The service only brokers tokens; the speaker communicates with Amazon's playback infrastructure directly.
|
||||
|
||||
### 2. Start the service
|
||||
|
||||
```bash
|
||||
./soundtouch-service \
|
||||
--amazon-client-id amzn1.application-oa2-client.xxx \
|
||||
--amazon-client-secret yyy \
|
||||
--amazon-redirect-uri http://<your-host>:8000/mgmt/amazon/callback
|
||||
```
|
||||
|
||||
Or set the equivalent environment variables: `AMAZON_CLIENT_ID`, `AMAZON_CLIENT_SECRET`, `AMAZON_REDIRECT_URI`.
|
||||
|
||||
### 3. Trigger the OAuth flow
|
||||
|
||||
```bash
|
||||
# Get the LWA authorization URL
|
||||
curl -u admin:change_me! -X POST http://localhost:8000/mgmt/amazon/init
|
||||
# → {"redirectUrl":"https://www.amazon.com/ap/oa?client_id=...&scope=profile&..."}
|
||||
```
|
||||
|
||||
Open the `redirectUrl` in a browser, log in with your Amazon account, and authorize the app. Amazon redirects back to `/mgmt/amazon/callback`, which responds with an HTML page saying "Amazon Music Connected".
|
||||
|
||||
### 4. Verify the account is linked
|
||||
|
||||
```bash
|
||||
curl -u admin:change_me! http://localhost:8000/mgmt/amazon/accounts
|
||||
# → {"accounts":[{"user_id":"amzn1.account.xxx","display_name":"Your Name","email":"you@example.com",...}]}
|
||||
```
|
||||
|
||||
### 5. Prime a speaker
|
||||
|
||||
```bash
|
||||
# Discover device IDs first
|
||||
curl -u admin:change_me! http://localhost:8000/mgmt/accounts/default/speakers
|
||||
|
||||
# Push the token to a specific speaker via ZeroConf
|
||||
curl -u admin:change_me! -X POST \
|
||||
"http://localhost:8000/mgmt/amazon/prime?deviceId=<deviceId>"
|
||||
# → {"status":"Priming triggered"}
|
||||
```
|
||||
|
||||
### 6. Verify token refresh from the speaker
|
||||
|
||||
Once a speaker has Amazon Music as a source, it will periodically POST to:
|
||||
|
||||
```
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1
|
||||
```
|
||||
|
||||
The service looks up the account by refresh token, refreshes it via LWA, and returns a fresh `access_token`. Check the service logs for `[Amazon]` entries confirming this flow.
|
||||
|
||||
### DNS requirement
|
||||
|
||||
The speaker derives the OAuth hostname by appending `oauth` to its configured streaming subdomain. If the service is at `myhost.local`, the speaker calls `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP is required — the built-in DNS discovery server handles this automatically when `--dns-discovery` is enabled.
|
||||
|
||||
### Open question: `site_id`
|
||||
|
||||
The `AmazonSecret` credential envelope contains a `site_id` field (e.g. `"1464855981"` seen in a real migrated device). Its origin is unconfirmed — it may be a static Bose partner ID or a per-user Amazon Music identifier. The service currently stores an empty string.
|
||||
|
||||
Real-world testing shows the device's `AmazonClient` calls `CheckBaseUrlRedirect` with empty `data` (the `site_id`) and then tries `https://music-api.amazon.com/` directly, receiving a 401 with no redirect to a regional endpoint (e.g. `music-api.amazon.de` for a German account). This suggests that:
|
||||
|
||||
1. A correct `site_id` might cause the device to use the right regional endpoint instead of the US default.
|
||||
2. Even so, the token scope issue (see Status above) would still block playback — resolving `site_id` alone is not sufficient.
|
||||
|
||||
`site_id` is likely secondary to the partner scope problem. It remains an open question for the post-scope-resolution phase.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|-------------------------------------------------------------|-------|----------------------------------------------------|
|
||||
| `POST` | `/oauth/device/{deviceID}/music/musicprovider/20/token/cs1` | None | Token refresh from speaker |
|
||||
| `GET` | `/mgmt/amazon/callback` | None | Browser OAuth callback (redirect from Amazon LWA) |
|
||||
| `POST` | `/mgmt/amazon/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| `POST` | `/mgmt/amazon/confirm` | Basic | Mobile app confirm (deep link delivers code) |
|
||||
| `GET` | `/mgmt/amazon/accounts` | Basic | List linked Amazon accounts (tokens stripped) |
|
||||
| `GET` | `/mgmt/amazon/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| `POST` | `/mgmt/amazon/prime` | Basic | Push token to speaker via ZeroConf |
|
||||
|
||||
## Security
|
||||
|
||||
Same model as Spotify:
|
||||
- `/mgmt/amazon/callback` is intentionally outside Basic Auth to allow direct redirects from Amazon's authorization server.
|
||||
- All other `/mgmt/*` endpoints require Basic Auth.
|
||||
- Accounts persist to `{dataDir}/amazon/accounts.json` with `0600` permissions.
|
||||
- `GetAccounts` strips `AccessToken` and `RefreshToken` from responses.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
**Secret is a JSON envelope, not a bare token.** The stored credential is `{"AmazonSecret":{"refresh_token":"Atzr|...","site_id":"..."}}`, HTML-entity-escaped when written to XML attributes. This was confirmed from a real migrated device's `Sources.xml`. `HandleBoseAmazonToken` must parse this structure to extract the `refresh_token`, and `bridgeAmazonToMarge` must produce it when storing after OAuth.
|
||||
|
||||
**`site_id` origin is unconfirmed.** It may be a static Bose partner ID or a per-user Amazon Music identifier. Needs verification — likely obtained by calling the Amazon Music API or the LWA profile endpoint post-authentication.
|
||||
|
||||
**Credential type is `token`, not `token_version_3`.** `CredentialTypeTokenV3` is Spotify-specific (`cs3`). Amazon uses `cs1`, which maps to the plain `CredentialTypeToken` ("token") constant. Do not upgrade Amazon credentials to v3 in `marge.go`.
|
||||
|
||||
**POST body credentials, not Basic Auth.** The Amazon LWA token endpoint (`/auth/o2/token`) expects `client_id` and `client_secret` as POST body fields, not as an HTTP Basic Auth header. This is the single most important difference from the Spotify implementation.
|
||||
|
||||
**No entity resolution.** `ResolveEntity()` is not implemented for Amazon — the Amazon Music API is in closed beta. Return HTTP 501 if an entity endpoint is ever requested.
|
||||
|
||||
**`scope` omitted from token response.** The Spotify handler returns a hardcoded scope string. Amazon Music scopes for playback are undocumented; returning an empty or absent `scope` is safer than inventing values.
|
||||
|
||||
**ZeroConf extraction is a prerequisite.** The DH-blob crypto in `spotify/zeroconf.go` should be extracted to a shared package before Amazon is added to avoid duplicating cryptographic code.
|
||||
@@ -4,7 +4,31 @@ This document outlines the strategy for ensuring Bose SoundTouch devices are cor
|
||||
|
||||
## Overview
|
||||
|
||||
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token.
|
||||
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves a two-step exchange with the speaker's ZeroConf API (port 8200):
|
||||
|
||||
1. **`getInfo`** — retrieve the speaker's Diffie-Hellman public key and device metadata.
|
||||
2. **`addUser`** — push encrypted Spotify credentials using the shared DH secret.
|
||||
|
||||
This is the standard Spotify Connect ZeroConf protocol. Once the speaker holds a properly encrypted credential blob it can independently authenticate with Spotify's servers and refresh its own session without any further involvement from AfterTouch.
|
||||
|
||||
### ZeroConf Protocol
|
||||
|
||||
The current implementation follows the full Spotify Connect ZeroConf protocol (`pkg/service/spotify/zeroconf.go`):
|
||||
|
||||
1. `GET http://{ip}:8200/zc?action=getInfo` → parse `publicKey` (base64 DH key, 768-bit Oakley Group 1 prime) from the response.
|
||||
2. Generate a client DH key pair using the same group parameters.
|
||||
3. Compute `sharedSecret = DH(clientPrivate, speakerPublicKey)`.
|
||||
4. Derive keys: `baseKey = SHA1(sharedSecret)[:16]`, then HMAC-SHA1 with labels `"encryption"` and `"checksum"`.
|
||||
5. Encrypt a protobuf-encoded `LoginCredentials` blob (username, `AUTHENTICATION_SPOTIFY_TOKEN=4`, access token) using AES-128-CTR + HMAC-SHA1 checksum.
|
||||
6. `POST http://{ip}:8200/zc?action=addUser` with `blob={encryptedBlob}`, `clientKey={clientPublicKeyBase64}`.
|
||||
|
||||
The speaker decrypts the blob, stores long-lived credentials, and can handle token refresh with Spotify independently. No periodic re-priming is required for token expiry.
|
||||
|
||||
The algorithm is based on [librespot](https://github.com/librespot-org/librespot) (Rust reference implementation).
|
||||
|
||||
### Fallback for Older Firmware
|
||||
|
||||
If `getInfo` fails (e.g. firmware that does not implement the DH exchange), `PushSpotifyCredentials` automatically falls back to the simplified `tokenType=accesstoken` approach: the raw OAuth access token is sent as the `blob` with an empty `clientKey`. This token expires after ~60 minutes and the speaker cannot self-refresh, so periodic re-priming is required in that case.
|
||||
|
||||
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
|
||||
|
||||
@@ -14,7 +38,7 @@ AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device clea
|
||||
AfterTouch replicates the native Bose "Add Source" experience. No Spotify priming occurs until a user explicitly links their Spotify account through the AfterTouch Management Dashboard. This ensures privacy and respects users who do not wish to use Spotify.
|
||||
|
||||
### 2. Device Cleanliness (Minimalist Footprint)
|
||||
We avoid invasive modifications to the speaker's filesystem.
|
||||
We avoid invasive modifications to the speaker's filesystem.
|
||||
- **No On-Device Scripts:** We deprecate the use of internal boot-primer scripts.
|
||||
- **Native Communication:** We rely on the speaker's native ability to talk to Bose services, which are intercepted via DNS to point to the AfterTouch server.
|
||||
|
||||
@@ -53,6 +77,8 @@ The logic for account management and device interaction remains decoupled:
|
||||
4. AfterTouch pushes a fresh token from the Spotify Service.
|
||||
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
|
||||
|
||||
> **Note:** With the proper encrypted-blob flow now in place, the watchdog is only needed for the "speaker reboots and loses state" case — not for token expiry. Speakers running older firmware that trigger the `tokenType=accesstoken` fallback still require periodic re-priming (~45 min) because the raw access token expires.
|
||||
|
||||
### Manual Override
|
||||
Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device.
|
||||
|
||||
@@ -76,9 +102,11 @@ As AfterTouch moves to the Server-Centric model, we will:
|
||||
2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts.
|
||||
3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text.
|
||||
|
||||
## Implementation Roadmap (Conceptual)
|
||||
## Implementation Roadmap
|
||||
|
||||
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks.
|
||||
2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API.
|
||||
3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state.
|
||||
4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
|
||||
1. ✅ **Server-Side Priming Logic:** `PrimeDeviceWithSpotify(ip)` and `pushSpotifyTokenToDevice` in `pkg/service/handlers/server.go`. Triggered on device registration (marge handlers) and via the manual `HandleMgmtPrimeDevice` endpoint.
|
||||
2. ✅ **Discovery Hook:** `handleDiscoveredDevice` calls `PrimeDeviceWithSpotify` when a speaker is found.
|
||||
3. ✅ **Proper ZeroConf Blob:** Full DH key exchange + AES-128-CTR encrypted `LoginCredentials` blob implemented in `pkg/service/spotify/zeroconf.go`. Automatically falls back to `tokenType=accesstoken` if `getInfo` fails (older firmware).
|
||||
4. ⬜ **Watchdog / Session Refresh:** Background timer to re-prime all known devices on a schedule. Only strictly needed for older firmware (fallback path) or "speaker lost state" recovery; not required for token expiry on modern firmware.
|
||||
5. ⬜ **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
|
||||
6. ⬜ **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
|
||||
|
||||
@@ -816,7 +816,7 @@ func TestAccountManager_CreateAccount(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := manager.CreateAccount(tt.input)
|
||||
@@ -848,6 +848,18 @@ go test ./... -v -cover
|
||||
go test -bench=. ./...
|
||||
```
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
### Response Time Targets
|
||||
- Local API requests: < 100ms (95th percentile)
|
||||
- Mirror requests: < 200ms overhead (asynchronous)
|
||||
- Discovery time: < 5s for network scan
|
||||
|
||||
### Resource Constraints
|
||||
- Memory usage: < 64MB for small deployments
|
||||
- CPU usage: < 5% on dual-core ARM systems (idle)
|
||||
- Storage: < 100MB for interaction logs (rotatable)
|
||||
|
||||
### Security Considerations
|
||||
|
||||
#### Simple Security Model
|
||||
@@ -949,12 +961,12 @@ func (s *Server) HandleHealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
Version: version,
|
||||
Uptime: time.Since(startTime).String(),
|
||||
}
|
||||
|
||||
|
||||
// Simple checks
|
||||
if !s.canWriteToDataDir() {
|
||||
health.Status = "error"
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(health)
|
||||
}
|
||||
@@ -986,4 +998,4 @@ func (m *SimpleMetrics) Save(dataDir string) error {
|
||||
}
|
||||
```
|
||||
|
||||
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
|
||||
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
|
||||
|
||||
@@ -129,7 +129,7 @@ For each SoundTouch device:
|
||||
1. **Prepare a USB drive**:
|
||||
- Format as FAT32
|
||||
- Create an empty file named `remote_services` (no extension)
|
||||
- (Optional) Firmware update/reset, see [Bose SoundTouch USB Update](https://downloads.bose.com/ced/soundtouch/soundtouch_usb/index.html)
|
||||
- ~~(Optional) Firmware update/reset.~~ The official Bose SoundTouch USB update website is not available anymore.
|
||||
|
||||
2. **Insert USB drive** into your SoundTouch speaker
|
||||
3. **Power cycle** the device (unplug for 10 seconds, then reconnect)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Connecting Music Services (Spotify & Amazon Music)
|
||||
|
||||
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Connecting a music service happens in three separate steps, each done once:
|
||||
|
||||
1. **Register a developer app** with Spotify or Amazon (one-time setup by the person running AfterTouch).
|
||||
2. **Authorize your personal account** so AfterTouch can access your music library.
|
||||
3. **Prime your speaker** so the speaker itself learns about the source.
|
||||
|
||||
Each step is described below. If someone else is hosting AfterTouch for you, step 1 may already be done — ask them.
|
||||
|
||||
---
|
||||
|
||||
## Who registers the developer app?
|
||||
|
||||
The developer app is what allows AfterTouch to talk to Spotify's or Amazon's servers on behalf of users. It requires registering an account on a developer portal.
|
||||
|
||||
**If you run AfterTouch for yourself only:** You register one app and use it yourself.
|
||||
|
||||
**If you run AfterTouch for a group** (e.g., your household): You register one app, configure it in AfterTouch, and everyone who uses your AfterTouch instance shares it. They never see your app credentials — those stay on your server. However, they do need to trust you, since their music account tokens are stored by your AfterTouch installation.
|
||||
|
||||
**If you don't trust the AfterTouch operator:** Run your own AfterTouch instance and register your own app. That way everything stays under your control.
|
||||
|
||||
---
|
||||
|
||||
## Spotify
|
||||
|
||||
### Step 1: Register a Spotify developer app
|
||||
|
||||
1. Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and log in with your Spotify account.
|
||||
2. Click **Create app**.
|
||||
3. Give it any name and description (e.g., "My AfterTouch").
|
||||
4. Under **Redirect URIs**, add:
|
||||
```
|
||||
http://<your-aftertouch-ip>:8000/mgmt/spotify/callback
|
||||
```
|
||||
Replace `<your-aftertouch-ip>:8000` with the address of your AfterTouch server.
|
||||
5. Save the app.
|
||||
6. Open the app's settings and note down the **Client ID** and **Client Secret**.
|
||||
|
||||
### Step 2: Enter credentials in AfterTouch
|
||||
|
||||
1. Open the AfterTouch web interface and go to the **Settings** tab.
|
||||
2. Scroll to **Spotify Integration**.
|
||||
3. Enter your **Client ID**, **Client Secret**, and the **Redirect URI** you registered above.
|
||||
4. Click **Save Settings**.
|
||||
|
||||
The status should change to **Active**.
|
||||
|
||||
### Step 3: Authorize your Spotify account
|
||||
|
||||
1. Go to the **Local Account** tab (tab 7).
|
||||
2. Click **Connect Spotify to this Account**.
|
||||
3. A Spotify login window opens. Log in and grant permission.
|
||||
4. When the window closes, your account is linked. You should see your Spotify username appear.
|
||||
|
||||
### Step 4: Prime your speaker
|
||||
|
||||
After authorizing, each speaker needs to be told about the Spotify source.
|
||||
|
||||
1. Go to the **Devices** tab (tab 2).
|
||||
2. Find your speaker and click **Prime Spotify**.
|
||||
3. The speaker will now show Spotify as an available source.
|
||||
|
||||
Repeat step 4 for each speaker.
|
||||
|
||||
---
|
||||
|
||||
## Amazon Music
|
||||
|
||||
> **Current status: account linking works, streaming does not.**
|
||||
>
|
||||
> The OAuth flow and token storage are fully functional. However, the speaker's `AmazonClient` contacts `music-api.amazon.com` directly with the access token and receives a 401. Amazon Music's streaming API requires scopes that are only available to registered Amazon Music partners — a standard Login with Amazon app does not qualify. The infrastructure is in place and will work if those scopes ever become available, but following these steps will not result in working Amazon Music playback today.
|
||||
|
||||
### Step 1: Register an Amazon developer app (LWA)
|
||||
|
||||
1. Go to [developer.amazon.com/loginwithamazon/console/site/lwa/overview.html](https://developer.amazon.com/loginwithamazon/console/site/lwa/overview.html) and log in with your Amazon account.
|
||||
2. Click **Create a New Security Profile**.
|
||||
3. Give it any name and description (e.g., "My AfterTouch").
|
||||
4. In the security profile's **Web Settings**, add under **Allowed Return URLs**:
|
||||
```
|
||||
http://<your-aftertouch-ip>:8000/mgmt/amazon/callback
|
||||
```
|
||||
Replace `<your-aftertouch-ip>:8000` with the address of your AfterTouch server.
|
||||
5. Save and note down the **Client ID** and **Client Secret**.
|
||||
|
||||
### Step 2: Enter credentials in AfterTouch
|
||||
|
||||
1. Open the AfterTouch web interface and go to the **Settings** tab.
|
||||
2. Scroll to **Amazon Music Integration**.
|
||||
3. Enter your **Client ID**, **Client Secret**, and the **Redirect URI** you registered above.
|
||||
4. Click **Save Settings**.
|
||||
|
||||
The status should change to **Active**.
|
||||
|
||||
### Step 3: Authorize your Amazon account
|
||||
|
||||
1. Go to the **Local Account** tab (tab 7).
|
||||
2. Click **Connect Amazon Music to this Account**.
|
||||
3. An Amazon login window opens. Log in and grant permission.
|
||||
4. When the window closes, your account is linked.
|
||||
|
||||
### Step 4: Prime your speaker
|
||||
|
||||
1. Go to the **Devices** tab (tab 2).
|
||||
2. Find your speaker and click **Prime Amazon**.
|
||||
3. The speaker will now show Amazon Music as an available source.
|
||||
|
||||
Repeat step 4 for each speaker.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Failed to initialize" when clicking Connect:**
|
||||
The app credentials in Settings are missing or incorrect. Double-check the Client ID, Client Secret, and Redirect URI. The Redirect URI in AfterTouch must exactly match the one registered in the developer portal.
|
||||
|
||||
**The login window opens but redirects to an error page:**
|
||||
The Redirect URI registered with Spotify/Amazon does not match what AfterTouch is sending. Make sure the address (including the port) is identical in both places.
|
||||
|
||||
**The speaker doesn't show the new source after priming:**
|
||||
Try rebooting the speaker. It may take a minute to update its source list after priming.
|
||||
|
||||
**The login window doesn't open (popup blocked):**
|
||||
Allow popups from the AfterTouch address in your browser settings, then try again. Alternatively, the status message will show a direct link you can click.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Self-Hosting AfterTouch
|
||||
|
||||
This guide walks you through running AfterTouch on your own computer or server. No programming knowledge required.
|
||||
|
||||
---
|
||||
|
||||
## What is self-hosting?
|
||||
|
||||
AfterTouch is software that runs on a computer in your home and takes over the role of Bose's cloud servers. Your speakers talk to it instead of Bose.
|
||||
|
||||
For this to work, the computer running AfterTouch must be:
|
||||
|
||||
- **Always on** (or at least on whenever you want to use your speakers)
|
||||
- **On the same local network** as your speakers
|
||||
- **Reachable by a stable IP address** (see [Stable IP Address](#stable-ip-address) below)
|
||||
|
||||
Good choices: a Raspberry Pi, a NAS (like Synology or QNAP), an always-on PC or Mac, or a small server. A laptop that you close and put away is not ideal.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Get the software
|
||||
|
||||
Go to the [AfterTouch releases page](https://github.com/gesellix/Bose-SoundTouch/releases) and download the latest release for your operating system:
|
||||
|
||||
| Your system | File to download |
|
||||
|-----------------------|------------------------------------------|
|
||||
| Raspberry Pi (64-bit) | `soundtouch-service_linux_arm64.tar.gz` |
|
||||
| Raspberry Pi (32-bit) | `soundtouch-service_linux_arm.tar.gz` |
|
||||
| Linux (64-bit PC) | `soundtouch-service_linux_amd64.tar.gz` |
|
||||
| macOS (Apple Silicon) | `soundtouch-service_darwin_arm64.tar.gz` |
|
||||
| macOS (Intel) | `soundtouch-service_darwin_amd64.tar.gz` |
|
||||
| Windows | `soundtouch-service_windows_amd64.zip` |
|
||||
|
||||
Extract the archive. You will find a single file called `soundtouch-service` (or `soundtouch-service.exe` on Windows).
|
||||
|
||||
### Alternative: Docker
|
||||
|
||||
If you already use Docker, you can run AfterTouch as a container instead. See the [Deployment Guide](DEPLOYMENT.md) for Docker instructions.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Run it
|
||||
|
||||
Open a terminal (or Command Prompt on Windows), navigate to the folder where you extracted the file, and run:
|
||||
|
||||
```
|
||||
./soundtouch-service
|
||||
```
|
||||
|
||||
On Windows:
|
||||
```
|
||||
soundtouch-service.exe
|
||||
```
|
||||
|
||||
You should see log output like:
|
||||
```
|
||||
Starting AfterTouch service on :8000
|
||||
```
|
||||
|
||||
AfterTouch is now running on port 8000.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Open the web interface
|
||||
|
||||
In a web browser on any device on your network, go to:
|
||||
|
||||
```
|
||||
http://<your-server-ip>:8000
|
||||
```
|
||||
|
||||
Replace `<your-server-ip>` with the actual IP address of the computer running AfterTouch. For example: `http://192.168.1.100:8000`.
|
||||
|
||||
If you are on the same computer that is running AfterTouch, you can use `http://localhost:8000`.
|
||||
|
||||
You should see the AfterTouch web interface with tabs: Overview, Settings, Devices, and so on.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Configure the server URL
|
||||
|
||||
This is the most important setting. Go to the **Settings** tab and set the **Target Domain** to the full address of your AfterTouch server — the same address you used to open the web interface:
|
||||
|
||||
```
|
||||
http://192.168.1.100:8000
|
||||
```
|
||||
|
||||
Use the IP address of your server, **not** `localhost`. Your speakers need to reach this address over the network, and they cannot resolve `localhost`.
|
||||
|
||||
Click **Save Settings**.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Proceed with migration
|
||||
|
||||
You are now ready to migrate your speakers. Follow the main [Migration Guide](MIGRATION-GUIDE.md) for the remaining steps (discovering devices, syncing data, and redirecting your speakers to AfterTouch).
|
||||
|
||||
---
|
||||
|
||||
## Keeping AfterTouch running
|
||||
|
||||
By default, AfterTouch stops when you close the terminal. To keep it running permanently:
|
||||
|
||||
**Raspberry Pi / Linux:** See the [Raspberry Pi Guide](RASPBERRY-PI.md) for instructions on running AfterTouch as a background service using `systemd`.
|
||||
|
||||
**NAS devices:** Most NAS systems support Docker. Use the Docker instructions in the [Deployment Guide](DEPLOYMENT.md).
|
||||
|
||||
**macOS:** You can use `launchd` to run AfterTouch at login. Creating a `launchd` plist is beyond this guide, but the [Deployment Guide](DEPLOYMENT.md) has a systemd example you can adapt.
|
||||
|
||||
**Windows:** You can use Task Scheduler to run AfterTouch at startup.
|
||||
|
||||
---
|
||||
|
||||
## Stable IP address
|
||||
|
||||
AfterTouch must always be reachable at the same address, because your speakers will be configured to point to it. If the IP changes, your speakers will stop working until you reconfigure them.
|
||||
|
||||
The easiest solution is to assign a **static (fixed) IP address** to the computer running AfterTouch in your router's settings. Look for "DHCP reservation" or "static IP" in your router's administration interface, and bind the server's MAC address to a fixed IP.
|
||||
|
||||
---
|
||||
|
||||
## Security note
|
||||
|
||||
AfterTouch's web interface and management API have no login by default. On a typical home network this is fine, since only devices on your local network can reach it.
|
||||
|
||||
If you want to restrict access — for example, on a shared network — start the service with a username and password:
|
||||
|
||||
```
|
||||
./soundtouch-service --mgmt-username admin --mgmt-password yourpassword
|
||||
```
|
||||
|
||||
This protects the Settings tab (where your Spotify and Amazon credentials are stored) from being read or changed by others on the network.
|
||||
@@ -116,7 +116,7 @@ volumes:
|
||||
And run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
- 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.
|
||||
- RadioBrowser provides a native SoundTouch-compatible API at `https://all.api.radio-browser.info/soundtouch`.
|
||||
|
||||
### Architecture
|
||||
|
||||
This service registers RadioBrowser in its BMX service registry (provider ID 39) pointing to RadioBrowser's SoundTouch API. The device discovers it from there and communicates directly with RadioBrowser for browsing and playback — the local service does not proxy streams.
|
||||
|
||||
The source is registered with type `RADIO_BROWSER` in the Marge sources list. The RadioBrowser provider ID (39) in the source entry identifies it as RadioBrowser within the BMX layer.
|
||||
|
||||
The device's own `Sources.xml` (`/mnt/nv/BoseApp-Persistence/1/Sources.xml`) must contain a `RADIO_BROWSER` entry for playback to work:
|
||||
|
||||
```xml
|
||||
<source secret="" secretType="">
|
||||
<sourceKey type="RADIO_BROWSER" account="" />
|
||||
</source>
|
||||
```
|
||||
|
||||
**A device reboot is required after adding this entry.** The firmware only registers `RADIO_BROWSER` as a selectable source type during the boot-time `Sources.xml` load. The Marge runtime sync stores the source in the registry but does not complete the activation — without a reboot, selecting a `RADIO_BROWSER` station results in `INVALID_SOURCE`.
|
||||
|
||||
A reboot achieves two things in sequence:
|
||||
|
||||
1. The speaker fetches all sources from the soundtouch-service via a `/full` request, which updates the device-local `Sources.xml`.
|
||||
2. The firmware initialises and registers the `RADIO_BROWSER` source type from that updated file.
|
||||
|
||||
The `INVALID_SOURCE_TYPE` message from the Bluetooth daemon visible in device logs (e.g. during `GET /serviceAvailability`) is informational noise and does not affect playback.
|
||||
|
||||
### Triggering a sources refresh without rebooting
|
||||
|
||||
Step 1 above (the `/full` fetch that updates `Sources.xml`) can be triggered independently by posting a `sourcesUpdated` notification directly to the speaker. This is useful for verifying that the soundtouch-service serves the correct sources list before committing to a full reboot:
|
||||
|
||||
```bash
|
||||
curl -v -X POST http://<speaker-ip>:8090/notification \
|
||||
-H "Content-Type: application/xml" \
|
||||
-d '<updates deviceID="<deviceID>"><sourcesUpdated/></updates>'
|
||||
```
|
||||
|
||||
Replace `<speaker-ip>` with your speaker's IP address and `<deviceID>` with its device ID (visible in `/info`). After this call the speaker re-fetches its sources from the service. Step 2 (source-type registration) still requires a reboot.
|
||||
|
||||
### Search for stations
|
||||
|
||||
@@ -9,10 +45,7 @@
|
||||
- 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}`.
|
||||
### Playing the station
|
||||
|
||||
```xml
|
||||
<ContentItem
|
||||
@@ -20,15 +53,34 @@
|
||||
type="stationurl"
|
||||
isPresetable="true"
|
||||
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
|
||||
<itemName>RADIO_BROWSER</itemName>
|
||||
<itemName>Radio Station Name</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
|
||||
```
|
||||
|
||||
### BMX service registry entry
|
||||
|
||||
The entry in `pkg/service/handlers/static/bmx_services.json` that enables RadioBrowser:
|
||||
|
||||
```json
|
||||
{
|
||||
"baseUrl": "https://all.api.radio-browser.info/soundtouch",
|
||||
"id": {
|
||||
"name": "RADIO_BROWSER",
|
||||
"value": 39
|
||||
},
|
||||
"streamTypes": ["liveRadio", "onDemand"],
|
||||
"authenticationModel": {
|
||||
"anonymousAccount": {
|
||||
"autoCreate": true,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Spotify Account Addition Technical Reference
|
||||
|
||||
This document details the exact network requests performed by the Bose SoundTouch "Stockholm" application and the SoundTouch speaker when adding a new Spotify account. This information is based on analysis of the Stockholm firmware version `27.0.13-4277-8963611`.
|
||||
|
||||
## Flow Overview
|
||||
|
||||
1. **User Authorization Initiation**: The app opens the system browser to Spotify's authorization page.
|
||||
2. **Redirect Handling**: After authorization, Spotify redirects back to the app via a custom URI scheme, delivering an authorization `code`.
|
||||
3. **OAuth Token Exchange**: The app sends this `code` to the background worker, which exchanges it for a Bose-mediated token.
|
||||
4. **Cloud Source Registration**: The app registers the Spotify account as a "source" in the user's Bose Cloud (Marge) profile.
|
||||
5. **Local Device Sync**: The app notifies the local SoundTouch speaker about the new source, which then updates its internal configuration.
|
||||
|
||||
---
|
||||
|
||||
## 0. User Authorization Initiation
|
||||
|
||||
The process begins in the Stockholm UI when the user selects Spotify to add a new account.
|
||||
|
||||
### Request Details (App to Browser)
|
||||
- **Action**: Open System Browser
|
||||
- **Base URL**: `[SPOTIFY_AUTH_URL]` (e.g., `https://accounts.spotify.com/authorize`)
|
||||
- **Query Parameters**:
|
||||
- `client_id`: Bose Spotify Client ID
|
||||
- `response_type`: `code`
|
||||
- `redirect_uri`: `http://localhost` (often used as a placeholder or specifically handled by the app's internal webview/proxy)
|
||||
- `scope`: `user-read-private user-read-email ...`
|
||||
- `state`: A base64-encoded JSON object containing metadata, e.g., `{"service": "SPOTIFY"}`.
|
||||
|
||||
### Redirect (Browser to App)
|
||||
Upon successful login and authorization, Spotify redirects the browser to a URL that the SoundTouch app intercepts.
|
||||
|
||||
- **URL Format**: `soundtouch://bose/musicservice/spotify/login?code=[AUTH_CODE]&state=[STATE]`
|
||||
- **App Action**: The `UIMain` component (in `ui_main.js`) handles this "deep link". It extracts the `code` from the query parameters and prepares to send it to the background worker.
|
||||
|
||||
---
|
||||
|
||||
## 1. OAuth Token Exchange (Bose Cloud)
|
||||
|
||||
After the UI intercepts the redirect and extracts the `code`, it sends a `createOAuthAccountRequest` to the background `SpotifyWorker`. The worker then performs the exchange for a Bose-mediated token.
|
||||
|
||||
### What is a "Bose-mediated token"?
|
||||
The "Bose-mediated token" is a token issued by the Bose OAuth proxy. When the app (or device) requests a token via `oauth.streaming.bose.com`, Bose's service performs the actual OAuth2 exchange with Spotify.
|
||||
|
||||
- **It is not directly a Spotify refresh token**: Instead, it is a Bose-issued token that *represents* the underlying Spotify session.
|
||||
- **Token Version 3**: Modern firmware uses `token_version_3`, which signifies that the device doesn't store the raw Spotify tokens but instead uses a Bose-specific "secret" that the Bose Cloud uses to fetch fresh Spotify access tokens on the device's behalf.
|
||||
- **Access vs Refresh**: The initial response from the `.../token/cs` endpoint typically contains an `access_token` (valid for ~1 hour) and a `token_type: "Bearer"`. The Bose cloud service manages the persistent refresh token internally.
|
||||
|
||||
### Internal Message (UI to Worker)
|
||||
- **Message Type**: `createOAuthAccountRequest`
|
||||
- **Payload**:
|
||||
```json
|
||||
{
|
||||
"source": "SPOTIFY",
|
||||
"code": "[AUTH_CODE_FROM_REDIRECT]",
|
||||
"credentialType": "token_version_3"
|
||||
}
|
||||
```
|
||||
|
||||
### Outgoing Request (Worker to Bose OAuth Proxy)
|
||||
- **Endpoint**: `https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `Accept: application/json`
|
||||
- `Authorization: Bearer [SESSION_TOKEN]` (The user's Bose account session token)
|
||||
|
||||
### Payload (JSON)
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "[AUTH_CODE_FROM_SPOTIFY]",
|
||||
"redirect_uri": "http://localhost"
|
||||
}
|
||||
```
|
||||
|
||||
### curl Example
|
||||
```bash
|
||||
curl -X POST "https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer [SESSION_TOKEN]" \
|
||||
-d '{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "[AUTH_CODE_FROM_SPOTIFY]",
|
||||
"redirect_uri": "http://localhost"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Cloud Source Registration (Marge)
|
||||
|
||||
The app now registers the Spotify account with the Bose "Marge" service. This makes the source available across all devices linked to the same Bose account.
|
||||
|
||||
### Request Details
|
||||
- **Endpoint**: `https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
- `Content-Type: application/vnd.bose.streaming-v1.1+xml`
|
||||
- `Authorization: [MARGE_TOKEN]`
|
||||
- `GUID: [DEVICE_GUID]`
|
||||
- `ClientType: Stockholm`
|
||||
|
||||
### Payload (XML)
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<source>
|
||||
<username>[SPOTIFY_USER_ID]</username>
|
||||
<sourceproviderid>15</sourceproviderid>
|
||||
<credential type="token_version_3">[SECRET_TOKEN_OBTAINED_IN_STEP_1]</credential>
|
||||
<sourcename>[DISPLAY_NAME_E_G_EMAIL]</sourcename>
|
||||
</source>
|
||||
```
|
||||
|
||||
### curl Example
|
||||
```bash
|
||||
curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source" \
|
||||
-H "Content-Type: application/vnd.bose.streaming-v1.1+xml" \
|
||||
-H "Authorization: [MARGE_TOKEN]" \
|
||||
-d '<?xml version="1.0" encoding="UTF-8"?><source><username>[USER]</username><sourceproviderid>15</sourceproviderid><credential type="token_version_3">[TOKEN]</credential><sourcename>[NAME]</sourcename></source>'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Local Device Sync (LISA API)
|
||||
|
||||
The app notifies the physical SoundTouch speaker about the new source. This is usually done via the device's management API on port 8090.
|
||||
|
||||
#### Modern Flow (OAuth)
|
||||
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount`
|
||||
- **Method**: `POST`
|
||||
- **Payload**:
|
||||
```xml
|
||||
<OAuthCredentials source="SPOTIFY" displayName="[DISPLAY_NAME]">
|
||||
<user>[SPOTIFY_USER_ID]</user>
|
||||
<code>[AUTH_CODE_OR_TOKEN]</code>
|
||||
<version>token_version_3</version>
|
||||
</OAuthCredentials>
|
||||
```
|
||||
|
||||
#### Marge-Sync Notification (Fall-back)
|
||||
If the speaker returns `1029 UNKNOWN_ACTION_ERROR`, it signifies the LISA API version is too old for the OAuth flow. Stockholm-based firmware often expects the account to be registered in Marge first, followed by a notification to sync.
|
||||
- **Endpoint**: `http://[DEVICE_IP]:8090/notification`
|
||||
- **Method**: `POST`
|
||||
- **Payload**:
|
||||
```xml
|
||||
<updates deviceID="[DEVICE_UID]">
|
||||
<sourcesUpdated></sourcesUpdated>
|
||||
</updates>
|
||||
```
|
||||
|
||||
#### Legacy Flow (Fall-back)
|
||||
For older firmware that doesn't use Marge for Spotify:
|
||||
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount`
|
||||
- **Method**: `POST`
|
||||
- **Payload**:
|
||||
```xml
|
||||
<credentials source="SPOTIFY" displayName="Spotify Premium">
|
||||
<user>[USER]</user>
|
||||
<pass>[TOKEN]</pass>
|
||||
</credentials>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation in SoundTouch-Service
|
||||
|
||||
This project implements the "Bose-mediated token" flow as follows:
|
||||
|
||||
1. **Surrogate Secrets**: When a user links their Spotify account via `soundtouch-service`, the service generates a 32-character hex string (a "Bose Secret").
|
||||
2. **Marge & LISA registration**: This secret is sent to the speaker and stored in the emulated Marge cloud as the `credential`. The raw Spotify refresh token never leaves the server.
|
||||
3. **Token Refresh Proxy**: When the speaker needs a fresh Spotify `access_token`, it calls the `soundtouch-service` proxy (`/oauth/device/.../token/cs3`) providing this secret. The server maps the secret back to the actual Spotify account, performs the refresh with Spotify, and returns a fresh short-lived `access_token` to the speaker.
|
||||
|
||||
---
|
||||
|
||||
## Placeholders and Constants
|
||||
|
||||
| Placeholder | Description |
|
||||
|:------------------|:----------------------------------------------------|
|
||||
| `[ACCOUNT_ID]` | The internal Bose account ID (UUID). |
|
||||
| `[SESSION_TOKEN]` | Temporary token from Bose login. |
|
||||
| `[MARGE_TOKEN]` | Persistent authorization token for Marge services. |
|
||||
| `[DEVICE_GUID]` | Unique identifier for the controller app instance. |
|
||||
| `[DEVICE_IP]` | Local IP address of the SoundTouch speaker. |
|
||||
| `15` | Constant `sourceproviderid` for Spotify. |
|
||||
| `token_version_3` | Credential type for modern OAuth2 Spotify accounts. |
|
||||
|
||||
---
|
||||
|
||||
## Resulting Persistence
|
||||
|
||||
Once these requests succeed, the device updates its `/mnt/nv/BoseApp-Persistence/1/Sources.xml` file:
|
||||
|
||||
```xml
|
||||
<source displayName="user@example.com" secret="[SECRET_BLOB]" secretType="token_version_3">
|
||||
<sourceKey type="SPOTIFY" account="user" />
|
||||
</source>
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.2
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.43.0
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.2
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.43.0
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.6
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
|
||||
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -44,8 +69,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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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=
|
||||
@@ -56,6 +81,7 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -67,8 +93,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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -79,8 +105,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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
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=
|
||||
@@ -91,13 +117,22 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
+115
-3
@@ -146,7 +146,9 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -192,12 +194,51 @@ func NewClient(config *Config) *Client {
|
||||
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
|
||||
}
|
||||
|
||||
if config.Port == 0 {
|
||||
config.Port = 8090
|
||||
host := config.Host
|
||||
if !strings.Contains(host, "://") {
|
||||
host = "http://" + host
|
||||
}
|
||||
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
// Fallback for invalid URLs
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
|
||||
httpClient: &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
},
|
||||
timeout: config.Timeout,
|
||||
userAgent: config.UserAgent,
|
||||
}
|
||||
}
|
||||
|
||||
// Use SplitHostPort to check for port in the host string
|
||||
_, p, splitErr := net.SplitHostPort(u.Host)
|
||||
if splitErr != nil {
|
||||
// No port in the host string, use the one from config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
|
||||
} else if p == "" {
|
||||
// Empty port, use config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
|
||||
baseURL: u.String(),
|
||||
httpClient: &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
},
|
||||
@@ -1116,6 +1157,19 @@ func (c *Client) post(endpoint string, payload interface{}) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
responseBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Try to parse as ErrorsResponse (speaker error format)
|
||||
var errs models.ErrorsResponse
|
||||
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
|
||||
return &errs
|
||||
}
|
||||
|
||||
// Try to parse as APIError (standard format)
|
||||
var apiError models.APIError
|
||||
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
|
||||
return &apiError
|
||||
}
|
||||
|
||||
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
@@ -1160,6 +1214,19 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
responseBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Try to parse as ErrorsResponse (speaker error format)
|
||||
var errs models.ErrorsResponse
|
||||
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
|
||||
return &errs
|
||||
}
|
||||
|
||||
// Try to parse as APIError (standard format)
|
||||
var apiError models.APIError
|
||||
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
|
||||
return &apiError
|
||||
}
|
||||
|
||||
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
@@ -1172,6 +1239,11 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
|
||||
// Parse the actual response first
|
||||
if err := xml.Unmarshal(responseBody, result); err != nil {
|
||||
// Check if it might be an API error response instead
|
||||
var errs models.ErrorsResponse
|
||||
if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
|
||||
return &errs
|
||||
}
|
||||
|
||||
var apiError models.APIError
|
||||
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
|
||||
return &apiError
|
||||
@@ -1890,6 +1962,46 @@ func (c *Client) SetMusicServiceAccount(credentials *models.MusicServiceCredenti
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMusicServiceOAuthAccount adds or updates a music service account using OAuth credentials
|
||||
func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredentials) error {
|
||||
if credentials == nil {
|
||||
return fmt.Errorf("credentials cannot be nil")
|
||||
}
|
||||
|
||||
var response models.MusicServiceAccountResponse
|
||||
|
||||
// Note: Modern firmware uses /setMusicServiceOAuthAccount, but we reuse the success logic
|
||||
err := c.postWithResponse("/setMusicServiceOAuthAccount", credentials, &response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set music service OAuth account for %s: %w", credentials.Source, err)
|
||||
}
|
||||
|
||||
// The speaker returns /setMusicServiceOAuthAccount on success
|
||||
if response.Status != "/setMusicServiceOAuthAccount" {
|
||||
return fmt.Errorf("music service OAuth account operation failed: unexpected response %s", response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifySourcesUpdated notifies the device that sources have been updated in Marge
|
||||
func (c *Client) NotifySourcesUpdated(deviceID string) error {
|
||||
notification := models.NewSourcesUpdatedNotification(deviceID)
|
||||
|
||||
var response models.MusicServiceAccountResponse
|
||||
|
||||
err := c.postWithResponse("/notification", notification, &response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send sources updated notification: %w", err)
|
||||
}
|
||||
|
||||
if response.Status != "/notification" {
|
||||
return fmt.Errorf("sources updated notification 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 {
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestNewClientFromHost(t *testing.T) {
|
||||
|
||||
func TestGetDeviceInfo_Success(t *testing.T) {
|
||||
// Load test data
|
||||
testData := loadTestData(t, "info_response.xml")
|
||||
testData := loadTestData(t, "info_response_st10.xml")
|
||||
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,8 +117,8 @@ func TestGetDeviceInfo_Success(t *testing.T) {
|
||||
t.Errorf("Expected Name 'My SoundTouch Device', got '%s'", deviceInfo.Name)
|
||||
}
|
||||
|
||||
if deviceInfo.MargeAccountUUID != "3230304" {
|
||||
t.Errorf("Expected MargeAccountUUID '3230304', got '%s'", deviceInfo.MargeAccountUUID)
|
||||
if deviceInfo.MargeAccountUUID != "1234567" {
|
||||
t.Errorf("Expected MargeAccountUUID '1234567', got '%s'", deviceInfo.MargeAccountUUID)
|
||||
}
|
||||
|
||||
if deviceInfo.ModuleType != "sm2" {
|
||||
@@ -227,7 +227,7 @@ func TestGetDeviceInfo_APIError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPing_Success(t *testing.T) {
|
||||
testData := loadTestData(t, "info_response.xml")
|
||||
testData := loadTestData(t, "info_response_st10.xml")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
@@ -1059,12 +1059,7 @@ func loadTestData(t *testing.T, filename string) string {
|
||||
}
|
||||
|
||||
func createTestClient(serverURL string) *Client {
|
||||
config := DefaultConfig()
|
||||
config.Host = "localhost" // Will be overridden by baseURL
|
||||
client := NewClient(config)
|
||||
client.baseURL = serverURL
|
||||
|
||||
return client
|
||||
return NewClientFromHost(serverURL)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
// Mock speaker error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="08DF1F0BA325">
|
||||
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
|
||||
</errors>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(errorXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := createTestClient(server.URL)
|
||||
|
||||
// Test post method
|
||||
err := c.post("/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
errs := &models.ErrorsResponse{}
|
||||
ok := errors.As(err, &errs)
|
||||
if !ok {
|
||||
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if errs.DeviceID != "08DF1F0BA325" {
|
||||
t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID)
|
||||
}
|
||||
|
||||
if len(errs.Errors) != 1 {
|
||||
t.Fatalf("expected 1 error, got %d", len(errs.Errors))
|
||||
}
|
||||
|
||||
if errs.Errors[0].Value != 1029 {
|
||||
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
|
||||
}
|
||||
|
||||
if errs.Errors[0].Name != "UNKNOWN_ACTION_ERROR" {
|
||||
t.Errorf("expected error name UNKNOWN_ACTION_ERROR, got %s", errs.Errors[0].Name)
|
||||
}
|
||||
|
||||
expectedMsg := "This version of SCM does not support spotify create account functionality."
|
||||
if errs.Errors[0].Message != expectedMsg {
|
||||
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
|
||||
}
|
||||
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) {
|
||||
// Mock speaker error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="08DF1F0BA325">
|
||||
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
|
||||
</errors>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(errorXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := createTestClient(server.URL)
|
||||
|
||||
// Test postWithResponse method
|
||||
var result struct {
|
||||
XMLName xml.Name `xml:"status"`
|
||||
Data string `xml:",chardata"`
|
||||
}
|
||||
err := c.postWithResponse("/test", nil, &result)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
errs := &models.ErrorsResponse{}
|
||||
ok := errors.As(err, &errs)
|
||||
if !ok {
|
||||
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if errs.Errors[0].Value != 1029 {
|
||||
t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Post_StandardAPIError(t *testing.T) {
|
||||
// Mock standard API error response
|
||||
errorXML := `<?xml version="1.0" encoding="UTF-8"?><error code="404">Not Found</error>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(errorXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := createTestClient(server.URL)
|
||||
|
||||
err := c.post("/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
apiErr := &models.APIError{}
|
||||
ok := errors.As(err, &apiErr)
|
||||
if !ok {
|
||||
t.Fatalf("expected models.APIError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if apiErr.Code != 404 {
|
||||
t.Errorf("expected code 404, got %d", apiErr.Code)
|
||||
}
|
||||
|
||||
if apiErr.Message != "Not Found" {
|
||||
t.Errorf("expected message 'Not Found', got '%s'", apiErr.Message)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
|
||||
+42
-2
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
@@ -19,6 +20,7 @@ type WebSocketClient struct {
|
||||
conn *websocket.Conn
|
||||
handlers *models.WebSocketEventHandlers
|
||||
mu sync.RWMutex
|
||||
writeMu sync.Mutex // serializes all writes; gorilla/websocket allows one concurrent writer
|
||||
connected bool
|
||||
reconnect bool
|
||||
ctx context.Context
|
||||
@@ -336,8 +338,12 @@ func (ws *WebSocketClient) pingLoop(config *WebSocketConfig) {
|
||||
}
|
||||
|
||||
// Set write deadline for ping
|
||||
ws.writeMu.Lock()
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
err := conn.WriteMessage(websocket.PingMessage, nil)
|
||||
ws.writeMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
ws.logger.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -514,9 +520,43 @@ func (ws *WebSocketClient) SendMessage(message []byte) error {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ws.writeMu.Lock()
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
err := conn.WriteMessage(websocket.TextMessage, message)
|
||||
ws.writeMu.Unlock()
|
||||
|
||||
return conn.WriteMessage(websocket.TextMessage, message)
|
||||
return err
|
||||
}
|
||||
|
||||
// PairWithAccount sends a request to pair the device with a specific account
|
||||
func (ws *WebSocketClient) PairWithAccount(accountID, userAuthToken string) error {
|
||||
request := models.PairDeviceWithAccount{
|
||||
AccountID: accountID,
|
||||
UserAuthToken: userAuthToken,
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal pairing request: %w", err)
|
||||
}
|
||||
|
||||
ws.logger.Printf("Sending PairDeviceWithAccount for account %s", accountID)
|
||||
|
||||
return ws.SendMessage(data)
|
||||
}
|
||||
|
||||
// UnPairFromAccount sends a request to unpair the device from its account
|
||||
func (ws *WebSocketClient) UnPairFromAccount() error {
|
||||
request := models.UnPairDeviceWithAccount{}
|
||||
|
||||
data, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal unpairing request: %w", err)
|
||||
}
|
||||
|
||||
ws.logger.Printf("Sending UnPairDeviceWithAccount")
|
||||
|
||||
return ws.SendMessage(data)
|
||||
}
|
||||
|
||||
// Wait blocks until the WebSocket connection is closed or context is cancelled
|
||||
|
||||
@@ -384,8 +384,24 @@ func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
|
||||
|
||||
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
|
||||
upstreamDNS := []string{"192.0.2.1:53"} // TEST-NET-1, usually non-routable
|
||||
|
||||
// Mock server that deliberately delays its response
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
time.Sleep(200 * time.Millisecond) // Longer than the timeout
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5358", Net: "udp", Handler: mux}
|
||||
go func() { _ = ts.ListenAndServe() }()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
// Give the server a moment to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
upstreamDNS := []string{"127.0.0.1:5358"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.timeout = 100 * time.Millisecond
|
||||
|
||||
@@ -398,12 +414,14 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
d.forward(rw, m)
|
||||
duration := time.Since(start)
|
||||
|
||||
// Since we're forwarding to a local server that sleeps for 200ms,
|
||||
// and our timeout is 100ms, it should take at least 100ms.
|
||||
if duration < 100*time.Millisecond {
|
||||
t.Errorf("Expected forward to take at least 100ms (timeout), but took %v", duration)
|
||||
}
|
||||
|
||||
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
|
||||
t.Errorf("Expected RcodeServerFailure after timeout")
|
||||
t.Errorf("Expected RcodeServerFailure after timeout, got msg: %v", rw.msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+48
-1
@@ -110,13 +110,60 @@ func (cred *MusicServiceCredentials) GetDescription() string {
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthCredentials represents the credentials sent to /setMusicServiceOAuthAccount
|
||||
type OAuthCredentials struct {
|
||||
XMLName xml.Name `xml:"OAuthCredentials"`
|
||||
Source string `xml:"source,attr"`
|
||||
DisplayName string `xml:"displayName,attr,omitempty"`
|
||||
User string `xml:"user"`
|
||||
Code string `xml:"code"`
|
||||
Version string `xml:"version"`
|
||||
}
|
||||
|
||||
// NewSpotifyOAuthCredentials creates OAuth credentials for Spotify
|
||||
func NewSpotifyOAuthCredentials(user, code, displayName string) *OAuthCredentials {
|
||||
if displayName == "" {
|
||||
displayName = user
|
||||
}
|
||||
|
||||
return &OAuthCredentials{
|
||||
Source: "SPOTIFY",
|
||||
DisplayName: displayName,
|
||||
User: user,
|
||||
Code: code,
|
||||
Version: "token_version_3",
|
||||
}
|
||||
}
|
||||
|
||||
// NewAmazonOAuthCredentials creates OAuth credentials for Amazon Music (cs1 / "token").
|
||||
// code is the AmazonSecret JSON envelope stored as the credential in Sources.xml.
|
||||
func NewAmazonOAuthCredentials(user, code, displayName string) *OAuthCredentials {
|
||||
if displayName == "" {
|
||||
displayName = user
|
||||
}
|
||||
|
||||
return &OAuthCredentials{
|
||||
Source: "AMAZON",
|
||||
DisplayName: displayName,
|
||||
User: user,
|
||||
Code: code,
|
||||
Version: "token",
|
||||
}
|
||||
}
|
||||
|
||||
// MusicServiceAccountResponse represents the response from account management operations
|
||||
type MusicServiceAccountResponse struct {
|
||||
XMLName xml.Name `xml:"status"`
|
||||
Status string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// SourcesUpdatedResponse represents the response from /notification
|
||||
type SourcesUpdatedResponse struct {
|
||||
XMLName xml.Name `xml:"status"`
|
||||
Status string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// IsSuccess returns true if the account operation was successful
|
||||
func (resp *MusicServiceAccountResponse) IsSuccess() bool {
|
||||
return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount"
|
||||
return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount" || resp.Status == "/notification"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ type NetworkInfo struct {
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
}
|
||||
|
||||
// SourcesUpdatedNotification represents the notification XML sent to the device
|
||||
type SourcesUpdatedNotification struct {
|
||||
XMLName xml.Name `xml:"updates"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Sources struct {
|
||||
XMLName xml.Name `xml:"sourcesUpdated"`
|
||||
} `xml:"sourcesUpdated"`
|
||||
}
|
||||
|
||||
// NewSourcesUpdatedNotification creates a new sources updated notification
|
||||
func NewSourcesUpdatedNotification(deviceID string) *SourcesUpdatedNotification {
|
||||
return &SourcesUpdatedNotification{
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
// XMLResponse is a generic wrapper for API responses
|
||||
type XMLResponse struct {
|
||||
XMLName xml.Name
|
||||
@@ -53,6 +69,29 @@ func (e *APIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ErrorsResponse represents a multi-error response from the API (common in some firmware versions)
|
||||
type ErrorsResponse struct {
|
||||
XMLName xml.Name `xml:"errors"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Errors []DeviceError `xml:"error"`
|
||||
}
|
||||
|
||||
// Error implements the error interface for ErrorsResponse
|
||||
func (e *ErrorsResponse) Error() string {
|
||||
if len(e.Errors) > 0 {
|
||||
return e.Errors[0].Message
|
||||
}
|
||||
|
||||
return "unknown API error"
|
||||
}
|
||||
|
||||
// DeviceError represents a single error in an ErrorsResponse
|
||||
type DeviceError struct {
|
||||
Value int `xml:"value,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Message string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// DiscoveredDevice represents a device found through network discovery
|
||||
type DiscoveredDevice struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// Group represents a stereo pair of two ST10 SoundTouch speakers.
|
||||
type Group struct {
|
||||
XMLName xml.Name `xml:"group"`
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
Name string `xml:"name"`
|
||||
MasterDeviceID string `xml:"masterDeviceId"`
|
||||
Roles GroupRoles `xml:"roles"`
|
||||
SenderIPAddress string `xml:"senderIPAddress,omitempty"`
|
||||
}
|
||||
|
||||
// GroupRoles contains the role assignments for devices in a group.
|
||||
type GroupRoles struct {
|
||||
Roles []GroupRole `xml:"groupRole"`
|
||||
}
|
||||
|
||||
// GroupRole describes the role (LEFT or RIGHT) of a single device in a group.
|
||||
type GroupRole struct {
|
||||
DeviceID string `xml:"deviceId"`
|
||||
Role string `xml:"role"`
|
||||
IPAddress string `xml:"ipAddress,omitempty"`
|
||||
}
|
||||
+562
-100
@@ -5,12 +5,20 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Link represents a navigational link with URL and client usage preferences.
|
||||
type Link struct {
|
||||
Href string `json:"href" xml:"href,attr"`
|
||||
UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"`
|
||||
Href string `json:"href" xml:"href,attr"`
|
||||
UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"`
|
||||
ContainerArt string `json:"containerArt,omitempty" xml:"-"`
|
||||
Filters interface{} `json:"filters,omitempty" xml:"-"`
|
||||
Name string `json:"name,omitempty" xml:"-"`
|
||||
Templated *bool `json:"templated,omitempty" xml:"-"`
|
||||
Type string `json:"type,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// Links contains various navigation links used by BMX services.
|
||||
@@ -25,6 +33,32 @@ type Links struct {
|
||||
BmxFavorite *Link `json:"bmx_favorite,omitempty" xml:"bmx_favorite,omitempty"`
|
||||
BmxNowPlaying *Link `json:"bmx_nowplaying,omitempty" xml:"bmx_nowplaying,omitempty"`
|
||||
BmxTrack *Link `json:"bmx_track,omitempty" xml:"bmx_track,omitempty"`
|
||||
BmxSearch *Link `json:"bmx_search,omitempty" xml:"-"`
|
||||
BmxPlayback *Link `json:"bmx_playback,omitempty" xml:"-"`
|
||||
BmxPreset *Link `json:"bmx_preset,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// BmxNavItem represents a single item in a TuneIn browse or search result.
|
||||
type BmxNavItem struct {
|
||||
Links *Links `json:"_links,omitempty"`
|
||||
ImageUrl string `json:"imageUrl,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
}
|
||||
|
||||
// BmxNavSection represents a group of navigation items with a layout hint.
|
||||
type BmxNavSection struct {
|
||||
Links *Links `json:"_links,omitempty"`
|
||||
Items []BmxNavItem `json:"items"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// BmxNavResponse is the top-level response for TuneIn navigate and search endpoints.
|
||||
type BmxNavResponse struct {
|
||||
Links *Links `json:"_links,omitempty"`
|
||||
BmxSections []BmxNavSection `json:"bmx_sections"`
|
||||
Layout string `json:"layout"`
|
||||
}
|
||||
|
||||
// IconSet represents a collection of icons with different sizes for media content.
|
||||
@@ -135,58 +169,318 @@ type ServiceContentItem struct {
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
|
||||
Location string `json:"location" xml:"location"`
|
||||
Type string `json:"type,omitempty" xml:"type,attr,omitempty"`
|
||||
ContentItemType string `json:"content_item_type,omitempty" xml:"contentItemType,omitempty"`
|
||||
Location string `json:"location,omitempty" xml:"location,attr,omitempty"`
|
||||
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty" xml:"sourceid"`
|
||||
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
|
||||
Username string `json:"username,omitempty" xml:"username,omitempty"`
|
||||
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
|
||||
}
|
||||
|
||||
// ServicePreset represents a user-defined preset for quick access to media content.
|
||||
type ServicePreset struct {
|
||||
ServiceContentItem
|
||||
ContainerArt string `json:"container_art" xml:"containerArt"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
|
||||
Username string `json:"-" xml:"username,omitempty"`
|
||||
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
|
||||
ID string `json:"id,omitempty" xml:"id,attr,omitempty"`
|
||||
ContainerArt string `json:"container_art" xml:"containerArt"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
|
||||
Username string `json:"-" xml:"username,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceRecent represents recently played media content.
|
||||
// MarshalXML implements the xml.Marshaler interface for ServicePreset to match upstream parity.
|
||||
func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
type Alias struct {
|
||||
ButtonNumber string `xml:"buttonNumber,attr,omitempty"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
ContentItemType string `xml:"contentItemType"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
Location string `xml:"location"`
|
||||
Name string `xml:"name"`
|
||||
SourceID string `xml:"sourceid,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
|
||||
createdOn := p.CreatedOn
|
||||
if _, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
|
||||
if t, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
|
||||
createdOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
||||
}
|
||||
}
|
||||
|
||||
updatedOn := p.UpdatedOn
|
||||
if _, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
|
||||
if t, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
|
||||
updatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
||||
}
|
||||
}
|
||||
|
||||
a := Alias{
|
||||
ButtonNumber: p.ButtonNumber,
|
||||
ContainerArt: p.ContainerArt,
|
||||
ContentItemType: p.ContentItemType,
|
||||
CreatedOn: createdOn,
|
||||
Location: p.Location,
|
||||
Name: p.Name,
|
||||
SourceID: p.SourceID,
|
||||
UpdatedOn: updatedOn,
|
||||
Username: p.Username,
|
||||
}
|
||||
|
||||
if a.Username == "" && a.Name != "" {
|
||||
a.Username = a.Name
|
||||
}
|
||||
|
||||
start.Name.Local = "preset"
|
||||
// Remove all attributes because they are handled in Alias
|
||||
start.Attr = nil
|
||||
|
||||
return e.EncodeElement(a, start)
|
||||
}
|
||||
|
||||
// ServiceRecent represents recently played media content as stored in Recents.xml.
|
||||
type ServiceRecent struct {
|
||||
XMLName xml.Name `json:"-" xml:"recent"`
|
||||
ServiceContentItem
|
||||
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
||||
UtcTime string `json:"utc_time" xml:"utcTime,attr"`
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
|
||||
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
|
||||
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
|
||||
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat"`
|
||||
DeviceID string `json:"device_id" xml:"deviceID,attr,omitempty"`
|
||||
UtcTime string `json:"utc_time" xml:"utcTime,attr,omitempty"`
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
|
||||
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat,omitempty"`
|
||||
}
|
||||
|
||||
// RecentItemParity represents recently played media content for web API responses (flat format).
|
||||
type RecentItemParity struct {
|
||||
XMLName xml.Name `xml:"recent"`
|
||||
ID string `xml:"id,attr"`
|
||||
ContentItemType string `xml:"contentItemType"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
LastPlayedAt string `xml:"lastplayedat"`
|
||||
Location string `xml:"location"`
|
||||
Name string `xml:"name"`
|
||||
Source *RecentItemParitySource `xml:"source,omitempty"`
|
||||
SourceID string `xml:"sourceid"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// RecentItemParitySource represents the source in a RecentItemParity.
|
||||
type RecentItemParitySource struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
Credential *RecentItemParityCredential `xml:"credential"`
|
||||
Name string `xml:"name"`
|
||||
SourceProviderID string `xml:"sourceproviderid"`
|
||||
SourceName string `xml:"sourcename"`
|
||||
SourceSettings string `xml:"sourceSettings"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
|
||||
// RecentItemParityCredential represents the credential in a RecentItemParitySource.
|
||||
type RecentItemParityCredential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// UnmarshalXML implements the xml.Unmarshaler interface to handle both nested and flat formats for ServiceRecent.
|
||||
func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
type NestedContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt,omitempty"`
|
||||
}
|
||||
|
||||
type Alias struct {
|
||||
XMLName xml.Name `xml:"recent"`
|
||||
ServiceContentItem
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
UtcTime string `xml:"utcTime,attr"`
|
||||
ID string `xml:"id,attr"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
ContainerArt string `xml:"containerArt,omitempty"`
|
||||
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
|
||||
LastPlayedAt string `xml:"lastplayedat"`
|
||||
ContentItem *NestedContentItem `xml:"contentItem,omitempty"`
|
||||
// Flat format might use these tags
|
||||
FlatLocation string `xml:"location"`
|
||||
FlatTypeTag string `xml:"type"`
|
||||
AttrType string `xml:"type,attr"`
|
||||
FlatSourceAccount string `xml:"sourceAccount"`
|
||||
FlatIsPresetable string `xml:"isPresetable"`
|
||||
FlatContentItemType string `xml:"contentItemType"`
|
||||
AttrContentItemType string `xml:"contentItemType,attr"`
|
||||
FlatName string `xml:"name"`
|
||||
FlatSourceID string `xml:"sourceid"`
|
||||
FlatSourceIDAttr string `xml:"sourceid,attr"`
|
||||
FlatSource string `xml:"source_key"`
|
||||
AttrSource string `xml:"source,attr"`
|
||||
}
|
||||
|
||||
var a Alias
|
||||
if err := d.DecodeElement(&a, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.ServiceContentItem = a.ServiceContentItem
|
||||
r.DeviceID = a.DeviceID
|
||||
r.UtcTime = a.UtcTime
|
||||
r.ID = a.ID
|
||||
r.CreatedOn = a.CreatedOn
|
||||
r.UpdatedOn = a.UpdatedOn
|
||||
r.ContainerArt = a.ContainerArt
|
||||
r.LastPlayedAt = a.LastPlayedAt
|
||||
r.SourceID = a.FlatSourceID
|
||||
|
||||
// Prefer nested contentItem data if present
|
||||
if a.ContentItem != nil {
|
||||
r.Source = a.ContentItem.Source
|
||||
r.Type = a.ContentItem.Type
|
||||
r.ContentItemType = a.ContentItem.Type // Set ContentItemType from nested type
|
||||
r.Location = a.ContentItem.Location
|
||||
r.SourceAccount = a.ContentItem.SourceAccount
|
||||
r.IsPresetable = a.ContentItem.IsPresetable
|
||||
|
||||
r.Name = a.ContentItem.ItemName
|
||||
if a.ContentItem.ContainerArt != "" {
|
||||
r.ContainerArt = a.ContentItem.ContainerArt
|
||||
}
|
||||
} else {
|
||||
// Fallback to flat fields
|
||||
if a.FlatLocation != "" {
|
||||
r.Location = a.FlatLocation
|
||||
}
|
||||
|
||||
switch {
|
||||
case a.FlatContentItemType != "":
|
||||
r.ContentItemType = a.FlatContentItemType
|
||||
case a.FlatTypeTag != "":
|
||||
r.ContentItemType = a.FlatTypeTag
|
||||
case a.AttrType != "":
|
||||
r.ContentItemType = a.AttrType
|
||||
}
|
||||
|
||||
switch {
|
||||
case a.FlatTypeTag != "":
|
||||
r.Type = a.FlatTypeTag
|
||||
case a.AttrType != "":
|
||||
r.Type = a.AttrType
|
||||
}
|
||||
|
||||
switch {
|
||||
case a.FlatSourceID != "":
|
||||
r.SourceID = a.FlatSourceID
|
||||
case a.FlatSourceIDAttr != "":
|
||||
r.SourceID = a.FlatSourceIDAttr
|
||||
}
|
||||
|
||||
switch {
|
||||
case a.FlatSource != "":
|
||||
r.Source = a.FlatSource
|
||||
case a.AttrSource != "":
|
||||
r.Source = a.AttrSource
|
||||
}
|
||||
|
||||
if a.FlatName != "" {
|
||||
r.Name = a.FlatName
|
||||
}
|
||||
|
||||
if a.FlatSourceAccount != "" {
|
||||
r.SourceAccount = a.FlatSourceAccount
|
||||
}
|
||||
|
||||
if a.FlatIsPresetable != "" {
|
||||
r.IsPresetable = a.FlatIsPresetable
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ServiceRecent (nested format).
|
||||
func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
type NestedContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt,omitempty"`
|
||||
}
|
||||
|
||||
type Alias struct {
|
||||
XMLName xml.Name `xml:"recent"`
|
||||
ID string `xml:"id,attr"`
|
||||
DeviceID string `xml:"deviceID,attr,omitempty"`
|
||||
UtcTime string `xml:"utcTime,attr,omitempty"`
|
||||
ContentItem *NestedContentItem `xml:"contentItem"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
LastPlayedAt string `xml:"lastplayedat"`
|
||||
SourceID string `xml:"sourceid"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
|
||||
a := Alias{
|
||||
ID: r.ID,
|
||||
DeviceID: r.DeviceID,
|
||||
UtcTime: r.UtcTime,
|
||||
CreatedOn: r.CreatedOn,
|
||||
UpdatedOn: r.UpdatedOn,
|
||||
LastPlayedAt: r.LastPlayedAt,
|
||||
SourceID: r.SourceID,
|
||||
Username: r.Name, // Using Name as Username for parity
|
||||
ContentItem: &NestedContentItem{
|
||||
Source: r.Source,
|
||||
Type: r.Type,
|
||||
Location: r.Location,
|
||||
SourceAccount: r.SourceAccount,
|
||||
IsPresetable: r.IsPresetable,
|
||||
ItemName: r.Name,
|
||||
ContainerArt: r.ContainerArt,
|
||||
},
|
||||
}
|
||||
|
||||
start.Name.Local = "recent"
|
||||
|
||||
return e.EncodeElement(a, start)
|
||||
}
|
||||
|
||||
// ConfiguredSource represents a configured media source with authentication details.
|
||||
type ConfiguredSource struct {
|
||||
XMLName xml.Name `json:"-" xml:"source"`
|
||||
DisplayName string `json:"display_name" xml:"name"`
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Secret string `json:"secret" xml:"credential"`
|
||||
SecretType string `json:"secret_type" xml:"credential_type,attr"`
|
||||
SourceKey struct {
|
||||
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
|
||||
ID string `json:"id" xml:"id,attr,omitempty"`
|
||||
Secret string `json:"secret" xml:"secret,attr,omitempty"`
|
||||
SecretType string `json:"secret_type" xml:"secretType,attr,omitempty"`
|
||||
Credential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `json:"-" xml:"credential"`
|
||||
SourceKey struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
} `json:"source_key" xml:"source_key"`
|
||||
Type string `xml:"type,attr"`
|
||||
} `json:"source_key" xml:"sourceKey"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
|
||||
// Parity fields
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
|
||||
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid"`
|
||||
Username string `json:"username,omitempty" xml:"username"`
|
||||
SourceName string `json:"source_name,omitempty" xml:"sourcename"`
|
||||
SourceSettings string `json:"-" xml:"sourceSettings"`
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
|
||||
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid,omitempty"`
|
||||
Username string `json:"username,omitempty" xml:"username,omitempty"`
|
||||
SourceName string `json:"source_name,omitempty" xml:"sourcename,omitempty"`
|
||||
Name string `json:"name,omitempty" xml:"name,omitempty"`
|
||||
SourceSettings string `json:"-" xml:"sourceSettings,omitempty"`
|
||||
Status string `json:"status,omitempty" xml:"-"`
|
||||
|
||||
// Legacy fields for backward compatibility in code if needed,
|
||||
// though it's better to update the code to use SourceKey.
|
||||
@@ -194,22 +488,98 @@ type ConfiguredSource struct {
|
||||
SourceKeyAccount string `json:"source_key_account" xml:"-"`
|
||||
}
|
||||
|
||||
type sourceCredential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type sourceAlias struct {
|
||||
XMLName xml.Name `xml:"source"`
|
||||
DisplayName string `xml:"displayName,attr,omitempty"`
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
Credential *sourceCredential `xml:"credential,omitempty"`
|
||||
Name string `xml:"name"`
|
||||
SourceProviderID string `xml:"sourceproviderid,omitempty"`
|
||||
SourceName string `xml:"sourcename"`
|
||||
SourceSettings string `xml:"sourceSettings"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
|
||||
func (s ConfiguredSource) getFirstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
|
||||
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
type Alias ConfiguredSource
|
||||
|
||||
a := struct {
|
||||
Alias
|
||||
Username string `xml:"username"`
|
||||
SourceName string `xml:"sourcename"`
|
||||
SourceSettings string `xml:"sourceSettings"`
|
||||
}{
|
||||
Alias: Alias(s),
|
||||
a := sourceAlias{
|
||||
XMLName: xml.Name{Local: start.Name.Local},
|
||||
DisplayName: s.DisplayName,
|
||||
ID: s.ID,
|
||||
Type: s.Type,
|
||||
CreatedOn: s.CreatedOn,
|
||||
Name: s.Name,
|
||||
SourceProviderID: s.SourceProviderID,
|
||||
SourceName: s.SourceName,
|
||||
SourceSettings: s.SourceSettings,
|
||||
UpdatedOn: s.UpdatedOn,
|
||||
Username: s.Username,
|
||||
}
|
||||
a.Username = s.Username
|
||||
|
||||
// Bose XML for sources usually does NOT include displayName attribute
|
||||
// except for when it's explicitly stored in our datastore as such.
|
||||
// For parity with official responses, we omit it if ID is present or for standard sources.
|
||||
if s.ID != "" || s.SourceKeyType != "" || s.Type != "" {
|
||||
a.DisplayName = ""
|
||||
}
|
||||
|
||||
a.Name = s.Name
|
||||
a.SourceName = s.SourceName
|
||||
// We want <sourceSettings/>
|
||||
a.SourceSettings = ""
|
||||
a.Username = s.Username
|
||||
|
||||
// Parity: for TuneIn and some others, sourcename, name and username should NOT automatically fall back to displayName
|
||||
// if they are intended to be empty. However, if they are ALL empty, we need some value.
|
||||
isTuneIn := strings.EqualFold(s.DisplayName, "TUNEIN") || strings.EqualFold(s.SourceKeyType, "TUNEIN") || strings.EqualFold(s.ID, "TUNEIN")
|
||||
|
||||
if a.Name == "" {
|
||||
a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
|
||||
}
|
||||
|
||||
if a.SourceName == "" && !isTuneIn {
|
||||
a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
|
||||
}
|
||||
|
||||
if a.Username == "" && !isTuneIn {
|
||||
a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
|
||||
}
|
||||
|
||||
if s.Secret != "" || s.SecretType != "" {
|
||||
a.Credential = &sourceCredential{
|
||||
Type: s.SecretType,
|
||||
Value: s.Secret,
|
||||
}
|
||||
} else if s.Credential.Value != "" || s.Credential.Type != "" {
|
||||
a.Credential = &sourceCredential{
|
||||
Type: s.Credential.Type,
|
||||
Value: s.Credential.Value,
|
||||
}
|
||||
}
|
||||
|
||||
if a.SourceSettings == "" {
|
||||
a.SourceSettings = ""
|
||||
}
|
||||
|
||||
// Important: Clear automatically generated attributes from the start element
|
||||
// because we are using Alias to control attribute order and presence.
|
||||
start.Attr = nil
|
||||
|
||||
return e.EncodeElement(a, start)
|
||||
}
|
||||
@@ -231,11 +601,19 @@ type ServiceDeviceInfo struct {
|
||||
|
||||
// ServiceComponent represents a hardware or software component of a device.
|
||||
type ServiceComponent struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Category string `xml:"category,attr,omitempty"`
|
||||
SoftwareVersion string `xml:"firmware-version"`
|
||||
SerialNumber string `xml:"serialnumber"`
|
||||
Label string `xml:"componentlabel,omitempty"`
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
Category string `json:"category,omitempty" xml:"category,attr,omitempty"`
|
||||
SoftwareVersion string `json:"firmware_version" xml:"firmware-version"`
|
||||
SerialNumber string `json:"serial_number" xml:"serialnumber"`
|
||||
Label string `json:"label,omitempty" xml:"componentlabel"`
|
||||
}
|
||||
|
||||
// ServiceAccountInfo represents account-level metadata.
|
||||
type ServiceAccountInfo struct {
|
||||
AccountID string `json:"account_id"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
ProviderSettings []ProviderSetting `json:"provider_settings"`
|
||||
IsPlaceholder bool `json:"is_placeholder,omitempty"`
|
||||
}
|
||||
|
||||
// CustomerSupportDevice represents device information for customer support purposes.
|
||||
@@ -361,45 +739,50 @@ type EmailAddressResponse struct {
|
||||
// FullResponseSource represents a configured media source specifically for the /full response.
|
||||
// It follows the specific XML structure and field order of the upstream /full response.
|
||||
type FullResponseSource struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
Credential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"credential"`
|
||||
Name string `xml:"name"`
|
||||
SourceProviderID string `xml:"sourceproviderid"`
|
||||
SourceName string `xml:"sourcename"`
|
||||
SourceSettings string `xml:"sourceSettings"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
Credential struct {
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
Value string `json:"value" xml:",chardata"`
|
||||
} `json:"credential" xml:"credential"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
SourceProviderID string `json:"sourceproviderid" xml:"sourceproviderid"`
|
||||
SourceName string `json:"source_name" xml:"sourcename"`
|
||||
SourceSettings string `json:"source_settings" xml:"sourceSettings"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
Username string `json:"username" xml:"username"`
|
||||
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
|
||||
SourceLabel string `json:"source_label" xml:"-"`
|
||||
ProviderLabel string `json:"provider_label,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// FullResponsePreset represents a preset specifically for the /full response.
|
||||
type FullResponsePreset struct {
|
||||
ButtonNumber string `xml:"buttonNumber,attr"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
ContentItemType string `xml:"contentItemType"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
Location string `xml:"location"`
|
||||
Name string `xml:"name"`
|
||||
Source FullResponseSource `xml:"source"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
ButtonNumber string `json:"button_number" xml:"buttonNumber,attr"`
|
||||
ContainerArt string `json:"container_art" xml:"containerArt"`
|
||||
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
Location string `json:"location" xml:"location"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
Source FullResponseSource `json:"source" xml:"source"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
Username string `json:"username" xml:"username"`
|
||||
}
|
||||
|
||||
// FullResponseRecent represents a recent item specifically for the /full response.
|
||||
type FullResponseRecent struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ContentItemType string `xml:"contentItemType"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
LastPlayedAt string `xml:"lastplayedat"`
|
||||
Location string `xml:"location"`
|
||||
Name string `xml:"name"`
|
||||
Source FullResponseSource `xml:"source"`
|
||||
SourceID string `xml:"sourceid"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
LastPlayedAt string `json:"last_played_at" xml:"lastplayedat"`
|
||||
Location string `json:"location" xml:"location"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
Source FullResponseSource `json:"source" xml:"source"`
|
||||
SourceID string `json:"source_id" xml:"sourceid"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
Username string `json:"username" xml:"username"`
|
||||
}
|
||||
|
||||
// AccountFullResponse represents the complete account XML structure.
|
||||
@@ -414,33 +797,112 @@ type AccountFullResponse struct {
|
||||
Sources []FullResponseSource `xml:"sources>source"`
|
||||
}
|
||||
|
||||
// AccountSourcesResponse represents the response from /streaming/account/{accountId}/sources.
|
||||
type AccountSourcesResponse struct {
|
||||
XMLName xml.Name `xml:"sources"`
|
||||
Sources []FullResponseSource `xml:"source"`
|
||||
}
|
||||
|
||||
// AccountDevicesResponse represents the response from /streaming/account/{accountId}/devices.
|
||||
type AccountDevicesResponse struct {
|
||||
XMLName xml.Name `xml:"devices"`
|
||||
Devices []MargeAccountDevice `xml:"device"`
|
||||
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
|
||||
}
|
||||
|
||||
// MargeAccountDevice represents a device specifically for the /devices response.
|
||||
// It matches the structure in 06_orig.xml, which is a subset of AccountDevice.
|
||||
type MargeAccountDevice struct {
|
||||
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
||||
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
IPAddress string `json:"ip_address" xml:"ipaddress"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// AccountDevice represents a device in the account response.
|
||||
type AccountDevice struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
AttachedProduct *AttachedProduct `xml:"attachedProduct"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
FirmwareVersion string `xml:"firmwareVersion"`
|
||||
IPAddress string `xml:"ipaddress"`
|
||||
Name string `xml:"name"`
|
||||
Presets []FullResponsePreset `xml:"presets>preset"`
|
||||
Recents []FullResponseRecent `xml:"recents>recent"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
||||
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
|
||||
IPAddress string `json:"ip_address" xml:"ipaddress"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
Presets []FullResponsePreset `json:"presets" xml:"presets>preset,omitempty"`
|
||||
ProductCode string `json:"product_code" xml:"-"`
|
||||
Recents []FullResponseRecent `json:"recents" xml:"recents>recent,omitempty"`
|
||||
SerialNumber string `json:"serial_number" xml:"serialNumber,omitempty"`
|
||||
DeviceSerialNumber string `json:"device_serial_number,omitempty" xml:"-"`
|
||||
MacAddress string `json:"mac_address,omitempty" xml:"-"`
|
||||
DiscoveryMethod string `json:"discovery_method,omitempty" xml:"-"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// AttachedProduct represents product information for a device.
|
||||
type AttachedProduct struct {
|
||||
ProductCode string `xml:"product_code,attr"`
|
||||
Components []ServiceComponent `xml:"components>component"`
|
||||
ProductLabel string `xml:"productlabel"`
|
||||
SerialNumber string `xml:"serialnumber"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
ProductCode string `json:"product_code" xml:"product_code,attr"`
|
||||
Components []ServiceComponent `json:"components" xml:"components>component,omitempty"`
|
||||
ProductLabel string `json:"product_label" xml:"productlabel"`
|
||||
SerialNumber string `json:"serial_number" xml:"serialnumber"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// ProviderSetting represents a single provider setting.
|
||||
type ProviderSetting struct {
|
||||
BoseID string `xml:"boseId"`
|
||||
KeyName string `xml:"keyName"`
|
||||
Value string `xml:"value"`
|
||||
ProviderID string `xml:"providerId"`
|
||||
BoseID string `json:"bose_id" xml:"boseId"`
|
||||
KeyName string `json:"key_name" xml:"keyName"`
|
||||
Value string `json:"value" xml:"value"`
|
||||
ProviderID string `json:"provider_id" xml:"providerId"`
|
||||
ProviderName string `json:"provider_name,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// MargeLoginRequest represents a login request from Stockholm.
|
||||
type MargeLoginRequest struct {
|
||||
XMLName xml.Name `xml:"login"`
|
||||
Username string `xml:"username"`
|
||||
Password string `xml:"password"`
|
||||
}
|
||||
|
||||
// MargeAccountCreateRequest represents an account creation request from Stockholm.
|
||||
type MargeAccountCreateRequest struct {
|
||||
XMLName xml.Name `xml:"account"`
|
||||
ID string `xml:"id,attr,omitempty"` // Optional ID for testing/overrides
|
||||
FirstName string `xml:"firstName"`
|
||||
LastName string `xml:"lastName"`
|
||||
Email string `xml:"email"`
|
||||
Password string `xml:"password"`
|
||||
CountryCode string `xml:"countryCode"`
|
||||
PreferredLanguage string `xml:"preferredLanguage"`
|
||||
}
|
||||
|
||||
// MargeAddSourceResponse represents the response after adding a source to Marge.
|
||||
type MargeAddSourceResponse struct {
|
||||
XMLName xml.Name `xml:"source"`
|
||||
SourceID string `xml:"sourceID"`
|
||||
SourceProviderID string `xml:"sourceProviderID"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// EligibilityResponse represents the XML response for music provider eligibility.
|
||||
type EligibilityResponse struct {
|
||||
XMLName xml.Name `xml:"eligibility"`
|
||||
IsEligible bool `xml:"isEligible"`
|
||||
}
|
||||
|
||||
// MargeAPIVersionsResponse represents the XML response for Marge API versions.
|
||||
type MargeAPIVersionsResponse struct {
|
||||
XMLName xml.Name `xml:"marge"`
|
||||
Version string `xml:"version,attr"`
|
||||
Project string `xml:"project,attr"`
|
||||
Apis []MargeAPI `xml:"apis>api"`
|
||||
Dependencies string `xml:"dependencies"`
|
||||
}
|
||||
|
||||
// MargeAPI represents a single API entry in MargeAPIVersionsResponse.
|
||||
type MargeAPI struct {
|
||||
Type string `xml:"type,attr"`
|
||||
XML string `xml:"xml"`
|
||||
JSON string `xml:"json"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServiceRecent_Parity(t *testing.T) {
|
||||
t.Run("Unmarshal local response (nested contentItem)", func(t *testing.T) {
|
||||
localXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<recent deviceID="" utcTime="1774176828" id="2568595253">
|
||||
<contentItem source="Audio" type="" location="/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u" sourceAccount="user-name" isPresetable="true">
|
||||
<itemName>Coco, Pt. 1</itemName>
|
||||
</contentItem>
|
||||
<createdOn>2026-03-14T22:39:17.000+00:00</createdOn>
|
||||
<updatedOn>2026-03-14T22:39:17.000+00:00</updatedOn>
|
||||
<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>
|
||||
<sourceid>10863533</sourceid>
|
||||
<source displayName="user-name" secret="TOKEN" secretType="token_version_3" id="10863533" type="Audio" createdOn="2016-01-06T08:52:04.000+00:00" updatedOn="2020-04-25T20:29:11.000+00:00" sourceproviderid="15">
|
||||
<sourceKey type="Audio" account="user-name"></sourceKey>
|
||||
</source>
|
||||
</recent>`
|
||||
var recent ServiceRecent
|
||||
err := xml.Unmarshal([]byte(localXML), &recent)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if recent.ID != "2568595253" {
|
||||
t.Errorf("Expected ID 2568595253, got %s", recent.ID)
|
||||
}
|
||||
if recent.Name != "Coco, Pt. 1" {
|
||||
t.Errorf("Expected Name 'Coco, Pt. 1', got %s", recent.Name)
|
||||
}
|
||||
if recent.SourceID != "10863533" {
|
||||
t.Errorf("Expected SourceID 10863533, got %s", recent.SourceID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unmarshal upstream response (flat contentItem)", func(t *testing.T) {
|
||||
upstreamXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<recent id="2569047180">
|
||||
<contentItemType>tracklisturl</contentItemType>
|
||||
<createdOn>2026-03-22T10:00:04.000+00:00</createdOn>
|
||||
<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>
|
||||
<location>/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP</location>
|
||||
<name>Dopamine</name>
|
||||
<source id="10863533" type="Audio">
|
||||
<createdOn>2016-01-06T08:52:04.000+00:00</createdOn>
|
||||
<credential type="token_version_3">TOKEN</credential>
|
||||
<name>user-name</name>
|
||||
<sourceproviderid>15</sourceproviderid>
|
||||
<sourcename>user-name@mail.internal</sourcename>
|
||||
<sourceSettings/>
|
||||
<updatedOn>2020-04-25T20:29:11.000+00:00</updatedOn>
|
||||
<username>user-name</username>
|
||||
</source>
|
||||
<sourceid>10863533</sourceid>
|
||||
<updatedOn>2026-03-22T10:53:50.719+00:00</updatedOn>
|
||||
</recent>`
|
||||
var recent ServiceRecent
|
||||
err := xml.Unmarshal([]byte(upstreamXML), &recent)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if recent.ID != "2569047180" {
|
||||
t.Errorf("Expected ID 2569047180, got %s", recent.ID)
|
||||
}
|
||||
if recent.Name != "Dopamine" {
|
||||
t.Errorf("Expected Name 'Dopamine', got %s", recent.Name)
|
||||
}
|
||||
if recent.ContentItemType != "tracklisturl" {
|
||||
t.Errorf("Expected ContentItemType 'tracklisturl', got %s", recent.ContentItemType)
|
||||
}
|
||||
if recent.Location != "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP" {
|
||||
t.Errorf("Expected Location '/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP', got %s", recent.Location)
|
||||
}
|
||||
if recent.SourceID != "10863533" {
|
||||
t.Errorf("Expected SourceID 10863533, got %s", recent.SourceID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Marshal ServiceRecent should follow local style (nested)", func(t *testing.T) {
|
||||
recent := ServiceRecent{
|
||||
ServiceContentItem: ServiceContentItem{
|
||||
ID: "2569047180",
|
||||
Name: "Dopamine",
|
||||
ContentItemType: "tracklisturl",
|
||||
Location: "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP",
|
||||
SourceID: "10863533",
|
||||
Source: "SPOTIFY",
|
||||
Type: "tracklisturl",
|
||||
SourceAccount: "user-name",
|
||||
IsPresetable: "true",
|
||||
},
|
||||
CreatedOn: "2026-03-22T10:00:04.000+00:00",
|
||||
UpdatedOn: "2026-03-22T10:53:50.719+00:00",
|
||||
LastPlayedAt: "2026-03-22T10:53:48.000+00:00",
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(recent, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(data)
|
||||
if !contains_substr(xmlStr, "<contentItem ") || !contains_substr(xmlStr, "<itemName>Dopamine</itemName>") {
|
||||
t.Errorf("Marshaled ServiceRecent missing nested <contentItem> element\nGot: %s", xmlStr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Marshal RecentItemParity should follow upstream style (flat)", func(t *testing.T) {
|
||||
recent := RecentItemParity{
|
||||
ID: "2569047180",
|
||||
Name: "Dopamine",
|
||||
ContentItemType: "tracklisturl",
|
||||
Location: "/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP",
|
||||
SourceID: "10863533",
|
||||
CreatedOn: "2026-03-22T10:00:04.000+00:00",
|
||||
UpdatedOn: "2026-03-22T10:53:50.719+00:00",
|
||||
LastPlayedAt: "2026-03-22T10:53:48.000+00:00",
|
||||
Source: &RecentItemParitySource{
|
||||
ID: "10863533",
|
||||
Type: "Audio",
|
||||
Credential: &RecentItemParityCredential{
|
||||
Type: "token",
|
||||
Value: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(recent, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(data)
|
||||
expectedElements := []string{
|
||||
`<recent id="2569047180">`,
|
||||
`<contentItemType>tracklisturl</contentItemType>`,
|
||||
`<createdOn>2026-03-22T10:00:04.000+00:00</createdOn>`,
|
||||
`<lastplayedat>2026-03-22T10:53:48.000+00:00</lastplayedat>`,
|
||||
`<location>/playback/container/c3BvdGlmeTphbGJ1bTowMUpRS3RjQ1hIZGppVHpHRFk3NXhP</location>`,
|
||||
`<name>Dopamine</name>`,
|
||||
`<sourceid>10863533</sourceid>`,
|
||||
`<updatedOn>2026-03-22T10:53:50.719+00:00</updatedOn>`,
|
||||
`<credential type="token"></credential>`,
|
||||
}
|
||||
|
||||
for _, expected := range expectedElements {
|
||||
if !contains_substr(xmlStr, expected) {
|
||||
t.Errorf("Marshaled XML missing expected element: %s\nGot: %s", expected, xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
// It should NOT have nested contentItem
|
||||
if contains_substr(xmlStr, "<contentItem ") || contains_substr(xmlStr, "<contentItem>") {
|
||||
t.Errorf("Marshaled RecentItemParity should not have nested <contentItem> element\nGot: %s", xmlStr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Round-trip: Nested XML -> ServiceRecent -> Unmarshal -> Marshal -> Nested XML", func(t *testing.T) {
|
||||
nestedXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<recent deviceID="DEVICE_ID" utcTime="1774176828" id="2568595253">
|
||||
<contentItem source="Audio" type="TRACK" location="/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u" sourceAccount="user-name" isPresetable="true">
|
||||
<itemName>Coco, Pt. 1</itemName>
|
||||
</contentItem>
|
||||
</recent>`
|
||||
var recent1 ServiceRecent
|
||||
if err := xml.Unmarshal([]byte(nestedXML), &recent1); err != nil {
|
||||
t.Fatalf("Unmarshal nested failed: %v", err)
|
||||
}
|
||||
|
||||
// Marshal it (should produce nested XML again)
|
||||
nestedData, err := xml.MarshalIndent(recent1, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(nestedData)
|
||||
if !contains_substr(xmlStr, "<contentItem ") || !contains_substr(xmlStr, "<itemName>Coco, Pt. 1</itemName>") {
|
||||
t.Errorf("Round-trip failed to maintain nested structure\nGot: %s", xmlStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains_substr(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || (len(substr) > 0 && (s[:len(substr)] == substr || contains_substr(s[1:], substr))))
|
||||
}
|
||||
@@ -35,6 +35,10 @@ const (
|
||||
EventTypeRecentsUpdated WebSocketEventType = "recentsUpdated"
|
||||
// EventTypeLanguageUpdated indicates a language setting change
|
||||
EventTypeLanguageUpdated WebSocketEventType = "languageUpdated"
|
||||
// EventTypePairDeviceWithAccount indicates a device pairing request
|
||||
EventTypePairDeviceWithAccount WebSocketEventType = "PairDeviceWithAccount"
|
||||
// EventTypeUnPairDeviceWithAccount indicates a device unpairing request
|
||||
EventTypeUnPairDeviceWithAccount WebSocketEventType = "UnPairDeviceWithAccount"
|
||||
// EventTypeUnknown indicates an unrecognized event type
|
||||
EventTypeUnknown WebSocketEventType = "unknown"
|
||||
)
|
||||
@@ -66,6 +70,10 @@ func (e WebSocketEventType) String() string {
|
||||
return "Recents Updated"
|
||||
case EventTypeLanguageUpdated:
|
||||
return "Language Updated"
|
||||
case EventTypePairDeviceWithAccount:
|
||||
return "Pair Device With Account"
|
||||
case EventTypeUnPairDeviceWithAccount:
|
||||
return "UnPair Device With Account"
|
||||
default:
|
||||
return "Unknown Event"
|
||||
}
|
||||
@@ -299,6 +307,18 @@ type Language struct {
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// PairDeviceWithAccount represents a device pairing request message
|
||||
type PairDeviceWithAccount struct {
|
||||
XMLName xml.Name `xml:"PairDeviceWithAccount"`
|
||||
AccountID string `xml:"accountId"`
|
||||
UserAuthToken string `xml:"userAuthToken"`
|
||||
}
|
||||
|
||||
// UnPairDeviceWithAccount represents a device unpairing request message
|
||||
type UnPairDeviceWithAccount struct {
|
||||
XMLName xml.Name `xml:"UnPairDeviceWithAccount"`
|
||||
}
|
||||
|
||||
// SpecialMessageType represents message types that are not part of <updates>
|
||||
type SpecialMessageType string
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
// Package amazon provides Amazon Music (Login with Amazon) OAuth integration
|
||||
// and token management for the SoundTouch service.
|
||||
package amazon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// AmazonAuthorizeURL is the Login with Amazon (LWA) authorization endpoint.
|
||||
AmazonAuthorizeURL = "https://www.amazon.com/ap/oa"
|
||||
// AmazonTokenURL is the LWA token endpoint.
|
||||
AmazonTokenURL = "https://api.amazon.com/auth/o2/token"
|
||||
// AmazonProfileURL is the LWA user profile endpoint.
|
||||
AmazonProfileURL = "https://api.amazon.com/user/profile"
|
||||
// AmazonScopes are the OAuth scopes for account linking.
|
||||
// amazon_music:access is required for music-api.amazon.com but is only available
|
||||
// to device client IDs (Amazon Music partner apps), not standard application
|
||||
// client IDs (amzn1.application-oa2-client.*). Requesting it returns a 400
|
||||
// lwa-invalid-parameter-bad-scope error from the LWA authorization endpoint.
|
||||
AmazonScopes = "profile"
|
||||
)
|
||||
|
||||
// Account represents a stored Amazon account with tokens.
|
||||
type Account struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
BoseSecret string `json:"bose_secret,omitempty"`
|
||||
// SiteID is written into the AmazonSecret credential envelope. Its origin is
|
||||
// unconfirmed (may be a static Bose partner ID or a per-user Music API value).
|
||||
SiteID string `json:"site_id,omitempty"`
|
||||
}
|
||||
|
||||
// Service manages Amazon OAuth flow and token lifecycle.
|
||||
type Service struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURI string
|
||||
dataDir string
|
||||
mu sync.RWMutex
|
||||
accounts map[string]*Account
|
||||
|
||||
// Overridable URLs for testing
|
||||
tokenURL string
|
||||
profileURL string
|
||||
}
|
||||
|
||||
// NewAmazonService creates a new Service and loads any persisted accounts.
|
||||
func NewAmazonService(clientID, clientSecret, redirectURI, dataDir string) *Service {
|
||||
return &Service{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
redirectURI: redirectURI,
|
||||
dataDir: dataDir,
|
||||
accounts: make(map[string]*Account),
|
||||
tokenURL: AmazonTokenURL,
|
||||
profileURL: AmazonProfileURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads persisted accounts from disk.
|
||||
func (s *Service) Load() error {
|
||||
return s.load()
|
||||
}
|
||||
|
||||
// SetEndpoints allows overriding default Amazon API endpoints (for testing).
|
||||
func (s *Service) SetEndpoints(tokenURL, profileURL string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.tokenURL = tokenURL
|
||||
s.profileURL = profileURL
|
||||
}
|
||||
|
||||
// BuildAuthorizeURL constructs the LWA OAuth authorization URL.
|
||||
func (s *Service) BuildAuthorizeURL(state string) string {
|
||||
params := url.Values{
|
||||
"client_id": {s.clientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {s.redirectURI},
|
||||
"scope": {AmazonScopes},
|
||||
}
|
||||
if state != "" {
|
||||
params.Set("state", state)
|
||||
}
|
||||
|
||||
return AmazonAuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// ExchangeCodeAndStore exchanges an authorization code for tokens,
|
||||
// fetches the user profile, and stores the account.
|
||||
func (s *Service) ExchangeCodeAndStore(code string) error {
|
||||
tokenResp, err := s.exchangeCode(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token exchange: %w", err)
|
||||
}
|
||||
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
if expiresIn == 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
|
||||
profile, err := s.getUserProfile(accessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch profile: %w", err)
|
||||
}
|
||||
|
||||
// LWA profile uses "user_id" and "name" (not "id" and "display_name" like Spotify).
|
||||
userID, _ := profile["user_id"].(string)
|
||||
displayName, _ := profile["name"].(string)
|
||||
email, _ := profile["email"].(string)
|
||||
|
||||
boseSecret := s.generateBoseSecret()
|
||||
|
||||
account := &Account{
|
||||
UserID: userID,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(expiresIn),
|
||||
BoseSecret: boseSecret,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.accounts[userID] = account
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.save(); err != nil {
|
||||
return fmt.Errorf("save accounts: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Amazon] Account linked: %s (%s)", displayName, userID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchangeCode exchanges an authorization code for tokens.
|
||||
// Amazon LWA requires client_id and client_secret as POST body fields,
|
||||
// not as HTTP Basic Auth (unlike Spotify).
|
||||
func (s *Service) exchangeCode(code string) (map[string]interface{}, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {s.redirectURI},
|
||||
"client_id": {s.clientID},
|
||||
"client_secret": {s.clientSecret},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) getUserProfile(accessToken string) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, s.profileURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("profile request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("profile fetch failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse profile: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes the access token for the given account.
|
||||
// Amazon LWA requires client credentials as POST body fields.
|
||||
func (s *Service) RefreshAccessToken(account *Account) error {
|
||||
data := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {account.RefreshToken},
|
||||
"client_id": {s.clientID},
|
||||
"client_secret": {s.clientSecret},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("token refresh failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
account.AccessToken, _ = result["access_token"].(string)
|
||||
|
||||
expiresIn, _ := result["expires_in"].(float64)
|
||||
if expiresIn == 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
|
||||
account.ExpiresAt = time.Now().Unix() + int64(expiresIn)
|
||||
if newRefresh, ok := result["refresh_token"].(string); ok && newRefresh != "" {
|
||||
account.RefreshToken = newRefresh
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.save(); err != nil {
|
||||
return fmt.Errorf("save accounts: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFreshToken returns a valid access token and username, refreshing if needed.
|
||||
func (s *Service) GetFreshToken() (accessToken, username string, err error) {
|
||||
s.mu.RLock()
|
||||
|
||||
if len(s.accounts) == 0 {
|
||||
s.mu.RUnlock()
|
||||
return "", "", fmt.Errorf("no Amazon accounts linked")
|
||||
}
|
||||
|
||||
var account *Account
|
||||
for _, a := range s.accounts {
|
||||
account = a
|
||||
break
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Check if token needs refresh (expired or within 60s of expiry)
|
||||
if account.ExpiresAt < time.Now().Unix()+60 {
|
||||
if err := s.RefreshAccessToken(account); err != nil {
|
||||
return "", "", fmt.Errorf("refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return account.AccessToken, account.UserID, nil
|
||||
}
|
||||
|
||||
// GetAccounts returns a copy of all accounts with tokens stripped for API responses.
|
||||
func (s *Service) GetAccounts() []Account {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]Account, 0, len(s.accounts))
|
||||
for _, a := range s.accounts {
|
||||
result = append(result, Account{
|
||||
UserID: a.UserID,
|
||||
DisplayName: a.DisplayName,
|
||||
Email: a.Email,
|
||||
ExpiresAt: a.ExpiresAt,
|
||||
BoseSecret: a.BoseSecret,
|
||||
// AccessToken and RefreshToken deliberately omitted
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountBySecret retrieves an Amazon account by its Bose surrogate secret.
|
||||
func (s *Service) GetAccountBySecret(secret string) (*Account, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, a := range s.accounts {
|
||||
if a.BoseSecret == secret {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetAllAccounts returns all accounts including tokens. Used internally by
|
||||
// bridgeAmazonToMarge to build the AmazonSecret credential envelope.
|
||||
func (s *Service) GetAllAccounts() []*Account {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]*Account, 0, len(s.accounts))
|
||||
for _, a := range s.accounts {
|
||||
result = append(result, a)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountByRefreshToken retrieves an Amazon account by its current refresh token.
|
||||
// Used by the token handler because the speaker sends back the actual LWA refresh token
|
||||
// (extracted from the AmazonSecret JSON in Sources.xml), not a surrogate.
|
||||
func (s *Service) GetAccountByRefreshToken(refreshToken string) (*Account, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, a := range s.accounts {
|
||||
if a.RefreshToken == refreshToken {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Service) generateBoseSecret() string {
|
||||
prefix := "ba-"
|
||||
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
return prefix + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// save persists accounts to disk as JSON.
|
||||
func (s *Service) save() error {
|
||||
s.mu.RLock()
|
||||
|
||||
data := make(map[string]*Account, len(s.accounts))
|
||||
for k, v := range s.accounts {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
dir := filepath.Join(s.dataDir, "amazon")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal accounts: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "accounts.json")
|
||||
if err := os.WriteFile(path, jsonData, 0600); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// load reads persisted accounts from disk.
|
||||
func (s *Service) load() error {
|
||||
path := filepath.Join(s.dataDir, "amazon", "accounts.json")
|
||||
|
||||
jsonData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // No accounts file yet, not an error
|
||||
}
|
||||
|
||||
return fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
var accounts map[string]*Account
|
||||
if err := json.Unmarshal(jsonData, &accounts); err != nil {
|
||||
return fmt.Errorf("unmarshal accounts: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.accounts = accounts
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[Amazon] Loaded %d account(s)", len(accounts))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package amazon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildAuthorizeURL(t *testing.T) {
|
||||
svc := NewAmazonService("test-client-id", "test-secret", "ueberboese-login://amazon", t.TempDir())
|
||||
|
||||
state := "test-state"
|
||||
gotURL := svc.BuildAuthorizeURL(state)
|
||||
|
||||
if !strings.Contains(gotURL, "client_id=test-client-id") {
|
||||
t.Errorf("URL should contain client_id, got: %s", gotURL)
|
||||
}
|
||||
if !strings.Contains(gotURL, "redirect_uri=") {
|
||||
t.Errorf("URL should contain redirect_uri, got: %s", gotURL)
|
||||
}
|
||||
if !strings.Contains(gotURL, "scope=") {
|
||||
t.Errorf("URL should contain scope, got: %s", gotURL)
|
||||
}
|
||||
if !strings.Contains(gotURL, "response_type=code") {
|
||||
t.Errorf("URL should contain response_type=code, got: %s", gotURL)
|
||||
}
|
||||
if !strings.Contains(gotURL, "state=test-state") {
|
||||
t.Errorf("URL should contain state=test-state, got: %s", gotURL)
|
||||
}
|
||||
if !strings.HasPrefix(gotURL, AmazonAuthorizeURL) {
|
||||
t.Errorf("URL should start with %s, got: %s", AmazonAuthorizeURL, gotURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountsStripsTokens(t *testing.T) {
|
||||
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
||||
|
||||
svc.mu.Lock()
|
||||
svc.accounts["amzn1.account.EXAMPLE"] = &Account{
|
||||
UserID: "amzn1.account.EXAMPLE",
|
||||
DisplayName: "Test User",
|
||||
Email: "test@example.com",
|
||||
AccessToken: "secret-access-token",
|
||||
RefreshToken: "secret-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
|
||||
if len(accounts) != 1 {
|
||||
t.Fatalf("expected 1 account, got %d", len(accounts))
|
||||
}
|
||||
|
||||
if accounts[0].AccessToken != "" {
|
||||
t.Errorf("AccessToken should be stripped, got: %s", accounts[0].AccessToken)
|
||||
}
|
||||
if accounts[0].RefreshToken != "" {
|
||||
t.Errorf("RefreshToken should be stripped, got: %s", accounts[0].RefreshToken)
|
||||
}
|
||||
if accounts[0].UserID != "amzn1.account.EXAMPLE" {
|
||||
t.Errorf("UserID should be preserved, got: %s", accounts[0].UserID)
|
||||
}
|
||||
if accounts[0].DisplayName != "Test User" {
|
||||
t.Errorf("DisplayName should be preserved, got: %s", accounts[0].DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeAndStore(t *testing.T) {
|
||||
// Mock token endpoint — Amazon uses POST body credentials, not Basic Auth.
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
switch r.Form.Get("grant_type") {
|
||||
case "authorization_code":
|
||||
if r.Form.Get("code") != "test-auth-code" {
|
||||
t.Errorf("expected code=test-auth-code, got %s", r.Form.Get("code"))
|
||||
}
|
||||
|
||||
// Amazon uses POST body credentials, not HTTP Basic Auth.
|
||||
if r.Form.Get("client_id") != "cid" {
|
||||
t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id"))
|
||||
}
|
||||
if r.Form.Get("client_secret") != "csecret" {
|
||||
t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret"))
|
||||
}
|
||||
_, _, hasBasicAuth := r.BasicAuth()
|
||||
if hasBasicAuth {
|
||||
t.Error("Amazon token endpoint must NOT use HTTP Basic Auth")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "new-at",
|
||||
"refresh_token": "new-rt",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type"))
|
||||
http.Error(w, "bad request", 400)
|
||||
}
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
// Mock profile endpoint — LWA returns "user_id" and "name" (not "id" / "display_name").
|
||||
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer new-at" {
|
||||
t.Errorf("expected Bearer new-at, got %s", auth)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"user_id": "amzn1.account.TESTUSER123",
|
||||
"name": "Amazon User",
|
||||
"email": "user@amazon.com",
|
||||
})
|
||||
}))
|
||||
defer profileServer.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
|
||||
svc.SetEndpoints(tokenServer.URL, profileServer.URL)
|
||||
|
||||
err := svc.ExchangeCodeAndStore("test-auth-code")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCodeAndStore failed: %v", err)
|
||||
}
|
||||
|
||||
svc.mu.RLock()
|
||||
account, ok := svc.accounts["amzn1.account.TESTUSER123"]
|
||||
svc.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
t.Fatal("account not found after exchange")
|
||||
}
|
||||
if account.DisplayName != "Amazon User" {
|
||||
t.Errorf("expected Amazon User, got %s", account.DisplayName)
|
||||
}
|
||||
if account.Email != "user@amazon.com" {
|
||||
t.Errorf("expected user@amazon.com, got %s", account.Email)
|
||||
}
|
||||
if account.AccessToken != "new-at" {
|
||||
t.Errorf("expected new-at, got %s", account.AccessToken)
|
||||
}
|
||||
if account.RefreshToken != "new-rt" {
|
||||
t.Errorf("expected new-rt, got %s", account.RefreshToken)
|
||||
}
|
||||
|
||||
// Verify saved to disk under amazon/ (not spotify/).
|
||||
accountsFile := filepath.Join(dir, "amazon", "accounts.json")
|
||||
data, err := os.ReadFile(accountsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read accounts file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "amzn1.account.TESTUSER123") {
|
||||
t.Error("accounts file should contain the user ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAccessToken(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Form.Get("grant_type") != "refresh_token" {
|
||||
t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type"))
|
||||
}
|
||||
if r.Form.Get("refresh_token") != "my-refresh-token" {
|
||||
t.Errorf("expected refresh_token=my-refresh-token, got %s", r.Form.Get("refresh_token"))
|
||||
}
|
||||
|
||||
// Amazon uses POST body credentials.
|
||||
if r.Form.Get("client_id") != "cid" {
|
||||
t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id"))
|
||||
}
|
||||
if r.Form.Get("client_secret") != "csecret" {
|
||||
t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret"))
|
||||
}
|
||||
_, _, hasBasicAuth := r.BasicAuth()
|
||||
if hasBasicAuth {
|
||||
t.Error("Amazon token endpoint must NOT use HTTP Basic Auth")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "new-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "new-refresh-token",
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
||||
svc.tokenURL = tokenServer.URL
|
||||
|
||||
account := &Account{
|
||||
UserID: "amzn1.account.USER",
|
||||
AccessToken: "old-expired-token",
|
||||
RefreshToken: "my-refresh-token",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
svc.mu.Lock()
|
||||
svc.accounts[account.UserID] = account
|
||||
svc.mu.Unlock()
|
||||
|
||||
if err := svc.RefreshAccessToken(account); err != nil {
|
||||
t.Fatalf("RefreshAccessToken: %v", err)
|
||||
}
|
||||
|
||||
if account.AccessToken != "new-access-token" {
|
||||
t.Errorf("expected new-access-token, got %s", account.AccessToken)
|
||||
}
|
||||
if account.RefreshToken != "new-refresh-token" {
|
||||
t.Errorf("expected new-refresh-token, got %s", account.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFreshTokenRefreshesExpired(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "new-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "new-refresh-token",
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
||||
svc.tokenURL = tokenServer.URL
|
||||
|
||||
svc.mu.Lock()
|
||||
svc.accounts["amzn1.account.USER"] = &Account{
|
||||
UserID: "amzn1.account.USER",
|
||||
AccessToken: "old-expired-token",
|
||||
RefreshToken: "my-refresh-token",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
accessToken, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if accessToken != "new-access-token" {
|
||||
t.Errorf("expected new-access-token, got %s", accessToken)
|
||||
}
|
||||
if username != "amzn1.account.USER" {
|
||||
t.Errorf("expected amzn1.account.USER, got %s", username)
|
||||
}
|
||||
|
||||
svc.mu.RLock()
|
||||
account := svc.accounts["amzn1.account.USER"]
|
||||
svc.mu.RUnlock()
|
||||
|
||||
if account.RefreshToken != "new-refresh-token" {
|
||||
t.Errorf("refresh token should be updated, got %s", account.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFreshTokenNoAccounts(t *testing.T) {
|
||||
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
||||
|
||||
_, _, err := svc.GetFreshToken()
|
||||
if err == nil {
|
||||
t.Error("expected error when no accounts exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFreshTokenNotExpired(t *testing.T) {
|
||||
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir())
|
||||
|
||||
svc.mu.Lock()
|
||||
svc.accounts["amzn1.account.USER"] = &Account{
|
||||
UserID: "amzn1.account.USER",
|
||||
AccessToken: "valid-token",
|
||||
RefreshToken: "rt",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
token, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "valid-token" {
|
||||
t.Errorf("expected valid-token, got %s", token)
|
||||
}
|
||||
if username != "amzn1.account.USER" {
|
||||
t.Errorf("expected amzn1.account.USER, got %s", username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
|
||||
svc.mu.Lock()
|
||||
svc.accounts["amzn1.account.USER1"] = &Account{
|
||||
UserID: "amzn1.account.USER1",
|
||||
DisplayName: "Test User",
|
||||
Email: "test@example.com",
|
||||
AccessToken: "at",
|
||||
RefreshToken: "rt",
|
||||
ExpiresAt: 1234567890,
|
||||
}
|
||||
svc.accounts["amzn1.account.USER2"] = &Account{
|
||||
UserID: "amzn1.account.USER2",
|
||||
DisplayName: "User Two",
|
||||
Email: "two@example.com",
|
||||
AccessToken: "at2",
|
||||
RefreshToken: "rt2",
|
||||
ExpiresAt: 9876543210,
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
if err := svc.save(); err != nil {
|
||||
t.Fatalf("save failed: %v", err)
|
||||
}
|
||||
|
||||
accountsFile := filepath.Join(dir, "amazon", "accounts.json")
|
||||
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
|
||||
t.Fatal("amazon/accounts.json was not created")
|
||||
}
|
||||
|
||||
svc2 := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir)
|
||||
if err := svc2.Load(); err != nil {
|
||||
t.Fatalf("load failed: %v", err)
|
||||
}
|
||||
|
||||
svc2.mu.RLock()
|
||||
defer svc2.mu.RUnlock()
|
||||
|
||||
if len(svc2.accounts) != 2 {
|
||||
t.Fatalf("expected 2 accounts after load, got %d", len(svc2.accounts))
|
||||
}
|
||||
|
||||
u1, ok := svc2.accounts["amzn1.account.USER1"]
|
||||
if !ok {
|
||||
t.Fatal("USER1 not found after load")
|
||||
}
|
||||
if u1.DisplayName != "Test User" {
|
||||
t.Errorf("expected Test User, got %s", u1.DisplayName)
|
||||
}
|
||||
if u1.AccessToken != "at" {
|
||||
t.Errorf("expected at, got %s", u1.AccessToken)
|
||||
}
|
||||
if u1.ExpiresAt != 1234567890 {
|
||||
t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package amazon
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// PushAmazonCredentials pushes Amazon Music credentials to a speaker using the
|
||||
// ZeroConf DH key exchange protocol. Falls back to simplified token push if
|
||||
// the speaker does not support DH (older firmware).
|
||||
// zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc".
|
||||
func PushAmazonCredentials(zcBaseURL, username, accessToken string) error {
|
||||
return zeroconf.PushCredentials(zcBaseURL, username, accessToken)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package amazon
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
)
|
||||
|
||||
func TestPushAmazonCredentials_FullRoundTrip(t *testing.T) {
|
||||
speakerPrivate, speakerPublicBytes, err := zeroconf.GenerateDHKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("speaker keygen: %v", err)
|
||||
}
|
||||
|
||||
type received struct {
|
||||
username string
|
||||
authData string
|
||||
authType int
|
||||
}
|
||||
var got received
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": 101,
|
||||
"statusString": "OK",
|
||||
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
||||
})
|
||||
|
||||
case "addUser":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
blobBytes, err := base64.StdEncoding.DecodeString(r.FormValue("blob"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad blob base64: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
clientKeyBytes, err := base64.StdEncoding.DecodeString(r.FormValue("clientKey"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad clientKey base64: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
shared := zeroconf.ComputeSharedSecret(speakerPrivate, clientKeyBytes)
|
||||
encKey, macKey := zeroconf.DeriveKeys(shared)
|
||||
|
||||
plaintext, err := zeroconf.DecryptBlob(encKey, macKey, blobBytes)
|
||||
if err != nil {
|
||||
http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Minimal protobuf parse: field 1 = username, field 4 = authData, field 5 = authType
|
||||
i := 0
|
||||
for i < len(plaintext) {
|
||||
tag := plaintext[i]
|
||||
i++
|
||||
fieldNum := tag >> 3
|
||||
wireType := tag & 0x07
|
||||
switch wireType {
|
||||
case 0:
|
||||
val, n := readVarint(plaintext[i:])
|
||||
i += n
|
||||
if fieldNum == 5 {
|
||||
got.authType = int(val)
|
||||
}
|
||||
case 2:
|
||||
length, n := readVarint(plaintext[i:])
|
||||
i += n
|
||||
value := plaintext[i : i+int(length)]
|
||||
i += int(length)
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
got.username = string(value)
|
||||
case 4:
|
||||
got.authData = string(value)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unexpected wire type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
const wantUsername = "amazonuser@example.com"
|
||||
const wantToken = "Atza|access-token"
|
||||
|
||||
if err := PushAmazonCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
|
||||
t.Fatalf("PushAmazonCredentials: %v", err)
|
||||
}
|
||||
|
||||
if got.username != wantUsername {
|
||||
t.Errorf("username = %q, want %q", got.username, wantUsername)
|
||||
}
|
||||
if got.authData != wantToken {
|
||||
t.Errorf("authData = %q, want %q", got.authData, wantToken)
|
||||
}
|
||||
if uint64(got.authType) != zeroconf.AuthTypeOAuthToken {
|
||||
t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", got.authType, zeroconf.AuthTypeOAuthToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushAmazonCredentials_FallbackOnGetInfoFailure(t *testing.T) {
|
||||
var receivedForm map[string]string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
receivedForm = map[string]string{
|
||||
"userName": r.FormValue("userName"),
|
||||
"blob": r.FormValue("blob"),
|
||||
"clientKey": r.FormValue("clientKey"),
|
||||
"tokenType": r.FormValue("tokenType"),
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
const wantUsername = "amazonuser@example.com"
|
||||
const wantToken = "Atza|raw-access-token"
|
||||
|
||||
if err := PushAmazonCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
|
||||
t.Fatalf("PushAmazonCredentials: %v", err)
|
||||
}
|
||||
|
||||
if receivedForm == nil {
|
||||
t.Fatal("addUser was never called")
|
||||
}
|
||||
if receivedForm["userName"] != wantUsername {
|
||||
t.Errorf("userName = %q, want %q", receivedForm["userName"], wantUsername)
|
||||
}
|
||||
if receivedForm["blob"] != wantToken {
|
||||
t.Errorf("blob = %q, want raw token %q", receivedForm["blob"], wantToken)
|
||||
}
|
||||
if receivedForm["tokenType"] != "accesstoken" {
|
||||
t.Errorf("tokenType = %q, want %q", receivedForm["tokenType"], "accesstoken")
|
||||
}
|
||||
if receivedForm["clientKey"] != "" {
|
||||
t.Errorf("clientKey = %q, want empty for simplified fallback", receivedForm["clientKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func readVarint(data []byte) (uint64, int) {
|
||||
var val uint64
|
||||
for i, b := range data {
|
||||
val |= uint64(b&0x7f) << (7 * uint(i))
|
||||
if b&0x80 == 0 {
|
||||
return val, i + 1
|
||||
}
|
||||
}
|
||||
return 0, len(data)
|
||||
}
|
||||
+534
-2
@@ -12,16 +12,548 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TuneIn endpoint templates used to resolve station and stream URLs.
|
||||
const (
|
||||
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
|
||||
TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg"
|
||||
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
|
||||
TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg"
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
)
|
||||
|
||||
var tuneInClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
|
||||
var allowedTuneInHosts = map[string]bool{
|
||||
"opml.radiotime.com": true,
|
||||
"api.radiotime.com": true,
|
||||
}
|
||||
|
||||
func isTuneInURL(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return allowedTuneInHosts[u.Hostname()]
|
||||
}
|
||||
|
||||
// isTuneInOpmlURI returns true when the URL's host is opml.radiotime.com,
|
||||
// used to select the OPML/ashx parser over the JSON API parser.
|
||||
func isTuneInOpmlURI(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(u.Hostname(), "opml.radiotime.com")
|
||||
}
|
||||
|
||||
// tuneInRenderJSONURI returns the URL with render=json set as a query parameter,
|
||||
// replacing any existing render value instead of appending a duplicate.
|
||||
func tuneInRenderJSONURI(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("render", "json")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// tuneInSearchURI returns the TuneIn search API URL with the query properly URL-encoded.
|
||||
func tuneInSearchURI(query string) string {
|
||||
return TuneInSearchAPI + url.QueryEscape(query)
|
||||
}
|
||||
|
||||
func fetchJSON(fetchURL string) (map[string]interface{}, error) {
|
||||
if !isTuneInURL(fetchURL) {
|
||||
return nil, fmt.Errorf("URL not in allowed list: %s", fetchURL)
|
||||
}
|
||||
|
||||
resp, err := tuneInClient.Get(fetchURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeBase64URI(encoded string) (string, error) {
|
||||
b, err := base64.URLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
b, err = base64.StdEncoding.DecodeString(encoded)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// TuneInNavigate returns a live browse response for the given encoded TuneIn URI.
|
||||
// Pass subsection as nil for a full page, or a pointer to an int for a single subsection.
|
||||
func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse, error) {
|
||||
var (
|
||||
tuneInURI string
|
||||
bmxSearchLink *models.Link
|
||||
)
|
||||
|
||||
if encodedURI != "" {
|
||||
decoded, err := decodeBase64URI(encodedURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuneInURI = decoded
|
||||
} else {
|
||||
tuneInURI = TuneInNavigateAshx
|
||||
templated := true
|
||||
bmxSearchLink = &models.Link{
|
||||
Filters: []interface{}{},
|
||||
Href: "/v1/search?q={query}",
|
||||
Templated: &templated,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
sections []models.BmxNavSection
|
||||
err error
|
||||
)
|
||||
|
||||
if isTuneInOpmlURI(tuneInURI) {
|
||||
sections, err = tuneInSectionsAshx(tuneInURI, subsection)
|
||||
} else {
|
||||
sections, err = tuneInSectionsJSONAPI(tuneInURI, subsection)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var subsectionPart, uriPart string
|
||||
if subsection != nil {
|
||||
subsectionPart = fmt.Sprintf("/sub/%d", *subsection)
|
||||
}
|
||||
|
||||
if encodedURI != "" {
|
||||
uriPart = "/" + encodedURI
|
||||
}
|
||||
|
||||
return &models.BmxNavResponse{
|
||||
Links: &models.Links{
|
||||
Self: &models.Link{Href: fmt.Sprintf("/v1/navigate%s%s", subsectionPart, uriPart)},
|
||||
BmxSearch: bmxSearchLink,
|
||||
},
|
||||
BmxSections: sections,
|
||||
Layout: "classic",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) {
|
||||
data, err := fetchJSON(tuneInURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
layout := "list"
|
||||
|
||||
var (
|
||||
sections []models.BmxNavSection
|
||||
topItems []models.BmxNavItem
|
||||
)
|
||||
|
||||
body, _ := data["body"].([]interface{})
|
||||
|
||||
for idx, rawItem := range body {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
itemType, _ := item["type"].(string)
|
||||
if itemType == "link" {
|
||||
topItems = append(topItems, tuneInNavigateLink(item))
|
||||
continue
|
||||
}
|
||||
|
||||
if subsection != nil && *subsection != idx {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(body) == 1 || subsection != nil {
|
||||
layout = "responsiveGrid"
|
||||
} else {
|
||||
layout = "ribbon"
|
||||
}
|
||||
|
||||
maxCount := 5
|
||||
if layout == "responsiveGrid" {
|
||||
maxCount = 500
|
||||
}
|
||||
|
||||
sectionTitle, _ := item["text"].(string)
|
||||
|
||||
var sectionItems []models.BmxNavItem
|
||||
|
||||
count := 0
|
||||
|
||||
children, _ := item["children"].([]interface{})
|
||||
for _, rawChild := range children {
|
||||
child, ok := rawChild.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
childType, _ := child["type"].(string)
|
||||
switch childType {
|
||||
case "audio":
|
||||
sectionItems = append(sectionItems, tuneInNavigatePlayItem(child))
|
||||
case "link":
|
||||
sectionItems = append(sectionItems, tuneInNavigateLink(child))
|
||||
}
|
||||
|
||||
count++
|
||||
if count >= maxCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
encURI := base64.URLEncoding.EncodeToString([]byte(tuneInURI))
|
||||
sections = append(sections, models.BmxNavSection{
|
||||
Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/sub/%d/%s", idx, encURI)}},
|
||||
Items: sectionItems,
|
||||
Layout: layout,
|
||||
Name: sectionTitle,
|
||||
})
|
||||
}
|
||||
|
||||
head, _ := data["head"].(map[string]interface{})
|
||||
title, _ := head["title"].(string)
|
||||
|
||||
var subsectionPart string
|
||||
if subsection != nil {
|
||||
subsectionPart = fmt.Sprintf("sub/%d/", *subsection)
|
||||
}
|
||||
|
||||
encURI := base64.URLEncoding.EncodeToString([]byte(tuneInURI))
|
||||
sections = append(sections, models.BmxNavSection{
|
||||
Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s%s", subsectionPart, encURI)}},
|
||||
Items: topItems,
|
||||
Layout: layout,
|
||||
Name: title,
|
||||
})
|
||||
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
func tuneInSectionsJSONAPI(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) {
|
||||
data, err := fetchJSON(tuneInURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sections []models.BmxNavSection
|
||||
|
||||
items, _ := data["Items"].([]interface{})
|
||||
for idx, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if subsection != nil && *subsection != idx {
|
||||
continue
|
||||
}
|
||||
|
||||
itemType, _ := item["Type"].(string)
|
||||
containerType, _ := item["ContainerType"].(string)
|
||||
|
||||
if itemType == "Container" && containerType != "NotPlayableStations" {
|
||||
sections = append(sections, tuneInSearchSection(item, idx, "", "shortList"))
|
||||
}
|
||||
}
|
||||
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
func tuneInNavigatePlayItem(item map[string]interface{}) models.BmxNavItem {
|
||||
guideID, _ := item["guide_id"].(string)
|
||||
imageURL, _ := item["image"].(string)
|
||||
text, _ := item["text"].(string)
|
||||
subtext, _ := item["subtext"].(string)
|
||||
|
||||
playbackHref := fmt.Sprintf("/v1/playback/station/%s", guideID)
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{
|
||||
BmxPlayback: &models.Link{Href: playbackHref, Type: "stationurl"},
|
||||
BmxPreset: &models.Link{ContainerArt: imageURL, Href: guideID, Name: text, Type: "stationurl"},
|
||||
},
|
||||
ImageUrl: imageURL,
|
||||
Name: text,
|
||||
Subtitle: subtext,
|
||||
}
|
||||
}
|
||||
|
||||
func tuneInNavigateLink(item map[string]interface{}) models.BmxNavItem {
|
||||
rawURL, _ := item["URL"].(string)
|
||||
imageURL, _ := item["image"].(string)
|
||||
text, _ := item["text"].(string)
|
||||
subtext, _ := item["subtext"].(string)
|
||||
|
||||
encURL := base64.URLEncoding.EncodeToString([]byte(tuneInRenderJSONURI(rawURL)))
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s", encURL)}},
|
||||
ImageUrl: imageURL,
|
||||
Name: text,
|
||||
Subtitle: subtext,
|
||||
}
|
||||
}
|
||||
|
||||
// TuneInSearch returns live search results from TuneIn for the given query.
|
||||
func TuneInSearch(query string) (*models.BmxNavResponse, error) {
|
||||
tuneInURI := tuneInSearchURI(query)
|
||||
|
||||
templated := true
|
||||
bmxSearchLink := &models.Link{
|
||||
Filters: []interface{}{},
|
||||
Href: "/v1/search?q={query}",
|
||||
Templated: &templated,
|
||||
}
|
||||
|
||||
data, err := fetchJSON(tuneInURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sections []models.BmxNavSection
|
||||
|
||||
items, _ := data["Items"].([]interface{})
|
||||
for idx, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
itemType, _ := item["Type"].(string)
|
||||
containerType, _ := item["ContainerType"].(string)
|
||||
|
||||
if itemType == "Container" && containerType != "NotPlayableStations" {
|
||||
sections = append(sections, tuneInSearchSection(item, idx, query, "shortList"))
|
||||
}
|
||||
}
|
||||
|
||||
return &models.BmxNavResponse{
|
||||
Links: &models.Links{
|
||||
Self: &models.Link{Href: fmt.Sprintf("/v1/search?q=%s", url.QueryEscape(query))},
|
||||
BmxSearch: bmxSearchLink,
|
||||
},
|
||||
BmxSections: sections,
|
||||
Layout: "classic",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tuneInSearchSection(item map[string]interface{}, idx int, query, layout string) models.BmxNavSection {
|
||||
pivots, _ := item["Pivots"].(map[string]interface{})
|
||||
more, _ := pivots["More"].(map[string]interface{})
|
||||
pivotURL, _ := more["Url"].(string)
|
||||
|
||||
var href string
|
||||
if pivotURL != "" {
|
||||
href = fmt.Sprintf("/v1/navigate/%s", base64.URLEncoding.EncodeToString([]byte(pivotURL)))
|
||||
} else {
|
||||
encodedQuery := base64.URLEncoding.EncodeToString([]byte(tuneInSearchURI(query)))
|
||||
href = fmt.Sprintf("/v1/navigate/sub/%d/%s", idx, encodedQuery)
|
||||
}
|
||||
|
||||
var sectionItems []models.BmxNavItem
|
||||
|
||||
children, _ := item["Children"].([]interface{})
|
||||
for _, rawChild := range children {
|
||||
child, ok := rawChild.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
childType, _ := child["Type"].(string)
|
||||
switch childType {
|
||||
case "Station":
|
||||
sectionItems = append(sectionItems, tuneInSearchPlayItem(child))
|
||||
case "Topic":
|
||||
sectionItems = append(sectionItems, tuneInSearchTopic(child))
|
||||
case "Program":
|
||||
sectionItems = append(sectionItems, tuneInSearchProfile(child, "Program"))
|
||||
case "Artist":
|
||||
sectionItems = append(sectionItems, tuneInSearchProfile(child, "Artist"))
|
||||
case "Category":
|
||||
actions, _ := child["Actions"].(map[string]interface{})
|
||||
browse, _ := actions["Browse"].(map[string]interface{})
|
||||
categoryHref, _ := browse["Url"].(string)
|
||||
encHref := base64.URLEncoding.EncodeToString([]byte(categoryHref))
|
||||
image, _ := child["Image"].(string)
|
||||
title, _ := child["Title"].(string)
|
||||
subtitle, _ := child["Subtitle"].(string)
|
||||
sectionItems = append(sectionItems, models.BmxNavItem{
|
||||
Links: &models.Links{BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s", encHref)}},
|
||||
ImageUrl: image,
|
||||
Name: title,
|
||||
Subtitle: subtitle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
title, _ := item["Title"].(string)
|
||||
|
||||
return models.BmxNavSection{
|
||||
Links: &models.Links{Self: &models.Link{Href: href}},
|
||||
Items: sectionItems,
|
||||
Layout: layout,
|
||||
Name: title,
|
||||
}
|
||||
}
|
||||
|
||||
func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem {
|
||||
guideID, _ := item["GuideId"].(string)
|
||||
image, _ := item["Image"].(string)
|
||||
title, _ := item["Title"].(string)
|
||||
subtitle, _ := item["Subtitle"].(string)
|
||||
|
||||
href := fmt.Sprintf("/v1/playback/station/%s", guideID)
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{
|
||||
BmxPlayback: &models.Link{Href: href, Type: "stationurl"},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: href, Name: title, Type: "stationurl"},
|
||||
},
|
||||
ImageUrl: image,
|
||||
Name: title,
|
||||
Subtitle: subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
func tuneInSearchTopic(item map[string]interface{}) models.BmxNavItem {
|
||||
guideID, _ := item["GuideId"].(string)
|
||||
image, _ := item["Image"].(string)
|
||||
title, _ := item["Title"].(string)
|
||||
subtitle, _ := item["Subtitle"].(string)
|
||||
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(title))
|
||||
href := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{
|
||||
BmxPlayback: &models.Link{Href: href, Type: "tracklisturl"},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: href, Name: title, Type: "tracklisturl"},
|
||||
},
|
||||
ImageUrl: image,
|
||||
Name: title,
|
||||
Subtitle: subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
func tuneInSearchProfile(item map[string]interface{}, name string) models.BmxNavItem {
|
||||
guideID, _ := item["GuideId"].(string)
|
||||
image, _ := item["Image"].(string)
|
||||
title, _ := item["Title"].(string)
|
||||
subtitle, _ := item["Subtitle"].(string)
|
||||
|
||||
actions, _ := item["Actions"].(map[string]interface{})
|
||||
profile, _ := actions["Profile"].(map[string]interface{})
|
||||
apiURL, _ := profile["Url"].(string)
|
||||
apiURLEncoded := base64.URLEncoding.EncodeToString([]byte(apiURL))
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{
|
||||
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
|
||||
},
|
||||
ImageUrl: image,
|
||||
Name: title,
|
||||
Subtitle: subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
// TuneInNavigateProfile returns a profile (artist/program) navigation response.
|
||||
func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
tuneInURI, err := decodeBase64URI(encodedURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profileData, err := fetchJSON(tuneInURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profileItem, _ := profileData["Item"].(map[string]interface{})
|
||||
profileTitle, _ := profileItem["Title"].(string)
|
||||
profileImage, _ := profileItem["Image"].(string)
|
||||
profileSubtitle, _ := profileItem["Subtitle"].(string)
|
||||
|
||||
sections := []models.BmxNavSection{
|
||||
{
|
||||
Items: []models.BmxNavItem{{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}},
|
||||
Layout: "hero",
|
||||
Name: "",
|
||||
},
|
||||
}
|
||||
|
||||
pivots, _ := profileItem["Pivots"].(map[string]interface{})
|
||||
contents, _ := pivots["Contents"].(map[string]interface{})
|
||||
contentsURL, _ := contents["Url"].(string)
|
||||
|
||||
if contentsURL != "" {
|
||||
if contentsData, fetchErr := fetchJSON(contentsURL); fetchErr == nil {
|
||||
contentsItems, _ := contentsData["Items"].([]interface{})
|
||||
for idx, rawItem := range contentsItems {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
itemType, _ := item["Type"].(string)
|
||||
containerType, _ := item["ContainerType"].(string)
|
||||
|
||||
if itemType == "Container" && containerType != "NotPlayableStations" {
|
||||
sections = append(sections, tuneInSearchSection(item, idx, "", "list"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &models.BmxNavResponse{
|
||||
Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s", encodedURI)}},
|
||||
BmxSections: sections,
|
||||
Layout: "classic",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
|
||||
// playback response with primary stream and variants.
|
||||
func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) {
|
||||
|
||||
@@ -3,9 +3,155 @@ package bmx
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTuneInRenderJSONURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty URL returns empty",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "URL with no query params gets render=json added",
|
||||
input: "http://opml.radiotime.com/Browse.ashx",
|
||||
want: "http://opml.radiotime.com/Browse.ashx?render=json",
|
||||
},
|
||||
{
|
||||
name: "URL with other params gets render=json appended",
|
||||
input: "http://opml.radiotime.com/Browse.ashx?c=news",
|
||||
want: "http://opml.radiotime.com/Browse.ashx?c=news&render=json",
|
||||
},
|
||||
{
|
||||
name: "URL already containing render=json is not duplicated",
|
||||
input: "http://opml.radiotime.com/?render=json",
|
||||
want: "http://opml.radiotime.com/?render=json",
|
||||
},
|
||||
{
|
||||
name: "URL with render=xml gets render replaced with json",
|
||||
input: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=xml",
|
||||
want: "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tuneInRenderJSONURI(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("tuneInRenderJSONURI(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTuneInOpmlURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"http://opml.radiotime.com/Browse.ashx", true},
|
||||
{"https://opml.radiotime.com/Browse.ashx", true},
|
||||
{"http://opml.radiotime.com/?render=json", true},
|
||||
{"http://api.radiotime.com/profiles?fulltextsearch=true", false},
|
||||
{"http://example.com", false},
|
||||
{"not-a-url", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isTuneInOpmlURI(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isTuneInOpmlURI(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuneInSearchURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
check func(string) bool
|
||||
}{
|
||||
{
|
||||
name: "spaces are percent-encoded",
|
||||
query: "radio paradise",
|
||||
check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "radio+paradise") },
|
||||
},
|
||||
{
|
||||
name: "ampersand is encoded",
|
||||
query: "news & talk",
|
||||
check: func(u string) bool { return !strings.Contains(u, " ") && strings.Contains(u, "%26") },
|
||||
},
|
||||
{
|
||||
name: "plain query is appended to base URL",
|
||||
query: "jazz",
|
||||
check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tuneInSearchURI(tt.query)
|
||||
if !tt.check(got) {
|
||||
t.Errorf("tuneInSearchURI(%q) = %q: check failed", tt.query, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuneInNavigateLinkEncodesRenderJSON(t *testing.T) {
|
||||
item := map[string]interface{}{
|
||||
"URL": "http://opml.radiotime.com/Browse.ashx?c=news",
|
||||
"text": "News",
|
||||
"subtext": "Latest",
|
||||
"image": "http://example.com/news.png",
|
||||
}
|
||||
|
||||
result := tuneInNavigateLink(item)
|
||||
|
||||
href := result.Links.BmxNavigate.Href
|
||||
encoded := strings.TrimPrefix(href, "/v1/navigate/")
|
||||
decoded, err := base64.URLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode navigate href: %v", err)
|
||||
}
|
||||
|
||||
got := string(decoded)
|
||||
if !strings.Contains(got, "render=json") {
|
||||
t.Errorf("navigate href %q missing render=json", got)
|
||||
}
|
||||
if strings.Count(got, "render=json") > 1 {
|
||||
t.Errorf("navigate href %q has duplicate render=json", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuneInNavigateLinkNoDuplicateRenderJSON(t *testing.T) {
|
||||
item := map[string]interface{}{
|
||||
"URL": "http://opml.radiotime.com/Browse.ashx?c=podcast&render=json",
|
||||
}
|
||||
|
||||
result := tuneInNavigateLink(item)
|
||||
|
||||
href := result.Links.BmxNavigate.Href
|
||||
encoded := strings.TrimPrefix(href, "/v1/navigate/")
|
||||
decoded, err := base64.URLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode navigate href: %v", err)
|
||||
}
|
||||
|
||||
got := string(decoded)
|
||||
if strings.Count(got, "render=json") != 1 {
|
||||
t.Errorf("navigate href %q should contain render=json exactly once", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayCustomStream(t *testing.T) {
|
||||
// Simple test for custom stream XML generation
|
||||
dataObj := struct {
|
||||
|
||||
@@ -1,98 +1,294 @@
|
||||
// Package constants defines file names, directories, and common values used by the service layer.
|
||||
package constants
|
||||
|
||||
import "strconv"
|
||||
|
||||
// SourceProvider represents a media source provider configuration.
|
||||
type SourceProvider struct {
|
||||
ID int
|
||||
Name string
|
||||
Label string
|
||||
CreatedOn string
|
||||
UpdatedOn string
|
||||
}
|
||||
|
||||
const (
|
||||
// ProviderPandora is the identifier for Pandora.
|
||||
ProviderPandora = "PANDORA"
|
||||
// ProviderInternetRadio is the identifier for Internet Radio.
|
||||
ProviderInternetRadio = "INTERNET_RADIO"
|
||||
// ProviderOff is the identifier for Off.
|
||||
ProviderOff = "OFF"
|
||||
// ProviderLocal is the identifier for Local.
|
||||
ProviderLocal = "LOCAL"
|
||||
// ProviderAirplay is the identifier for AirPlay.
|
||||
ProviderAirplay = "AIRPLAY"
|
||||
// ProviderCuratedRadio is the identifier for Curated Radio.
|
||||
ProviderCuratedRadio = "CURRATED_RADIO"
|
||||
// ProviderStoredMusic is the identifier for Stored Music.
|
||||
ProviderStoredMusic = "STORED_MUSIC"
|
||||
// ProviderSlaveSource is the identifier for Slave Source.
|
||||
ProviderSlaveSource = "SLAVE_SOURCE"
|
||||
// ProviderAux is the identifier for Aux.
|
||||
ProviderAux = "AUX"
|
||||
// ProviderRecommendedInternetRadio is the identifier for Recommended Internet Radio.
|
||||
ProviderRecommendedInternetRadio = "RECOMMENDED_INTERNET_RADIO"
|
||||
// ProviderLocalInternetRadio is the identifier for Local Internet Radio.
|
||||
ProviderLocalInternetRadio = "LOCAL_INTERNET_RADIO"
|
||||
// ProviderGlobalInternetRadio is the identifier for Global Internet Radio.
|
||||
ProviderGlobalInternetRadio = "GLOBAL_INTERNET_RADIO"
|
||||
// ProviderHello is the identifier for Hello.
|
||||
ProviderHello = "HELLO"
|
||||
// ProviderDeezer is the identifier for Deezer.
|
||||
ProviderDeezer = "DEEZER"
|
||||
// ProviderSpotify is the identifier for Spotify.
|
||||
ProviderSpotify = "SPOTIFY"
|
||||
// ProviderIHeart is the identifier for iHeartRadio.
|
||||
ProviderIHeart = "IHEART"
|
||||
// ProviderSiriusXM is the identifier for SiriusXM.
|
||||
ProviderSiriusXM = "SIRIUSXM"
|
||||
// ProviderGooglePlayMusic is the identifier for Google Play Music.
|
||||
ProviderGooglePlayMusic = "GOOGLE_PLAY_MUSIC"
|
||||
// ProviderQQMusic is the identifier for QQMusic.
|
||||
ProviderQQMusic = "QQMUSIC"
|
||||
// ProviderAmazon is the identifier for Amazon Music.
|
||||
ProviderAmazon = "AMAZON"
|
||||
// ProviderLocalMusic is the identifier for Local Music Library.
|
||||
ProviderLocalMusic = "LOCAL_MUSIC"
|
||||
// ProviderWbmx is the identifier for WBMX.
|
||||
ProviderWbmx = "WBMX"
|
||||
// ProviderSoundcloud is the identifier for SoundCloud.
|
||||
ProviderSoundcloud = "SOUNDCLOUD"
|
||||
// ProviderTidal is the identifier for Tidal.
|
||||
ProviderTidal = "TIDAL"
|
||||
// ProviderTunein is the identifier for TuneIn Radio.
|
||||
ProviderTunein = "TUNEIN"
|
||||
// ProviderQPlay is the identifier for QPlay.
|
||||
ProviderQPlay = "QPLAY"
|
||||
// ProviderJuke is the identifier for Juke.
|
||||
ProviderJuke = "JUKE"
|
||||
// ProviderBbc is the identifier for BBC.
|
||||
ProviderBbc = "BBC"
|
||||
// ProviderDarfm is the identifier for DAR.fm.
|
||||
ProviderDarfm = "DARFM"
|
||||
// Provider7Digital is the identifier for 7digital.
|
||||
Provider7Digital = "7DIGITAL"
|
||||
// ProviderSaavn is the identifier for Saavn.
|
||||
ProviderSaavn = "SAAVN"
|
||||
// ProviderRdio is the identifier for Rdio.
|
||||
ProviderRdio = "RDIO"
|
||||
// ProviderPhoneMusic is the identifier for Phone Music.
|
||||
ProviderPhoneMusic = "PHONE_MUSIC"
|
||||
// ProviderAlexa is the identifier for Amazon Alexa.
|
||||
ProviderAlexa = "ALEXA"
|
||||
// ProviderRadioplayer is the identifier for Radioplayer.
|
||||
// RADIOPLAYER is deprecated: https://www.radioplayer.de/apps/bose.html
|
||||
ProviderRadioplayer = "RADIOPLAYER"
|
||||
// ProviderRadioDotCom is the identifier for Radio.com.
|
||||
ProviderRadioDotCom = "RADIO.COM"
|
||||
// ProviderRadioCom is the identifier for Radio.com (alternate).
|
||||
ProviderRadioCom = "RADIO_COM"
|
||||
// ProviderSiriusXmEverest is the identifier for SiriusXM Everest.
|
||||
ProviderSiriusXmEverest = "SIRIUSXM_EVEREST"
|
||||
// ProviderRadioBrowser is the identifier for Radio Browser.
|
||||
ProviderRadioBrowser = "RADIO_BROWSER"
|
||||
// ProviderBluetooth is the identifier for Bluetooth.
|
||||
ProviderBluetooth = "BLUETOOTH"
|
||||
// ProviderBmx is the identifier for BMX.
|
||||
ProviderBmx = "BMX"
|
||||
// ProviderNotification is the identifier for Notifications.
|
||||
ProviderNotification = "NOTIFICATION"
|
||||
// ProviderAuxIn is the identifier for AUX IN.
|
||||
ProviderAuxIn = "AUX_IN"
|
||||
)
|
||||
|
||||
const (
|
||||
// PandoraProviderID is the provider identifier for Pandora.
|
||||
PandoraProviderID = 1
|
||||
// InternetRadioProviderID is the provider identifier for Internet Radio.
|
||||
InternetRadioProviderID = 2
|
||||
// OffProviderID is the provider identifier for Off.
|
||||
OffProviderID = 3
|
||||
// LocalProviderID is the provider identifier for Local.
|
||||
LocalProviderID = 4
|
||||
// AirplayProviderID is the provider identifier for AirPlay.
|
||||
AirplayProviderID = 5
|
||||
// CuratedRadioProviderID is the provider identifier for Curated Radio.
|
||||
CuratedRadioProviderID = 6
|
||||
// StoredMusicProviderID is the provider identifier for Stored Music.
|
||||
StoredMusicProviderID = 7
|
||||
// SlaveSourceProviderID is the provider identifier for Slave Source.
|
||||
SlaveSourceProviderID = 8
|
||||
// AuxProviderID is the provider identifier for Aux.
|
||||
AuxProviderID = 9
|
||||
// RecommendedInternetRadioProviderID is the provider identifier for Recommended Internet Radio.
|
||||
RecommendedInternetRadioProviderID = 10
|
||||
// LocalInternetRadioProviderID is the provider identifier for Local Internet Radio.
|
||||
LocalInternetRadioProviderID = 11
|
||||
// GlobalInternetRadioProviderID is the provider identifier for Global Internet Radio.
|
||||
GlobalInternetRadioProviderID = 12
|
||||
// HelloProviderID is the provider identifier for Hello.
|
||||
HelloProviderID = 13
|
||||
// DeezerProviderID is the provider identifier for Deezer.
|
||||
DeezerProviderID = 14
|
||||
// SpotifyProviderID is the provider identifier for Spotify.
|
||||
SpotifyProviderID = 15
|
||||
// IHeartProviderID is the provider identifier for iHeartRadio.
|
||||
IHeartProviderID = 16
|
||||
// SiriusXMProviderID is the provider identifier for SiriusXM.
|
||||
SiriusXMProviderID = 17
|
||||
// GooglePlayMusicProviderID is the provider identifier for Google Play Music.
|
||||
GooglePlayMusicProviderID = 18
|
||||
// QQMusicProviderID is the provider identifier for QQMusic.
|
||||
QQMusicProviderID = 19
|
||||
// AmazonProviderID is the provider identifier for Amazon Music.
|
||||
AmazonProviderID = 20
|
||||
// LocalMusicProviderID is the provider identifier for Local Music Library.
|
||||
LocalMusicProviderID = 21
|
||||
// WbmxProviderID is the provider identifier for WBMX.
|
||||
WbmxProviderID = 22
|
||||
// SoundcloudProviderID is the provider identifier for SoundCloud.
|
||||
SoundcloudProviderID = 23
|
||||
// TidalProviderID is the provider identifier for Tidal.
|
||||
TidalProviderID = 24
|
||||
// TuneinProviderID is the provider identifier for TuneIn Radio.
|
||||
TuneinProviderID = 25
|
||||
// QPlayProviderID is the provider identifier for QPlay.
|
||||
QPlayProviderID = 26
|
||||
// JukeProviderID is the provider identifier for Juke.
|
||||
JukeProviderID = 27
|
||||
// BbcProviderID is the provider identifier for BBC.
|
||||
BbcProviderID = 28
|
||||
// DarfmProviderID is the provider identifier for DAR.fm.
|
||||
DarfmProviderID = 29
|
||||
// SevenDigitalProviderID is the provider identifier for 7digital.
|
||||
SevenDigitalProviderID = 30
|
||||
// SaavnProviderID is the provider identifier for Saavn.
|
||||
SaavnProviderID = 31
|
||||
// RdioProviderID is the provider identifier for Rdio.
|
||||
RdioProviderID = 32
|
||||
// PhoneMusicProviderID is the provider identifier for Phone Music.
|
||||
PhoneMusicProviderID = 33
|
||||
// AlexaProviderID is the provider identifier for Amazon Alexa.
|
||||
AlexaProviderID = 34
|
||||
// RadioplayerProviderID is the provider identifier for Radioplayer.
|
||||
RadioplayerProviderID = 35
|
||||
// RadioDotComProviderID is the provider identifier for Radio.com.
|
||||
RadioDotComProviderID = 36
|
||||
// RadioComProviderID is the provider identifier for Radio.com (alternate).
|
||||
RadioComProviderID = 37
|
||||
// SiriusXmEverestProviderID is the provider identifier for SiriusXM Everest.
|
||||
SiriusXmEverestProviderID = 38
|
||||
// RadioBrowserProviderID is the provider identifier for Radio Browser.
|
||||
RadioBrowserProviderID = 39
|
||||
// BluetoothProviderID is the provider identifier for Bluetooth.
|
||||
BluetoothProviderID = 40
|
||||
// BmxProviderID is the provider identifier for BMX.
|
||||
BmxProviderID = 41
|
||||
// NotificationProviderID is the provider identifier for Notifications.
|
||||
NotificationProviderID = 42
|
||||
// AuxInProviderID is the provider identifier for AUX IN.
|
||||
AuxInProviderID = 43
|
||||
)
|
||||
|
||||
// StaticProviders lists known source provider identifiers with their metadata.
|
||||
var StaticProviders = []SourceProvider{
|
||||
{ID: 1, Name: "PANDORA", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: 2, Name: "INTERNET_RADIO", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: 3, Name: "OFF", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
|
||||
{ID: 4, Name: "LOCAL", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 5, Name: "AIRPLAY", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 6, Name: "CURRATED_RADIO", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 7, Name: "STORED_MUSIC", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 8, Name: "SLAVE_SOURCE", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 9, Name: "AUX", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 10, Name: "RECOMMENDED_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: 11, Name: "LOCAL_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: 12, Name: "GLOBAL_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: 13, Name: "HELLO", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
|
||||
{ID: 14, Name: "DEEZER", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 15, Name: "SPOTIFY", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 16, Name: "IHEART", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 17, Name: "SIRIUSXM", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 18, Name: "GOOGLE_PLAY_MUSIC", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 19, Name: "QQMUSIC", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 20, Name: "AMAZON", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 21, Name: "LOCAL_MUSIC", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
|
||||
{ID: 22, Name: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 23, Name: "SOUNDCLOUD", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 24, Name: "TIDAL", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 25, Name: "TUNEIN", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 26, Name: "QPLAY", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
|
||||
{ID: 27, Name: "JUKE", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 28, Name: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 29, Name: "DARFM", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 30, Name: "7DIGITAL", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 31, Name: "SAAVN", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 32, Name: "RDIO", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 33, Name: "PHONE_MUSIC", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
|
||||
{ID: 34, Name: "ALEXA", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
|
||||
{ID: 35, Name: "RADIOPLAYER", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
|
||||
{ID: 36, Name: "RADIO.COM", CreatedOn: "2019-05-28T18:21:41.000+00:00", UpdatedOn: "2019-05-28T18:21:41.000+00:00"},
|
||||
{ID: 37, Name: "RADIO_COM", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
|
||||
{ID: 38, Name: "SIRIUSXM_EVEREST", CreatedOn: "2019-11-25T18:00:33.000+00:00", UpdatedOn: "2019-11-25T18:00:33.000+00:00"},
|
||||
{ID: 39, Name: "RADIO_BROWSER", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
|
||||
{ID: PandoraProviderID, Name: ProviderPandora, Label: "Pandora", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: InternetRadioProviderID, Name: ProviderInternetRadio, Label: "Internet Radio", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: OffProviderID, Name: ProviderOff, Label: "Off", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
|
||||
{ID: LocalProviderID, Name: ProviderLocal, Label: "Local", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: AirplayProviderID, Name: ProviderAirplay, Label: "AirPlay", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: CuratedRadioProviderID, Name: ProviderCuratedRadio, Label: "Curated Radio", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: StoredMusicProviderID, Name: ProviderStoredMusic, Label: "Stored Music", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: SlaveSourceProviderID, Name: ProviderSlaveSource, Label: "Slave Source", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: AuxProviderID, Name: ProviderAux, Label: "Aux", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: RecommendedInternetRadioProviderID, Name: ProviderRecommendedInternetRadio, Label: "Recommended Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: LocalInternetRadioProviderID, Name: ProviderLocalInternetRadio, Label: "Local Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: GlobalInternetRadioProviderID, Name: ProviderGlobalInternetRadio, Label: "Global Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: HelloProviderID, Name: ProviderHello, Label: "Hello", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
|
||||
{ID: DeezerProviderID, Name: ProviderDeezer, Label: "Deezer", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: SpotifyProviderID, Name: ProviderSpotify, Label: "Spotify", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: IHeartProviderID, Name: ProviderIHeart, Label: "iHeartRadio", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: SiriusXMProviderID, Name: ProviderSiriusXM, Label: "SiriusXM", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: GooglePlayMusicProviderID, Name: ProviderGooglePlayMusic, Label: "Google Play Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: QQMusicProviderID, Name: ProviderQQMusic, Label: "QQMusic", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: AmazonProviderID, Name: ProviderAmazon, Label: "Amazon Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: LocalMusicProviderID, Name: ProviderLocalMusic, Label: "Local Music Library", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
|
||||
{ID: WbmxProviderID, Name: ProviderWbmx, Label: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: SoundcloudProviderID, Name: ProviderSoundcloud, Label: "SoundCloud", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: TidalProviderID, Name: ProviderTidal, Label: "Tidal", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: TuneinProviderID, Name: ProviderTunein, Label: "TuneIn Radio", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: QPlayProviderID, Name: ProviderQPlay, Label: "QPlay", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
|
||||
{ID: JukeProviderID, Name: ProviderJuke, Label: "Juke", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: BbcProviderID, Name: ProviderBbc, Label: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: DarfmProviderID, Name: ProviderDarfm, Label: "DAR.fm", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: SevenDigitalProviderID, Name: Provider7Digital, Label: "7digital", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: SaavnProviderID, Name: ProviderSaavn, Label: "Saavn", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: RdioProviderID, Name: ProviderRdio, Label: "Rdio", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: PhoneMusicProviderID, Name: ProviderPhoneMusic, Label: "Phone Music", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
|
||||
{ID: AlexaProviderID, Name: ProviderAlexa, Label: "Amazon Alexa", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
|
||||
{ID: RadioplayerProviderID, Name: ProviderRadioplayer, Label: "Radioplayer", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
|
||||
{ID: RadioDotComProviderID, Name: ProviderRadioDotCom, Label: "Radio.com", CreatedOn: "2019-05-28T18:21:41.000+00:00", UpdatedOn: "2019-05-28T18:21:41.000+00:00"},
|
||||
{ID: RadioComProviderID, Name: ProviderRadioCom, Label: "Radio.com", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
|
||||
{ID: SiriusXmEverestProviderID, Name: ProviderSiriusXmEverest, Label: "SiriusXM Everest", CreatedOn: "2019-11-25T18:00:33.000+00:00", UpdatedOn: "2019-11-25T18:00:33.000+00:00"},
|
||||
{ID: RadioBrowserProviderID, Name: ProviderRadioBrowser, Label: "Radio Browser", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
|
||||
{ID: BluetoothProviderID, Name: ProviderBluetooth, Label: "Bluetooth", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: BmxProviderID, Name: ProviderBmx, Label: "BMX", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: NotificationProviderID, Name: ProviderNotification, Label: "Notifications", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: AuxInProviderID, Name: ProviderAuxIn, Label: "AUX IN", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
}
|
||||
|
||||
// Providers lists known source provider identifiers used by Bose SoundTouch.
|
||||
var Providers = []string{
|
||||
"PANDORA",
|
||||
"INTERNET_RADIO",
|
||||
"OFF",
|
||||
"LOCAL",
|
||||
"AIRPLAY",
|
||||
"CURRATED_RADIO",
|
||||
"STORED_MUSIC",
|
||||
"SLAVE_SOURCE",
|
||||
"AUX",
|
||||
"RECOMMENDED_INTERNET_RADIO",
|
||||
"LOCAL_INTERNET_RADIO",
|
||||
"GLOBAL_INTERNET_RADIO",
|
||||
"HELLO",
|
||||
"DEEZER",
|
||||
"SPOTIFY",
|
||||
"IHEART",
|
||||
"SIRIUSXM",
|
||||
"GOOGLE_PLAY_MUSIC",
|
||||
"QQMUSIC",
|
||||
"AMAZON",
|
||||
"LOCAL_MUSIC",
|
||||
"WBMX",
|
||||
"SOUNDCLOUD",
|
||||
"TIDAL",
|
||||
"TUNEIN",
|
||||
"QPLAY",
|
||||
"JUKE",
|
||||
"BBC",
|
||||
"DARFM",
|
||||
"7DIGITAL",
|
||||
"SAAVN",
|
||||
"RDIO",
|
||||
"PHONE_MUSIC",
|
||||
"ALEXA",
|
||||
"RADIOPLAYER",
|
||||
"RADIO.COM",
|
||||
"RADIO_COM",
|
||||
"SIRIUSXM_EVEREST",
|
||||
"RADIO_BROWSER",
|
||||
// GetSourceLabel returns a user-friendly label for a source type.
|
||||
func GetSourceLabel(sourceType string) string {
|
||||
for _, provider := range StaticProviders {
|
||||
if provider.Name == sourceType {
|
||||
return provider.Label
|
||||
}
|
||||
}
|
||||
|
||||
return sourceType
|
||||
}
|
||||
|
||||
// GetProviderName returns the human-readable name for a provider ID.
|
||||
func GetProviderName(providerID string) string {
|
||||
id, err := strconv.Atoi(providerID)
|
||||
if err != nil {
|
||||
return providerID
|
||||
}
|
||||
|
||||
for _, p := range StaticProviders {
|
||||
if p.ID == id {
|
||||
return p.Name
|
||||
}
|
||||
}
|
||||
|
||||
return providerID
|
||||
}
|
||||
|
||||
// GetProviderLabel returns the user-friendly label for a provider ID (e.g. "TuneIn Radio", "Spotify").
|
||||
func GetProviderLabel(providerID string) string {
|
||||
id, err := strconv.Atoi(providerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, p := range StaticProviders {
|
||||
if p.ID == id {
|
||||
return p.Label
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetProviders returns a list of known source provider names.
|
||||
func GetProviders() []string {
|
||||
var providers []string
|
||||
for _, p := range StaticProviders {
|
||||
providers = append(providers, p.Name)
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
// Common file and path constants used by the datastore and setup logic.
|
||||
@@ -114,4 +310,9 @@ const (
|
||||
|
||||
// XMLHeader is the standard XML declaration for Bose SoundTouch responses
|
||||
XMLHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
|
||||
|
||||
// CredentialTypeToken is the standard token credential type.
|
||||
CredentialTypeToken = "token"
|
||||
// CredentialTypeTokenV3 is the token version 3 credential type, used for Spotify.
|
||||
CredentialTypeTokenV3 = "token_version_3"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestConstants(t *testing.T) {
|
||||
t.Errorf("Expected SpeakerHTTPPort 8090, got %d", SpeakerHTTPPort)
|
||||
}
|
||||
|
||||
if len(Providers) == 0 {
|
||||
if len(GetProviders()) == 0 {
|
||||
t.Error("Providers should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
+988
-175
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -92,6 +93,37 @@ func TestDataStore(t *testing.T) {
|
||||
if ds.AccountDir(account) != expectedAccountDir {
|
||||
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
|
||||
}
|
||||
|
||||
// Test GetAccountInfo with placeholder
|
||||
accInfo, err := ds.GetAccountInfo("non-existent")
|
||||
if err != nil {
|
||||
t.Errorf("GetAccountInfo failed: %v", err)
|
||||
}
|
||||
if !accInfo.IsPlaceholder {
|
||||
t.Errorf("Expected IsPlaceholder to be true for non-existent account")
|
||||
}
|
||||
|
||||
// Test SaveAccountInfo and GetAccountInfo
|
||||
accountID := "acc-123"
|
||||
info2 := &models.ServiceAccountInfo{
|
||||
AccountID: accountID,
|
||||
PreferredLanguage: "en",
|
||||
}
|
||||
err = ds.SaveAccountInfo(accountID, info2)
|
||||
if err != nil {
|
||||
t.Errorf("SaveAccountInfo failed: %v", err)
|
||||
}
|
||||
|
||||
loadedAccInfo, err := ds.GetAccountInfo(accountID)
|
||||
if err != nil {
|
||||
t.Errorf("GetAccountInfo failed: %v", err)
|
||||
}
|
||||
if loadedAccInfo.PreferredLanguage != "en" {
|
||||
t.Errorf("Expected language en, got %s", loadedAccInfo.PreferredLanguage)
|
||||
}
|
||||
if loadedAccInfo.IsPlaceholder {
|
||||
t.Errorf("Expected IsPlaceholder to be false for existing account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllDevices_Empty(t *testing.T) {
|
||||
@@ -338,12 +370,27 @@ func TestConfiguredSources(t *testing.T) {
|
||||
t.Fatalf("Expected %d sources, got %d", len(sources), len(loadedSources))
|
||||
}
|
||||
|
||||
for i, s := range sources {
|
||||
for i := range sources {
|
||||
ls := loadedSources[i]
|
||||
if ls.DisplayName != s.DisplayName || ls.ID != s.ID || ls.Secret != s.Secret ||
|
||||
ls.SecretType != s.SecretType || ls.SourceKeyType != s.SourceKeyType ||
|
||||
ls.SourceKeyAccount != s.SourceKeyAccount {
|
||||
t.Errorf("Source %d mismatch. Expected %+v, got %+v", i, s, ls)
|
||||
expected := sources[i]
|
||||
expected.Secret = ""
|
||||
expected.SecretType = ""
|
||||
expected.Type = ls.Type // Ignore Type mismatch in this test if it's auto-populated
|
||||
|
||||
// Clear secrets for comparison since they are not loaded by GetConfiguredSources
|
||||
ls.Secret = ""
|
||||
ls.SecretType = ""
|
||||
|
||||
if ls.DisplayName != expected.DisplayName || ls.ID != expected.ID || ls.Secret != expected.Secret ||
|
||||
ls.SecretType != expected.SecretType || ls.SourceKeyType != expected.SourceKeyType ||
|
||||
ls.SourceKeyAccount != expected.SourceKeyAccount || ls.Type != expected.Type {
|
||||
// Clean XMLName for comparison
|
||||
ls.XMLName = xml.Name{}
|
||||
if ls.DisplayName != expected.DisplayName || ls.ID != expected.ID || ls.Secret != expected.Secret ||
|
||||
ls.SecretType != expected.SecretType || ls.SourceKeyType != expected.SourceKeyType ||
|
||||
ls.SourceKeyAccount != expected.SourceKeyAccount || ls.Type != expected.Type {
|
||||
t.Errorf("Source %d mismatch. Expected %+v, got %+v", i, expected, ls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ func TestSaveDeviceInfo_MergesName(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
device := "A81B6A536A98"
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
// 1. Initial save with name
|
||||
info1 := &models.ServiceDeviceInfo{
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestSavePresets_Format(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-format-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "test-playlist",
|
||||
Source: "SPOTIFY",
|
||||
Type: "tracklisturl",
|
||||
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
|
||||
SourceAccount: "test-user",
|
||||
IsPresetable: "true",
|
||||
},
|
||||
ID: "1",
|
||||
ButtonNumber: "1",
|
||||
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
|
||||
CreatedOn: "1719128436",
|
||||
UpdatedOn: "1728740382",
|
||||
},
|
||||
}
|
||||
|
||||
err = ds.SavePresets(account, device, presets)
|
||||
if err != nil {
|
||||
t.Fatalf("SavePresets failed: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
xmlContent := string(data)
|
||||
|
||||
// Check for correct id attribute
|
||||
if !strings.Contains(xmlContent, `id="1"`) {
|
||||
t.Errorf("Presets.xml missing correct id attribute, got: %s", xmlContent)
|
||||
}
|
||||
|
||||
// Check that contentItemType is NOT present (as requested in previous issues)
|
||||
if strings.Contains(xmlContent, "contentItemType") {
|
||||
t.Errorf("Presets.xml should not contain contentItemType tag, got: %s", xmlContent)
|
||||
}
|
||||
|
||||
// Verify unmarshaling still works
|
||||
loadedPresets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loadedPresets) != 1 {
|
||||
t.Fatalf("Expected 1 preset, got %d", len(loadedPresets))
|
||||
}
|
||||
|
||||
if loadedPresets[0].ID != "1" {
|
||||
t.Errorf("Expected ID 1, got %s", loadedPresets[0].ID)
|
||||
}
|
||||
|
||||
if loadedPresets[0].ContentItemType != "tracklisturl" {
|
||||
t.Errorf("Expected ContentItemType to be tracklisturl, got %s", loadedPresets[0].ContentItemType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePresets_PreservesID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-id-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "test-acc"
|
||||
device := "test-dev"
|
||||
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Preset 1",
|
||||
},
|
||||
ID: "1",
|
||||
ButtonNumber: "1",
|
||||
},
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Preset 2",
|
||||
},
|
||||
ID: "2",
|
||||
// ButtonNumber is empty, should fall back to ID
|
||||
},
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Preset 3",
|
||||
},
|
||||
ButtonNumber: "3",
|
||||
// ID is empty, should use ButtonNumber
|
||||
},
|
||||
}
|
||||
|
||||
err = ds.SavePresets(account, device, presets)
|
||||
if err != nil {
|
||||
t.Fatalf("SavePresets failed: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
xmlContent := string(data)
|
||||
|
||||
if !strings.Contains(xmlContent, `id="1"`) {
|
||||
t.Errorf("Expected id=\"1\", got: %s", xmlContent)
|
||||
}
|
||||
if !strings.Contains(xmlContent, `id="2"`) {
|
||||
t.Errorf("Expected id=\"2\", got: %s", xmlContent)
|
||||
}
|
||||
if !strings.Contains(xmlContent, `id="3"`) {
|
||||
t.Errorf("Expected id=\"3\", got: %s", xmlContent)
|
||||
}
|
||||
|
||||
// Now check if GetPresets loads them correctly
|
||||
loaded, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loaded) != 3 {
|
||||
t.Fatalf("Expected 3 presets, got %d", len(loaded))
|
||||
}
|
||||
|
||||
for i, p := range loaded {
|
||||
expectedID := strconv.Itoa(i + 1)
|
||||
if p.ID != expectedID {
|
||||
t.Errorf("At index %d, expected ID %s, got %s", i, expectedID, p.ID)
|
||||
}
|
||||
if p.ButtonNumber != expectedID {
|
||||
t.Errorf("At index %d, expected ButtonNumber %s, got %s", i, expectedID, p.ButtonNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsXML_NoID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-noid-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "No ID Preset",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = ds.SavePresets("acc", "dev", presets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir("acc", "dev"), "Presets.xml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), `id=""`) {
|
||||
t.Errorf("Expected empty id attribute, got: %s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestSaveRecents_Format(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore_recents_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
account := "test-account"
|
||||
device := "test-device"
|
||||
|
||||
recents := []models.ServiceRecent{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "2567119953",
|
||||
Name: "The National",
|
||||
Source: "SPOTIFY",
|
||||
ContentItemType: "tracklisturl",
|
||||
Location: "/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C",
|
||||
SourceAccount: "test-user",
|
||||
IsPresetable: "true",
|
||||
},
|
||||
DeviceID: "001122334455",
|
||||
UtcTime: "1771666755",
|
||||
},
|
||||
}
|
||||
|
||||
if err := ds.SaveRecents(account, device, recents); err != nil {
|
||||
t.Fatalf("SaveRecents failed: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), "Recents.xml")
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read Recents.xml: %v", err)
|
||||
}
|
||||
|
||||
expectedXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="2567119953" deviceID="001122334455" utcTime="1771666755">
|
||||
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C" sourceAccount="test-user" isPresetable="true">
|
||||
<itemName>The National</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
|
||||
// Normalize whitespace for comparison by unmarshaling both
|
||||
var expected, actual struct {
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
UtcTime string `xml:"utcTime,attr"`
|
||||
ContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
} `xml:"contentItem"`
|
||||
} `xml:"recent"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal([]byte(expectedXML), &expected); err != nil {
|
||||
t.Fatalf("Failed to unmarshal expected XML: %v", err)
|
||||
}
|
||||
if err := xml.Unmarshal(content, &actual); err != nil {
|
||||
t.Fatalf("Failed to unmarshal actual XML: %v", err)
|
||||
}
|
||||
|
||||
if len(actual.Recents) != 1 {
|
||||
t.Fatalf("Expected 1 recent, got %d", len(actual.Recents))
|
||||
}
|
||||
|
||||
r := actual.Recents[0]
|
||||
if r.ID != "2567119953" || r.DeviceID != "001122334455" || r.UtcTime != "1771666755" {
|
||||
t.Errorf("Attributes mismatch: %+v", r)
|
||||
}
|
||||
if r.ContentItem.ItemName != "The National" || r.ContentItem.Source != "SPOTIFY" {
|
||||
t.Errorf("ContentItem mismatch: %+v", r)
|
||||
}
|
||||
|
||||
// Now test Round-trip (GetRecents)
|
||||
loadedRecents, err := ds.GetRecents(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecents failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loadedRecents) != 1 {
|
||||
t.Fatalf("Expected 1 loaded recent, got %d", len(loadedRecents))
|
||||
}
|
||||
|
||||
lr := loadedRecents[0]
|
||||
if lr.ID != "2567119953" || lr.Name != "The National" || lr.Source != "SPOTIFY" || lr.SourceAccount != "test-user" {
|
||||
t.Errorf("Loaded recent mismatch: %+v", lr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestIsSafeIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
expected bool
|
||||
}{
|
||||
{"abc", true},
|
||||
{"ABC", true},
|
||||
{"123", true},
|
||||
{"abc_123", true},
|
||||
{"abc-123", true},
|
||||
{"abc.123", true},
|
||||
{"00:11:22:33:44:55", true},
|
||||
{"", false},
|
||||
{"/", false},
|
||||
{"\\", false},
|
||||
{"..", false},
|
||||
{"../etc/passwd", false},
|
||||
{"/etc/passwd", false},
|
||||
{"a/b", false},
|
||||
{"a\\b", false},
|
||||
{"a..b", false},
|
||||
{"a b", false},
|
||||
{"a!b", false},
|
||||
{"a@b", false},
|
||||
{"a#b", false},
|
||||
{"a$b", false},
|
||||
{"a%b", false},
|
||||
{"a^b", false},
|
||||
{"a&b", false},
|
||||
{"a*b", false},
|
||||
{"a(b", false},
|
||||
{"a)b", false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := isSafeIdentifier(test.id)
|
||||
if result != test.expected {
|
||||
t.Errorf("isSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDeviceInfo_Validation(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "datastore-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := NewDataStore(tmpDir)
|
||||
info := &models.ServiceDeviceInfo{DeviceID: "dev1"}
|
||||
|
||||
tests := []struct {
|
||||
account string
|
||||
device string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{"acc1", "dev1", false, ""},
|
||||
{"", "dev1", true, "account ID cannot be empty"},
|
||||
{"acc1", "", true, "device ID/name cannot be empty"},
|
||||
{"acc/1", "dev1", true, "invalid account ID"},
|
||||
{"acc1", "dev/1", true, "invalid device ID"},
|
||||
{"acc..1", "dev1", true, "invalid account ID"},
|
||||
{"acc1", "dev..1", true, "invalid device ID"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
err := ds.SaveDeviceInfo(test.account, test.device, info)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Errorf("SaveDeviceInfo(%q, %q) error = %v, wantErr %v", test.account, test.device, err, test.wantErr)
|
||||
continue
|
||||
}
|
||||
if test.wantErr && err.Error() != test.errMsg {
|
||||
t.Errorf("SaveDeviceInfo(%q, %q) error message = %q, want %q", test.account, test.device, err.Error(), test.errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetConfiguredSources_DeduceIDs(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "test-account"
|
||||
device := "test-device"
|
||||
|
||||
// Create recents with specific source IDs for provider IDs
|
||||
// Let's create a manual Recents.xml and Presets.xml in the temp directory to simulate the state.
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
<recent id="2184615630">
|
||||
<contentItemType></contentItemType>
|
||||
<createdOn>2017-02-07T11:22:00.000+00:00</createdOn>
|
||||
<lastplayedat>2017-05-17T13:18:57.000+00:00</lastplayedat>
|
||||
<location>52349</location>
|
||||
<name>Lounge FM Digital</name>
|
||||
<source id="9330201" type="Audio">
|
||||
<createdOn>2015-03-11T19:12:38.000+00:00</createdOn>
|
||||
<credential type="token"></credential>
|
||||
<name>9330201</name>
|
||||
<sourceproviderid>2</sourceproviderid>
|
||||
<sourcename></sourcename>
|
||||
<sourceSettings/>
|
||||
<updatedOn>2015-03-11T19:12:38.000+00:00</updatedOn>
|
||||
<username></username>
|
||||
</source>
|
||||
<sourceid>9330201</sourceid>
|
||||
<updatedOn>2017-05-17T17:18:58.000+00:00</updatedOn>
|
||||
<username>Lounge FM Digital</username>
|
||||
</recent>
|
||||
</recents>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Recents.xml: %v", err)
|
||||
}
|
||||
|
||||
// Now call GetConfiguredSources and expect it to have "9330201" for provider ID "2"
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
foundDeducted := false
|
||||
for _, s := range sources {
|
||||
if s.SourceProviderID == "2" {
|
||||
if s.ID == "9330201" {
|
||||
foundDeducted = true
|
||||
} else {
|
||||
t.Errorf("Expected source ID 9330201 for provider 2, got %s", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDeducted {
|
||||
t.Errorf("Did not find source with provider ID 2 and deducted ID 9330201")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfiguredSources_DeduceIDs_AllProviders(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-test-all-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "test-account"
|
||||
device := "test-device"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// 2: INTERNET_RADIO
|
||||
// 9: AUX
|
||||
// 11: LOCAL_INTERNET_RADIO
|
||||
// 25: TUNEIN
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<contentItem source="INTERNET_RADIO" sourceAccount="" isPresetable="true" type="station" itemName="Station 2">
|
||||
<containerArt>http://example.com/art2.png</containerArt>
|
||||
</contentItem>
|
||||
<source id="ID2" type="Audio" sourceproviderid="2" />
|
||||
<sourceid>ID2</sourceid>
|
||||
</preset>
|
||||
<preset id="2">
|
||||
<contentItem source="AUX" sourceAccount="AUX" isPresetable="true" type="station" itemName="Station 9">
|
||||
<containerArt>http://example.com/art9.png</containerArt>
|
||||
</contentItem>
|
||||
<source id="ID9" type="Audio" sourceproviderid="9" />
|
||||
<sourceid>ID9</sourceid>
|
||||
</preset>
|
||||
<preset id="3">
|
||||
<contentItem source="LOCAL_INTERNET_RADIO" sourceAccount="" isPresetable="true" type="station" itemName="Station 11">
|
||||
<containerArt>http://example.com/art11.png</containerArt>
|
||||
</contentItem>
|
||||
<source id="ID11" type="Audio" sourceproviderid="11" />
|
||||
<sourceid>ID11</sourceid>
|
||||
</preset>
|
||||
<preset id="4">
|
||||
<contentItem source="TUNEIN" sourceAccount="" isPresetable="true" type="station" itemName="Station 25">
|
||||
<containerArt>http://example.com/art25.png</containerArt>
|
||||
</contentItem>
|
||||
<source id="ID25" type="Audio" sourceproviderid="25" />
|
||||
<sourceid>ID25</sourceid>
|
||||
</preset>
|
||||
</presets>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
expected := map[string]string{
|
||||
"2": "ID2",
|
||||
"9": "ID9",
|
||||
"11": "ID11",
|
||||
"25": "ID25",
|
||||
}
|
||||
|
||||
found := make(map[string]bool)
|
||||
for _, s := range sources {
|
||||
if expID, ok := expected[s.SourceProviderID]; ok {
|
||||
if s.ID != expID {
|
||||
t.Errorf("Expected source ID %s for provider %s, got %s", expID, s.SourceProviderID, s.ID)
|
||||
}
|
||||
found[s.SourceProviderID] = true
|
||||
} else if s.SourceKeyType == "AUX" && s.SourceProviderID == "" {
|
||||
// Special case for AUX if it doesn't have provider ID 9 by default
|
||||
if expID, ok := expected["9"]; ok {
|
||||
if s.ID != expID {
|
||||
t.Errorf("Expected source ID %s for AUX, got %s", expID, s.ID)
|
||||
}
|
||||
found["9"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for pid := range expected {
|
||||
if !found[pid] {
|
||||
t.Errorf("Did not find source with provider ID %s", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestSaveSources_Format(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-sources-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
sources := []models.ConfiguredSource{
|
||||
{
|
||||
DisplayName: "AUX IN",
|
||||
SourceKey: struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
}{Type: "AUX", Account: "AUX"},
|
||||
},
|
||||
{
|
||||
SourceKey: struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
}{Type: "INTERNET_RADIO", Account: ""},
|
||||
},
|
||||
{
|
||||
DisplayName: "user@example.com",
|
||||
Secret: "dummy-token-spotify",
|
||||
SecretType: "token_version_3",
|
||||
SourceKey: struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
}{Type: "SPOTIFY", Account: "test-user"},
|
||||
},
|
||||
}
|
||||
|
||||
err = ds.SaveConfiguredSources(account, device, sources)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), "Sources.xml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
xmlContent := string(data)
|
||||
|
||||
// Check for correct attributes in first source
|
||||
if !strings.Contains(xmlContent, `<source displayName="AUX IN" secret="" secretType="">`) {
|
||||
t.Errorf("First source missing expected attributes. Got: %s", xmlContent)
|
||||
}
|
||||
if !strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX" />`) &&
|
||||
!strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX"></sourceKey>`) {
|
||||
t.Errorf("First sourceKey incorrect. Got: %s", xmlContent)
|
||||
}
|
||||
|
||||
// Check for credential element (new format)
|
||||
if !strings.Contains(xmlContent, `<credential type="token_version_3">dummy-token-spotify</credential>`) {
|
||||
t.Errorf("Spotify source missing <credential> element. Got: %s", xmlContent)
|
||||
}
|
||||
|
||||
// Check for third source (Spotify)
|
||||
if !strings.Contains(xmlContent, `displayName="user@example.com"`) {
|
||||
t.Errorf("Spotify source missing displayName. Got: %s", xmlContent)
|
||||
}
|
||||
if !strings.Contains(xmlContent, `secret="dummy-token-spotify" secretType="token_version_3">`) {
|
||||
t.Errorf("Spotify source missing secret. Got: %s", xmlContent)
|
||||
}
|
||||
if !strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user" />`) &&
|
||||
!strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user"></sourceKey>`) {
|
||||
t.Errorf("Spotify sourceKey incorrect. Got: %s", xmlContent)
|
||||
}
|
||||
|
||||
// Negative checks for extra tags
|
||||
if strings.Contains(xmlContent, "<sourcename>") {
|
||||
t.Errorf("Sources.xml should not contain <sourcename> tag")
|
||||
}
|
||||
if strings.Contains(xmlContent, "<username>") {
|
||||
t.Errorf("Sources.xml should not contain <username> tag")
|
||||
}
|
||||
if strings.Contains(xmlContent, "<name>") {
|
||||
t.Errorf("Sources.xml should not contain <name> tag")
|
||||
}
|
||||
if strings.Contains(xmlContent, "<sourceSettings>") {
|
||||
t.Errorf("Sources.xml should not contain <sourceSettings> tag")
|
||||
}
|
||||
}
|
||||
@@ -220,8 +220,8 @@ func TestUPnPDiscoveryToDatastoreMapping_FullFlow(t *testing.T) {
|
||||
{
|
||||
name: "InvalidMAC",
|
||||
requestMAC: "INVALID123456",
|
||||
shouldWork: false,
|
||||
description: "Invalid MAC (should fail)",
|
||||
shouldWork: true, // Changed: GetPresets now returns empty list instead of error if file missing
|
||||
description: "Invalid MAC (should return empty list)",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -234,7 +234,11 @@ func TestUPnPDiscoveryToDatastoreMapping_FullFlow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("%s failed: %v", tc.description, err)
|
||||
} else if len(presets) == 0 {
|
||||
t.Errorf("%s: no presets returned", tc.description)
|
||||
if tc.name != "InvalidMAC" {
|
||||
t.Errorf("%s: no presets returned", tc.description)
|
||||
} else {
|
||||
t.Logf("✓ %s: Successfully retrieved empty presets list", tc.description)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✓ %s: Successfully retrieved %d presets", tc.description, len(presets))
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestSpotifyBridge(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Mock Speaker (LISA API)
|
||||
var speakerReceived atomic.Bool
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/setMusicServiceOAuthAccount" {
|
||||
speakerReceived.Store(true)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceOAuthAccount</status>`))
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
// Register the speaker in the datastore so the bridge finds it
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEV123",
|
||||
AccountID: "acc123",
|
||||
Name: "Test Speaker",
|
||||
IPAddress: strings.TrimPrefix(speakerTS.URL, "http://"),
|
||||
}
|
||||
_ = ds.SaveDeviceInfo("acc123", "DEV123", devInfo)
|
||||
|
||||
// Ensure the directory structure exists for marge.AddSource
|
||||
_ = os.MkdirAll(ds.AccountDevicesDir("acc123"), 0755)
|
||||
_ = os.MkdirAll(filepath.Join(ds.AccountDevicesDir("acc123"), "DEV123"), 0755)
|
||||
|
||||
// Mock Spotify response
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "access-123",
|
||||
"refresh_token": "refresh-123",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
case "/me":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"email": "user@example.com",
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Initialize Spotify service
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints(ts.URL+"/token", ts.URL)
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/mgmt/spotify/callback", server.HandleMgmtSpotifyCallback)
|
||||
|
||||
// Trigger the callback
|
||||
req := httptest.NewRequest("GET", "/mgmt/spotify/callback?code=fake-code&account=acc123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200 OK, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 1. Verify Marge registration
|
||||
// We need to check if the source was added to the datastore
|
||||
foundInMarge := false
|
||||
sources, err := ds.GetConfiguredSources("acc123", "DEV123")
|
||||
if err == nil {
|
||||
for _, src := range sources {
|
||||
t.Logf(" Found source: %s (User: %s)", src.SourceKey.Type, src.Username)
|
||||
if (src.Username == "spotify-user" || src.SourceKey.Account == "spotify-user") &&
|
||||
(src.SourceProviderID == "15" || src.SourceKey.Type == "SPOTIFY") {
|
||||
foundInMarge = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundInMarge {
|
||||
// Log what we found to debug
|
||||
allDevices, _ := ds.ListAllDevices()
|
||||
t.Logf("Total devices in datastore: %d", len(allDevices))
|
||||
for _, d := range allDevices {
|
||||
t.Logf("Device: %s (Account: %s)", d.DeviceID, d.AccountID)
|
||||
s, _ := ds.GetConfiguredSources(d.AccountID, d.DeviceID)
|
||||
t.Logf(" Sources: %d", len(s))
|
||||
}
|
||||
t.Errorf("Spotify user not found in Marge configured sources")
|
||||
}
|
||||
|
||||
// 2. Verify Speaker notification (LISA API)
|
||||
// Using time.Sleep for simplicity in this test
|
||||
// Wait up to 1 second
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && !speakerReceived.Load() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !speakerReceived.Load() {
|
||||
t.Errorf("Speaker did not receive /setMusicServiceOAuthAccount notification")
|
||||
}
|
||||
|
||||
// 3. Verify Token Refresh via Surrogate
|
||||
// Now simulate the speaker asking for a fresh token using the surrogate secret it received.
|
||||
// We need to find the surrogate first.
|
||||
sources, _ = ds.GetConfiguredSources("acc123", "DEV123")
|
||||
var surrogate string
|
||||
for _, src := range sources {
|
||||
if src.SourceKey.Type == "SPOTIFY" {
|
||||
surrogate = src.Secret
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if surrogate == "" {
|
||||
t.Fatal("Could not find surrogate token in Marge sources")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(surrogate, "bs-") || len(surrogate) != 35 {
|
||||
t.Errorf("Expected surrogate to have 'bs-' prefix and be 35 chars, got %s", surrogate)
|
||||
}
|
||||
|
||||
// Request refresh
|
||||
refreshReqBody := map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": surrogate,
|
||||
}
|
||||
body, err := json.Marshal(refreshReqBody)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal refresh request: %v", err)
|
||||
}
|
||||
|
||||
refreshReq := httptest.NewRequest("POST", "/oauth/device/DEV123/music/musicprovider/15/token/cs3", strings.NewReader(string(body)))
|
||||
refreshW := httptest.NewRecorder()
|
||||
|
||||
// Need to register the route for testing
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
|
||||
r.ServeHTTP(refreshW, refreshReq)
|
||||
|
||||
if refreshW.Code != http.StatusOK {
|
||||
t.Fatalf("Token refresh failed: %d: %s", refreshW.Code, refreshW.Body.String())
|
||||
}
|
||||
|
||||
var refreshResp map[string]interface{}
|
||||
if err := json.Unmarshal(refreshW.Body.Bytes(), &refreshResp); err != nil {
|
||||
t.Fatalf("Failed to parse refresh response: %v", err)
|
||||
}
|
||||
|
||||
if refreshResp["access_token"] != "access-123" {
|
||||
t.Errorf("Expected access_token 'access-123', got '%v'", refreshResp["access_token"])
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,6 +23,8 @@ func TestDocsConsistency(t *testing.T) {
|
||||
|
||||
summaryText := string(summaryContent)
|
||||
|
||||
docsIgnore := readDocsIgnore(t, filepath.Join(projectRoot, ".docsignore"))
|
||||
|
||||
// List of directories to check
|
||||
dirsToCheck := []string{".", "guides", "reference", "analysis"}
|
||||
|
||||
@@ -48,6 +51,13 @@ func TestDocsConsistency(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files listed in .docsignore at the project root
|
||||
for _, skip := range docsIgnore {
|
||||
if strings.HasSuffix(path, filepath.FromSlash(skip)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get relative path from docs/
|
||||
relPath, err := filepath.Rel(docsDir, path)
|
||||
if err != nil {
|
||||
@@ -69,3 +79,31 @@ func TestDocsConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readDocsIgnore reads a .docsignore file and returns the non-empty, non-comment lines.
|
||||
// If the file does not exist it returns nil without failing the test.
|
||||
func readDocsIgnore(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var patterns []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("Error reading %s: %v", path, err)
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// validatePathID ensures that an identifier is safe to use as a single path component.
|
||||
func validatePathID(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(id, "..") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleMgmtAccountDetails returns full details for an account for the Web UI.
|
||||
func (s *Server) HandleMgmtAccountDetails(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := chi.URLParam(r, "accountId")
|
||||
if !validatePathID(accountID) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Get account info
|
||||
accountInfo, err := s.ds.GetAccountInfo(accountID)
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Failed to get account info for %s: %v", accountID, err)
|
||||
accountInfo = &models.ServiceAccountInfo{AccountID: accountID}
|
||||
}
|
||||
|
||||
// Enrich provider settings with names
|
||||
for i := range accountInfo.ProviderSettings {
|
||||
s := &accountInfo.ProviderSettings[i]
|
||||
if s.ProviderName == "" {
|
||||
s.ProviderName = constants.GetProviderName(s.ProviderID)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. List all devices for this account
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Failed to list devices: %v", err)
|
||||
}
|
||||
|
||||
accountDevices := make([]deviceDetail, 0)
|
||||
|
||||
for i := range allDevices {
|
||||
d := &allDevices[i]
|
||||
if d.AccountID != accountID {
|
||||
continue
|
||||
}
|
||||
|
||||
detail := s.getDeviceDetail(accountID, d)
|
||||
accountDevices = append(accountDevices, detail)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"account": accountInfo,
|
||||
"devices": accountDevices,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode account details: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtUpdateAccountLanguage updates the preferred language for an account.
|
||||
func (s *Server) HandleMgmtUpdateAccountLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := chi.URLParam(r, "accountId")
|
||||
if !validatePathID(accountID) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Language != "en" && req.Language != "de" {
|
||||
http.Error(w, "Language must be 'en' or 'de'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Load current account info
|
||||
accountInfo, err := s.ds.GetAccountInfo(accountID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Update language
|
||||
accountInfo.AccountID = accountID // Ensure ID is correct
|
||||
accountInfo.PreferredLanguage = req.Language
|
||||
accountInfo.IsPlaceholder = false
|
||||
|
||||
// 3. Save account info
|
||||
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMgmtUpdateAccountProviderSetting updates a specific provider setting for an account.
|
||||
func (s *Server) HandleMgmtUpdateAccountProviderSetting(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := chi.URLParam(r, "accountId")
|
||||
if !validatePathID(accountID) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ProviderID string `json:"provider_id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ProviderID == "" || req.Key == "" {
|
||||
http.Error(w, "provider_id and key are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Load current account info
|
||||
accountInfo, err := s.ds.GetAccountInfo(accountID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Update the specific setting
|
||||
found := false
|
||||
|
||||
for i, setting := range accountInfo.ProviderSettings {
|
||||
if setting.ProviderID == req.ProviderID && setting.KeyName == req.Key {
|
||||
accountInfo.ProviderSettings[i].Value = req.Value
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// If not found, we can choose to add it or return error.
|
||||
// For now, let's return an error as we expect to edit existing ones.
|
||||
http.Error(w, "Provider setting not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
accountInfo.AccountID = accountID // Ensure ID is correct
|
||||
accountInfo.IsPlaceholder = false
|
||||
|
||||
// 3. Save account info
|
||||
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
type deviceDetail struct {
|
||||
models.AccountDevice
|
||||
Presets []models.FullResponsePreset `json:"presets,omitempty"`
|
||||
Recents []models.FullResponseRecent `json:"recents,omitempty"`
|
||||
Sources []models.FullResponseSource `json:"sources,omitempty"`
|
||||
Components []models.ServiceComponent `json:"components,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) getDeviceDetail(accountID string, d *models.ServiceDeviceInfo) deviceDetail {
|
||||
detail := deviceDetail{
|
||||
AccountDevice: models.AccountDevice{
|
||||
DeviceID: d.DeviceID,
|
||||
FirmwareVersion: d.FirmwareVersion,
|
||||
IPAddress: d.IPAddress,
|
||||
Name: d.Name,
|
||||
ProductCode: d.ProductCode,
|
||||
SerialNumber: d.DeviceSerialNumber,
|
||||
DeviceSerialNumber: d.DeviceSerialNumber,
|
||||
MacAddress: d.MacAddress,
|
||||
DiscoveryMethod: d.DiscoveryMethod,
|
||||
},
|
||||
}
|
||||
|
||||
// We also have AttachedProduct which has Components
|
||||
detail.AttachedProduct = &models.AttachedProduct{
|
||||
SerialNumber: d.DeviceSerialNumber,
|
||||
ProductCode: d.ProductCode,
|
||||
ProductLabel: d.Name,
|
||||
Components: d.Components,
|
||||
}
|
||||
detail.Components = d.Components
|
||||
|
||||
// Fetch sources
|
||||
var configuredSources []models.ConfiguredSource
|
||||
|
||||
sources, err := s.ds.GetConfiguredSources(accountID, d.DeviceID)
|
||||
if err == nil {
|
||||
configuredSources = sources
|
||||
for j := range sources {
|
||||
fs := mapToFullResponseSource(&sources[j])
|
||||
if fs.Type == "" && fs.Name == "" && fs.DisplayName == "" {
|
||||
log.Printf("[Mgmt] Skipping empty source for device %s", d.DeviceID)
|
||||
continue
|
||||
}
|
||||
|
||||
detail.Sources = append(detail.Sources, fs)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch presets
|
||||
if presets, err := s.ds.GetPresets(accountID, d.DeviceID); err == nil {
|
||||
for j := range presets {
|
||||
detail.Presets = append(detail.Presets, mapToFullResponsePreset(&presets[j], configuredSources))
|
||||
}
|
||||
}
|
||||
|
||||
detail.AccountDevice.Presets = detail.Presets
|
||||
|
||||
// Fetch recents
|
||||
if recents, err := s.ds.GetRecents(accountID, d.DeviceID); err == nil {
|
||||
for j := range recents {
|
||||
detail.Recents = append(detail.Recents, mapToFullResponseRecent(&recents[j], configuredSources))
|
||||
}
|
||||
}
|
||||
|
||||
detail.AccountDevice.Recents = detail.Recents
|
||||
|
||||
return detail
|
||||
}
|
||||
|
||||
func mapToFullResponseSource(src *models.ConfiguredSource) models.FullResponseSource {
|
||||
fs := models.FullResponseSource{
|
||||
ID: src.ID,
|
||||
Type: src.Type,
|
||||
DisplayName: src.DisplayName,
|
||||
Name: src.DisplayName,
|
||||
Username: src.Username,
|
||||
SourceName: src.SourceName,
|
||||
SourceProviderID: src.SourceProviderID,
|
||||
CreatedOn: src.CreatedOn,
|
||||
UpdatedOn: src.UpdatedOn,
|
||||
Account: src.SourceKey.Account,
|
||||
SourceLabel: constants.GetSourceLabel(src.Type),
|
||||
ProviderLabel: constants.GetProviderLabel(src.SourceProviderID),
|
||||
SourceSettings: src.SourceSettings,
|
||||
}
|
||||
fs.Credential.Value = src.Secret
|
||||
fs.Credential.Type = src.SecretType
|
||||
|
||||
// If DisplayName is generic (e.g. "Audio") and we have a more specific Account name, use it.
|
||||
if fs.DisplayName == fs.Type && fs.Account != "" {
|
||||
fs.DisplayName = fs.Account
|
||||
fs.Name = fs.Account
|
||||
}
|
||||
|
||||
// Provide fallback for Name and SourceName if missing
|
||||
switch {
|
||||
case fs.Name != "":
|
||||
// Name already set to DisplayName
|
||||
case fs.Account != "":
|
||||
fs.Name = fs.Account
|
||||
case fs.SourceLabel != "":
|
||||
fs.Name = fs.SourceLabel
|
||||
default:
|
||||
fs.Name = fs.Type
|
||||
}
|
||||
|
||||
if fs.SourceName == "" {
|
||||
fs.SourceName = fs.Name
|
||||
}
|
||||
|
||||
if fs.DisplayName == "" {
|
||||
fs.DisplayName = fs.Name
|
||||
}
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models.ConfiguredSource) models.FullResponsePreset {
|
||||
fp := models.FullResponsePreset{
|
||||
ButtonNumber: p.ButtonNumber,
|
||||
ContainerArt: p.ContainerArt,
|
||||
ContentItemType: p.ContentItemType,
|
||||
CreatedOn: p.CreatedOn,
|
||||
Location: p.Location,
|
||||
Name: p.Name,
|
||||
UpdatedOn: p.UpdatedOn,
|
||||
Username: p.Username,
|
||||
}
|
||||
if fp.Username == "" {
|
||||
fp.Username = p.Name
|
||||
}
|
||||
|
||||
if fp.Name == "" {
|
||||
fp.Name = p.Name
|
||||
}
|
||||
|
||||
if fp.CreatedOn == "" && p.CreatedOn != "" {
|
||||
fp.CreatedOn = p.CreatedOn
|
||||
}
|
||||
|
||||
// Attempt to find matching source in configuredSources
|
||||
found := false
|
||||
|
||||
for k := range configuredSources {
|
||||
src := &configuredSources[k]
|
||||
if src.SourceKey.Type == p.Source && (src.SourceKey.Account == p.SourceAccount || p.SourceAccount == "") {
|
||||
fp.Source = mapToFullResponseSource(src)
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found && p.Source != "" {
|
||||
// Create a dummy source for UI purposes if not found in configured sources
|
||||
dummy := &models.ConfiguredSource{
|
||||
Type: p.Source,
|
||||
}
|
||||
dummy.SourceKey.Type = p.Source
|
||||
dummy.SourceKey.Account = p.SourceAccount
|
||||
fp.Source = mapToFullResponseSource(dummy)
|
||||
}
|
||||
|
||||
return fp
|
||||
}
|
||||
|
||||
func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models.ConfiguredSource) models.FullResponseRecent {
|
||||
fr := models.FullResponseRecent{
|
||||
ID: r.ID,
|
||||
ContentItemType: r.ContentItemType,
|
||||
CreatedOn: r.CreatedOn,
|
||||
LastPlayedAt: r.LastPlayedAt,
|
||||
Location: r.Location,
|
||||
Name: r.Name,
|
||||
SourceID: r.SourceID,
|
||||
UpdatedOn: r.UpdatedOn,
|
||||
Username: r.Username,
|
||||
}
|
||||
if fr.LastPlayedAt == "" && r.UtcTime != "" {
|
||||
if ut, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil {
|
||||
fr.LastPlayedAt = time.Unix(ut, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
||||
}
|
||||
}
|
||||
|
||||
if fr.Username == "" {
|
||||
fr.Username = r.Name
|
||||
}
|
||||
|
||||
if fr.Name == "" {
|
||||
fr.Name = r.Name
|
||||
}
|
||||
|
||||
if fr.CreatedOn == "" && r.CreatedOn != "" {
|
||||
fr.CreatedOn = r.CreatedOn
|
||||
} else if fr.CreatedOn == "" && r.UtcTime != "" {
|
||||
fr.CreatedOn = r.UtcTime
|
||||
}
|
||||
|
||||
// Attempt to find matching source in configuredSources
|
||||
found := false
|
||||
|
||||
for k := range configuredSources {
|
||||
src := &configuredSources[k]
|
||||
if src.SourceKey.Type == r.Source && (src.SourceKey.Account == r.SourceAccount || r.SourceAccount == "") {
|
||||
fr.Source = mapToFullResponseSource(src)
|
||||
if fr.SourceID == "" {
|
||||
fr.SourceID = fr.Source.ID
|
||||
}
|
||||
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found && r.Source != "" {
|
||||
// Create a dummy source for UI purposes if not found in configured sources
|
||||
dummy := &models.ConfiguredSource{
|
||||
Type: r.Source,
|
||||
}
|
||||
dummy.SourceKey.Type = r.Source
|
||||
dummy.SourceKey.Account = r.SourceAccount
|
||||
|
||||
fr.Source = mapToFullResponseSource(dummy)
|
||||
if fr.SourceID == "" {
|
||||
fr.SourceID = fr.Source.ID
|
||||
}
|
||||
}
|
||||
|
||||
return fr
|
||||
}
|
||||
|
||||
// HandleMgmtListAccounts returns a list of all account IDs in the datastore.
|
||||
func (s *Server) HandleMgmtListAccounts(w http.ResponseWriter, _ *http.Request) {
|
||||
accounts, err := s.ds.ListAccounts()
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Failed to list accounts: %v", err)
|
||||
|
||||
accounts = []string{"default"}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"accounts": accounts,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode accounts: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
|
||||
tempBaseDir := "mgmt_test_data"
|
||||
err := os.MkdirAll(tempBaseDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempBaseDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempBaseDir)
|
||||
err = ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accountID := "1234567"
|
||||
deviceID := "001122334455"
|
||||
|
||||
// Setup a device with a recent item that has utcTime and name in ContentItem
|
||||
deviceDir := ds.AccountDeviceDir(accountID, deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<recents>
|
||||
<recent id="2538285498" utcTime="1690000000">
|
||||
<contentItem source="INTERNET_RADIO" type="stationurl">
|
||||
<itemName>For Your Darkest Days</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
err = os.WriteFile(deviceDir+"/Recents.xml", []byte(recentsXML), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Also need a device info file to be listed
|
||||
deviceInfo := models.ServiceDeviceInfo{
|
||||
AccountID: accountID,
|
||||
DeviceID: deviceID,
|
||||
Name: "Test Device",
|
||||
}
|
||||
err = ds.SaveDeviceInfo(accountID, deviceID, &deviceInfo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{ds: ds}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/mgmt/accounts/{accountId}", server.HandleMgmtAccountDetails)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/accounts/1234567", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Devices []struct {
|
||||
Recents []models.FullResponseRecent `json:"recents"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(response.Devices) == 0 {
|
||||
t.Fatal("Expected at least one device")
|
||||
}
|
||||
|
||||
recents := response.Devices[0].Recents
|
||||
if len(recents) == 0 {
|
||||
t.Fatal("Expected one recent item")
|
||||
}
|
||||
|
||||
r0 := recents[0]
|
||||
if r0.Name != "For Your Darkest Days" {
|
||||
t.Errorf("Expected recent name 'For Your Darkest Days', got '%s'", r0.Name)
|
||||
}
|
||||
|
||||
if r0.CreatedOn != "1690000000" {
|
||||
t.Errorf("Expected recent created_on '1690000000' (from utcTime), got '%s'", r0.CreatedOn)
|
||||
}
|
||||
|
||||
// Test Preset mapping as well
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1690000001">
|
||||
<contentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
|
||||
<itemName>test-playlist</itemName>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
err = os.WriteFile(deviceDir+"/Presets.xml", []byte(presetsXML), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w2, req)
|
||||
|
||||
var response2 struct {
|
||||
Devices []struct {
|
||||
Presets []models.FullResponsePreset `json:"presets"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
err = json.Unmarshal(w2.Body.Bytes(), &response2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(response2.Devices[0].Presets) == 0 {
|
||||
t.Fatal("Expected one preset")
|
||||
}
|
||||
p0 := response2.Devices[0].Presets[0]
|
||||
if p0.Name != "test-playlist" {
|
||||
t.Errorf("Expected preset name 'test-playlist', got '%s'", p0.Name)
|
||||
}
|
||||
if p0.CreatedOn != "1690000001" {
|
||||
t.Errorf("Expected preset created_on '1690000001', got '%s'", p0.CreatedOn)
|
||||
}
|
||||
|
||||
// Verify ButtonNumber/ID handling
|
||||
if p0.ButtonNumber != "1" {
|
||||
t.Errorf("Expected button_number '1', got '%s'", p0.ButtonNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMgmtUpdateAccountLanguage(t *testing.T) {
|
||||
tempBaseDir := "mgmt_test_data_lang"
|
||||
err := os.MkdirAll(tempBaseDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempBaseDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempBaseDir)
|
||||
err = ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accountID := "1234567"
|
||||
server := &Server{ds: ds}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/mgmt/accounts/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
|
||||
t.Run("Valid Language de", func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]string{"language": "de"})
|
||||
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
accInfo, _ := ds.GetAccountInfo(accountID)
|
||||
if accInfo.PreferredLanguage != "de" {
|
||||
t.Errorf("Expected language 'de', got '%s'", accInfo.PreferredLanguage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid Language fr", func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]string{"language": "fr"})
|
||||
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleMgmtUpdateAccountProviderSetting(t *testing.T) {
|
||||
tempBaseDir := "mgmt_test_data_provider"
|
||||
err := os.MkdirAll(tempBaseDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempBaseDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempBaseDir)
|
||||
err = ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accountID := "1234567"
|
||||
server := &Server{ds: ds}
|
||||
|
||||
// Setup initial account info
|
||||
initialInfo := &models.ServiceAccountInfo{
|
||||
AccountID: accountID,
|
||||
ProviderSettings: []models.ProviderSetting{
|
||||
{
|
||||
ProviderID: "15",
|
||||
KeyName: "STREAMING_QUALITY",
|
||||
Value: "2",
|
||||
},
|
||||
},
|
||||
}
|
||||
ds.SaveAccountInfo(accountID, initialInfo)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/mgmt/accounts/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
|
||||
t.Run("Valid Update", func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"provider_id": "15",
|
||||
"key": "STREAMING_QUALITY",
|
||||
"value": "3",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
accInfo, _ := ds.GetAccountInfo(accountID)
|
||||
found := false
|
||||
for _, s := range accInfo.ProviderSettings {
|
||||
if s.ProviderID == "15" && s.KeyName == "STREAMING_QUALITY" {
|
||||
if s.Value != "3" {
|
||||
t.Errorf("Expected value '3', got '%s'", s.Value)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Provider setting not found after update")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Setting Not Found", func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"provider_id": "99",
|
||||
"key": "NON_EXISTENT",
|
||||
"value": "val",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
|
||||
tempBaseDir := "sources_test_data"
|
||||
err := os.MkdirAll(tempBaseDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempBaseDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempBaseDir)
|
||||
err = ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accountID := "1234567"
|
||||
deviceID := "001122334455"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(accountID, deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Mock Sources.xml as it might be read by Sync/Save logic if we were using it,
|
||||
// but here we will save them directly via DataStore.
|
||||
sources := []models.ConfiguredSource{
|
||||
{
|
||||
ID: "9330201",
|
||||
Type: "Audio",
|
||||
DisplayName: "Audio",
|
||||
SourceName: "Audio",
|
||||
Name: "Audio",
|
||||
SourceKey: struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
}{
|
||||
Type: "Audio",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "10863533",
|
||||
Type: "Audio",
|
||||
DisplayName: "Audio",
|
||||
SourceName: "Audio",
|
||||
Name: "Audio",
|
||||
SourceKey: struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
}{
|
||||
Type: "Audio",
|
||||
Account: "gesellix",
|
||||
},
|
||||
},
|
||||
}
|
||||
err = ds.SaveConfiguredSources(accountID, deviceID, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Also need a device info file to be listed
|
||||
deviceInfo := models.ServiceDeviceInfo{
|
||||
AccountID: accountID,
|
||||
DeviceID: deviceID,
|
||||
Name: "Test Device",
|
||||
}
|
||||
err = ds.SaveDeviceInfo(accountID, deviceID, &deviceInfo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{ds: ds}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/mgmt/accounts/{accountId}", server.HandleMgmtAccountDetails)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/accounts/1234567", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Devices []struct {
|
||||
Sources []models.FullResponseSource `json:"sources"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(response.Devices) == 0 {
|
||||
t.Fatal("Expected one device")
|
||||
}
|
||||
|
||||
if len(response.Devices[0].Sources) < 2 {
|
||||
t.Fatalf("Expected at least 2 sources, got %d", len(response.Devices[0].Sources))
|
||||
}
|
||||
|
||||
// Find the gesellix source
|
||||
var gesellixSource *models.FullResponseSource
|
||||
for i := range response.Devices[0].Sources {
|
||||
if response.Devices[0].Sources[i].ID == "10863533" {
|
||||
gesellixSource = &response.Devices[0].Sources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if gesellixSource == nil {
|
||||
t.Fatal("gesellix source not found")
|
||||
} else {
|
||||
// It should have fallen back to Account name "gesellix" because DisplayName was generic "Audio"
|
||||
if gesellixSource.DisplayName != "gesellix" {
|
||||
t.Errorf("Expected display_name 'gesellix', got '%s'", gesellixSource.DisplayName)
|
||||
}
|
||||
if gesellixSource.Name != "gesellix" {
|
||||
t.Errorf("Expected name 'gesellix', got '%s'", gesellixSource.Name)
|
||||
}
|
||||
if gesellixSource.Type != "Audio" {
|
||||
t.Errorf("Expected type 'Audio', got '%s'", gesellixSource.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the generic audio source
|
||||
var audioSource *models.FullResponseSource
|
||||
for i := range response.Devices[0].Sources {
|
||||
if response.Devices[0].Sources[i].ID == "9330201" {
|
||||
audioSource = &response.Devices[0].Sources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if audioSource == nil {
|
||||
t.Fatal("audio source not found")
|
||||
} else {
|
||||
// It should still be "Audio" as there is no account fallback
|
||||
if audioSource.DisplayName != "Audio" {
|
||||
t.Errorf("Expected display_name 'Audio', got '%s'", audioSource.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
@@ -33,8 +34,30 @@ func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(content))
|
||||
}
|
||||
|
||||
// HandleBMXServicesAvailability returns the BMX services availability.
|
||||
func (s *Server) HandleBMXServicesAvailability(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(bmxServicesAvailabilityJSON)
|
||||
}
|
||||
|
||||
func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`<!doctype html>
|
||||
<html lang=en>
|
||||
<title>401 Unauthorized</title>
|
||||
<h1>Unauthorized</h1>
|
||||
<p>Authorization not set. No access token found.</p>
|
||||
`))
|
||||
}
|
||||
|
||||
// HandleTuneInPlayback returns TuneIn playback information.
|
||||
func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID)
|
||||
@@ -53,6 +76,11 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleTuneInPodcastInfo returns TuneIn podcast information.
|
||||
func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
encodedName := r.URL.Query().Get("encoded_name")
|
||||
|
||||
@@ -72,6 +100,11 @@ func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information.
|
||||
func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID)
|
||||
@@ -88,8 +121,40 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInToken returns a TuneIn access token.
|
||||
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we return the provided refresh_token as access_token and refresh_token,
|
||||
// mirroring the behavior seen in the recordings.
|
||||
resp := map[string]string{
|
||||
"access_token": req.RefreshToken,
|
||||
"refresh_token": req.RefreshToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
data := chi.URLParam(r, "data")
|
||||
|
||||
resp, err := bmx.PlayCustomStream(data)
|
||||
@@ -143,3 +208,136 @@ func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInReport handles TuneIn playback reporting.
|
||||
func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
EventType string `json:"eventType"`
|
||||
}
|
||||
|
||||
// We don't strictly need the body to determine the response,
|
||||
// but we decode it to see the eventType.
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.EventType == "START" {
|
||||
// Mirroring the response from 0196-20260329-233306.072-POST.http
|
||||
resp := map[string]interface{}{
|
||||
"_links": map[string]interface{}{
|
||||
"self": map[string]interface{}{
|
||||
"href": "/v1/report?" + r.URL.RawQuery,
|
||||
},
|
||||
},
|
||||
"nextReportIn": 1800,
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For STOP and other events, return an empty object
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate returns live TuneIn navigation results.
|
||||
// Path variants handled via chi wildcard:
|
||||
// - (empty) → top-level browse
|
||||
// - {encodedURI} → browse the given TuneIn URI
|
||||
// - sub/{n}/{encodedURI} → single subsection of a browse page
|
||||
// - profiles/{type}/{id}/{encodedURI} → artist/program profile page
|
||||
func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
resp, err := parseTuneInNavigatePath(wildcard)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTuneInNavigatePath(wildcard string) (interface{}, error) {
|
||||
if wildcard == "" {
|
||||
return bmx.TuneInNavigate("", nil)
|
||||
}
|
||||
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
prefix := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch prefix {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
return bmx.TuneInNavigate(rest, nil)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(rest[:secondSlash])
|
||||
if err != nil {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
|
||||
case "profiles":
|
||||
// profiles/{type}/{id}/{encodedURI}
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigateProfile(parts[2])
|
||||
|
||||
default:
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch returns live TuneIn search results for the given query.
|
||||
func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmx.TuneInSearch(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user