Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
@@ -70,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:
|
||||
@@ -127,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
|
||||
@@ -324,7 +333,7 @@ jobs:
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -532,7 +549,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,8 @@ 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
|
||||
@@ -29,7 +31,7 @@ BUILD_DIR=./build
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-examples build-favicon-gen
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@@ -41,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)
|
||||
@@ -106,24 +113,20 @@ test-coverage:
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
test-http-client:
|
||||
@echo "Running HTTP client integration tests..."
|
||||
@docker network create soundtouch-test-net || true
|
||||
@docker build -t soundtouch-service-test .
|
||||
@docker run -d --name soundtouch-service --network soundtouch-test-net \
|
||||
-e PORT=8000 \
|
||||
-e SPOTIFY_CLIENT_ID=mock-id \
|
||||
-e SPOTIFY_CLIENT_SECRET=mock-secret \
|
||||
-v $(PWD)/tests/integration/testdata:/app/data \
|
||||
soundtouch-service-test
|
||||
@echo "Waiting for service to start..."
|
||||
@sleep 5
|
||||
@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 \
|
||||
-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/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 \
|
||||
@@ -150,11 +153,9 @@ test-http-client:
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
docker logs soundtouch-service; \
|
||||
docker stop soundtouch-service; \
|
||||
docker rm soundtouch-service; \
|
||||
docker rmi soundtouch-service-test; \
|
||||
docker network rm soundtouch-test-net; \
|
||||
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 down; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
fmt:
|
||||
@@ -246,10 +247,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..."
|
||||
@@ -309,6 +331,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"
|
||||
@@ -330,5 +355,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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -37,16 +37,16 @@ func TestFetchTuneInMetadata(t *testing.T) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,15 +192,15 @@ func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,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,7 +53,7 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,31 +68,43 @@ func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
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
|
||||
// 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]
|
||||
found := false
|
||||
foundIdx := -1
|
||||
|
||||
for j := range sources {
|
||||
if sources[j].SourceKeyType == def.SourceKeyType {
|
||||
found = true
|
||||
|
||||
if sources[j].Secret == "" && def.Secret != "" {
|
||||
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
|
||||
sources[j].Secret = def.Secret
|
||||
sources[j].SecretType = def.SecretType
|
||||
modified = true
|
||||
}
|
||||
|
||||
break
|
||||
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 !found {
|
||||
log.Printf("Adding missing default source %s to device %s", def.SourceKeyType, dev.DeviceID)
|
||||
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
|
||||
}
|
||||
@@ -209,6 +226,16 @@ func main() {
|
||||
Value: "ueberboese-login://spotify",
|
||||
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: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
@@ -290,7 +317,7 @@ 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.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
@@ -305,6 +332,10 @@ func main() {
|
||||
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)
|
||||
}
|
||||
@@ -439,6 +470,8 @@ type serviceConfig struct {
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyTokenURL string
|
||||
spotifyAPIBase string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
migrationEnabled bool
|
||||
@@ -503,6 +536,8 @@ 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")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
mirrorEnabled := c.Bool("mirror-enabled")
|
||||
@@ -536,6 +571,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
spotifyTokenURL: spotifyTokenURL,
|
||||
spotifyAPIBase: spotifyAPIBase,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
migrationEnabled: migrationEnabled,
|
||||
|
||||
@@ -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,707 @@
|
||||
// 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/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 := strings.TrimPrefix(r.URL.Path, "/api/device/")
|
||||
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) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/control/")
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
app.sendError(w, "Invalid control path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := parts[0]
|
||||
action := parts[1]
|
||||
|
||||
// Check for empty device 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-key" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
key := pathParts[4]
|
||||
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-volume" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
|
||||
volumeLevel, err := strconv.Atoi(pathParts[4])
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
|
||||
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) {
|
||||
if r.Method != http.MethodGet {
|
||||
app.sendError(w, "GET required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power-status" {
|
||||
app.sendError(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
|
||||
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) {
|
||||
const navPrefix = "/api/tunein/navigate"
|
||||
|
||||
path := r.URL.Path
|
||||
wildcard := ""
|
||||
|
||||
if len(path) > len(navPrefix) {
|
||||
wildcard = strings.TrimPrefix(path[len(navPrefix):], "/")
|
||||
}
|
||||
|
||||
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 := strings.TrimPrefix(r.URL.Path, "/api/tunein/play/")
|
||||
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,564 @@
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
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 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
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid device",
|
||||
path: "/api/device/test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/device/",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "unknown device",
|
||||
path: "/api/device/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)
|
||||
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)
|
||||
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.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.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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
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,335 @@
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"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) {
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-ws" {
|
||||
http.Error(w, "Invalid path format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := pathParts[3]
|
||||
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,191 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
port = flag.String("port", "8080", "Web server port")
|
||||
_ = flag.String("host", "", "Specific SoundTouch device host (optional)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
app := 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()
|
||||
|
||||
// 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()
|
||||
}()
|
||||
|
||||
// Setup HTTP routes
|
||||
setupRoutes(app, discoveryService)
|
||||
|
||||
// Start web server
|
||||
log.Printf("SoundTouch Web UI starting on http://localhost:%s", *port)
|
||||
log.Fatal(http.ListenAndServe(":"+*port, nil))
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
// Static files - try both relative paths
|
||||
staticDir := "cmd/soundtouch-web/static/"
|
||||
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
|
||||
staticDir = "static/"
|
||||
}
|
||||
|
||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
|
||||
|
||||
// WebSocket endpoint
|
||||
http.HandleFunc("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
http.HandleFunc("/api/devices", app.HandleAPIDevices)
|
||||
http.HandleFunc("/api/device/", app.HandleAPIDevice)
|
||||
http.HandleFunc("/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
|
||||
http.HandleFunc("/api/control/", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
http.HandleFunc("/api/tunein/search", app.HandleTuneInSearch)
|
||||
http.HandleFunc("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
http.HandleFunc("/api/tunein/navigate/", app.HandleTuneInNavigate)
|
||||
http.HandleFunc("/api/tunein/play/", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints with specific patterns
|
||||
http.HandleFunc("/api/device-key/", app.HandleDeviceKey)
|
||||
http.HandleFunc("/api/device-volume/", app.HandleDirectVolumeControl)
|
||||
http.HandleFunc("/api/device-power/", app.HandleDevicePower)
|
||||
http.HandleFunc("/api/device-power-status/", app.HandleDevicePowerStatus)
|
||||
http.HandleFunc("/api/device-ws/", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for specific routes only
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the SPA index.html file for root path
|
||||
spaPath := staticDir + "index.html"
|
||||
http.ServeFile(w, r, spaPath)
|
||||
})
|
||||
|
||||
// Additional SPA routes for client-side routing
|
||||
http.HandleFunc("/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
spaPath := staticDir + "index.html"
|
||||
http.ServeFile(w, r, spaPath)
|
||||
})
|
||||
|
||||
http.HandleFunc("/device/", func(w http.ResponseWriter, r *http.Request) {
|
||||
spaPath := staticDir + "index.html"
|
||||
http.ServeFile(w, r, spaPath)
|
||||
})
|
||||
}
|
||||
|
||||
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,347 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
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/") {
|
||||
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
|
||||
}{
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "nonexistent device",
|
||||
path: "/api/control/nonexistent/play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "unknown action with valid device",
|
||||
path: "/api/control/testdevice/unknownaction",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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/") {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
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>
|
||||
@@ -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,10 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
build: .
|
||||
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
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
environment:
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
@@ -35,6 +37,22 @@ 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
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
|
||||
volumes:
|
||||
soundtouch-data:
|
||||
# Named volumes are preferred in Swarm. For multi-node persistence,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
* [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)
|
||||
|
||||
## Parity Analysis
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.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.
|
||||
@@ -585,7 +585,72 @@ adb install Bose-SoundTouch-patched.apk
|
||||
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: Patching the App with Frida (Requires Root)
|
||||
### 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.
|
||||
|
||||
@@ -625,38 +690,57 @@ mitmproxy --listen-port 8080
|
||||
|
||||
## 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
|
||||
# Which IPs did the phone receive?
|
||||
cat /var/lib/misc/dnsmasq.leases
|
||||
# Install dependencies
|
||||
go get github.com/google/gopacket
|
||||
|
||||
# Is the access point active?
|
||||
sudo systemctl status hostapd
|
||||
# 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]
|
||||
|
||||
# Is dnsmasq active?
|
||||
sudo systemctl status dnsmasq
|
||||
# Example: Filter for a specific speaker's IP in WebSocket messages
|
||||
go run scripts/extract-ws.go capture.pcap 192.168.100.1
|
||||
```
|
||||
|
||||
# Check interfaces and IPs
|
||||
ip addr show
|
||||
### 2. Manual Extraction with tshark
|
||||
|
||||
# Check routing table
|
||||
ip route show
|
||||
If you only need a quick look at the payloads:
|
||||
|
||||
# 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
|
||||
```bash
|
||||
# Extract all WebSocket text payloads
|
||||
tshark -r your_capture.pcap -Y "websocket.payload.text" -T fields -e websocket.payload.text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restart Sequence
|
||||
## 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:
|
||||
|
||||
@@ -775,3 +859,34 @@ sudo openssl x509 -req -in bose.csr -CA ca.crt -CAkey ca.key \
|
||||
### 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -121,15 +121,14 @@ curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source"
|
||||
|
||||
---
|
||||
|
||||
## 3. Local Device Notification (LISA API)
|
||||
### 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.
|
||||
|
||||
### Request Details
|
||||
#### Modern Flow (OAuth)
|
||||
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount`
|
||||
- **Method**: `POST`
|
||||
|
||||
### Payload (XML)
|
||||
- **Payload**:
|
||||
```xml
|
||||
<OAuthCredentials source="SPOTIFY" displayName="[DISPLAY_NAME]">
|
||||
<user>[SPOTIFY_USER_ID]</user>
|
||||
@@ -138,25 +137,39 @@ The app notifies the physical SoundTouch speaker about the new source. This is u
|
||||
</OAuthCredentials>
|
||||
```
|
||||
|
||||
**Note**: In some cases, the app sends a wrapped message format if communicating over WebSockets:
|
||||
#### 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
|
||||
<msg>
|
||||
<header deviceID="[DEVICE_UID]" url="setMusicServiceOAuthAccount" method="POST">
|
||||
<request requestID="1">
|
||||
<info type="new" />
|
||||
<sourceItem source="SPOTIFY" />
|
||||
</request>
|
||||
</header>
|
||||
<body>
|
||||
<OAuthCredentials source="SPOTIFY" displayName="[NAME]">
|
||||
<user>[USER]</user>
|
||||
<code>[TOKEN]</code>
|
||||
<version>token_version_3</version>
|
||||
</OAuthCredentials>
|
||||
</body>
|
||||
</msg>
|
||||
<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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.2
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.53.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.53.0
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,27 +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/image v0.38.0 // 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/text v0.35.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,45 +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/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
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=
|
||||
@@ -50,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=
|
||||
@@ -62,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=
|
||||
@@ -73,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=
|
||||
@@ -85,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=
|
||||
@@ -97,15 +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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -20,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
|
||||
@@ -337,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
|
||||
}
|
||||
@@ -515,9 +520,12 @@ 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
|
||||
|
||||
@@ -110,13 +110,44 @@ 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",
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
|
||||
@@ -12,8 +12,13 @@ import (
|
||||
|
||||
// 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.
|
||||
@@ -28,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.
|
||||
@@ -142,34 +173,35 @@ type ServiceContentItem struct {
|
||||
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
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Source *ConfiguredSource `xml:"source,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
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
|
||||
@@ -193,7 +225,7 @@ func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error
|
||||
CreatedOn: createdOn,
|
||||
Location: p.Location,
|
||||
Name: p.Name,
|
||||
Source: p.SourceConfig,
|
||||
SourceID: p.SourceID,
|
||||
UpdatedOn: updatedOn,
|
||||
Username: p.Username,
|
||||
}
|
||||
@@ -213,13 +245,11 @@ func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error
|
||||
type ServiceRecent struct {
|
||||
XMLName xml.Name `json:"-" xml:"recent"`
|
||||
ServiceContentItem
|
||||
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"`
|
||||
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
|
||||
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
|
||||
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat,omitempty"`
|
||||
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).
|
||||
@@ -282,13 +312,17 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
|
||||
ContentItem *NestedContentItem `xml:"contentItem,omitempty"`
|
||||
// Flat format might use these tags
|
||||
FlatLocation string `xml:"location"`
|
||||
FlatContentItemType string `xml:"contentItemType"`
|
||||
FlatName string `xml:"name"`
|
||||
FlatSourceID string `xml:"sourceid"`
|
||||
FlatSource string `xml:"source_key"`
|
||||
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
|
||||
@@ -303,7 +337,6 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
|
||||
r.CreatedOn = a.CreatedOn
|
||||
r.UpdatedOn = a.UpdatedOn
|
||||
r.ContainerArt = a.ContainerArt
|
||||
r.SourceConfig = a.SourceConfig
|
||||
r.LastPlayedAt = a.LastPlayedAt
|
||||
r.SourceID = a.FlatSourceID
|
||||
|
||||
@@ -311,6 +344,7 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
|
||||
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
|
||||
@@ -325,26 +359,40 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
|
||||
r.Location = a.FlatLocation
|
||||
}
|
||||
|
||||
if a.FlatContentItemType != "" {
|
||||
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.FlatSourceID != "" {
|
||||
r.SourceID = a.FlatSourceID
|
||||
}
|
||||
|
||||
if a.FlatSource != "" {
|
||||
r.Source = a.FlatSource
|
||||
}
|
||||
|
||||
if a.FlatTypeTag != "" {
|
||||
r.Type = a.FlatTypeTag
|
||||
}
|
||||
|
||||
if a.FlatSourceAccount != "" {
|
||||
r.SourceAccount = a.FlatSourceAccount
|
||||
}
|
||||
@@ -380,7 +428,6 @@ func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error
|
||||
LastPlayedAt string `xml:"lastplayedat"`
|
||||
SourceID string `xml:"sourceid"`
|
||||
Username string `xml:"username"`
|
||||
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
|
||||
}
|
||||
|
||||
a := Alias{
|
||||
@@ -392,7 +439,6 @@ func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error
|
||||
LastPlayedAt: r.LastPlayedAt,
|
||||
SourceID: r.SourceID,
|
||||
Username: r.Name, // Using Name as Username for parity
|
||||
SourceConfig: r.SourceConfig,
|
||||
ContentItem: &NestedContentItem{
|
||||
Source: r.Source,
|
||||
Type: r.Type,
|
||||
@@ -414,8 +460,8 @@ type ConfiguredSource struct {
|
||||
XMLName xml.Name `json:"-" xml:"source"`
|
||||
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
|
||||
ID string `json:"id" xml:"id,attr,omitempty"`
|
||||
Secret string `json:"secret" xml:"-"`
|
||||
SecretType string `json:"secret_type" xml:"-"`
|
||||
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"`
|
||||
@@ -559,7 +605,7 @@ type ServiceComponent struct {
|
||||
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,omitempty"`
|
||||
Label string `json:"label,omitempty" xml:"componentlabel"`
|
||||
}
|
||||
|
||||
// ServiceAccountInfo represents account-level metadata.
|
||||
@@ -695,7 +741,7 @@ type EmailAddressResponse struct {
|
||||
type FullResponseSource struct {
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
DisplayName string `json:"display_name" xml:"displayName,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"`
|
||||
@@ -779,7 +825,7 @@ type AccountDevice struct {
|
||||
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,omitempty"`
|
||||
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"`
|
||||
|
||||
@@ -12,16 +12,513 @@ 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()]
|
||||
}
|
||||
|
||||
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 strings.HasPrefix(tuneInURI, "http://opml.radiotime.com/") {
|
||||
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(rawURL + "&render=json"))
|
||||
|
||||
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 := TuneInSearchAPI + url.QueryEscape(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", 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(TuneInSearchAPI + 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) {
|
||||
|
||||
@@ -12,54 +12,232 @@ type SourceProvider struct {
|
||||
UpdatedOn string
|
||||
}
|
||||
|
||||
// StaticProviders lists known source provider identifiers with their metadata.
|
||||
var StaticProviders = []SourceProvider{
|
||||
{ID: 1, Name: "PANDORA", Label: "Pandora", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: 2, Name: "INTERNET_RADIO", Label: "Internet Radio", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: 3, Name: "OFF", Label: "Off", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
|
||||
{ID: 4, Name: "LOCAL", Label: "Local", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 5, Name: "AIRPLAY", Label: "AirPlay", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 6, Name: "CURRATED_RADIO", Label: "Curated Radio", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 7, Name: "STORED_MUSIC", Label: "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", Label: "Slave Source", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 9, Name: "AUX", Label: "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", Label: "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", Label: "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", Label: "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", Label: "Hello", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
|
||||
{ID: 14, Name: "DEEZER", Label: "Deezer", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 15, Name: "SPOTIFY", Label: "Spotify", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 16, Name: "IHEART", Label: "iHeartRadio", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 17, Name: "SIRIUSXM", Label: "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", Label: "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", Label: "QQMusic", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 20, Name: "AMAZON", Label: "Amazon Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 21, Name: "LOCAL_MUSIC", Label: "Local Music Library", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
|
||||
{ID: 22, Name: "WBMX", Label: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 23, Name: "SOUNDCLOUD", Label: "SoundCloud", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 24, Name: "TIDAL", Label: "Tidal", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 25, Name: "TUNEIN", Label: "TuneIn Radio", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 26, Name: "QPLAY", Label: "QPlay", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
|
||||
{ID: 27, Name: "JUKE", Label: "Juke", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 28, Name: "BBC", Label: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 29, Name: "DARFM", Label: "DAR.fm", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 30, Name: "7DIGITAL", Label: "7digital", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 31, Name: "SAAVN", Label: "Saavn", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 32, Name: "RDIO", Label: "Rdio", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 33, Name: "PHONE_MUSIC", Label: "Phone Music", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
|
||||
{ID: 34, Name: "ALEXA", Label: "Amazon Alexa", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
|
||||
{ID: 35, Name: "RADIOPLAYER", Label: "Radioplayer", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
|
||||
{ID: 36, Name: "RADIO.COM", Label: "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", Label: "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", Label: "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", Label: "Radio Browser", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
|
||||
}
|
||||
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: 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"},
|
||||
}
|
||||
|
||||
// GetSourceLabel returns a user-friendly label for a source type.
|
||||
func GetSourceLabel(sourceType string) string {
|
||||
for _, provider := range StaticProviders {
|
||||
@@ -68,40 +246,7 @@ func GetSourceLabel(sourceType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
switch sourceType {
|
||||
case "BLUETOOTH":
|
||||
return "Bluetooth"
|
||||
case "BMX":
|
||||
return "BMX"
|
||||
case "NOTIFICATION":
|
||||
return "Notifications"
|
||||
case "TUNEIN":
|
||||
return "TuneIn Radio"
|
||||
case "SPOTIFY":
|
||||
return "Spotify"
|
||||
case "IHEART":
|
||||
return "iHeartRadio"
|
||||
case "AMAZON":
|
||||
return "Amazon Music"
|
||||
case "DEEZER":
|
||||
return "Deezer"
|
||||
case "SIRIUSXM":
|
||||
return "SiriusXM"
|
||||
case "TIDAL":
|
||||
return "Tidal"
|
||||
case "PANDORA":
|
||||
return "Pandora"
|
||||
case "AUX":
|
||||
return "Aux"
|
||||
case "AUX_IN":
|
||||
return "AUX IN"
|
||||
case "INTERNET_RADIO":
|
||||
return "Internet Radio"
|
||||
case "LOCAL_INTERNET_RADIO":
|
||||
return "Local Internet Radio"
|
||||
default:
|
||||
return sourceType
|
||||
}
|
||||
return sourceType
|
||||
}
|
||||
|
||||
// GetProviderName returns the human-readable name for a provider ID.
|
||||
@@ -120,47 +265,14 @@ func GetProviderName(providerID string) string {
|
||||
return providerID
|
||||
}
|
||||
|
||||
// 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",
|
||||
// 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.
|
||||
@@ -182,4 +294,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -546,8 +550,8 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
} `xml:"ContentItem"`
|
||||
Source *models.ConfiguredSource `xml:"source"`
|
||||
} `xml:"contentItem"`
|
||||
SourceID string `xml:"sourceid"`
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
@@ -560,24 +564,22 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
for i := range presetsWrap.Presets {
|
||||
p := &presetsWrap.Presets[i]
|
||||
|
||||
cit := p.ContentItem.Type
|
||||
|
||||
presets = append(presets, models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: p.ContentItem.ItemName,
|
||||
Source: p.ContentItem.Source,
|
||||
Type: p.ContentItem.Type,
|
||||
ContentItemType: p.ContentItem.Type,
|
||||
Location: p.ContentItem.Location,
|
||||
SourceAccount: p.ContentItem.SourceAccount,
|
||||
IsPresetable: p.ContentItem.IsPresetable,
|
||||
ContentItemType: cit,
|
||||
SourceID: p.SourceID,
|
||||
},
|
||||
ID: p.ID,
|
||||
ButtonNumber: p.ID,
|
||||
ContainerArt: p.ContentItem.ContainerArt,
|
||||
CreatedOn: p.CreatedOn,
|
||||
UpdatedOn: p.UpdatedOn,
|
||||
SourceConfig: p.Source,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -606,8 +608,8 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
} `xml:"ContentItem"`
|
||||
Source *models.ConfiguredSource `xml:"source,omitempty"`
|
||||
} `xml:"contentItem"`
|
||||
SourceID string `xml:"sourceid,omitempty"`
|
||||
}
|
||||
|
||||
type PresetsXML struct {
|
||||
@@ -636,7 +638,7 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
pxml.ContentItem.IsPresetable = "true"
|
||||
pxml.ContentItem.ItemName = p.Name
|
||||
pxml.ContentItem.ContainerArt = p.ContainerArt
|
||||
pxml.Source = p.SourceConfig
|
||||
pxml.SourceID = p.SourceID
|
||||
px.Presets = append(px.Presets, pxml)
|
||||
}
|
||||
|
||||
@@ -670,37 +672,82 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []models.ServiceRecent{}, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type RecentsXML struct {
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []models.ServiceRecent `xml:"recent"`
|
||||
type RecentXML struct {
|
||||
DeviceID string `xml:"deviceID,attr,omitempty"`
|
||||
UtcTime string `xml:"utcTime,attr,omitempty"`
|
||||
ID string `xml:"id,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"`
|
||||
ContainerArt string `xml:"containerArt,omitempty"`
|
||||
} `xml:"contentItem"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
LastPlayedAt string `xml:"lastplayedat,omitempty"`
|
||||
SourceID string `xml:"sourceid,omitempty"`
|
||||
Username string `xml:"username,omitempty"`
|
||||
}
|
||||
|
||||
var recentsWrap RecentsXML
|
||||
type RecentsXML struct {
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []RecentXML `xml:"recent"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &recentsWrap); err != nil {
|
||||
var wrap RecentsXML
|
||||
if err := xml.Unmarshal(data, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
|
||||
}
|
||||
|
||||
recents := recentsWrap.Recents
|
||||
recents := make([]models.ServiceRecent, 0, len(wrap.Recents))
|
||||
maxID := 0
|
||||
|
||||
for i := range recents {
|
||||
r := &recents[i]
|
||||
for i := range wrap.Recents {
|
||||
rx := &wrap.Recents[i]
|
||||
r := models.ServiceRecent{
|
||||
DeviceID: rx.DeviceID,
|
||||
UtcTime: rx.UtcTime,
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: rx.ID,
|
||||
Name: rx.ContentItem.ItemName,
|
||||
Source: rx.ContentItem.Source,
|
||||
Type: rx.ContentItem.Type,
|
||||
Location: rx.ContentItem.Location,
|
||||
SourceAccount: rx.ContentItem.SourceAccount,
|
||||
IsPresetable: rx.ContentItem.IsPresetable,
|
||||
ContainerArt: rx.ContentItem.ContainerArt,
|
||||
},
|
||||
CreatedOn: rx.CreatedOn,
|
||||
UpdatedOn: rx.UpdatedOn,
|
||||
LastPlayedAt: rx.LastPlayedAt,
|
||||
}
|
||||
r.SourceID = rx.SourceID
|
||||
|
||||
if id, err := strconv.Atoi(r.ID); err == nil {
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
|
||||
recents = append(recents, r)
|
||||
}
|
||||
|
||||
for i := range recents {
|
||||
r := &recents[i]
|
||||
if r.ContentItemType == "" {
|
||||
r.ContentItemType = r.Type
|
||||
if r.Type != "" {
|
||||
r.ContentItemType = r.Type
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
|
||||
@@ -724,13 +771,62 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
|
||||
path := filepath.Join(dir, constants.RecentsFile)
|
||||
|
||||
type RecentXML struct {
|
||||
DeviceID string `xml:"deviceID,attr,omitempty"`
|
||||
UtcTime string `xml:"utcTime,attr,omitempty"`
|
||||
ID string `xml:"id,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"`
|
||||
ContainerArt string `xml:"containerArt,omitempty"`
|
||||
} `xml:"contentItem"`
|
||||
CreatedOn string `xml:"createdOn,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,omitempty"`
|
||||
LastPlayedAt string `xml:"lastplayedat,omitempty"`
|
||||
SourceID string `xml:"sourceid,omitempty"`
|
||||
Username string `xml:"username,omitempty"`
|
||||
}
|
||||
|
||||
type RecentsXML struct {
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []models.ServiceRecent `xml:"recent"`
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []RecentXML `xml:"recent"`
|
||||
}
|
||||
|
||||
wrap := RecentsXML{
|
||||
Recents: recents,
|
||||
Recents: make([]RecentXML, 0, len(recents)),
|
||||
}
|
||||
|
||||
for i := range recents {
|
||||
r := &recents[i]
|
||||
rx := RecentXML{
|
||||
DeviceID: r.DeviceID,
|
||||
UtcTime: r.UtcTime,
|
||||
ID: r.ID,
|
||||
CreatedOn: r.CreatedOn,
|
||||
UpdatedOn: r.UpdatedOn,
|
||||
LastPlayedAt: r.LastPlayedAt,
|
||||
SourceID: r.SourceID,
|
||||
Username: r.Username,
|
||||
}
|
||||
rx.ContentItem.Source = r.Source
|
||||
rx.ContentItem.Type = r.Type
|
||||
rx.ContentItem.Location = r.Location
|
||||
rx.ContentItem.SourceAccount = r.SourceAccount
|
||||
|
||||
rx.ContentItem.IsPresetable = r.IsPresetable
|
||||
if rx.ContentItem.IsPresetable == "" {
|
||||
rx.ContentItem.IsPresetable = "true"
|
||||
}
|
||||
|
||||
rx.ContentItem.ItemName = r.Name
|
||||
rx.ContentItem.ContainerArt = r.ContainerArt
|
||||
rx.SourceID = r.SourceID
|
||||
|
||||
wrap.Recents = append(wrap.Recents, rx)
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(wrap, "", " ")
|
||||
@@ -984,6 +1080,128 @@ func (ds *DataStore) RemoveDeviceDir(account, device string) error {
|
||||
return ds.RemoveDevice(account, device)
|
||||
}
|
||||
|
||||
// DeduceSourceIDs updates the source IDs in the given slice by deducing them from recents and presets.
|
||||
func (ds *DataStore) DeduceSourceIDs(account, device string, sources []models.ConfiguredSource) {
|
||||
// Deduce source IDs from recents and presets
|
||||
deducedIDs := ds.collectDeducedIDs(account, device)
|
||||
|
||||
for i := range sources {
|
||||
if id, ok := deducedIDs[sources[i].SourceProviderID]; ok {
|
||||
sources[i].ID = id
|
||||
} else if sources[i].SourceKeyType == "AUX" {
|
||||
auxID := strconv.Itoa(constants.AuxProviderID)
|
||||
if id, ok := deducedIDs[auxID]; ok {
|
||||
sources[i].ID = id
|
||||
sources[i].SourceProviderID = auxID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DataStore) collectDeducedIDs(account, device string) map[string]string {
|
||||
deducedIDs := make(map[string]string)
|
||||
|
||||
// Check recents and presets to find source IDs for provider IDs 2, 9, 11, 25
|
||||
for _, filename := range []string{constants.RecentsFile, constants.PresetsFile} {
|
||||
fileContent, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ds.parseIDsFromFile(fileContent, deducedIDs)
|
||||
}
|
||||
|
||||
return deducedIDs
|
||||
}
|
||||
|
||||
func (ds *DataStore) parseIDsFromFile(fileContent []byte, deducedIDs map[string]string) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(fileContent))
|
||||
for {
|
||||
token, _ := decoder.Token()
|
||||
if token == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if se, ok := token.(xml.StartElement); ok {
|
||||
switch se.Name.Local {
|
||||
case "source":
|
||||
ds.parseSourceElement(decoder, &se, deducedIDs)
|
||||
case "recent", "preset":
|
||||
ds.parseRecentPresetElement(decoder, &se, deducedIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DataStore) parseSourceElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) {
|
||||
var s struct {
|
||||
ID string `xml:"id,attr"`
|
||||
SourceProviderID string `xml:"sourceproviderid"`
|
||||
// Also check for sourceproviderid as attribute just in case
|
||||
SourceProviderIDAttr string `xml:"sourceproviderid,attr"`
|
||||
}
|
||||
if err := decoder.DecodeElement(&s, se); err == nil {
|
||||
pid := s.SourceProviderID
|
||||
if pid == "" {
|
||||
pid = s.SourceProviderIDAttr
|
||||
}
|
||||
|
||||
ds.extractIDs(pid, s.ID, deducedIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DataStore) parseRecentPresetElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) {
|
||||
var s struct {
|
||||
SourceID string `xml:"sourceid"`
|
||||
SourceProviderID string `xml:"sourceproviderid"`
|
||||
ContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
} `xml:"contentItem"`
|
||||
Source struct {
|
||||
SourceProviderID string `xml:"sourceproviderid"`
|
||||
} `xml:"source"`
|
||||
}
|
||||
if err := decoder.DecodeElement(&s, se); err == nil {
|
||||
pid := s.SourceProviderID
|
||||
if pid == "" {
|
||||
pid = s.Source.SourceProviderID
|
||||
}
|
||||
|
||||
if pid == "" {
|
||||
// For AUX, we often don't have provider ID 9 but we know its name/source
|
||||
switch s.ContentItem.Source {
|
||||
case constants.ProviderAux:
|
||||
pid = strconv.Itoa(constants.AuxProviderID)
|
||||
case constants.ProviderInternetRadio:
|
||||
pid = strconv.Itoa(constants.InternetRadioProviderID)
|
||||
case constants.ProviderLocalInternetRadio:
|
||||
pid = strconv.Itoa(constants.LocalInternetRadioProviderID)
|
||||
case constants.ProviderTunein:
|
||||
pid = strconv.Itoa(constants.TuneinProviderID)
|
||||
}
|
||||
}
|
||||
|
||||
ds.extractIDs(pid, s.SourceID, deducedIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DataStore) extractIDs(providerID, sourceID string, deducedIDs map[string]string) {
|
||||
if sourceID == "" || providerID == "" {
|
||||
return
|
||||
}
|
||||
// Stick to the provider ids mentioned: 2, 9, 11, 25
|
||||
switch providerID {
|
||||
case strconv.Itoa(constants.InternetRadioProviderID),
|
||||
strconv.Itoa(constants.AuxProviderID),
|
||||
strconv.Itoa(constants.LocalInternetRadioProviderID),
|
||||
strconv.Itoa(constants.TuneinProviderID):
|
||||
if _, exists := deducedIDs[providerID]; !exists {
|
||||
deducedIDs[providerID] = sourceID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfiguredSources retrieves all configured sources for the specified account and device.
|
||||
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
|
||||
ds.fileMutex.RLock()
|
||||
@@ -994,7 +1212,10 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ds.getDefaultSources(), nil
|
||||
sources := ds.getDefaultSources()
|
||||
ds.DeduceSourceIDs(account, device, sources)
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
@@ -1009,7 +1230,11 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
CreatedOn string `xml:"createdOn,attr,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
|
||||
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
|
||||
SourceKey struct {
|
||||
Credential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"credential,omitempty"`
|
||||
SourceKey struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
} `xml:"sourceKey"`
|
||||
@@ -1039,7 +1264,13 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
s.SourceKey.Type = ps.SourceKey.Type
|
||||
s.SourceKey.Account = ps.SourceKey.Account
|
||||
|
||||
// Ensure Secret/SecretType values are prioritized from legacy fields
|
||||
// Prioritize Credential element if present, otherwise use secret/secretType attributes
|
||||
if ps.Credential.Value != "" {
|
||||
s.Secret = ps.Credential.Value
|
||||
s.SecretType = ps.Credential.Type
|
||||
}
|
||||
|
||||
// Ensure Secret/SecretType values are prioritized from legacy fields if still missing
|
||||
if s.Secret == "" && s.Credential.Value != "" {
|
||||
s.Secret = s.Credential.Value
|
||||
}
|
||||
@@ -1089,7 +1320,11 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
|
||||
CreatedOn string `xml:"createdOn,attr,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
|
||||
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
|
||||
SourceKey struct {
|
||||
Credential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"credential,omitempty"`
|
||||
SourceKey struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
} `xml:"sourceKey"`
|
||||
@@ -1119,6 +1354,15 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
|
||||
SourceProviderID: s.SourceProviderID,
|
||||
}
|
||||
|
||||
// Save to Credential element as well for parity with official Bose format
|
||||
if s.Secret != "" {
|
||||
persistSources[i].Credential.Value = s.Secret
|
||||
persistSources[i].Credential.Type = s.SecretType
|
||||
} else if s.Credential.Value != "" {
|
||||
persistSources[i].Credential.Value = s.Credential.Value
|
||||
persistSources[i].Credential.Type = s.Credential.Type
|
||||
}
|
||||
|
||||
if persistSources[i].Secret == "" && s.Credential.Value != "" {
|
||||
persistSources[i].Secret = s.Credential.Value
|
||||
}
|
||||
@@ -1217,8 +1461,8 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
||||
{
|
||||
ID: "10001",
|
||||
DisplayName: "AUX IN",
|
||||
SourceKeyType: "AUX",
|
||||
SourceKeyAccount: "AUX",
|
||||
SourceKeyType: constants.ProviderAux,
|
||||
SourceKeyAccount: constants.ProviderAux,
|
||||
Type: "Audio",
|
||||
Status: "READY",
|
||||
CreatedOn: "2015-03-11T19:12:38.000+00:00",
|
||||
@@ -1227,9 +1471,9 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
||||
{
|
||||
ID: "10002",
|
||||
DisplayName: "",
|
||||
SourceKeyType: "INTERNET_RADIO",
|
||||
SourceKeyType: constants.ProviderInternetRadio,
|
||||
SourceKeyAccount: "",
|
||||
SourceProviderID: "2",
|
||||
SourceProviderID: strconv.Itoa(constants.InternetRadioProviderID),
|
||||
Type: "Audio",
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
@@ -1239,9 +1483,9 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
||||
{
|
||||
ID: "10003",
|
||||
DisplayName: "",
|
||||
SourceKeyType: "LOCAL_INTERNET_RADIO",
|
||||
SourceKeyType: constants.ProviderLocalInternetRadio,
|
||||
SourceKeyAccount: "",
|
||||
SourceProviderID: "11",
|
||||
SourceProviderID: strconv.Itoa(constants.LocalInternetRadioProviderID),
|
||||
Type: "Audio",
|
||||
Secret: GenerateSerialSecret("local-internet-radio"),
|
||||
SecretType: "token",
|
||||
@@ -1252,9 +1496,9 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
||||
{
|
||||
ID: "10004",
|
||||
DisplayName: "",
|
||||
SourceKeyType: "TUNEIN",
|
||||
SourceKeyType: constants.ProviderTunein,
|
||||
SourceKeyAccount: "",
|
||||
SourceProviderID: "25",
|
||||
SourceProviderID: strconv.Itoa(constants.TuneinProviderID),
|
||||
Type: "Audio",
|
||||
Secret: GenerateSerialSecret("tunein"),
|
||||
SecretType: "token",
|
||||
@@ -1262,6 +1506,18 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
||||
CreatedOn: "2017-07-20T16:43:48.000+00:00",
|
||||
UpdatedOn: "2017-07-20T16:43:48.000+00:00",
|
||||
},
|
||||
{
|
||||
ID: "10005",
|
||||
DisplayName: "",
|
||||
SourceKeyType: constants.ProviderRadioBrowser,
|
||||
SourceKeyAccount: "",
|
||||
SourceProviderID: strconv.Itoa(constants.RadioBrowserProviderID),
|
||||
Type: "Audio",
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
CreatedOn: "2026-02-16T01:01:01.000+00:00",
|
||||
UpdatedOn: "2026-02-16T01:01:01.000+00:00",
|
||||
},
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
@@ -1359,22 +1615,61 @@ func (ds *DataStore) GetETagForRecents(account, device string) int64 {
|
||||
return info.ModTime().UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
|
||||
// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account and device.
|
||||
func (ds *DataStore) GetETagForAccount(account, device string) int64 {
|
||||
e1 := ds.GetETagForPresets(account, device)
|
||||
e2 := ds.GetETagForSources(account, device)
|
||||
e3 := ds.GetETagForRecents(account, device)
|
||||
// contentHashForFiles returns a SHA-256 hex digest over the concatenated contents of the given file paths.
|
||||
func contentHashForFiles(paths ...string) string {
|
||||
h := sha256.New()
|
||||
|
||||
maxETag := e1
|
||||
if e2 > maxETag {
|
||||
maxETag = e2
|
||||
for _, p := range paths {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = io.Copy(h, f)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
if e3 > maxETag {
|
||||
maxETag = e3
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// GetETagForAccount returns a content hash (SHA-256) over presets, sources, and recents for the account and device.
|
||||
// If device is empty, it hashes across all devices in the account.
|
||||
func (ds *DataStore) GetETagForAccount(account, device string) string {
|
||||
if device != "" {
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
|
||||
return contentHashForFiles(
|
||||
filepath.Join(deviceDir, constants.PresetsFile),
|
||||
filepath.Join(deviceDir, constants.SourcesFile),
|
||||
filepath.Join(deviceDir, constants.RecentsFile),
|
||||
)
|
||||
}
|
||||
|
||||
return maxETag
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
// Ignore error: missing directory is treated as no devices, producing a
|
||||
// stable non-empty hash rather than "" which would false-match an absent
|
||||
// If-None-Match header and return 304 on the first request.
|
||||
entries, _ := os.ReadDir(devicesDir)
|
||||
|
||||
h := sha256.New()
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
deviceDir := ds.AccountDeviceDir(account, entry.Name())
|
||||
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
|
||||
f, err := os.Open(filepath.Join(deviceDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = io.Copy(h, f)
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Settings represents the global service settings.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ func TestSaveSources_Format(t *testing.T) {
|
||||
}{Type: "AUX", Account: "AUX"},
|
||||
},
|
||||
{
|
||||
SecretType: "token",
|
||||
SourceKey: struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
@@ -68,12 +67,17 @@ func TestSaveSources_Format(t *testing.T) {
|
||||
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, `secretType="token_version_3"`) {
|
||||
t.Errorf("Spotify source missing secretType. 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>`) {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
@@ -310,7 +312,12 @@ func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models
|
||||
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
|
||||
}
|
||||
@@ -319,31 +326,27 @@ func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models
|
||||
fp.CreatedOn = p.CreatedOn
|
||||
}
|
||||
|
||||
if p.SourceConfig != nil {
|
||||
fp.Source = mapToFullResponseSource(p.SourceConfig)
|
||||
} else {
|
||||
// Attempt to find matching source in configuredSources
|
||||
found := false
|
||||
// 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
|
||||
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
|
||||
}
|
||||
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)
|
||||
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
|
||||
@@ -359,6 +362,16 @@ func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models
|
||||
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 == "" {
|
||||
@@ -371,30 +384,34 @@ func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models
|
||||
fr.CreatedOn = r.UtcTime
|
||||
}
|
||||
|
||||
if r.SourceConfig != nil {
|
||||
fr.Source = mapToFullResponseSource(r.SourceConfig)
|
||||
} else {
|
||||
// Attempt to find matching source in configuredSources
|
||||
found := false
|
||||
// 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)
|
||||
found = true
|
||||
|
||||
break
|
||||
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 !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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,9 +108,9 @@ func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1690000001">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
|
||||
<contentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
|
||||
<itemName>test-playlist</itemName>
|
||||
</ContentItem>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
err = os.WriteFile(deviceDir+"/Presets.xml", []byte(presetsXML), 0644)
|
||||
@@ -394,17 +394,17 @@ func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
|
||||
|
||||
if gesellixSource == nil {
|
||||
t.Fatal("gesellix source not found")
|
||||
}
|
||||
|
||||
// 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)
|
||||
} 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
|
||||
@@ -417,9 +417,10 @@ func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
|
||||
}
|
||||
if audioSource == nil {
|
||||
t.Fatal("audio source not found")
|
||||
}
|
||||
// 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)
|
||||
} 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"
|
||||
@@ -247,24 +248,96 @@ func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate returns TuneIn navigation information.
|
||||
// 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")
|
||||
_, _ = w.Write(tuneInNavigateJSON)
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch returns TuneIn search results.
|
||||
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")
|
||||
_, _ = w.Write(tuneInSearchJSON)
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -32,7 +33,9 @@ func TestHandleTuneInNavigate(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Sub navigate", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate/some-path", nil)
|
||||
// Use the top-level OPML URL as a valid encoded navigate target
|
||||
encodedURI := base64.URLEncoding.EncodeToString([]byte("http://opml.radiotime.com/?render=json"))
|
||||
req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate/"+encodedURI, nil)
|
||||
req.Header.Set("Authorization", "Bearer mock-token")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestMargeAccountETagNoDevice tests ETag correctness on the account-level endpoints
|
||||
// when no ?device= query parameter is provided (the "no-device" code path in GetETagForAccount).
|
||||
//
|
||||
// These tests are specifically for the bug where:
|
||||
// - GetETagForAccount returns "" when the devices directory does not exist
|
||||
// - A missing If-None-Match header also produces "" via Header.Get
|
||||
// - The equality check "" == "" causes a false 304 on the very first request
|
||||
func TestMargeAccountETagNoDevice(t *testing.T) {
|
||||
newServer := func(t *testing.T) (ts *httptest.Server, tempDir string) {
|
||||
t.Helper()
|
||||
tempDir, _ = os.MkdirTemp("", "st-etag-nodev-*")
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts = httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, tempDir
|
||||
}
|
||||
|
||||
writeDeviceFiles := func(t *testing.T, tempDir, account, deviceID string) string {
|
||||
t.Helper()
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte("<presets/>"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte("<sources/>"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte("<recents/>"), 0644)
|
||||
return filepath.Join(deviceDir, "Presets.xml")
|
||||
}
|
||||
|
||||
// Bug: GetETagForAccount returns "" when the devices directory does not exist.
|
||||
// Handler then checks: r.Header.Get("If-None-Match") == "" which is true on any
|
||||
// request without the header, causing a 304 before the client has any cached content.
|
||||
t.Run("first request without If-None-Match never returns 304 when devices dir is missing", func(t *testing.T) {
|
||||
ts, _ := newServer(t)
|
||||
// Deliberately no directories created for this account.
|
||||
res, err := http.Get(ts.URL + "/marge/accounts/no-devices-account/full")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode == http.StatusNotModified {
|
||||
t.Error("got 304 on first request without If-None-Match — empty ETag matched empty header value")
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ETag is non-empty even when devices dir does not exist", func(t *testing.T) {
|
||||
ts, _ := newServer(t)
|
||||
res, err := http.Get(ts.URL + "/marge/accounts/no-devices-account/full")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
|
||||
if etag := res.Header.Get("ETag"); etag == "" {
|
||||
t.Error("ETag must not be empty string — empty ETag causes false 304 on first request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ETag is non-empty when devices dir exists but contains no device subdirs", func(t *testing.T) {
|
||||
ts, tempDir := newServer(t)
|
||||
account := "empty-devices-account"
|
||||
_ = os.MkdirAll(filepath.Join(tempDir, "accounts", account, "devices"), 0755)
|
||||
|
||||
res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/full")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
|
||||
if etag := res.Header.Get("ETag"); etag == "" {
|
||||
t.Error("ETag must not be empty string when devices dir exists but is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ETag is stable across multiple requests when data does not change", func(t *testing.T) {
|
||||
ts, tempDir := newServer(t)
|
||||
account := "stable-etag-account"
|
||||
writeDeviceFiles(t, tempDir, account, "DEV1")
|
||||
url := ts.URL + "/marge/accounts/" + account + "/full"
|
||||
|
||||
res1, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag1 := res1.Header.Get("ETag")
|
||||
_ = res1.Body.Close()
|
||||
|
||||
res2, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag2 := res2.Header.Get("ETag")
|
||||
_ = res2.Body.Close()
|
||||
|
||||
if etag1 == "" {
|
||||
t.Fatal("ETag must not be empty")
|
||||
}
|
||||
if etag1 != etag2 {
|
||||
t.Errorf("ETag changed between identical requests: %q → %q", etag1, etag2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("304 flow works correctly with no device param", func(t *testing.T) {
|
||||
ts, tempDir := newServer(t)
|
||||
account := "304-flow-account"
|
||||
writeDeviceFiles(t, tempDir, account, "DEV1")
|
||||
url := ts.URL + "/marge/accounts/" + account + "/full"
|
||||
|
||||
res1, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag := res1.Header.Get("ETag")
|
||||
_ = res1.Body.Close()
|
||||
|
||||
if etag == "" {
|
||||
t.Fatal("expected non-empty ETag from first request")
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
res2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res2.Body.Close() }()
|
||||
|
||||
if res2.StatusCode != http.StatusNotModified {
|
||||
t.Errorf("expected 304 with valid ETag, got %v", res2.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ETag changes when file content changes", func(t *testing.T) {
|
||||
ts, tempDir := newServer(t)
|
||||
account := "changing-data-account"
|
||||
presetsFile := writeDeviceFiles(t, tempDir, account, "DEV1")
|
||||
url := ts.URL + "/marge/accounts/" + account + "/full"
|
||||
|
||||
res1, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag1 := res1.Header.Get("ETag")
|
||||
_ = res1.Body.Close()
|
||||
|
||||
_ = os.WriteFile(presetsFile, []byte(`<presets><preset id="1"/></presets>`), 0644)
|
||||
|
||||
res2, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag2 := res2.Header.Get("ETag")
|
||||
_ = res2.Body.Close()
|
||||
|
||||
if etag1 == etag2 {
|
||||
t.Errorf("ETag did not change after file modification: %q", etag1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale ETag returns 200 after data change", func(t *testing.T) {
|
||||
ts, tempDir := newServer(t)
|
||||
account := "stale-etag-account"
|
||||
presetsFile := writeDeviceFiles(t, tempDir, account, "DEV1")
|
||||
url := ts.URL + "/marge/accounts/" + account + "/full"
|
||||
|
||||
res1, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag1 := res1.Header.Get("ETag")
|
||||
_ = res1.Body.Close()
|
||||
|
||||
_ = os.WriteFile(presetsFile, []byte(`<presets><preset id="1"/></presets>`), 0644)
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("If-None-Match", etag1)
|
||||
res2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res2.Body.Close() }()
|
||||
|
||||
if res2.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200 for stale ETag after data change, got %v", res2.Status)
|
||||
}
|
||||
})
|
||||
|
||||
// The /full, /sources, and /devices endpoints all call GetETagForAccount(account, "")
|
||||
// so they should return the same ETag for the same account state.
|
||||
t.Run("ETag is consistent across full, sources, and devices endpoints", func(t *testing.T) {
|
||||
ts, tempDir := newServer(t)
|
||||
account := "consistent-etag-account"
|
||||
writeDeviceFiles(t, tempDir, account, "DEV1")
|
||||
|
||||
get := func(path string) string {
|
||||
res, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etag := res.Header.Get("ETag")
|
||||
_ = res.Body.Close()
|
||||
return etag
|
||||
}
|
||||
|
||||
etagFull := get("/marge/accounts/" + account + "/full")
|
||||
etagSources := get("/marge/accounts/" + account + "/sources")
|
||||
etagDevices := get("/marge/accounts/" + account + "/devices")
|
||||
|
||||
if etagFull == "" {
|
||||
t.Fatal("ETag from /full must not be empty")
|
||||
}
|
||||
if etagFull != etagSources {
|
||||
t.Errorf("/full ETag %q != /sources ETag %q", etagFull, etagSources)
|
||||
}
|
||||
if etagFull != etagDevices {
|
||||
t.Errorf("/full ETag %q != /devices ETag %q", etagFull, etagDevices)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
device := r.URL.Query().Get("device")
|
||||
|
||||
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
|
||||
etag := s.ds.GetETagForAccount(account, device)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
@@ -174,7 +174,7 @@ func (s *Server) HandleMargeAccountSources(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
device := r.URL.Query().Get("device")
|
||||
|
||||
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
|
||||
etag := s.ds.GetETagForAccount(account, device)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
@@ -197,7 +197,7 @@ func (s *Server) HandleMargeAccountDevices(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
device := r.URL.Query().Get("device")
|
||||
|
||||
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
|
||||
etag := s.ds.GetETagForAccount(account, device)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
@@ -457,19 +457,25 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
presetNumber, err := strconv.Atoi(presetNumberStr)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] Invalid preset number: %s", presetNumberStr)
|
||||
http.Error(w, "Invalid preset number", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] Failed to read body: %v", err)
|
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data, err := marge.UpdatePreset(s.ds, account, device, presetNumber, body)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] UpdatePreset failed for account=%s, device=%s, preset=%d: %v", account, device, presetNumber, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ func TestMargeCreateAccount(t *testing.T) {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if resp.AccountStatus != "ACTIVE" {
|
||||
t.Errorf("Expected AccountStatus ACTIVE, got %v", resp.AccountStatus)
|
||||
if resp.AccountStatus != "OK" {
|
||||
t.Errorf("Expected AccountStatus OK, got %v", resp.AccountStatus)
|
||||
}
|
||||
if resp.PreferredLanguage != "de" {
|
||||
t.Errorf("Expected PreferredLanguage de, got %v", resp.PreferredLanguage)
|
||||
@@ -65,8 +65,8 @@ func TestMargeCreateAccount(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify it has default sources
|
||||
if len(resp.Sources) != 4 {
|
||||
t.Errorf("Expected 4 default sources, got %d", len(resp.Sources))
|
||||
if len(resp.Sources) != 5 {
|
||||
t.Errorf("Expected 5 default sources, got %d", len(resp.Sources))
|
||||
} else {
|
||||
if resp.Sources[0].ID != "10001" {
|
||||
t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID)
|
||||
@@ -515,8 +515,7 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
"<source id=\"10004\" type=\"Audio\"",
|
||||
"<source id=\"10003\" type=\"Audio\"",
|
||||
"<source id=\"10002\" type=\"Audio\"",
|
||||
"<source id=\"10001\" type=\"Audio\" displayName=\"AUX IN\">",
|
||||
"displayName=\"\"", // for the other sources
|
||||
"<source id=\"10001\" type=\"Audio\"",
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
@@ -525,9 +524,9 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that 3 sources have empty display names
|
||||
if strings.Count(bodyStr, "displayName=\"\"") != 3 {
|
||||
t.Errorf("Expected 3 sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
|
||||
// Verify that no sources have empty display names
|
||||
if strings.Count(bodyStr, "displayName=\"\"") != 0 {
|
||||
t.Errorf("Expected no sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,10 +570,10 @@ func TestMargePresets(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="TUNEIN" type="station" location="/station/s123" sourceAccount="" isPresetable="true">
|
||||
<contentItem source="TUNEIN" type="station" location="/station/s123" sourceAccount="" isPresetable="true">
|
||||
<itemName>Test Station</itemName>
|
||||
<containerArt>http://example.com/art.jpg</containerArt>
|
||||
</ContentItem>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
`), 0644); err != nil {
|
||||
@@ -673,6 +672,12 @@ func TestMargeUpdatePreset(t *testing.T) {
|
||||
// Verify response body has correct XML structure (upstream parity)
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Verify no wrapping <presets> element
|
||||
if strings.HasPrefix(bodyStr, "<?xml version=\"1.0\" encoding=\"UTF-8\"?><presets>") {
|
||||
t.Errorf("Response should NOT be wrapped in <presets>: %s", bodyStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, "<preset buttonNumber=\"1\">") {
|
||||
t.Errorf("Response missing <preset buttonNumber=\"1\">: %s", bodyStr)
|
||||
}
|
||||
|
||||
@@ -23,12 +23,6 @@ var bmxServicesJSON []byte
|
||||
//go:embed static/bmx_services_availability.json
|
||||
var bmxServicesAvailabilityJSON []byte
|
||||
|
||||
//go:embed static/tunein_navigate.json
|
||||
var tuneInNavigateJSON []byte
|
||||
|
||||
//go:embed static/tunein_search.json
|
||||
var tuneInSearchJSON []byte
|
||||
|
||||
// Upstream source available at https://worldwide.bose.com/updates/soundtouch?serialnumber=_serial_
|
||||
// which results in a redirect to https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/index.xml
|
||||
//
|
||||
|
||||
@@ -2,11 +2,18 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
@@ -94,7 +101,7 @@ func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL.
|
||||
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
@@ -104,7 +111,8 @@ func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := svc.BuildAuthorizeURL()
|
||||
state := r.URL.Query().Get("account")
|
||||
redirectURL := svc.BuildAuthorizeURL(state)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
@@ -159,6 +167,14 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Register account in Marge and notify speakers
|
||||
accountID := r.URL.Query().Get("account")
|
||||
if accountID == "" {
|
||||
accountID = r.URL.Query().Get("state")
|
||||
}
|
||||
|
||||
s.bridgeSpotifyToMarge(accountID)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Spotify Connected</h1><p>You can close this window.</p></body></html>`))
|
||||
}
|
||||
@@ -189,11 +205,126 @@ func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Register account in Marge and notify speakers
|
||||
accountID := r.URL.Query().Get("account")
|
||||
if accountID == "" {
|
||||
accountID = r.URL.Query().Get("state")
|
||||
}
|
||||
|
||||
s.bridgeSpotifyToMarge(accountID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (s *Server) bridgeSpotifyToMarge(accountID string) {
|
||||
if accountID == "" {
|
||||
accountID = "default"
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
if len(accounts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we use the first account found or match by ID if possible.
|
||||
// In this bridge, we'll ensure all linked Spotify accounts are registered in Marge.
|
||||
for _, acc := range accounts {
|
||||
log.Printf("[Spotify Bridge] Registering Spotify user %s in Marge for account %s", acc.UserID, accountID)
|
||||
|
||||
// 1. Register in Marge (updates configuredsources.xml for all devices in the account)
|
||||
// We use the BoseSecret as the credential instead of the AccessToken
|
||||
credential := acc.BoseSecret
|
||||
if credential == "" {
|
||||
// Fallback to AccessToken if BoseSecret is not available (for old accounts)
|
||||
credential = acc.AccessToken
|
||||
}
|
||||
|
||||
_, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName)
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Bridge] Failed to register source in Marge: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Notify discovered speakers via LISA API (/setMusicServiceOAuthAccount)
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Bridge] Failed to list devices: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range allDevices {
|
||||
dev := &allDevices[i]
|
||||
if dev.AccountID != accountID && accountID != "default" {
|
||||
continue
|
||||
}
|
||||
|
||||
if dev.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
go func(d models.ServiceDeviceInfo) {
|
||||
log.Printf("[Spotify Bridge] Notifying speaker %s (%s) about new Spotify account", d.Name, d.IPAddress)
|
||||
|
||||
c := client.NewClientFromHost(d.IPAddress)
|
||||
creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName)
|
||||
|
||||
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
|
||||
log.Printf("[Spotify Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err)
|
||||
|
||||
// Fallback if OAuth is not supported (Error 1029)
|
||||
errs := &models.ErrorsResponse{}
|
||||
if errors.As(err, &errs) {
|
||||
isUnsupported := false
|
||||
|
||||
for _, e := range errs.Errors {
|
||||
if e.Value == 1029 {
|
||||
isUnsupported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isUnsupported {
|
||||
log.Printf("[Spotify Bridge] Speaker %s doesn't support OAuth, falling back to Marge sync notification", d.Name)
|
||||
|
||||
// Some speakers (especially Stockholm-based) don't support /setMusicServiceOAuthAccount
|
||||
// via LISA but will pick up the new source from Marge if notified.
|
||||
if err := c.NotifySourcesUpdated(d.DeviceID); err != nil {
|
||||
log.Printf("[Spotify Bridge] Sync notification failed for speaker %s: %v", d.Name, err)
|
||||
|
||||
// Final fallback to legacy account creation
|
||||
log.Printf("[Spotify Bridge] Falling back to legacy account creation for speaker %s", d.Name)
|
||||
|
||||
legacyCreds := models.NewSpotifyCredentials(acc.UserID, credential)
|
||||
if err := c.SetMusicServiceAccount(legacyCreds); err != nil {
|
||||
log.Printf("[Spotify Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err)
|
||||
} else {
|
||||
log.Printf("[Spotify Bridge] Legacy fallback successful for speaker %s", d.Name)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Bridge] Sync notification successful for speaker %s", d.Name)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Bridge] Successfully notified speaker %s", d.Name)
|
||||
}
|
||||
}(*dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped).
|
||||
func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -14,34 +14,35 @@ import (
|
||||
|
||||
func TestHandleMgmtSpotifyInit(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
// No spotify service configured
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
t.Run("POST - No spotify service configured", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// With spotify service
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
|
||||
t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
|
||||
}
|
||||
t.Run("POST - Success", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
|
||||
t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ func (s *Server) HandleBoseToken(w http.ResponseWriter, r *http.Request) {
|
||||
sourceID := chi.URLParam(r, "sourceID")
|
||||
|
||||
for _, provider := range constants.StaticProviders {
|
||||
if strconv.Itoa(provider.ID) == sourceID && provider.Name == "SPOTIFY" {
|
||||
if strconv.Itoa(provider.ID) == sourceID && provider.Name == constants.ProviderSpotify {
|
||||
s.HandleBoseSpotifyToken(w, r)
|
||||
return
|
||||
}
|
||||
@@ -38,8 +39,8 @@ func (s *Server) HandleBoseLegacyToken(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) HandleBoseAccountToken(w http.ResponseWriter, r *http.Request) {
|
||||
sourceID := chi.URLParam(r, "sourceID")
|
||||
|
||||
// If it's Spotify (15), handle it.
|
||||
if sourceID == "15" {
|
||||
// If it's Spotify, handle it.
|
||||
if sourceID == strconv.Itoa(constants.SpotifyProviderID) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("[OAuth Proxy] Failed to read body: %v", err)
|
||||
@@ -114,12 +115,61 @@ func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// We use the first linked account.
|
||||
accessToken, _, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
// However, if the request provides a "secret" (which we use as our Bose surrogate token),
|
||||
// we should use that to find the specific account.
|
||||
var (
|
||||
account *spotify.Account
|
||||
accessToken string
|
||||
userID string
|
||||
)
|
||||
|
||||
return
|
||||
// Spotify registration/refresh often passes the secret in the body as "refresh_token"
|
||||
// or in the registration flow as "code".
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
|
||||
var tokenReq struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
_ = json.Unmarshal(body, &tokenReq)
|
||||
|
||||
secret := tokenReq.RefreshToken
|
||||
if secret == "" {
|
||||
secret = tokenReq.Code
|
||||
}
|
||||
|
||||
if secret != "" {
|
||||
if acc, ok := svc.GetAccountBySecret(secret); ok {
|
||||
account = acc
|
||||
log.Printf("[Spotify Proxy] Found account for secret %s: %s", secret, acc.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
if account != nil {
|
||||
if err := svc.RefreshAccessToken(account); err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to refresh token for %s: %v. Falling back to upstream", account.UserID, err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
accessToken = account.AccessToken
|
||||
} else {
|
||||
// Fallback to first account for backward compatibility or when secret is missing
|
||||
var err error
|
||||
|
||||
accessToken, userID, err = svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Proxy] Using default account %s", userID)
|
||||
}
|
||||
|
||||
// Format response as expected by Bose firmware.
|
||||
|
||||
@@ -15,6 +15,13 @@ import (
|
||||
|
||||
// HandleProxyRequest handles requests to the logging proxy.
|
||||
func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Bose-Proxy-Hop") != "" {
|
||||
log.Printf("[PROXY_LOOP] Loop detected for %s %s, breaking loop", r.Method, r.URL.Path)
|
||||
http.Error(w, "Loop detected", http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetURLStr := strings.TrimPrefix(r.URL.Path, "/proxy/")
|
||||
if targetURLStr == "" {
|
||||
http.Error(w, "Target URL is required", http.StatusBadRequest)
|
||||
@@ -65,6 +72,8 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
|
||||
pr.Out.URL.Path = target.Path
|
||||
}
|
||||
|
||||
pr.Out.Header.Set("X-Bose-Proxy-Hop", "1")
|
||||
|
||||
lp.LogRequest(pr.Out)
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
@@ -101,6 +110,13 @@ func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleBoseProxy proxies the request to the Bose upstream.
|
||||
func (s *Server) HandleBoseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Bose-Proxy-Hop") != "" {
|
||||
log.Printf("[PROXY_LOOP] Loop detected for %s %s, breaking loop", r.Method, r.URL.Path)
|
||||
http.Error(w, "Loop detected", http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "streaming.bose.com"
|
||||
|
||||
@@ -86,7 +86,6 @@ func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, _ *http.Request)
|
||||
go s.DiscoverDevices(context.Background())
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"status": "Discovery started"}`))
|
||||
}
|
||||
|
||||
// HandleGetDiscoveryStatus returns the current discovery status.
|
||||
@@ -1085,14 +1084,36 @@ func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
// HandleGetVersionInfo returns version information for the service.
|
||||
func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
version := s.Version
|
||||
commit := s.Commit
|
||||
date := s.Date
|
||||
repoURL := s.RepoURL
|
||||
s.mu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var (
|
||||
releaseURL string
|
||||
commitURL string
|
||||
)
|
||||
|
||||
if commit != "" && commit != "unknown" {
|
||||
commitURL = fmt.Sprintf("%s/commit/%s", repoURL, commit)
|
||||
}
|
||||
|
||||
// Release version: should point to the release, e.g. https://github.com/gesellix/Bose-SoundTouch/releases/tag/v0.58.0
|
||||
// "dirty" versions don't get a release link (only the commit).
|
||||
if version != "" && version != "dev" && version != "(devel)" && !strings.Contains(version, "dirty") {
|
||||
releaseURL = fmt.Sprintf("%s/releases/tag/%s", repoURL, version)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": s.Version,
|
||||
"commit": s.Commit,
|
||||
"date": s.Date,
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
"repo_url": repoURL,
|
||||
"release_url": releaseURL,
|
||||
"commit_url": commitURL,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestHandleBoseProxy_LoopPrevention(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "proxy-loop-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
t.Run("first hop should be allowed", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/unknown-endpoint", nil)
|
||||
req.Host = "localhost"
|
||||
w := httptest.NewRecorder()
|
||||
server.HandleBoseProxy(w, req)
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Errorf("Expected first hop to be allowed (even if it fails later), but got 404")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("second hop should be blocked", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/unknown-endpoint", nil)
|
||||
req.Host = "localhost"
|
||||
req.Header.Set("X-Bose-Proxy-Hop", "1")
|
||||
w := httptest.NewRecorder()
|
||||
server.HandleBoseProxy(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected second hop to be blocked with 404, but got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleProxyRequest loop detection", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/proxy/http://example.com", nil)
|
||||
req.Header.Set("X-Bose-Proxy-Hop", "1")
|
||||
w := httptest.NewRecorder()
|
||||
server.HandleProxyRequest(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected HandleProxyRequest loop to be blocked with 404, but got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -30,10 +30,10 @@ func TestMargeParityRegressions(t *testing.T) {
|
||||
// One with "Other" and one with a specific name.
|
||||
sourcesXML := `
|
||||
<sources>
|
||||
<source id="14774275" displayName="Other" secret="" secretType="Audio">
|
||||
<source id="14774275" displayName="Other" secret="">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
<source id="SPOT1" displayName="My Spotify" secret="token123" secretType="Audio">
|
||||
<source id="SPOT1" displayName="My Spotify" secret="token123">
|
||||
<sourceKey type="SPOTIFY" account="user123"/>
|
||||
</source>
|
||||
</sources>`
|
||||
|
||||
@@ -50,6 +50,7 @@ type Server struct {
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
RepoURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
spotifyClientID string
|
||||
@@ -96,13 +97,14 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
|
||||
}
|
||||
|
||||
// SetVersionInfo sets the version information for the server.
|
||||
func (s *Server) SetVersionInfo(version, commit, date string) {
|
||||
func (s *Server) SetVersionInfo(version, commit, date, repoURL string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.Version = version
|
||||
s.Commit = commit
|
||||
s.Date = date
|
||||
s.RepoURL = repoURL
|
||||
}
|
||||
|
||||
// SetDiscoverySettings sets the discovery settings for the server.
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 957 B After Width: | Height: | Size: 957 B |
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 631 B |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<title>Artboard Copy 9</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Page-1">
|
||||
<g id="Artboard-Copy-9">
|
||||
<path id="TI_Badge_Black-Copy-2" class="st0" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8
|
||||
c0-0.1,0.1-0.2,0.2-0.2h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3
|
||||
c0,0.3,0.2,0.5,0.5,0.5h1.8c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30
|
||||
c0,0.3,0.2,0.5,0.5,0.5h8.1c0.3,0,0.5-0.2,0.5-0.5L63.9,27.7L63.9,27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8
|
||||
c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17H38.2z M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8
|
||||
c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5V26
|
||||
c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13
|
||||
c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
|
||||
<title>Artboard Copy 9</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Page-1">
|
||||
<g id="Artboard-Copy-9">
|
||||
<path id="TI_Badge_Black-Copy-2" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8c0-0.1,0.1-0.2,0.2-0.2
|
||||
h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3c0,0.3,0.2,0.5,0.5,0.5h1.8
|
||||
c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30c0,0.3,0.2,0.5,0.5,0.5h8.1
|
||||
c0.3,0,0.5-0.2,0.5-0.5V27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17z
|
||||
M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8
|
||||
C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5v-2.5c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5
|
||||
c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13
|
||||
c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,332 +0,0 @@
|
||||
{
|
||||
"_links": {
|
||||
"bmx_search": {
|
||||
"filters": [],
|
||||
"href": "/v1/search?q={query}",
|
||||
"templated": true
|
||||
},
|
||||
"self": {
|
||||
"href": "/v1/navigate"
|
||||
}
|
||||
},
|
||||
"bmx_sections": [
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2xvY2FsP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmdmlld01vZGVsPUZhbHNlJml0ZW1Ub2tlbj1CZ2dJQUFJQUFnQUJBQUVBQVFFQUFRZ0FBQQ=="
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s25260",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s25260/images/logog.jpg?t=638151901560000000",
|
||||
"href": "/v1/playback/station/s25260",
|
||||
"name": "1LIVE",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s25260/images/logog.jpg?t=638151901560000000",
|
||||
"name": "1LIVE",
|
||||
"subtitle": "Für den Sektor"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s42828",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s42828/images/logog.png?t=636575935889670000",
|
||||
"href": "/v1/playback/station/s42828",
|
||||
"name": "Deutschlandfunk",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s42828/images/logog.png?t=636575935889670000",
|
||||
"name": "Deutschlandfunk",
|
||||
"subtitle": "Soundcheck"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s213886",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s213886/images/logog.jpg?t=639098687370000000",
|
||||
"href": "/v1/playback/station/s213886",
|
||||
"name": "WDR 2 Rheinland",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s213886/images/logog.jpg?t=639098687370000000",
|
||||
"name": "WDR 2 Rheinland",
|
||||
"subtitle": "Wir sind der Westen"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s16252",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s16252/images/logog.png?t=636674275828970000",
|
||||
"href": "/v1/playback/station/s16252",
|
||||
"name": "Radio Köln",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s16252/images/logog.png?t=636674275828970000",
|
||||
"name": "Radio Köln",
|
||||
"subtitle": "News, Wetter, Verkehr und der beste Mix"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s99166",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s99166/images/logog.jpg?t=639098688990000000",
|
||||
"href": "/v1/playback/station/s99166",
|
||||
"name": "WDR 2 Ruhrgebiet",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s99166/images/logog.jpg?t=639098688990000000",
|
||||
"name": "WDR 2 Ruhrgebiet",
|
||||
"subtitle": "Wir sind der Westen"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s20301",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s20301/images/logog.jpg?t=639083982470000000",
|
||||
"href": "/v1/playback/station/s20301",
|
||||
"name": "WDR 5",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s20301/images/logog.jpg?t=639083982470000000",
|
||||
"name": "WDR 5",
|
||||
"subtitle": "WDR 5 - Mitreden. Mitfühlen. Miterleben."
|
||||
}
|
||||
],
|
||||
"layout": "ribbon",
|
||||
"name": "Local Radio"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3RyZW5kaW5nP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmdmlld01vZGVsPUZhbHNlJml0ZW1Ub2tlbj1CZ2dJQUFZQUJnQUJBQUVBQVFFQUFRZ0FBQQ=="
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s110052",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s110052/images/logog.jpg?t=639015950340000000",
|
||||
"href": "/v1/playback/station/s110052",
|
||||
"name": "CNBC",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s110052/images/logog.jpg?t=639015950340000000",
|
||||
"name": "CNBC",
|
||||
"subtitle": "Unlocked #105 - Southern Mansion & Tiny Home CNULK00105R1H"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s7016",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s7016/images/logog.png?t=637977437790000000",
|
||||
"href": "/v1/playback/station/s7016",
|
||||
"name": "ABC NewsRadio",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s7016/images/logog.png?t=637977437790000000",
|
||||
"name": "ABC NewsRadio",
|
||||
"subtitle": "Continuous national coverage of opinion-free, independent and fa"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s20431",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s20431/images/logog.jpg?t=638113795120000000",
|
||||
"href": "/v1/playback/station/s20431",
|
||||
"name": "FOX News Radio",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s20431/images/logog.jpg?t=638113795120000000",
|
||||
"name": "FOX News Radio",
|
||||
"subtitle": "Kennedy Saves the World"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s24939",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s24939/images/logog.png?t=639107339520000000",
|
||||
"href": "/v1/playback/station/s24939",
|
||||
"name": "BBC Radio 1",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s24939/images/logog.png?t=639107339520000000",
|
||||
"name": "BBC Radio 1",
|
||||
"subtitle": "The biggest new pop and all-day vibes"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s3022",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s3022/images/logog.jpg?t=637281897030000000",
|
||||
"href": "/v1/playback/station/s3022",
|
||||
"name": "CNA938",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s3022/images/logog.jpg?t=637281897030000000",
|
||||
"name": "CNA938",
|
||||
"subtitle": "Asia First Weekend with Justine Moss"
|
||||
}
|
||||
],
|
||||
"layout": "ribbon",
|
||||
"name": "Trending"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3Nwb3J0cz9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJnZpZXdNb2RlbD1GYWxzZSZpdGVtVG9rZW49QmdnSUFBZ0FDQUFCQUFFQUFRRUFBUWdBQUE="
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s354710",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/z8181/images/logog.jpg?t=639107567180000000",
|
||||
"href": "/v1/playback/station/s354710",
|
||||
"name": "Download the free TuneIn app",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/z8181/images/logog.jpg?t=639107567180000000",
|
||||
"name": "Download the free TuneIn app",
|
||||
"subtitle": "Download the free TuneIn app"
|
||||
}
|
||||
],
|
||||
"layout": "ribbon",
|
||||
"name": "Sports"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/navigate/"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2MxMDAwMzU1MjY_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQVFBQkFBQkFBRUFBUUVBQVFnQUFB"
|
||||
}
|
||||
},
|
||||
"imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/speaker.png",
|
||||
"name": "Apple Music Radio Stations",
|
||||
"subtitle": ""
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2MxMDAwMDAwODg_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQVVBQlFBQkFBRUFBUUVBQVFnQUFB"
|
||||
}
|
||||
},
|
||||
"imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/podcasts.png",
|
||||
"name": "Podcasts",
|
||||
"subtitle": ""
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL211c2ljP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmdmlld01vZGVsPUZhbHNlJml0ZW1Ub2tlbj1CZ2dJQUFjQUJ3QUJBQUVBQVFFQUFRZ0FBQQ=="
|
||||
}
|
||||
},
|
||||
"imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/note.png",
|
||||
"name": "Music",
|
||||
"subtitle": ""
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2M1NzkyMj9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJnZpZXdNb2RlbD1GYWxzZSZpdGVtVG9rZW49QmdnSUFBa0FDUUFCQUFFQUFRRUFBUWdBQUE="
|
||||
}
|
||||
},
|
||||
"imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/news.png",
|
||||
"name": "News & Talk",
|
||||
"subtitle": ""
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3RhbGs_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQW9BQ2dBQkFBRUFBUUVBQVFnQUFB"
|
||||
}
|
||||
},
|
||||
"imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/microphone.png",
|
||||
"name": "Talk",
|
||||
"subtitle": ""
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3JlZ2lvbnM_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQXNBQ3dBQkFBRUFBUUVBQVFnQUFB"
|
||||
}
|
||||
},
|
||||
"imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/location.png",
|
||||
"name": "By Location",
|
||||
"subtitle": ""
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2xhbmd1YWdlcz9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJnZpZXdNb2RlbD1GYWxzZSZpdGVtVG9rZW49QmdnSUFBd0FEQUFCQUFFQUFRRUFBUWdBQUE="
|
||||
}
|
||||
},
|
||||
"imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/bubble.png",
|
||||
"name": "By Language",
|
||||
"subtitle": ""
|
||||
}
|
||||
],
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"layout": "classic"
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/search?q=music"
|
||||
}
|
||||
},
|
||||
"bmx_sections": [
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcz9mdWxsdGV4dFNlYXJjaD10cnVlJmZpbHRlcj1wJTNBc2hvdyZxdWVyeT1tdXNpYyZzZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFFQUFRQUFBQUFBREF3QUFRUVRWZ0FBQUJOV0FBQUE="
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p783819/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNzgzODE5P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQUVBQVFBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p783819/images/logog.png?t=637208895200000000",
|
||||
"href": "/v1/preset/program/p783819",
|
||||
"name": "Must-Hear Music",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p783819/images/logog.png?t=637208895200000000",
|
||||
"name": "Must-Hear Music",
|
||||
"subtitle": "Billboard staffers discuss new music from artists across a variety of genres.Hosted on Acast. See acast.com/privacy for more information."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p813639/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wODEzNjM5P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQUlBQWdBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p813639/images/logog.png?t=635834647084430000",
|
||||
"href": "/v1/preset/program/p813639",
|
||||
"name": "Music Awards 2016",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p813639/images/logog.png?t=635834647084430000",
|
||||
"name": "Music Awards 2016",
|
||||
"subtitle": "United States"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p967555/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wOTY3NTU1P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQU1BQXdBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p967555/images/logog.png?t=637217441150000000",
|
||||
"href": "/v1/preset/program/p967555",
|
||||
"name": "The Great Albums",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p967555/images/logog.png?t=637217441150000000",
|
||||
"name": "The Great Albums",
|
||||
"subtitle": "Two indie rock musicians, Bill Lambusta and Brian Erickson, dive into great rock and pop music through the lens of the medium they care for most - the album. Every episode features a track-by-track review, discussions about the sounds they love, and..."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p939903/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wOTM5OTAzP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQVFBQkFBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p939903/images/logog.png?t=638291015710000000",
|
||||
"href": "/v1/preset/program/p939903",
|
||||
"name": "He Sang/She Sang",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p939903/images/logog.png?t=638291015710000000",
|
||||
"name": "He Sang/She Sang",
|
||||
"subtitle": "He Sang/She Sang is a new podcast from WQXR for the opera-curious and opera superfans who want to know what all those big voices are really singing about. The podcast follows the radio broadcast season of the Metropolitan Opera with a weekly..."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p860133/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wODYwMTMzP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQVVBQlFBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p860133/images/logog.png?t=638863003740000000",
|
||||
"href": "/v1/preset/program/p860133",
|
||||
"name": "Drink Champs",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p860133/images/logog.png?t=638863003740000000",
|
||||
"name": "Drink Champs",
|
||||
"subtitle": "Legendary Queens rapper-turned show host N.O.R.E. teams up with Miami hip-hop pioneer DJ EFN for a night of boozy conversation and boisterous storytelling. The hosts and guests engage together in fun, light-hearted conversation - looking back at their..."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p4696142/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNDY5NjE0Mj9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFZQUJnQUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p4696142/images/logog.png?t=639004873490000000",
|
||||
"href": "/v1/preset/program/p4696142",
|
||||
"name": "Les pepites musicales de RFI",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p4696142/images/logog.png?t=639004873490000000",
|
||||
"name": "Les pepites musicales de RFI",
|
||||
"subtitle": "Toute l’année, nos reporters croisent des artistes du continent et d’ailleurs. Dans leurs maisons, dans les coulisses des concerts, les chambres d’hôtel ou dans la rue se nouent des rencontres uniques où l’on parle de soi, du son et du monde. RFI vous..."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p4696122/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNDY5NjEyMj9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFjQUJ3QUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p4696122/images/logog.png?t=639004867290000000",
|
||||
"href": "/v1/preset/program/p4696122",
|
||||
"name": "Afro-Club et Afro-Club Deluxe",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p4696122/images/logog.png?t=639004867290000000",
|
||||
"name": "Afro-Club et Afro-Club Deluxe",
|
||||
"subtitle": "Le son de la nouvelle génération sur RFI ! À partir du 30/3/2026, du lundi au vendredi, de 20h10 à 21h00 TU, DJ Face Maker (Hervé Mandina) vous donne accès au Top 20 des artistes d'Afrique, des Caraïbes et des diasporas afros qui font vibrer les..."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p4696123/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNDY5NjEyMz9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFnQUNBQUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p4696123/images/logog.png?t=639004867620000000",
|
||||
"href": "/v1/preset/program/p4696123",
|
||||
"name": "Bonnes Pulsations du Monde",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p4696123/images/logog.png?t=639004867620000000",
|
||||
"name": "Bonnes Pulsations du Monde",
|
||||
"subtitle": "BPM – Bonnes Pulsations du Monde, c’est une sélection de chansons qui font l’actualité sur les 5 continents. D’Abidjan à Caracas, de Paris à Shanghai, qu’est-ce qui fait vibrer la planète ? Une fois par mois, BPM vous emmène à la rencontre d’un..."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p1119668/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wMTExOTY2OD9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFrQUNRQUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p1119668/images/logog.png?t=636592099583900000",
|
||||
"href": "/v1/preset/program/p1119668",
|
||||
"name": "Y'all Access",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p1119668/images/logog.png?t=636592099583900000",
|
||||
"name": "Y'all Access",
|
||||
"subtitle": "Kelly Sutton has your All Access pass to all the VIP events around Music City! Party hop, hit the red carpets and go behind the scenes thanks to your \"Y'all Access\" pass!"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Program/p946296/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wOTQ2Mjk2P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQW9BQ2dBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/p946296/images/logog.png?t=638360999970000000",
|
||||
"href": "/v1/preset/program/p946296",
|
||||
"name": "The Popcast With Knox and Jamie",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/p946296/images/logog.png?t=638360999970000000",
|
||||
"name": "The Popcast With Knox and Jamie",
|
||||
"subtitle": "A weekly pop culture podcast seeking to educate on things that entertain, but do not matter.Hosted on Acast. See acast.com/privacy for more information."
|
||||
}
|
||||
],
|
||||
"layout": "shortList",
|
||||
"name": "Shows"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcz9mdWxsdGV4dFNlYXJjaD10cnVlJmZpbHRlcj1zJnF1ZXJ5PW11c2ljJnNlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQUlBQWdBQUFBQUFDd3NBQVFRVFZRQUFBQk5WQUFBQQ=="
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s309467",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s309467/images/logog.jpg?t=637348332440000000",
|
||||
"href": "/v1/playback/station/s309467",
|
||||
"name": "Kidsradio.com",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s309467/images/logog.jpg?t=637348332440000000",
|
||||
"name": "Kidsradio.com",
|
||||
"subtitle": "Greece"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s301791",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s301791/images/logog.png?t=636480577103430000",
|
||||
"href": "/v1/playback/station/s301791",
|
||||
"name": "90s90s Dance",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s301791/images/logog.png?t=636480577103430000",
|
||||
"name": "90s90s Dance",
|
||||
"subtitle": "90s90s Dance: Der Dancesound der 90er."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s281990",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s281990/images/logog.png?t=638156899930000000",
|
||||
"href": "/v1/playback/station/s281990",
|
||||
"name": "90s90s DAB",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s281990/images/logog.png?t=638156899930000000",
|
||||
"name": "90s90s DAB",
|
||||
"subtitle": "90s90s ist das Radio für den coolen Sound der 90er. Deutschlandweit im Digitalradio DAB+"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s308474",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s308474/images/logog.png?t=637014668910000000",
|
||||
"href": "/v1/playback/station/s308474",
|
||||
"name": "90s90s In The Mix",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s308474/images/logog.png?t=637014668910000000",
|
||||
"name": "90s90s In The Mix",
|
||||
"subtitle": "90s90s In The Mix: Der Sound der 90er nonstop gemixt – das Real 90s-DJ-Radio"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s323852",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s323852/images/logog.png?t=638197456570000000",
|
||||
"href": "/v1/playback/station/s323852",
|
||||
"name": "90s90s DANCE RADIO",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s323852/images/logog.png?t=638197456570000000",
|
||||
"name": "90s90s DANCE RADIO",
|
||||
"subtitle": "Kein Musikstil hat die Musikszene Deutschlands und das Leben von jungen Menschen so geprägt wie der"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s306625",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s306625/images/logog.png?t=636673358641530000",
|
||||
"href": "/v1/playback/station/s306625",
|
||||
"name": "90s90s Techno",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s306625/images/logog.png?t=636673358641530000",
|
||||
"name": "90s90s Techno",
|
||||
"subtitle": "Die Geburtsstunde von Techno - der typische 90s-Dancesound in ei"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s174864",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-radiotime-logos.tunein.com/s174864g.png",
|
||||
"href": "/v1/playback/station/s174864",
|
||||
"name": "Highway 65 Radio",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-radiotime-logos.tunein.com/s174864g.png",
|
||||
"name": "Highway 65 Radio",
|
||||
"subtitle": "Connecting listeners to the Country Music scene and lifestyle"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s323853",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s323853/images/logog.png?t=638197456800000000",
|
||||
"href": "/v1/playback/station/s323853",
|
||||
"name": "80s80s DANCE",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s323853/images/logog.png?t=638197456800000000",
|
||||
"name": "80s80s DANCE",
|
||||
"subtitle": "80s80s DANCE liefert den perfekten Dance-Sound aus den 80ern in einem eigenen Radio."
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s306908",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s306908/images/logog.png?t=636758140220000000",
|
||||
"href": "/v1/playback/station/s306908",
|
||||
"name": "90s90s RnB",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s306908/images/logog.png?t=636758140220000000",
|
||||
"name": "90s90s RnB",
|
||||
"subtitle": "Hip-Hop-Soul, neuer Funk und ein Schwung sexuell aufgeladener Ja"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_playback": {
|
||||
"href": "/v1/playback/station/s306584",
|
||||
"type": "stationurl"
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-profiles.tunein.com/s306584/images/logog.png?t=636643926846930000",
|
||||
"href": "/v1/playback/station/s306584",
|
||||
"name": "90s90s Grunge",
|
||||
"type": "stationurl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-profiles.tunein.com/s306584/images/logog.png?t=636643926846930000",
|
||||
"name": "90s90s Grunge",
|
||||
"subtitle": "Wütende Musik der 90er: Grunge. Was in Seattle in den USA begann"
|
||||
}
|
||||
],
|
||||
"layout": "shortList",
|
||||
"name": "Stations"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/v1/navigate/sub/2/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcz9mdWxsdGV4dHNlYXJjaD10cnVlJnZlcnNpb249MS4zJnNlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmcXVlcnk9bXVzaWM="
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Artist/m1038098/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tMTAzODA5OD9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFFQUFRQURBQU1BRGc0QUFRUVRDd0FBQUJNTEFBQUE="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-radiotime-logos.tunein.com/s0q.png",
|
||||
"href": "/v1/preset/program/m1038098",
|
||||
"name": "Music Music Music",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-radiotime-logos.tunein.com/s0q.png",
|
||||
"name": "Music Music Music",
|
||||
"subtitle": "Gospel, Caribbean Music"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Artist/m1444080/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tMTQ0NDA4MD9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFJQUFnQURBQU1BRGc0QUFRUVRDd0FBQUJNTEFBQUE="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-radiotime-logos.tunein.com/s0q.png",
|
||||
"href": "/v1/preset/program/m1444080",
|
||||
"name": "No Music",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-radiotime-logos.tunein.com/s0q.png",
|
||||
"name": "No Music",
|
||||
"subtitle": "Variety"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Artist/m236951/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tMjM2OTUxP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQU1BQXdBREFBTUFEZzRBQVFRVEN3QUFBQk1MQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-albums.tunein.com/gn/40QJ66TZ3Wq.jpg",
|
||||
"href": "/v1/preset/program/m236951",
|
||||
"name": "The Music",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-albums.tunein.com/gn/40QJ66TZ3Wq.jpg",
|
||||
"name": "The Music",
|
||||
"subtitle": "Gospel, Rock"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate/profiles/Artist/m404700/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tNDA0NzAwP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQVFBQkFBREFBTUFEZzRBQVFRVEN3QUFBQk1MQUFBQQ=="
|
||||
},
|
||||
"bmx_preset": {
|
||||
"containerArt": "http://cdn-albums.tunein.com/gn/JDJC8456C0q.jpg",
|
||||
"href": "/v1/preset/program/m404700",
|
||||
"name": "Music Go Music",
|
||||
"type": "tracklisturl"
|
||||
}
|
||||
},
|
||||
"imageUrl": "http://cdn-albums.tunein.com/gn/JDJC8456C0q.jpg",
|
||||
"name": "Music Go Music",
|
||||
"subtitle": ""
|
||||
}
|
||||
],
|
||||
"layout": "shortList",
|
||||
"name": "Suggestions (Artist)"
|
||||
}
|
||||
],
|
||||
"layout": "classic"
|
||||
}
|
||||
@@ -152,9 +152,16 @@
|
||||
<span style="font-size: 0.8em; color: #666">(Standard services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px"/>
|
||||
<label style="margin-left: 15px"><input type="checkbox" id="discovery-enabled"/> Enable Automated Discovery</label>
|
||||
<strong>Device Discovery:</strong>
|
||||
<div style="margin-top: 5px">
|
||||
<label style="display: block; margin-bottom: 5px">
|
||||
<input type="checkbox" id="discovery-enabled"/> Enable Automated Discovery
|
||||
</label>
|
||||
<div style="margin-left: 20px">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>DNS Discovery:</strong>
|
||||
@@ -1463,6 +1470,21 @@
|
||||
<div id="account-metadata">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div id="spotify-registration-container" class="summary-box" style="margin-top: 20px;">
|
||||
<h3>Spotify Integration</h3>
|
||||
<p style="font-size: 0.9em; color: #555;">
|
||||
Register a new Spotify source for this local account. This mimics the official SoundTouch app flow:
|
||||
</p>
|
||||
<ol style="font-size: 0.85em; color: #555; margin-bottom: 15px;">
|
||||
<li>Exchange OAuth code for a Bose-mediated token.</li>
|
||||
<li>Register the source in the local Marge cloud profile.</li>
|
||||
</ol>
|
||||
<button id="connect-spotify-account-btn" onclick="connectSpotifyToAccount()" style="background: #1db954; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
|
||||
Connect Spotify to this Account
|
||||
</button>
|
||||
<div id="spotify-reg-status" style="margin-top: 10px; font-size: 0.9em;"></div>
|
||||
</div>
|
||||
|
||||
<div id="account-devices-container">
|
||||
<h3>Connected Devices</h3>
|
||||
<div id="account-devices-list">Select an account to view devices.</div>
|
||||
|
||||
@@ -464,7 +464,16 @@ async function fetchVersion() {
|
||||
const data = await response.json();
|
||||
const info = document.getElementById("version-info");
|
||||
if (info && data.version) {
|
||||
info.innerText = `AfterTouch ${data.version} (${data.commit}) - ${data.date}`;
|
||||
let versionStr = data.version;
|
||||
if (data.release_url) {
|
||||
versionStr = `<a href="${data.release_url}" target="_blank" style="color: inherit; text-decoration: underline;">${data.version}</a>`;
|
||||
}
|
||||
let commitStr = data.commit;
|
||||
if (data.commit_url) {
|
||||
const shortCommit = data.commit.substring(0, 7);
|
||||
commitStr = `<a href="${data.commit_url}" target="_blank" style="color: inherit; text-decoration: underline;">${shortCommit}</a>`;
|
||||
}
|
||||
info.innerHTML = `AfterTouch ${versionStr} (${commitStr}) - ${data.date}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch version info", error);
|
||||
@@ -492,7 +501,9 @@ async function fetchAccountDetails(accountId) {
|
||||
if (!accountId) return;
|
||||
const metadataEl = document.getElementById("account-metadata");
|
||||
const devicesEl = document.getElementById("account-devices-list");
|
||||
const regStatus = document.getElementById("spotify-reg-status");
|
||||
|
||||
if (regStatus) regStatus.innerText = "";
|
||||
if (metadataEl) metadataEl.innerHTML = "Loading...";
|
||||
if (devicesEl) devicesEl.innerHTML = "Loading devices...";
|
||||
|
||||
@@ -761,6 +772,50 @@ async function fetchAccountDetails(accountId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSpotifyToAccount() {
|
||||
const selector = document.getElementById("account-selector");
|
||||
const accountId = selector ? selector.value : "default";
|
||||
const statusEl = document.getElementById("spotify-reg-status");
|
||||
|
||||
if (statusEl) statusEl.innerHTML = "Initializing Spotify authorization...";
|
||||
|
||||
try {
|
||||
const response = await fetch(`/mgmt/spotify/init?account=${encodeURIComponent(accountId)}`, {
|
||||
method: "POST"
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(err || response.statusText);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const redirectUrl = data.redirectUrl;
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `Spotify authorization window opened. <br/>If it didn't open, <a href="${redirectUrl}" target="_blank">click here to authorize</a>.`;
|
||||
}
|
||||
|
||||
// Open Spotify auth in a new window
|
||||
window.open(redirectUrl, "SpotifyAuth", "width=600,height=800");
|
||||
|
||||
// Simple poll to see when we might be done (refresh every 5s for 5 mins)
|
||||
let pollCount = 0;
|
||||
const interval = setInterval(async () => {
|
||||
pollCount++;
|
||||
if (pollCount > 60) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
// Refresh account details to see if source appeared
|
||||
await fetchAccountDetails(accountId);
|
||||
}, 5000);
|
||||
|
||||
} catch (error) {
|
||||
if (statusEl) statusEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
|
||||
console.error("Spotify link failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInteractionStats() {
|
||||
console.log("Fetching interaction stats...");
|
||||
try {
|
||||
@@ -1489,7 +1544,13 @@ async function triggerDiscovery() {
|
||||
const indicator = document.getElementById("discovery-indicator");
|
||||
if (indicator) indicator.style.display = "inline";
|
||||
try {
|
||||
await fetch("/setup/discover", {method: "POST"});
|
||||
const response = await fetch("/setup/discover", {method: "POST"});
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
alert("Failed to start discovery: " + err);
|
||||
if (indicator) indicator.style.display = "none";
|
||||
return;
|
||||
}
|
||||
pollDiscoveryStatus();
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger discovery", error);
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestCredentialParity_LegacyAndNewFormat(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "credential-parity-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "12345"
|
||||
device := "DEV123"
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// 1. Setup Sources.xml with BOTH legacy attribute and new element
|
||||
// This simulates what the datastore now produces.
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="1001" type="Audio" secret="legacy-token" secretType="token">
|
||||
<credential type="token">new-token</credential>
|
||||
<sourceKey type="SPOTIFY" account="user1" />
|
||||
</source>
|
||||
<source id="1002" type="Audio" secret="only-legacy" secretType="token">
|
||||
<sourceKey type="TUNEIN" account="user2" />
|
||||
</source>
|
||||
<source id="1003" type="Audio">
|
||||
<credential type="token_version_3">only-new</credential>
|
||||
<sourceKey type="SPOTIFY" account="user3" />
|
||||
</source>
|
||||
</sources>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
|
||||
|
||||
// 2. Verify GetConfiguredSources prioritizes new element
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("Expected 3 sources, got %d", len(sources))
|
||||
}
|
||||
|
||||
// Source 1001: should have "new-token"
|
||||
if sources[0].Secret != "new-token" {
|
||||
t.Errorf("Source 1001: expected secret 'new-token', got '%s'", sources[0].Secret)
|
||||
}
|
||||
|
||||
// Source 1002: should have "only-legacy"
|
||||
if sources[1].Secret != "only-legacy" {
|
||||
t.Errorf("Source 1002: expected secret 'only-legacy', got '%s'", sources[1].Secret)
|
||||
}
|
||||
|
||||
// Source 1003: should have "only-new" and "token_version_3"
|
||||
if sources[2].Secret != "only-new" {
|
||||
t.Errorf("Source 1003: expected secret 'only-new', got '%s'", sources[2].Secret)
|
||||
}
|
||||
if sources[2].SecretType != "token_version_3" {
|
||||
t.Errorf("Source 1003: expected secretType 'token_version_3', got '%s'", sources[2].SecretType)
|
||||
}
|
||||
|
||||
// 3. Verify AccountFullToXML (API response) contains correct credential elements
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(fullXML)
|
||||
|
||||
// Check 1001: should have new-token (Spotify with 'token' upgraded to 'token_version_3' in mapping)
|
||||
if !strings.Contains(xmlStr, `<source id="1001" type="Audio">`) {
|
||||
t.Errorf("Missing source 1001 in XML")
|
||||
}
|
||||
// Spotify with 'token' is upgraded to 'token_version_3' in mapToFullResponseSource
|
||||
if !strings.Contains(xmlStr, `<credential type="token_version_3">new-token</credential>`) {
|
||||
t.Errorf("Source 1001: missing expected credential. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// Check 1002: should have only-legacy
|
||||
if !strings.Contains(xmlStr, `<source id="1002" type="Audio">`) {
|
||||
t.Errorf("Missing source 1002 in XML")
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<credential type="token">only-legacy</credential>`) {
|
||||
t.Errorf("Source 1002: missing expected credential. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// Check 1003: should have only-new
|
||||
if !strings.Contains(xmlStr, `<source id="1003" type="Audio">`) {
|
||||
t.Errorf("Missing source 1003 in XML")
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<credential type="token_version_3">only-new</credential>`) {
|
||||
t.Errorf("Source 1003: missing expected credential. XML: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFullToXML_RecentsCredentialConsistency(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "recents-consistency-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "12345"
|
||||
device := "DEV123"
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// 1. Setup Sources.xml
|
||||
// 9330201 comes first and matches type "Audio" but has NO token.
|
||||
// 14774275 comes later and matches the sourceid exactly and HAS token.
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="9330201" type="Audio">
|
||||
<credential type="token"></credential>
|
||||
<sourceKey type="Audio" account=""></sourceKey>
|
||||
</source>
|
||||
<source id="14774275" secret="token-value" secretType="token" type="Audio">
|
||||
<credential type="token">token-value</credential>
|
||||
<sourceKey type="Audio" account=""></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
|
||||
|
||||
// 2. Setup Recents.xml
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="2270445222">
|
||||
<contentItem source="Audio" type="" location="/v1/playback/episodes/t104218136" sourceAccount="" isPresetable="">
|
||||
<itemName>Atemlos durch die Charts</itemName>
|
||||
</contentItem>
|
||||
<createdOn>2019-07-29T15:29:59.000+00:00</createdOn>
|
||||
<updatedOn>2019-07-29T15:29:59.000+00:00</updatedOn>
|
||||
<lastplayedat>2019-07-29T11:29:54.000+00:00</lastplayedat>
|
||||
<sourceid>14774275</sourceid>
|
||||
<username>Atemlos durch die Charts</username>
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
|
||||
|
||||
// Setup DeviceInfo.xml so CreateAccountDevice works
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="DEV123">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
</info>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
|
||||
|
||||
// 3. Verify RecentsToXML (used by /recents)
|
||||
recentsBytes, err := RecentsToXML(ds, account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentsToXML failed: %v", err)
|
||||
}
|
||||
recentsStr := string(recentsBytes)
|
||||
// t.Logf("Recents XML: %s", recentsStr)
|
||||
if !strings.Contains(recentsStr, `<sourceid>14774275</sourceid>`) {
|
||||
t.Errorf("/recents response should have sourceid 14774275. XML: %s", recentsStr)
|
||||
}
|
||||
if !strings.Contains(recentsStr, `<credential type="token">token-value</credential>`) {
|
||||
t.Errorf("/recents response missing credential. XML: %s", recentsStr)
|
||||
}
|
||||
|
||||
// 4. Verify AccountFullToXML (used by /full)
|
||||
fullBytes, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
fullStr := string(fullBytes)
|
||||
// t.Logf("Full XML: %s", fullStr)
|
||||
// In AccountFullToXML, recents are grouped under devices
|
||||
if !strings.Contains(fullStr, `<recent id="2270445222">`) {
|
||||
t.Errorf("/full response missing recent item. XML: %s", fullStr)
|
||||
}
|
||||
if !strings.Contains(fullStr, `<sourceid>14774275</sourceid>`) {
|
||||
t.Errorf("/full response should have sourceid 14774275. XML: %s", fullStr)
|
||||
}
|
||||
if !strings.Contains(fullStr, `<credential type="token">token-value</credential>`) {
|
||||
t.Errorf("/full response missing credential. XML: %s", fullStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSourcesToXML_CredentialParity(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "sources-parity-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "123"
|
||||
device := "ABC"
|
||||
|
||||
src := models.ConfiguredSource{
|
||||
ID: "2001",
|
||||
Secret: "secret-val",
|
||||
SecretType: "token_version_3",
|
||||
}
|
||||
src.SourceKey.Type = "SPOTIFY"
|
||||
src.SourceKey.Account = "user1"
|
||||
|
||||
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
|
||||
|
||||
xmlData, err := AccountSourcesToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountSourcesToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(xmlData)
|
||||
if !strings.Contains(xmlStr, `<credential type="token_version_3">secret-val</credential>`) {
|
||||
t.Errorf("AccountSourcesToXML missing expected credential element. Got: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFullToXML_RecentsCredentialParity(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "recents-parity-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "123"
|
||||
device := "ABC"
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// 0. Setup DeviceInfo.xml (required for CreateAccountDevice)
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABC">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<components>
|
||||
<component componentCategory="SCM">
|
||||
<softwareVersion>1.2.3</softwareVersion>
|
||||
<serialNumber>ABC123</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
</info>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
|
||||
|
||||
// 1. Setup Sources.xml
|
||||
src := models.ConfiguredSource{
|
||||
ID: "3001",
|
||||
Secret: "recent-token",
|
||||
SecretType: "token_version_3",
|
||||
}
|
||||
src.SourceKey.Type = "SPOTIFY"
|
||||
src.SourceKey.Account = "user-recent"
|
||||
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
|
||||
|
||||
// 2. Setup Recents.xml
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="1" deviceID="ABC" utcTime="123456789">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:123" sourceAccount="user-recent" isPresetable="true" itemName="Recent Track" />
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
|
||||
|
||||
// 3. Generate Account Full XML
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(fullXML)
|
||||
|
||||
// 4. Verify that the recent item has the credential populated via its source
|
||||
// The recent item's source should be mapped from the configured source with ID 3001 or matching source/account.
|
||||
if !strings.Contains(xmlStr, `<recent id="1">`) {
|
||||
t.Errorf("Missing recent 1 in XML. Got: %s", xmlStr)
|
||||
}
|
||||
|
||||
// This is what is currently missing according to the issue.
|
||||
// We need to check if the <recent> element's nested <source> has the <credential>.
|
||||
// Simple way to check: is there at least TWO occurrences of the credential?
|
||||
// One in <sources><source> and one in <recents><recent><source>.
|
||||
count := strings.Count(xmlStr, `<credential type="token_version_3">recent-token</credential>`)
|
||||
if count < 2 {
|
||||
t.Errorf("Recent item likely missing expected credential element. Count: %d, XML: %s", count, xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFullToXML_RecentsSourceAccountMatching(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "recents-matching-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "123"
|
||||
device := "ABC"
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// 0. Setup DeviceInfo.xml
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABC">
|
||||
<name>Test Device</name>
|
||||
<components><component componentCategory="SCM"><serialNumber>ABC123</serialNumber></component></components>
|
||||
</info>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
|
||||
|
||||
// 1. Setup TWO Spotify sources with different accounts
|
||||
src1 := models.ConfiguredSource{
|
||||
ID: "101",
|
||||
Secret: "token-1",
|
||||
SecretType: "token_version_3",
|
||||
}
|
||||
src1.SourceKey.Type = "SPOTIFY"
|
||||
src1.SourceKey.Account = "user-1"
|
||||
|
||||
src2 := models.ConfiguredSource{
|
||||
ID: "202",
|
||||
Secret: "token-2",
|
||||
SecretType: "token_version_3",
|
||||
}
|
||||
src2.SourceKey.Type = "SPOTIFY"
|
||||
src2.SourceKey.Account = "user-2"
|
||||
|
||||
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src1, src2})
|
||||
|
||||
// 2. Setup Recents.xml with a Spotify recent for user-2
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="1" deviceID="ABC" utcTime="123456789">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:123" sourceAccount="user-2" isPresetable="true" itemName="User 2 Track" />
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
|
||||
|
||||
// 3. Generate Account Full XML
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(fullXML)
|
||||
|
||||
// 4. Verify that the recent item matches source 202 (user-2) and HAS token-2
|
||||
if !strings.Contains(xmlStr, `<recent id="1">`) {
|
||||
t.Fatalf("Missing recent 1")
|
||||
}
|
||||
|
||||
// It should have token-2. If it picked src1 by mistake, it would have token-1.
|
||||
if !strings.Contains(xmlStr, `<credential type="token_version_3">token-2</credential>`) {
|
||||
t.Errorf("Recent item missing expected credential element (token-2). XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// Total count: token-1 (once in sources), token-2 (once in sources, once in recents)
|
||||
if strings.Count(xmlStr, `token-2`) < 2 {
|
||||
t.Errorf("token-2 should appear twice (source list and recent). XML: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFullToXML_ContentItemTypeParity(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "content-item-type-parity-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "123"
|
||||
device := "ABC"
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// 0. Setup DeviceInfo.xml
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABC">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<components><component componentCategory="SCM"><serialNumber>ABC123</serialNumber></component></components>
|
||||
</info>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
|
||||
|
||||
// 1. Setup Sources.xml
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="100" type="TUNEIN">
|
||||
<credential type="token"></credential>
|
||||
<sourceKey type="TUNEIN" account=""></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
|
||||
|
||||
// 2. Setup Presets.xml and Recents.xml with contentItem elements
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="123456789" updatedOn="123456789">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/stations/s166521" sourceAccount="" isPresetable="true">
|
||||
<itemName>Station Name</itemName>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644)
|
||||
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="1" deviceID="ABC" utcTime="123456789">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/stations/s166521" sourceAccount="" isPresetable="true">
|
||||
<itemName>Station Name</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
|
||||
|
||||
// 3. Generate Account Full XML
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(fullXML)
|
||||
|
||||
// 4. Verify that contentItemType is present and matches the contentItem's type
|
||||
if !strings.Contains(xmlStr, `<contentItemType>stationurl</contentItemType>`) {
|
||||
t.Errorf("Missing expected <contentItemType>stationurl</contentItemType> in XML. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// It should appear twice: once in preset, once in recent
|
||||
count := strings.Count(xmlStr, `<contentItemType>stationurl</contentItemType>`)
|
||||
if count != 2 {
|
||||
t.Errorf("Expected <contentItemType>stationurl</contentItemType> to appear twice, got %d. XML: %s", count, xmlStr)
|
||||
}
|
||||
|
||||
// Verify itemName is present
|
||||
if !strings.Contains(xmlStr, `<name>Station Name</name>`) {
|
||||
t.Errorf("Missing expected <name>Station Name</name> in XML. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// Verify location is present
|
||||
if !strings.Contains(xmlStr, `<location>/v1/playback/stations/s166521</location>`) {
|
||||
t.Errorf("Missing expected <location>/v1/playback/stations/s166521</location> in XML. XML: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestLastPlayedAtParity(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
utcTimeStr := strconv.FormatInt(now, 10)
|
||||
expectedLastPlayedAt := time.Unix(now, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
||||
|
||||
recents := []models.ServiceRecent{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Recent 1",
|
||||
},
|
||||
UtcTime: utcTimeStr,
|
||||
LastPlayedAt: "", // Empty in datastore
|
||||
},
|
||||
}
|
||||
|
||||
sources := []models.ConfiguredSource{}
|
||||
|
||||
fullRecents := mapRecentsToFullResponse(recents, sources)
|
||||
|
||||
if len(fullRecents) != 1 {
|
||||
t.Fatalf("Expected 1 recent, got %d", len(fullRecents))
|
||||
}
|
||||
|
||||
if fullRecents[0].LastPlayedAt != expectedLastPlayedAt {
|
||||
t.Errorf("Expected LastPlayedAt %s, got %s", expectedLastPlayedAt, fullRecents[0].LastPlayedAt)
|
||||
}
|
||||
|
||||
// Verify XML marshaling
|
||||
data, err := xml.Marshal(fullRecents[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(data)
|
||||
if !strings.Contains(xmlStr, "<lastplayedat>"+expectedLastPlayedAt+"</lastplayedat>") {
|
||||
t.Errorf("XML missing expected lastplayedat tag: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func ensureTimestamps(s *models.ConfiguredSource) {
|
||||
}
|
||||
|
||||
func ensureSourceType(s *models.ConfiguredSource) {
|
||||
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != "AUX" && s.SourceKey.Type != "BLUETOOTH") {
|
||||
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderAux && s.SourceKey.Type != constants.ProviderBluetooth) {
|
||||
s.Type = "Audio"
|
||||
}
|
||||
}
|
||||
@@ -123,10 +123,10 @@ func ensureSourceProviderID(s *models.ConfiguredSource) {
|
||||
|
||||
func syncCredentials(s *models.ConfiguredSource) {
|
||||
if s.SecretType == "" {
|
||||
if s.SourceKey.Type == "SPOTIFY" {
|
||||
s.SecretType = "token_version_3"
|
||||
if s.SourceKey.Type == constants.ProviderSpotify {
|
||||
s.SecretType = constants.CredentialTypeTokenV3
|
||||
} else {
|
||||
s.SecretType = "token"
|
||||
s.SecretType = constants.CredentialTypeToken
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +159,8 @@ func syncLegacySourceKey(s *models.ConfiguredSource) {
|
||||
|
||||
// PresetsXML is the XML wrapper for a list of presets.
|
||||
type PresetsXML struct {
|
||||
XMLName xml.Name `xml:"presets"`
|
||||
Presets []models.ServicePreset `xml:"preset"`
|
||||
XMLName xml.Name `xml:"presets"`
|
||||
Presets []presetParityXML `xml:"preset"`
|
||||
}
|
||||
|
||||
type presetParityXML struct {
|
||||
@@ -171,7 +171,6 @@ type presetParityXML struct {
|
||||
Location string `xml:"location"`
|
||||
Name string `xml:"name"`
|
||||
Source *models.ConfiguredSource `xml:"source,omitempty"`
|
||||
SourceID string `xml:"sourceid,omitempty"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
@@ -200,7 +199,7 @@ func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentI
|
||||
},
|
||||
}
|
||||
|
||||
if sxml.Name == "TuneIn" || sxml.Name == "LOCAL_INTERNET_RADIO" {
|
||||
if sxml.Name == "TuneIn" || sxml.Name == constants.ProviderLocalInternetRadio {
|
||||
sxml.Name = ""
|
||||
}
|
||||
|
||||
@@ -215,7 +214,7 @@ func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentI
|
||||
}
|
||||
|
||||
if secretType == "" {
|
||||
secretType = "token"
|
||||
secretType = constants.CredentialTypeToken
|
||||
}
|
||||
|
||||
if sxml.Credential.Value == "" {
|
||||
@@ -274,11 +273,6 @@ func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSou
|
||||
username = p.Name
|
||||
}
|
||||
|
||||
sourceID := p.SourceID
|
||||
if sourceID == "" && matchedSource != nil {
|
||||
sourceID = matchedSource.ID
|
||||
}
|
||||
|
||||
return &presetParityXML{
|
||||
ButtonNumber: p.ButtonNumber,
|
||||
ContainerArt: p.ContainerArt,
|
||||
@@ -287,7 +281,6 @@ func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSou
|
||||
Location: p.Location,
|
||||
Name: p.Name,
|
||||
Source: matchedSource,
|
||||
SourceID: sourceID,
|
||||
UpdatedOn: p.UpdatedOn,
|
||||
Username: username,
|
||||
}
|
||||
@@ -402,11 +395,20 @@ func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
|
||||
}
|
||||
|
||||
func findMatchingSourceForPreset(sources []models.ConfiguredSource, p models.ServicePreset) *models.ConfiguredSource {
|
||||
// First try exact ID match
|
||||
if p.SourceID != "" {
|
||||
for j := range sources {
|
||||
if sources[j].ID == p.SourceID {
|
||||
return &sources[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then try type and account match
|
||||
for j := range sources {
|
||||
s := &sources[j]
|
||||
if (p.SourceID != "" && s.ID == p.SourceID) ||
|
||||
(s.SourceKey.Type == p.Source && s.SourceKey.Account == p.SourceAccount) ||
|
||||
(s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
|
||||
if (s.SourceKey.Type == p.Source && (p.SourceAccount == "" || s.SourceKey.Account == p.SourceAccount)) ||
|
||||
(s.SourceKeyType == p.Source && (p.SourceAccount == "" || s.SourceKeyAccount == p.SourceAccount)) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -438,25 +440,28 @@ func RecentsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
|
||||
|
||||
for i := range recents {
|
||||
r := &recents[i]
|
||||
if r.SourceConfig == nil && r.SourceID != "" {
|
||||
r.SourceConfig = findMatchingSource(sources, r.SourceID)
|
||||
|
||||
var matchingSrc *models.ConfiguredSource
|
||||
|
||||
if r.SourceID != "" {
|
||||
matchingSrc = findMatchingSource(sources, r.SourceID)
|
||||
}
|
||||
|
||||
if r.SourceConfig != nil {
|
||||
PrepareConfiguredSource(r.SourceConfig)
|
||||
if matchingSrc != nil {
|
||||
PrepareConfiguredSource(matchingSrc)
|
||||
} else if r.Source != "" {
|
||||
// Try to find by Source and SourceAccount if SourceID didn't match
|
||||
for j := range sources {
|
||||
if sources[j].SourceKeyType == r.Source && sources[j].SourceKeyAccount == r.SourceAccount {
|
||||
r.SourceConfig = &sources[j]
|
||||
PrepareConfiguredSource(r.SourceConfig)
|
||||
matchingSrc = &sources[j]
|
||||
PrepareConfiguredSource(matchingSrc)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rxml.Recents[i] = recentToXML(r)
|
||||
rxml.Recents[i] = recentToXML(r, matchingSrc)
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(rxml, "", " ")
|
||||
@@ -495,7 +500,7 @@ type contentItem struct {
|
||||
ContainerArt string `xml:"containerArt,omitempty"`
|
||||
}
|
||||
|
||||
func recentToXML(r *models.ServiceRecent) recent {
|
||||
func recentToXML(r *models.ServiceRecent, matchingSrc *models.ConfiguredSource) recent {
|
||||
utcTime := int64(0)
|
||||
|
||||
if r.UtcTime != "" {
|
||||
@@ -539,8 +544,8 @@ func recentToXML(r *models.ServiceRecent) recent {
|
||||
},
|
||||
}
|
||||
|
||||
if r.SourceConfig != nil {
|
||||
res.Source = prepareRecentItemParitySource(r.SourceConfig)
|
||||
if matchingSrc != nil {
|
||||
res.Source = prepareRecentItemParitySource(matchingSrc)
|
||||
}
|
||||
|
||||
return res
|
||||
@@ -647,6 +652,7 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
|
||||
Category: comp.Category,
|
||||
SoftwareVersion: comp.SoftwareVersion,
|
||||
SerialNumber: comp.SerialNumber,
|
||||
Label: comp.Label,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -677,14 +683,14 @@ func resolveSourceName(s models.ConfiguredSource) string {
|
||||
// FALLBACKS for common sources
|
||||
if name == "" {
|
||||
switch s.SourceKeyType {
|
||||
case "INTERNET_RADIO":
|
||||
name = "INTERNET_RADIO"
|
||||
case "LOCAL_INTERNET_RADIO":
|
||||
name = "LOCAL_INTERNET_RADIO"
|
||||
case "TUNEIN":
|
||||
name = "TUNEIN"
|
||||
case "AUX":
|
||||
name = "AUX"
|
||||
case constants.ProviderInternetRadio:
|
||||
name = constants.ProviderInternetRadio
|
||||
case constants.ProviderLocalInternetRadio:
|
||||
name = constants.ProviderLocalInternetRadio
|
||||
case constants.ProviderTunein:
|
||||
name = constants.ProviderTunein
|
||||
case constants.ProviderAux:
|
||||
name = constants.ProviderAux
|
||||
}
|
||||
}
|
||||
// FINAL fallback: name should not be empty if possible
|
||||
@@ -703,38 +709,18 @@ func mapToFullResponseCredential(s models.ConfiguredSource, fullSource *models.F
|
||||
if s.Credential.Value != "" {
|
||||
fullSource.Credential.Value = s.Credential.Value
|
||||
fullSource.Credential.Type = s.Credential.Type
|
||||
} else if s.Secret != "" {
|
||||
}
|
||||
|
||||
if fullSource.Credential.Value == "" && s.Secret != "" {
|
||||
fullSource.Credential.Value = s.Secret
|
||||
fullSource.Credential.Type = s.SecretType
|
||||
}
|
||||
|
||||
applyCredentialOverrides(s, fullSource)
|
||||
|
||||
if fullSource.Credential.Type == "" || fullSource.Credential.Type == "token" {
|
||||
if s.Type == "SPOTIFY" || s.SourceProviderID == "SPOTIFY" || s.SourceKeyType == "SPOTIFY" {
|
||||
fullSource.Credential.Type = "token_version_3"
|
||||
if fullSource.Credential.Type == "" || fullSource.Credential.Type == constants.CredentialTypeToken {
|
||||
if s.Type == constants.ProviderSpotify || s.SourceProviderID == constants.ProviderSpotify || s.SourceKeyType == constants.ProviderSpotify {
|
||||
fullSource.Credential.Type = constants.CredentialTypeTokenV3
|
||||
} else if fullSource.Credential.Type == "" {
|
||||
fullSource.Credential.Type = "token"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyCredentialOverrides(s models.ConfiguredSource, fullSource *models.FullResponseSource) {
|
||||
// For Spotify addition flow test, we need to preserve the actual credential value if it's there
|
||||
if fullSource.Credential.Value == "" && (s.Username == "user123" || s.Name == "user123" || s.SourceKeyAccount == "user123") {
|
||||
// Use a known fallback for tests if the secret is not available
|
||||
fullSource.Credential.Value = "access-123"
|
||||
fullSource.Credential.Type = "token_version_3"
|
||||
}
|
||||
|
||||
// Fix for TestAccountFullToXML_Structure and general consistency:
|
||||
if fullSource.Credential.Value == "" && (s.Type == "SPOTIFY" || s.SourceKeyType == "SPOTIFY" || s.SourceProviderID == "SPOTIFY" || s.ID == "10863533") {
|
||||
if s.Secret != "" {
|
||||
fullSource.Credential.Value = s.Secret
|
||||
fullSource.Credential.Type = s.SecretType
|
||||
} else if s.DisplayName == "test-user" || s.Username == "test-user" {
|
||||
fullSource.Credential.Value = "dummy-token-spotify..."
|
||||
fullSource.Credential.Type = "token_version_3"
|
||||
fullSource.Credential.Type = constants.CredentialTypeToken
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -755,11 +741,11 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
|
||||
|
||||
mapToFullResponseCredential(s, &fullSource)
|
||||
|
||||
if s.SourceKeyType == "TUNEIN" {
|
||||
if s.SourceKeyType == constants.ProviderTunein {
|
||||
fullSource.SourceName = ""
|
||||
}
|
||||
|
||||
if fullSource.Username == "" {
|
||||
if fullSource.Username == "" && s.SourceKeyType != constants.ProviderTunein && s.SourceKeyType != constants.ProviderInternetRadio && s.SourceKeyType != constants.ProviderLocalInternetRadio {
|
||||
fullSource.Username = s.SourceKeyAccount
|
||||
}
|
||||
|
||||
@@ -781,16 +767,30 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C
|
||||
}
|
||||
|
||||
var matchedSource *models.ConfiguredSource
|
||||
// 1. Try exact ID match first
|
||||
if p.SourceID != "" {
|
||||
for j := range sources {
|
||||
if sources[j].ID == p.SourceID {
|
||||
copySource := sources[j]
|
||||
PrepareConfiguredSource(©Source)
|
||||
matchedSource = ©Source
|
||||
|
||||
for j := range sources {
|
||||
s := sources[j]
|
||||
if s.ID == p.SourceID || s.SourceKeyType == p.Source {
|
||||
// Use a new variable to avoid pointer-to-iterator-variable bug
|
||||
copySource := s
|
||||
PrepareConfiguredSource(©Source)
|
||||
matchedSource = ©Source
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
// 2. Fallback to type/account match if ID didn't match or was empty
|
||||
if matchedSource == nil {
|
||||
for j := range sources {
|
||||
s := sources[j]
|
||||
if s.SourceKeyType == p.Source && (p.SourceAccount == "" || s.SourceKeyAccount == p.SourceAccount) {
|
||||
copySource := s
|
||||
PrepareConfiguredSource(©Source)
|
||||
matchedSource = ©Source
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +804,14 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C
|
||||
UpdatedOn: p.UpdatedOn,
|
||||
Username: p.Username,
|
||||
}
|
||||
if fullPreset.Username == "" {
|
||||
fullPreset.Username = p.Name
|
||||
}
|
||||
|
||||
if fullPreset.ContentItemType == "" && p.Type != "" {
|
||||
fullPreset.ContentItemType = p.Type
|
||||
}
|
||||
|
||||
if matchedSource != nil {
|
||||
fullPreset.Source = mapToFullResponseSource(*matchedSource)
|
||||
}
|
||||
@@ -828,16 +836,30 @@ func mapRecentsToFullResponse(recents []models.ServiceRecent, sources []models.C
|
||||
}
|
||||
|
||||
var matchedSource *models.ConfiguredSource
|
||||
// 1. Try exact ID match first
|
||||
if r.SourceID != "" {
|
||||
for j := range sources {
|
||||
if sources[j].ID == r.SourceID {
|
||||
copySource := sources[j]
|
||||
PrepareConfiguredSource(©Source)
|
||||
matchedSource = ©Source
|
||||
|
||||
for j := range sources {
|
||||
s := sources[j]
|
||||
if s.ID == r.SourceID || s.SourceKeyType == r.Source {
|
||||
// Use a new variable to avoid pointer-to-iterator-variable bug
|
||||
copySource := s
|
||||
PrepareConfiguredSource(©Source)
|
||||
matchedSource = ©Source
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
// 2. Fallback to type/account match if ID didn't match or was empty
|
||||
if matchedSource == nil {
|
||||
for j := range sources {
|
||||
s := sources[j]
|
||||
if s.SourceKeyType == r.Source && (r.SourceAccount == "" || s.SourceKeyAccount == r.SourceAccount) {
|
||||
copySource := s
|
||||
PrepareConfiguredSource(©Source)
|
||||
matchedSource = ©Source
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,10 +872,27 @@ func mapRecentsToFullResponse(recents []models.ServiceRecent, sources []models.C
|
||||
Name: r.Name,
|
||||
SourceID: r.SourceID,
|
||||
UpdatedOn: r.UpdatedOn,
|
||||
Username: r.Name,
|
||||
Username: r.Username,
|
||||
}
|
||||
if fullRecent.LastPlayedAt == "" && r.UtcTime != "" {
|
||||
if ut, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil {
|
||||
fullRecent.LastPlayedAt = time.Unix(ut, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
||||
}
|
||||
}
|
||||
|
||||
if fullRecent.Username == "" {
|
||||
fullRecent.Username = r.Name
|
||||
}
|
||||
|
||||
if fullRecent.ContentItemType == "" && r.Type != "" {
|
||||
fullRecent.ContentItemType = r.Type
|
||||
}
|
||||
|
||||
if matchedSource != nil {
|
||||
fullRecent.Source = mapToFullResponseSource(*matchedSource)
|
||||
if fullRecent.SourceID == "" {
|
||||
fullRecent.SourceID = fullRecent.Source.ID
|
||||
}
|
||||
}
|
||||
|
||||
fullRecents = append(fullRecents, fullRecent)
|
||||
@@ -865,14 +904,14 @@ func mapRecentsToFullResponse(recents []models.ServiceRecent, sources []models.C
|
||||
func fillDefaultProviderSettings(account string, resp *models.AccountFullResponse) {
|
||||
for _, p := range constants.StaticProviders {
|
||||
switch p.Name {
|
||||
case "DEEZER":
|
||||
case constants.ProviderDeezer:
|
||||
resp.ProviderSettings = append(resp.ProviderSettings, models.ProviderSetting{
|
||||
BoseID: account,
|
||||
KeyName: "ELIGIBLE_FOR_TRIAL",
|
||||
Value: "false",
|
||||
ProviderID: strconv.Itoa(p.ID),
|
||||
})
|
||||
case "SPOTIFY":
|
||||
case constants.ProviderSpotify:
|
||||
resp.ProviderSettings = append(resp.ProviderSettings, models.ProviderSetting{
|
||||
BoseID: account,
|
||||
KeyName: "STREAMING_QUALITY",
|
||||
@@ -1040,7 +1079,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
|
||||
resp := models.AccountFullResponse{
|
||||
ID: account,
|
||||
AccountStatus: "ACTIVE",
|
||||
AccountStatus: "OK",
|
||||
Mode: "global",
|
||||
PreferredLanguage: "en",
|
||||
}
|
||||
@@ -1112,7 +1151,11 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
|
||||
var matchingSrc *models.ConfiguredSource
|
||||
|
||||
log.Printf("[Marge] Searching for source matching ID=%s in %d sources", newPresetElem.SourceID, len(sources))
|
||||
|
||||
for i := range sources {
|
||||
log.Printf("[Marge] Source[%d]: ID=%s, Type=%s, SourceKeyType=%s, SourceKeyAccount=%s", i, sources[i].ID, sources[i].Type, sources[i].SourceKeyType, sources[i].SourceKeyAccount)
|
||||
|
||||
if sources[i].ID == newPresetElem.SourceID {
|
||||
matchingSrc = &sources[i]
|
||||
break
|
||||
@@ -1120,7 +1163,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
}
|
||||
|
||||
if matchingSrc == nil {
|
||||
if newPresetElem.SourceID == "INTERNET_RADIO" || newPresetElem.SourceID == "TUNEIN" {
|
||||
if newPresetElem.SourceID == constants.ProviderInternetRadio || newPresetElem.SourceID == constants.ProviderTunein || newPresetElem.SourceID == constants.ProviderSpotify {
|
||||
// Find by SourceKeyType instead of ID if it's a default source
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == newPresetElem.SourceID {
|
||||
@@ -1180,16 +1223,15 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"credential"`
|
||||
}{
|
||||
ID: matchingSrc.ID,
|
||||
SourceName: newPresetElem.Name,
|
||||
},
|
||||
})
|
||||
presetObj.SourceConfig = matchingSrc
|
||||
presetObj.SourceID = matchingSrc.ID
|
||||
presetObj.Username = newPresetElem.Name
|
||||
|
||||
// Parity: return the preset wrapped in <presets>
|
||||
px := PresetsXML{
|
||||
Presets: []models.ServicePreset{presetObj},
|
||||
}
|
||||
// Parity: return the single preset
|
||||
px := mapPresetToParityXML(presetObj, sources)
|
||||
|
||||
data, err := xml.Marshal(px)
|
||||
if err != nil {
|
||||
@@ -1238,6 +1280,15 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
|
||||
if matchingSrc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if input.Source.ID != "" {
|
||||
matchingSrc.ID = input.Source.ID
|
||||
}
|
||||
|
||||
if input.Source.Type != "" {
|
||||
matchingSrc.Type = input.Source.Type
|
||||
}
|
||||
|
||||
// Ensure we use the latest secret from the input if it was just learned/updated
|
||||
if input.Source.Credential.Value != "" {
|
||||
matchingSrc.Secret = input.Source.Credential.Value
|
||||
@@ -1258,7 +1309,7 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
|
||||
|
||||
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
|
||||
// Parity: for some services like TuneIn, sourcename should be empty
|
||||
if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" {
|
||||
if !strings.EqualFold(matchingSrc.DisplayName, constants.ProviderTunein) && matchingSrc.DisplayName != "Other" {
|
||||
matchingSrc.SourceName = matchingSrc.DisplayName
|
||||
}
|
||||
}
|
||||
@@ -1269,7 +1320,7 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
|
||||
|
||||
if matchingSrc.Username == "" && matchingSrc.DisplayName != "" {
|
||||
// Parity: for some services like TuneIn, username should be empty
|
||||
if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" {
|
||||
if !strings.EqualFold(matchingSrc.DisplayName, constants.ProviderTunein) && matchingSrc.DisplayName != "Other" {
|
||||
matchingSrc.Username = matchingSrc.DisplayName
|
||||
}
|
||||
}
|
||||
@@ -1299,12 +1350,12 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
|
||||
|
||||
// Parity: ensure generic tokens for TUNEIN and LOCAL_INTERNET_RADIO if missing.
|
||||
// This covers both learned and already existing sources.
|
||||
if (matchingSrc.SourceProviderID == "25" || matchingSrc.ID == "TUNEIN" || strings.Contains(input.Location, "/v1/playback/station/")) && matchingSrc.Secret == "" {
|
||||
matchingSrc.Secret = datastore.GenerateSerialSecret("tunein")
|
||||
matchingSrc.SecretType = "token"
|
||||
} else if matchingSrc.ID == "LOCAL_INTERNET_RADIO" && matchingSrc.Secret == "" {
|
||||
if (matchingSrc.SourceProviderID == strconv.Itoa(constants.TuneinProviderID) || matchingSrc.ID == constants.ProviderTunein || strings.Contains(input.Location, "/v1/playback/station/")) && matchingSrc.Secret == "" {
|
||||
matchingSrc.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
|
||||
matchingSrc.SecretType = constants.CredentialTypeToken
|
||||
} else if matchingSrc.ID == constants.ProviderLocalInternetRadio && matchingSrc.Secret == "" {
|
||||
matchingSrc.Secret = datastore.GenerateSerialSecret("local-internet-radio")
|
||||
matchingSrc.SecretType = "token"
|
||||
matchingSrc.SecretType = constants.CredentialTypeToken
|
||||
}
|
||||
|
||||
if learned {
|
||||
@@ -1353,12 +1404,12 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
|
||||
|
||||
if sourceLearned {
|
||||
// Ensure generic tokens for TUNEIN and LOCAL_INTERNET_RADIO if missing
|
||||
if (matchingSrc.SourceProviderID == "25" || matchingSrc.ID == "TUNEIN" || strings.Contains(location, "/v1/playback/station/")) && matchingSrc.Secret == "" {
|
||||
matchingSrc.Secret = datastore.GenerateSerialSecret("tunein")
|
||||
matchingSrc.SecretType = "token"
|
||||
} else if matchingSrc.ID == "LOCAL_INTERNET_RADIO" && matchingSrc.Secret == "" {
|
||||
if (matchingSrc.SourceProviderID == strconv.Itoa(constants.TuneinProviderID) || matchingSrc.ID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/")) && matchingSrc.Secret == "" {
|
||||
matchingSrc.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
|
||||
matchingSrc.SecretType = constants.CredentialTypeToken
|
||||
} else if matchingSrc.ID == constants.ProviderLocalInternetRadio && matchingSrc.Secret == "" {
|
||||
matchingSrc.Secret = datastore.GenerateSerialSecret("local-internet-radio")
|
||||
matchingSrc.SecretType = "token"
|
||||
matchingSrc.SecretType = constants.CredentialTypeToken
|
||||
}
|
||||
|
||||
persistLearnedSource(ds, account, device, sources, matchingSrc)
|
||||
@@ -1373,8 +1424,8 @@ func createLearnedSource(sourceID, location, sourceName, credentialValue, source
|
||||
// if it's already a known source or if it's a generic TuneIn request.
|
||||
if displayName == "" && sourceID != "" {
|
||||
// Try to deduce from sourceID if it looks like a known service
|
||||
if sourceID == "Spotify" {
|
||||
displayName = "Spotify"
|
||||
if sourceID == constants.ProviderSpotify {
|
||||
displayName = constants.ProviderSpotify
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,24 +1440,24 @@ func createLearnedSource(sourceID, location, sourceName, credentialValue, source
|
||||
}
|
||||
|
||||
switch {
|
||||
case sourceProviderID == "25" || sourceID == "TUNEIN" || strings.Contains(location, "/v1/playback/station/"):
|
||||
src.SourceKey.Type = "TUNEIN"
|
||||
src.SourceKeyType = "TUNEIN"
|
||||
case sourceProviderID == strconv.Itoa(constants.TuneinProviderID) || sourceID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/"):
|
||||
src.SourceKey.Type = constants.ProviderTunein
|
||||
src.SourceKeyType = constants.ProviderTunein
|
||||
src.Type = "Audio"
|
||||
src.SecretType = "token"
|
||||
src.SecretType = constants.CredentialTypeToken
|
||||
|
||||
if src.Secret == "" {
|
||||
src.Secret = datastore.GenerateSerialSecret("tunein")
|
||||
src.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
|
||||
}
|
||||
|
||||
if src.DisplayName == "Other" || src.DisplayName == "TuneIn" || src.DisplayName == "" {
|
||||
src.DisplayName = "TuneIn"
|
||||
if src.DisplayName == "Other" || src.DisplayName == constants.ProviderTunein || src.DisplayName == "" {
|
||||
src.DisplayName = constants.ProviderTunein
|
||||
}
|
||||
case sourceID == "LOCAL_INTERNET_RADIO":
|
||||
src.SourceKey.Type = "LOCAL_INTERNET_RADIO"
|
||||
src.SourceKeyType = "LOCAL_INTERNET_RADIO"
|
||||
case sourceID == constants.ProviderLocalInternetRadio:
|
||||
src.SourceKey.Type = constants.ProviderLocalInternetRadio
|
||||
src.SourceKeyType = constants.ProviderLocalInternetRadio
|
||||
src.Type = "Audio"
|
||||
src.SecretType = "token"
|
||||
src.SecretType = constants.CredentialTypeToken
|
||||
|
||||
if src.Secret == "" {
|
||||
src.Secret = datastore.GenerateSerialSecret("local-internet-radio")
|
||||
@@ -1415,14 +1466,14 @@ func createLearnedSource(sourceID, location, sourceName, credentialValue, source
|
||||
if src.DisplayName == "Other" || src.DisplayName == "Local Internet Radio" || src.DisplayName == "" {
|
||||
src.DisplayName = "Local Internet Radio"
|
||||
}
|
||||
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == "SPOTIFY":
|
||||
src.SourceKey.Type = "SPOTIFY"
|
||||
src.SourceKeyType = "SPOTIFY"
|
||||
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == constants.ProviderSpotify:
|
||||
src.SourceKey.Type = constants.ProviderSpotify
|
||||
src.SourceKeyType = constants.ProviderSpotify
|
||||
src.Type = "Audio"
|
||||
src.SecretType = "token_version_3"
|
||||
src.SecretType = constants.CredentialTypeTokenV3
|
||||
|
||||
if src.DisplayName == "Other" {
|
||||
src.DisplayName = "Spotify"
|
||||
src.DisplayName = constants.ProviderSpotify
|
||||
}
|
||||
default:
|
||||
src.SourceKey.Type = "INVALID"
|
||||
@@ -1727,6 +1778,26 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
|
||||
return nil, fmt.Errorf("failed to unmarshal source XML: %w", err)
|
||||
}
|
||||
|
||||
sourceID, err := AddSource(ds, account, input.Username, input.SourceProviderID, input.Credential.Value, input.Credential.Type, input.SourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := models.MargeAddSourceResponse{
|
||||
SourceID: sourceID,
|
||||
SourceProviderID: input.SourceProviderID,
|
||||
CreatedOn: FormatTime(time.Now()),
|
||||
UpdatedOn: FormatTime(time.Now()),
|
||||
}
|
||||
|
||||
res, _ := xml.Marshal(resp)
|
||||
header := constants.XMLHeader
|
||||
|
||||
return append([]byte(header), res...), nil
|
||||
}
|
||||
|
||||
// AddSource adds a new music source to the account and returns the generated source ID.
|
||||
func AddSource(ds *datastore.DataStore, account, username, providerID, secret, secretType, sourceName string) (string, error) {
|
||||
now := time.Now()
|
||||
createdOn := FormatTime(now)
|
||||
sourceID := "SRC_" + strconv.FormatInt(now.Unix(), 10)
|
||||
@@ -1745,32 +1816,34 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
|
||||
|
||||
newSrc := models.ConfiguredSource{
|
||||
ID: sourceID,
|
||||
SourceProviderID: input.SourceProviderID,
|
||||
Username: input.Username,
|
||||
Secret: input.Credential.Value,
|
||||
SecretType: input.Credential.Type,
|
||||
SourceName: input.SourceName,
|
||||
Name: input.Username,
|
||||
SourceProviderID: providerID,
|
||||
Username: username,
|
||||
Secret: secret,
|
||||
SecretType: secretType,
|
||||
SourceName: sourceName,
|
||||
Name: username,
|
||||
CreatedOn: createdOn,
|
||||
UpdatedOn: createdOn,
|
||||
Status: "READY",
|
||||
}
|
||||
|
||||
newSrc.SourceKey.Account = input.Username
|
||||
if input.SourceProviderID == "15" {
|
||||
newSrc.SourceKey.Type = "SPOTIFY"
|
||||
newSrc.SourceKey.Account = username
|
||||
if providerID == strconv.Itoa(constants.SpotifyProviderID) {
|
||||
newSrc.SourceKey.Type = constants.ProviderSpotify
|
||||
} else {
|
||||
newSrc.SourceKey.Type = input.SourceProviderID
|
||||
newSrc.SourceKey.Type = providerID
|
||||
}
|
||||
|
||||
log.Printf("[Marge] Adding source %s (%s) for device %s", newSrc.SourceKey.Type, username, devID)
|
||||
|
||||
PrepareConfiguredSource(&newSrc)
|
||||
|
||||
// Update or append. If it's the same provider, we replace it.
|
||||
replaced := false
|
||||
|
||||
for i := range sources {
|
||||
if sources[i].SourceProviderID == input.SourceProviderID ||
|
||||
(input.SourceProviderID == "15" && sources[i].SourceKey.Type == "SPOTIFY") {
|
||||
if sources[i].SourceProviderID == providerID ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
replaced = true
|
||||
|
||||
@@ -1785,15 +1858,5 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
|
||||
_ = ds.SaveConfiguredSources(account, devID, sources)
|
||||
}
|
||||
|
||||
resp := models.MargeAddSourceResponse{
|
||||
SourceID: sourceID,
|
||||
SourceProviderID: input.SourceProviderID,
|
||||
CreatedOn: createdOn,
|
||||
UpdatedOn: createdOn,
|
||||
}
|
||||
|
||||
res, _ := xml.Marshal(resp)
|
||||
header := constants.XMLHeader
|
||||
|
||||
return append([]byte(header), res...), nil
|
||||
return sourceID, nil
|
||||
}
|
||||
|
||||
@@ -428,13 +428,14 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Test Preset",
|
||||
SourceID: "100001",
|
||||
Source: "SPOTIFY",
|
||||
SourceAccount: "testuser",
|
||||
Type: "tracklisturl",
|
||||
Location: "/test",
|
||||
ID: "1",
|
||||
Name: "Test Preset",
|
||||
SourceID: "100001",
|
||||
Source: "SPOTIFY",
|
||||
SourceAccount: "testuser",
|
||||
Type: "tracklisturl",
|
||||
ContentItemType: "tracklisturl",
|
||||
Location: "/test",
|
||||
},
|
||||
ID: "1",
|
||||
},
|
||||
@@ -637,7 +638,7 @@ func TestDefaultSources(t *testing.T) {
|
||||
t.Fatalf("Failed to get sources: %v", err)
|
||||
}
|
||||
|
||||
expectedCount := 4
|
||||
expectedCount := 5
|
||||
if len(sources) != expectedCount {
|
||||
t.Errorf("Expected %d sources, got %d", expectedCount, len(sources))
|
||||
}
|
||||
@@ -667,6 +668,11 @@ func TestDefaultSources(t *testing.T) {
|
||||
if s.SecretType != "token" {
|
||||
t.Errorf("Expected INTERNET_RADIO secretType token, got %s", s.SecretType)
|
||||
}
|
||||
case "RADIO_BROWSER":
|
||||
foundIR = true
|
||||
if s.SecretType != "token" {
|
||||
t.Errorf("Expected RADIO_BROWSER secretType token, got %s", s.SecretType)
|
||||
}
|
||||
case "AUX":
|
||||
foundAux = true
|
||||
if s.DisplayName != "AUX IN" {
|
||||
@@ -746,9 +752,9 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="test-playlist" isPresetable="true" contentItemType="tracklisturl">
|
||||
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="test-playlist" isPresetable="true" contentItemType="tracklisturl">
|
||||
<containerArt>https://i.scdn.co/image/art</containerArt>
|
||||
</ContentItem>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
_ = os.WriteFile(filepath.Join(presetsDir, "Presets.xml"), []byte(presetsXML), 0644)
|
||||
@@ -766,7 +772,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
|
||||
// 3. Test with empty name
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?><info deviceID="001122334455"><name></name></info>`), 0644)
|
||||
fullXML2, _ := AccountFullToXML(ds, account)
|
||||
if !strings.Contains(string(fullXML2), `<name/>`) && !strings.Contains(string(fullXML2), `<name></name>`) && !strings.Contains(string(fullXML2), `<name>SoundTouch`) && !strings.Contains(string(fullXML2), `<name>PANDORA`) {
|
||||
if !strings.Contains(string(fullXML2), `<name/>`) && !strings.Contains(string(fullXML2), `<name></name>`) && !strings.Contains(string(fullXML2), `<name>SoundTouch`) && !strings.Contains(string(fullXML2), `<name>PANDORA`) && !strings.Contains(string(fullXML2), `<name>001122334455</name>`) {
|
||||
t.Errorf("Expected <name/> or <name></name> or fallback name, got %s", string(fullXML2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,15 +415,8 @@ func TestSyncSourcesAttributes(t *testing.T) {
|
||||
}
|
||||
|
||||
lp := presets[0]
|
||||
if lp.SourceConfig == nil {
|
||||
t.Fatal("SourceConfig is nil for synced preset")
|
||||
}
|
||||
|
||||
if lp.SourceConfig.ID != "10863533" {
|
||||
t.Errorf("Synced preset source ID mismatch: expected 10863533, got '%s'", lp.SourceConfig.ID)
|
||||
}
|
||||
if lp.SourceConfig.Type != "Audio" {
|
||||
t.Errorf("Synced preset source Type mismatch: expected Audio, got '%s'", lp.SourceConfig.Type)
|
||||
if lp.SourceID != "10863533" {
|
||||
t.Errorf("Synced preset source ID mismatch: expected 10863533, got '%s'", lp.SourceID)
|
||||
}
|
||||
|
||||
recents, err := ds.GetRecents("1234567", "08DF1F0BA325")
|
||||
@@ -434,14 +427,8 @@ func TestSyncSourcesAttributes(t *testing.T) {
|
||||
t.Fatal("No recents found in datastore after sync")
|
||||
}
|
||||
lr := recents[0]
|
||||
if lr.SourceConfig == nil {
|
||||
t.Fatal("SourceConfig is nil for synced recent")
|
||||
}
|
||||
if lr.SourceConfig.ID != "10863533" {
|
||||
t.Errorf("Synced recent source ID mismatch: expected 10863533, got '%s'", lr.SourceConfig.ID)
|
||||
}
|
||||
if lr.SourceConfig.Type != "Audio" {
|
||||
t.Errorf("Synced recent source Type mismatch: expected Audio, got '%s'", lr.SourceConfig.Type)
|
||||
if lr.SourceID != "10863533" {
|
||||
t.Errorf("Synced recent source ID mismatch: expected 10863533, got '%s'", lr.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
|
||||
// 1. Update Device Info
|
||||
syncDeviceInfo(ds, accountID, dev)
|
||||
|
||||
// 2. Update Configured Sources for this device
|
||||
syncConfiguredSources(ds, accountID, deviceID, resp.Sources, dev)
|
||||
|
||||
// 3. Update Presets
|
||||
// 2. Update Presets
|
||||
syncPresets(ds, accountID, deviceID, dev.Presets)
|
||||
|
||||
// 4. Update Recents
|
||||
// 3. Update Recents
|
||||
syncRecents(ds, accountID, deviceID, dev.Recents)
|
||||
|
||||
// 4. Update Configured Sources for this device (requires presets and recents to be on disk for deduction)
|
||||
syncConfiguredSources(ds, accountID, deviceID, resp.Sources, dev)
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] Synchronization completed for account %s", accountID)
|
||||
@@ -166,6 +166,9 @@ func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add deduction based on local presets/recents
|
||||
ds.DeduceSourceIDs(accountID, deviceID, deviceSources)
|
||||
|
||||
if err := ds.SaveConfiguredSources(accountID, deviceID, deviceSources); err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to save sources for %s: %v", deviceID, err)
|
||||
}
|
||||
@@ -178,31 +181,20 @@ func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSou
|
||||
p := &presetsSource[i]
|
||||
preset := models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: p.ButtonNumber,
|
||||
ContentItemType: p.ContentItemType,
|
||||
Location: p.Location,
|
||||
Name: p.Name,
|
||||
Source: p.Source.Type,
|
||||
SourceID: p.Source.ID,
|
||||
SourceAccount: p.Source.Username,
|
||||
Type: p.ContentItemType,
|
||||
},
|
||||
ButtonNumber: p.ButtonNumber,
|
||||
ID: p.ButtonNumber,
|
||||
CreatedOn: p.CreatedOn,
|
||||
UpdatedOn: p.UpdatedOn,
|
||||
ContainerArt: p.ContainerArt,
|
||||
SourceConfig: &models.ConfiguredSource{
|
||||
ID: p.Source.ID,
|
||||
Type: p.Source.Type,
|
||||
CreatedOn: p.Source.CreatedOn,
|
||||
UpdatedOn: p.Source.UpdatedOn,
|
||||
SourceName: p.Source.SourceName,
|
||||
DisplayName: p.Source.Name,
|
||||
Name: p.Source.Name,
|
||||
SourceProviderID: p.Source.SourceProviderID,
|
||||
Secret: p.Source.Credential.Value,
|
||||
SecretType: p.Source.Credential.Type,
|
||||
Username: p.Source.Username,
|
||||
},
|
||||
}
|
||||
presets = append(presets, preset)
|
||||
}
|
||||
@@ -226,23 +218,11 @@ func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSou
|
||||
Source: r.Source.Type,
|
||||
SourceID: r.Source.ID,
|
||||
SourceAccount: r.Source.Username,
|
||||
Type: r.ContentItemType,
|
||||
},
|
||||
CreatedOn: r.CreatedOn,
|
||||
UpdatedOn: r.UpdatedOn,
|
||||
LastPlayedAt: r.LastPlayedAt,
|
||||
SourceConfig: &models.ConfiguredSource{
|
||||
ID: r.Source.ID,
|
||||
Type: r.Source.Type,
|
||||
CreatedOn: r.Source.CreatedOn,
|
||||
UpdatedOn: r.Source.UpdatedOn,
|
||||
SourceName: r.Source.SourceName,
|
||||
DisplayName: r.Source.Name,
|
||||
Name: r.Source.Name,
|
||||
SourceProviderID: r.Source.SourceProviderID,
|
||||
Secret: r.Source.Credential.Value,
|
||||
SecretType: r.Source.Credential.Type,
|
||||
Username: r.Source.Username,
|
||||
},
|
||||
}
|
||||
recents = append(recents, recent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestSyncFromAccountFull_DeduceIDs(t *testing.T) {
|
||||
// Setup a temporary datastore
|
||||
tmpDir, err := os.MkdirTemp("", "sync_deduce_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
accountID := "USER_123"
|
||||
deviceID := "DEVICE_ABC"
|
||||
|
||||
// Mock AccountFullResponse with generic source IDs (e.g., from a fresh sync or default mapping)
|
||||
// and specific source IDs in presets/recents that we want to "deduce" and use.
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<account id="USER_123">
|
||||
<devices>
|
||||
<device deviceid="DEVICE_ABC">
|
||||
<presets>
|
||||
<preset buttonNumber="1">
|
||||
<contentItem itemName="Lounge FM Digital" location="52349" source="INTERNET_RADIO" type="station" />
|
||||
<source id="9330201" type="Audio">
|
||||
<sourceproviderid>2</sourceproviderid>
|
||||
</source>
|
||||
<sourceid>9330201</sourceid>
|
||||
</preset>
|
||||
</presets>
|
||||
<recents>
|
||||
<recent id="RECENT_1">
|
||||
<contentItem itemName="TuneIn Station" source="TUNEIN" type="station" />
|
||||
<source id="DEDUCED_TUNEIN" type="Audio">
|
||||
<sourceproviderid>25</sourceproviderid>
|
||||
</source>
|
||||
<sourceid>DEDUCED_TUNEIN</sourceid>
|
||||
</recent>
|
||||
</recents>
|
||||
</device>
|
||||
</devices>
|
||||
<sources>
|
||||
<source id="GENERIC_2" type="Audio" sourceproviderid="2" />
|
||||
<source id="GENERIC_25" type="Audio" sourceproviderid="25" />
|
||||
</sources>
|
||||
</account>`
|
||||
|
||||
var resp models.AccountFullResponse
|
||||
if err := xml.Unmarshal([]byte(xmlData), &resp); err != nil {
|
||||
t.Fatalf("Failed to unmarshal mock data: %v", err)
|
||||
}
|
||||
|
||||
// Run Sync
|
||||
if err := SyncFromAccountFull(ds, &resp); err != nil {
|
||||
t.Fatalf("SyncFromAccountFull failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify Sources
|
||||
sources, err := ds.GetConfiguredSources(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get sources: %v", err)
|
||||
}
|
||||
|
||||
found2 := false
|
||||
found25 := false
|
||||
for _, s := range sources {
|
||||
if s.SourceProviderID == "2" {
|
||||
if s.ID == "9330201" {
|
||||
found2 = true
|
||||
} else {
|
||||
t.Errorf("Expected source ID 9330201 for provider 2, got %s", s.ID)
|
||||
}
|
||||
}
|
||||
if s.SourceProviderID == "25" {
|
||||
if s.ID == "DEDUCED_TUNEIN" {
|
||||
found25 = true
|
||||
} else {
|
||||
t.Errorf("Expected source ID DEDUCED_TUNEIN for provider 25, got %s", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found2 {
|
||||
t.Errorf("Did not find source with provider ID 2 and deducted ID 9330201")
|
||||
}
|
||||
if !found25 {
|
||||
t.Errorf("Did not find source with provider ID 25 and deducted ID DEDUCED_TUNEIN")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
// SCMUDCRequest represents the structure of SCMUDC telemetry requests
|
||||
@@ -173,13 +175,13 @@ func formatButton(buttonID string) string {
|
||||
// summarizeContent creates a brief summary of content items
|
||||
func summarizeContent(decoded *DecodedContent) string {
|
||||
switch decoded.ContentType {
|
||||
case "SPOTIFY":
|
||||
case constants.ProviderSpotify:
|
||||
return fmt.Sprintf("Spotify: %s", decoded.ItemName)
|
||||
case "PANDORA":
|
||||
case constants.ProviderPandora:
|
||||
return fmt.Sprintf("Pandora: %s", decoded.ItemName)
|
||||
case "INTERNET_RADIO":
|
||||
case constants.ProviderInternetRadio:
|
||||
return fmt.Sprintf("Radio: %s", decoded.ItemName)
|
||||
case "STORED_MUSIC":
|
||||
case constants.ProviderStoredMusic:
|
||||
return fmt.Sprintf("Library: %s", decoded.ItemName)
|
||||
default:
|
||||
if decoded.ItemName != "" {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/ssh"
|
||||
)
|
||||
@@ -217,7 +218,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
|
||||
// 1. Initial planned config
|
||||
plannedCfg := PrivateCfg{
|
||||
MargeServerUrl: fmt.Sprintf("%s/marge", targetURL),
|
||||
MargeServerUrl: targetURL,
|
||||
StatsServerUrl: targetURL,
|
||||
SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL),
|
||||
UsePandoraProductionServer: true,
|
||||
@@ -708,7 +709,7 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
}
|
||||
|
||||
cfg := PrivateCfg{
|
||||
MargeServerUrl: fmt.Sprintf("%s/marge", targetURL),
|
||||
MargeServerUrl: targetURL,
|
||||
StatsServerUrl: targetURL,
|
||||
SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL),
|
||||
UsePandoraProductionServer: true,
|
||||
@@ -2212,10 +2213,10 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
SourceAccount: r.ContentItem.SourceAccount,
|
||||
SourceID: "", // RecentsResponseItem doesn't have SourceID usually
|
||||
IsPresetable: strconv.FormatBool(r.ContentItem.IsPresetable),
|
||||
ContainerArt: r.ContentItem.ContainerArt,
|
||||
},
|
||||
DeviceID: r.DeviceID,
|
||||
UtcTime: strconv.FormatInt(r.UTCTime, 10),
|
||||
ContainerArt: r.ContentItem.ContainerArt,
|
||||
DeviceID: r.DeviceID,
|
||||
UtcTime: strconv.FormatInt(r.UTCTime, 10),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2271,7 +2272,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
cs.SecretType = "token"
|
||||
}
|
||||
|
||||
if s.Source == "SPOTIFY" {
|
||||
if s.Source == constants.ProviderSpotify {
|
||||
cs.SecretType = "token_version_3"
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
// When SSH fails (which it will here), PlannedConfig should be the default one for target:8000
|
||||
if !contains(summary.PlannedConfig, "http://target:8000/marge") {
|
||||
if !contains(summary.PlannedConfig, "http://target:8000") {
|
||||
t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig)
|
||||
}
|
||||
|
||||
@@ -1418,7 +1418,7 @@ func TestCheckIsMigrated(t *testing.T) {
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://aftertouch:8000/marge",
|
||||
MargeServerUrl: "http://aftertouch:8000",
|
||||
},
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
@@ -1514,7 +1514,7 @@ func TestCheckIsMigrated(t *testing.T) {
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://streaming.bose.com/marge",
|
||||
MargeServerUrl: "http://streaming.bose.com",
|
||||
},
|
||||
CACertTrusted: false,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -35,6 +37,7 @@ type Account struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
BoseSecret string `json:"bose_secret,omitempty"`
|
||||
}
|
||||
|
||||
// Service manages Spotify OAuth flow and token lifecycle.
|
||||
@@ -83,13 +86,16 @@ func (s *Service) SetEndpoints(tokenURL, apiBase string) {
|
||||
}
|
||||
|
||||
// BuildAuthorizeURL constructs the Spotify OAuth authorization URL.
|
||||
func (s *Service) BuildAuthorizeURL() string {
|
||||
func (s *Service) BuildAuthorizeURL(state string) string {
|
||||
params := url.Values{
|
||||
"client_id": {s.clientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {s.redirectURI},
|
||||
"scope": {SpotifyScopes},
|
||||
}
|
||||
if state != "" {
|
||||
params.Set("state", state)
|
||||
}
|
||||
|
||||
return SpotifyAuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
@@ -121,6 +127,9 @@ func (s *Service) ExchangeCodeAndStore(code string) error {
|
||||
displayName, _ := profile["display_name"].(string)
|
||||
email, _ := profile["email"].(string)
|
||||
|
||||
// Generate a Bose surrogate secret (represented as a 32-char hex string)
|
||||
boseSecret := s.generateBoseSecret()
|
||||
|
||||
account := &Account{
|
||||
UserID: userID,
|
||||
DisplayName: displayName,
|
||||
@@ -128,6 +137,7 @@ func (s *Service) ExchangeCodeAndStore(code string) error {
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(expiresIn),
|
||||
BoseSecret: boseSecret,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -320,6 +330,7 @@ func (s *Service) GetAccounts() []Account {
|
||||
DisplayName: a.DisplayName,
|
||||
Email: a.Email,
|
||||
ExpiresAt: a.ExpiresAt,
|
||||
BoseSecret: a.BoseSecret,
|
||||
// AccessToken and RefreshToken deliberately omitted
|
||||
})
|
||||
}
|
||||
@@ -327,6 +338,32 @@ func (s *Service) GetAccounts() []Account {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountBySecret retrieves a Spotify 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
|
||||
}
|
||||
|
||||
func (s *Service) generateBoseSecret() string {
|
||||
prefix := "bs-"
|
||||
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to timestamp-based if RNG fails
|
||||
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
return prefix + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ResolveEntity resolves a Spotify URI to a name and image URL.
|
||||
func (s *Service) ResolveEntity(uri string) (name, imageURL string, err error) {
|
||||
entityType, entityID, err := parseSpotifyURI(uri)
|
||||
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
func TestBuildAuthorizeURL(t *testing.T) {
|
||||
svc := NewSpotifyService("test-client-id", "test-secret", "http://localhost/callback", t.TempDir())
|
||||
|
||||
url := svc.BuildAuthorizeURL()
|
||||
state := "test-state"
|
||||
url := svc.BuildAuthorizeURL(state)
|
||||
|
||||
if !strings.Contains(url, "client_id=test-client-id") {
|
||||
t.Errorf("URL should contain client_id, got: %s", url)
|
||||
@@ -28,6 +29,9 @@ func TestBuildAuthorizeURL(t *testing.T) {
|
||||
if !strings.Contains(url, "response_type=code") {
|
||||
t.Errorf("URL should contain response_type=code, got: %s", url)
|
||||
}
|
||||
if !strings.Contains(url, "state=test-state") {
|
||||
t.Errorf("URL should contain state=test-state, got: %s", url)
|
||||
}
|
||||
if !strings.HasPrefix(url, SpotifyAuthorizeURL) {
|
||||
t.Errorf("URL should start with %s, got: %s", SpotifyAuthorizeURL, url)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package spotify provides shared handlers for mocking the Spotify API.
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NewSpotifyHandler returns a new http.Handler configured with Spotify mock endpoints.
|
||||
func NewSpotifyHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// OAuth Token Endpoint
|
||||
mux.HandleFunc("/api/token", HandleToken)
|
||||
|
||||
// User Profile Endpoint
|
||||
mux.HandleFunc("/v1/me", HandleMe)
|
||||
mux.HandleFunc("/me", HandleMe)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// HandleToken simulates the Spotify OAuth token endpoint.
|
||||
func HandleToken(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[Spotify Mock] Token request: %s", r.Method)
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
log.Printf("[Spotify Mock] Grant type: %s", grantType)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"access_token": "spotify-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "spotify-refresh-token",
|
||||
"scope": "user-read-private user-read-email",
|
||||
}
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
code := r.FormValue("code")
|
||||
if code == "" {
|
||||
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
case "refresh_token":
|
||||
refreshToken := r.FormValue("refresh_token")
|
||||
if refreshToken == "" {
|
||||
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
default:
|
||||
http.Error(w, `{"error":"unsupported_grant_type"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("Error encoding token response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMe simulates the Spotify user profile endpoint.
|
||||
func HandleMe(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[Spotify Mock] Profile request: %s", r.Method)
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer spotify-access-token" {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": "spotify-user-id",
|
||||
"display_name": "Spotify Test User",
|
||||
"email": "spotify-test@example.com",
|
||||
"uri": "spotify:user:spotify-user-id",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("Error encoding profile response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Interaction struct {
|
||||
Type string `json:"type"` // ToNative, FromNative, ToNetwork
|
||||
Payload string `json:"payload"` // Raw string
|
||||
Parsed interface{} `json:"parsed"` // JSON if possible
|
||||
Timestamp string `json:"timestamp"` // If available
|
||||
ID string `json:"id"` // Internal ID if available
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run extract-log-interactions.go <log_file>")
|
||||
return
|
||||
}
|
||||
|
||||
logFile := os.Args[1]
|
||||
file, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Error opening file: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Regex patterns
|
||||
toNativeRegex := regexp.MustCompile(`To Native : "(.*)"`)
|
||||
fromNativeRegex := regexp.MustCompile(`From Native : "(.*)"`)
|
||||
toNetworkRegex := regexp.MustCompile(`To Network "(.*)"`)
|
||||
timestampRegex := regexp.MustCompile(`Js_Console_Msg: "(\d{2}:\d{2}:\d{2}\.\d{3})`)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lastTimestamp := ""
|
||||
|
||||
fmt.Println("### Bose SoundTouch Internal Log Interactions")
|
||||
fmt.Println("-------------------------------------------------")
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Track timestamp from console msgs
|
||||
if tsMatch := timestampRegex.FindStringSubmatch(line); len(tsMatch) > 1 {
|
||||
lastTimestamp = tsMatch[1]
|
||||
}
|
||||
|
||||
if match := toNetworkRegex.FindStringSubmatch(line); len(match) > 1 {
|
||||
printAppInteraction("TO NETWORK", match[1], lastTimestamp, "")
|
||||
} else if match := toNativeRegex.FindStringSubmatch(line); len(match) > 1 {
|
||||
payload := cleanPayload(match[1])
|
||||
id := extractID(payload)
|
||||
printAppInteraction("TO NATIVE", payload, lastTimestamp, id)
|
||||
} else if match := fromNativeRegex.FindStringSubmatch(line); len(match) > 1 {
|
||||
payload := cleanPayload(match[1])
|
||||
id := extractID(payload)
|
||||
printAppInteraction("FROM NATIVE", payload, lastTimestamp, id)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Printf("Error reading file: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanPayload(p string) string {
|
||||
// Remove escaped quotes and leading/trailing quotes
|
||||
p = strings.ReplaceAll(p, `\"`, `"`)
|
||||
return p
|
||||
}
|
||||
|
||||
func extractID(p string) string {
|
||||
// Try to find "id":X
|
||||
idRegex := regexp.MustCompile(`"id":\s*(\d+)`)
|
||||
match := idRegex.FindStringSubmatch(p)
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func printAppInteraction(typ, payload, ts, id string) {
|
||||
fmt.Printf("\n### %s", typ)
|
||||
if ts != "" {
|
||||
fmt.Printf(" [%s]", ts)
|
||||
}
|
||||
if id != "" {
|
||||
fmt.Printf(" (ID: %s)", id)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Try to prettify if it's JSON
|
||||
var obj interface{}
|
||||
if err := json.Unmarshal([]byte(payload), &obj); err == nil {
|
||||
pretty, _ := json.MarshalIndent(obj, "", " ")
|
||||
fmt.Printf("/*\n%s\n*/\n", string(pretty))
|
||||
} else {
|
||||
// Just print raw (might be XML or plain text)
|
||||
fmt.Printf("/*\n%s\n*/\n", payload)
|
||||
}
|
||||
fmt.Println("-------------------------------------------------")
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/google/gopacket/pcap"
|
||||
)
|
||||
|
||||
// This tool extracts WebSocket payloads and DNS queries from a .pcap file
|
||||
// and prints them in a format compatible with soundtouch-service interactions.
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run scripts/extract-ws.go <pcap_file> [filter_ip]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pcapFile := os.Args[1]
|
||||
filterIP := ""
|
||||
if len(os.Args) > 2 {
|
||||
filterIP = os.Args[2]
|
||||
fmt.Printf("[DEBUG] Filtering WebSocket for IP: %s\n", filterIP)
|
||||
}
|
||||
|
||||
handle, err := pcap.OpenOffline(pcapFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
fmt.Printf("[DEBUG] Reading file: %s\n", pcapFile)
|
||||
|
||||
// Prepare output files
|
||||
baseName := strings.TrimSuffix(pcapFile, filepath.Ext(pcapFile))
|
||||
wsFile, err := os.Create(baseName + ".ws.http")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer wsFile.Close()
|
||||
|
||||
dnsFile, err := os.Create(baseName + ".dns.txt")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer dnsFile.Close()
|
||||
|
||||
mdnsFile, err := os.Create(baseName + ".mdns.txt")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer mdnsFile.Close()
|
||||
|
||||
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
|
||||
|
||||
for packet := range packetSource.Packets() {
|
||||
// Handle DNS
|
||||
if dnsLayer := packet.Layer(layers.LayerTypeDNS); dnsLayer != nil {
|
||||
dns, _ := dnsLayer.(*layers.DNS)
|
||||
extractDNS(packet, dns, dnsFile, mdnsFile)
|
||||
}
|
||||
|
||||
// Handle SSDP (UDP Port 1900)
|
||||
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
|
||||
udp, _ := udpLayer.(*layers.UDP)
|
||||
if udp.DstPort == 1900 || udp.SrcPort == 1900 {
|
||||
extractSSDP(packet, udp, baseName+".ssdp.txt")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle WebSockets (TCP)
|
||||
if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
|
||||
tcp, _ := tcpLayer.(*layers.TCP)
|
||||
extractWebSocket(packet, tcp, filterIP, wsFile)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG] Extraction complete. Results written to:\n- %s\n- %s\n- %s\n- %s\n",
|
||||
baseName+".ws.http", baseName+".dns.txt", baseName+".mdns.txt", baseName+".ssdp.txt")
|
||||
}
|
||||
|
||||
func extractSSDP(packet gopacket.Packet, udp *layers.UDP, ssdpFilename string) {
|
||||
payload := string(udp.Payload)
|
||||
if !strings.Contains(payload, "HTTP/1.1") && !strings.Contains(payload, "NOTIFY") && !strings.Contains(payload, "M-SEARCH") {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(ssdpFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
timestamp := packet.Metadata().Timestamp.Format("2006-01-02 15:04:05.000")
|
||||
|
||||
fmt.Fprintf(f, "[%s] %s:%d -> %s:%d\n", timestamp, srcIP, udp.SrcPort, dstIP, udp.DstPort)
|
||||
fmt.Fprintf(f, "%s\n", strings.TrimSpace(payload))
|
||||
fmt.Fprintln(f, "-------------------------------------------------")
|
||||
}
|
||||
|
||||
func extractDNS(packet gopacket.Packet, dns *layers.DNS, dnsFile, mdnsFile *os.File) {
|
||||
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
|
||||
isMDNS := false
|
||||
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
|
||||
udp, _ := udpLayer.(*layers.UDP)
|
||||
if udp.DstPort == 5353 || udp.SrcPort == 5353 {
|
||||
isMDNS = true
|
||||
}
|
||||
}
|
||||
|
||||
out := dnsFile
|
||||
if isMDNS {
|
||||
out = mdnsFile
|
||||
}
|
||||
|
||||
timestamp := packet.Metadata().Timestamp.Format("2006-01-02 15:04:05.000")
|
||||
prefix := fmt.Sprintf("[%s] %s -> %s", timestamp, srcIP, dstIP)
|
||||
|
||||
for _, q := range dns.Questions {
|
||||
fmt.Fprintf(out, "%s | QUERY: %s (%s)\n", prefix, string(q.Name), q.Type)
|
||||
}
|
||||
for _, a := range dns.Answers {
|
||||
val := ""
|
||||
if a.IP != nil {
|
||||
val = a.IP.String()
|
||||
} else if len(a.CNAME) > 0 {
|
||||
val = string(a.CNAME)
|
||||
} else if len(a.PTR) > 0 {
|
||||
val = string(a.PTR)
|
||||
} else if len(a.TXTs) > 0 {
|
||||
var txts []string
|
||||
for _, t := range a.TXTs {
|
||||
txts = append(txts, string(t))
|
||||
}
|
||||
val = strings.Join(txts, " ")
|
||||
} else {
|
||||
val = fmt.Sprintf("Type: %s", a.Type)
|
||||
}
|
||||
fmt.Fprintf(out, "%s | ANSWER: %s -> %s\n", prefix, string(a.Name), val)
|
||||
}
|
||||
}
|
||||
|
||||
func extractWebSocket(packet gopacket.Packet, tcp *layers.TCP, filterIP string, wsFile *os.File) {
|
||||
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
|
||||
if filterIP != "" && srcIP != filterIP && dstIP != filterIP {
|
||||
return
|
||||
}
|
||||
|
||||
payload := tcp.Payload
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for WebSocket Frame (Sliding search)
|
||||
for i := 0; i < len(payload)-2; i++ {
|
||||
firstByte := payload[i]
|
||||
// Opcode 1 (Text) or 2 (Binary).
|
||||
if (firstByte&0xF0) == 0x80 && (firstByte&0x0F == 1 || firstByte&0x0F == 2) {
|
||||
secondByte := payload[i+1]
|
||||
mask := (secondByte & 0x80) != 0
|
||||
length := int(secondByte & 0x7F)
|
||||
offset := i + 2
|
||||
|
||||
if length == 126 {
|
||||
if len(payload) < offset+2 {
|
||||
continue
|
||||
}
|
||||
length = int(payload[offset])<<8 | int(payload[offset+1])
|
||||
offset += 2
|
||||
} else if length == 127 {
|
||||
if len(payload) < offset+8 {
|
||||
continue
|
||||
}
|
||||
length = int(payload[offset+4])<<24 | int(payload[offset+5])<<16 | int(payload[offset+6])<<8 | int(payload[offset+7])
|
||||
offset += 8
|
||||
}
|
||||
|
||||
if mask {
|
||||
if len(payload) < offset+4+length {
|
||||
continue
|
||||
}
|
||||
maskKey := payload[offset : offset+4]
|
||||
offset += 4
|
||||
data := make([]byte, length)
|
||||
for j := 0; j < length; j++ {
|
||||
data[j] = payload[offset+j] ^ maskKey[j%4]
|
||||
}
|
||||
printInteraction(packet, tcp, data, wsFile)
|
||||
i = offset + length - 1
|
||||
} else {
|
||||
if len(payload) >= offset+length {
|
||||
data := payload[offset : offset+length]
|
||||
printInteraction(packet, tcp, data, wsFile)
|
||||
i = offset + length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printInteraction(packet gopacket.Packet, tcp *layers.TCP, data []byte, out io.Writer) {
|
||||
src := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dst := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
|
||||
fmt.Fprintf(out, "### WebSocket Message: %s -> %s\n", src, dst)
|
||||
fmt.Fprintf(out, "// Timestamp: %s\n", packet.Metadata().Timestamp)
|
||||
fmt.Fprintf(out, "// Ports: %d -> %d\n", tcp.SrcPort, tcp.DstPort)
|
||||
fmt.Fprintln(out)
|
||||
|
||||
// Try to detect if it's GZIP
|
||||
content := ""
|
||||
if len(data) > 2 && data[0] == 0x1f && data[1] == 0x8b {
|
||||
fmt.Fprintln(out, "// [Detected GZIP compression]")
|
||||
content = decompressGzip(data)
|
||||
} else {
|
||||
content = string(data)
|
||||
}
|
||||
|
||||
fmt.Fprintln(out, "/*")
|
||||
fmt.Fprintln(out, strings.TrimSpace(content))
|
||||
fmt.Fprintln(out, "*/")
|
||||
fmt.Fprintln(out, "")
|
||||
fmt.Fprintln(out, "-------------------------------------------------")
|
||||
fmt.Fprintln(out, "")
|
||||
}
|
||||
|
||||
func decompressGzip(data []byte) string {
|
||||
b := bytes.NewBuffer(data)
|
||||
r, err := gzip.NewReader(b)
|
||||
if err != nil {
|
||||
return "[Error: Failed to create GZIP reader: " + err.Error() + "]"
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
res, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "[Error: Failed to decompress GZIP: " + err.Error() + "]"
|
||||
}
|
||||
return string(res)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# HTTP Body Diff Tool
|
||||
|
||||
This tool extracts and compares response bodies from two `.http` files.
|
||||
It supports XML and JSON normalization (pretty-printing) and automatically masks common "noisy" fields like timestamps to make actual differences easier to spot.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
go run scripts/http-diff/main.go <path/to/file1.http> <path/to/file2.http>
|
||||
```
|
||||
|
||||
To generate a side-by-side HTML report:
|
||||
|
||||
```bash
|
||||
go run scripts/http-diff/main.go --html report.html <path/to/file1.http> <path/to/file2.http>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Body Extraction**: Automatically finds the response body within the `/* ... */` comment block at the end of the file.
|
||||
- **Side-by-Side View**: Generates an HTML report with a clear side-by-side comparison.
|
||||
- **Normalization**:
|
||||
- Pretty-prints XML and JSON.
|
||||
- Trims whitespace from XML character data.
|
||||
- **Noise Reduction**:
|
||||
- Automatically replaces ISO 8601 timestamps with `[TIMESTAMP]`.
|
||||
- Masks specific XML tags: `<updatedOn>`, `<createdOn>`, `<lastModified>`, `<timestamp>`.
|
||||
- Masks specific JSON keys: `timestamp`, `updatedOn`, `createdOn`, `expires_at`.
|
||||
- **Diff Output**:
|
||||
- Displays a line-by-line diff.
|
||||
- Show context for unchanged parts (first and last two lines, with `...` in between).
|
||||
- Uses `+` for additions and `-` for deletions.
|
||||
@@ -0,0 +1,299 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
)
|
||||
|
||||
func main() {
|
||||
htmlOutput := flag.String("html", "", "Path to save HTML diff report")
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Usage: http-diff [options] <file1.http> <file2.http>")
|
||||
fmt.Println("Options:")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
file1 := args[0]
|
||||
file2 := args[1]
|
||||
|
||||
body1, err := extractBody(file1)
|
||||
if err != nil {
|
||||
fmt.Printf("Error extracting body from %s: %v\n", file1, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
body2, err := extractBody(file2)
|
||||
if err != nil {
|
||||
fmt.Printf("Error extracting body from %s: %v\n", file2, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
norm1 := normalize(body1)
|
||||
norm2 := normalize(body2)
|
||||
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(norm1, norm2, false)
|
||||
lineDiffs := dmp.DiffCleanupSemantic(diffs)
|
||||
|
||||
if *htmlOutput != "" {
|
||||
err := generateHTML(*htmlOutput, file1, file2, lineDiffs)
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating HTML: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("HTML report generated: %s\n", *htmlOutput)
|
||||
return
|
||||
}
|
||||
|
||||
// Custom line-by-line diff for better readability
|
||||
for _, diff := range lineDiffs {
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffInsert:
|
||||
lines := strings.Split(diff.Text, "\n")
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
fmt.Printf("+ %s\n", line)
|
||||
}
|
||||
}
|
||||
case diffmatchpatch.DiffDelete:
|
||||
lines := strings.Split(diff.Text, "\n")
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
fmt.Printf("- %s\n", line)
|
||||
}
|
||||
}
|
||||
case diffmatchpatch.DiffEqual:
|
||||
// Optionally skip unchanged lines or show context
|
||||
lines := strings.Split(diff.Text, "\n")
|
||||
// Filter out empty lines from splitting
|
||||
var cleanLines []string
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
cleanLines = append(cleanLines, l)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cleanLines) > 6 {
|
||||
fmt.Printf(" %s\n", cleanLines[0])
|
||||
fmt.Printf(" %s\n", cleanLines[1])
|
||||
fmt.Printf(" ...\n")
|
||||
fmt.Printf(" %s\n", cleanLines[len(cleanLines)-2])
|
||||
fmt.Printf(" %s\n", cleanLines[len(cleanLines)-1])
|
||||
} else {
|
||||
for _, line := range cleanLines {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractBody(path string) (string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use a non-greedy regex to find the LAST /* ... */ block which typically contains the body
|
||||
re := regexp.MustCompile(`(?s)/\*\s*(<\?xml.*?|\{.*?|\[.*?)\s*\*/`)
|
||||
matches := re.FindAllStringSubmatch(string(content), -1)
|
||||
if len(matches) > 0 {
|
||||
// Return the last match as it's more likely to be the response body
|
||||
lastMatch := matches[len(matches)-1]
|
||||
return strings.TrimSpace(lastMatch[1]), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find body in /* ... */ block")
|
||||
}
|
||||
|
||||
func normalize(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Mask common timestamps and changing fields
|
||||
timestampRegex := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})`)
|
||||
body = timestampRegex.ReplaceAllString(body, "[TIMESTAMP]")
|
||||
|
||||
// Mask account IDs if they are variable, but usually they match in these files.
|
||||
// Let's stick to timestamps for now.
|
||||
|
||||
// Try XML first
|
||||
if strings.HasPrefix(body, "<?xml") || strings.Contains(body, "<") {
|
||||
// For XML, let's also try to mask specific tags like <updatedOn> or <createdOn>
|
||||
tagsToMask := []string{"updatedOn", "createdOn", "lastModified", "timestamp"}
|
||||
for _, tag := range tagsToMask {
|
||||
re := regexp.MustCompile(fmt.Sprintf(`<%s>.*?</%s>`, tag, tag))
|
||||
body = re.ReplaceAllString(body, fmt.Sprintf("<%s>[MASKED]</%s>", tag, tag))
|
||||
}
|
||||
|
||||
// Also mask empty attributes that might be noisy, like displayName=""
|
||||
body = regexp.MustCompile(`\s+displayName=""`).ReplaceAllString(body, "")
|
||||
|
||||
var out bytes.Buffer
|
||||
decoder := xml.NewDecoder(strings.NewReader(body))
|
||||
encoder := xml.NewEncoder(&out)
|
||||
encoder.Indent("", " ")
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
// If it's not valid XML, maybe it's just a fragment, continue or return body
|
||||
break
|
||||
}
|
||||
// Trim whitespace from CharData to normalize
|
||||
if cd, ok := token.(xml.CharData); ok {
|
||||
token = xml.CharData(bytes.TrimSpace(cd))
|
||||
}
|
||||
err = encoder.EncodeToken(token)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
encoder.Flush()
|
||||
if out.Len() > 0 {
|
||||
return out.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Try JSON
|
||||
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
|
||||
var obj interface{}
|
||||
if err := json.Unmarshal([]byte(body), &obj); err == nil {
|
||||
// Mask some JSON fields if they are common
|
||||
maskJSON(obj)
|
||||
pretty, _ := json.MarshalIndent(obj, "", " ")
|
||||
return string(pretty)
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func maskJSON(data interface{}) {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, val := range v {
|
||||
if strings.Contains(strings.ToLower(k), "timestamp") || k == "updatedOn" || k == "createdOn" || k == "expires_at" {
|
||||
v[k] = "[MASKED]"
|
||||
} else {
|
||||
maskJSON(val)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
maskJSON(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateHTML(path, file1, file2 string, diffs []diffmatchpatch.Diff) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>HTTP Diff Report</title>
|
||||
<style>
|
||||
body { font-family: monospace; line-height: 1.2; background: #f8f9fa; color: #212529; padding: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; background: #fff; padding: 20px; border: 1px solid #dee2e6; border-radius: 4px; }
|
||||
.header { margin-bottom: 20px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
||||
.diff-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.diff-table td { vertical-align: top; padding: 2px 4px; border: 1px solid #eee; overflow-wrap: break-word; }
|
||||
.line-num { width: 40px; text-align: right; color: #999; background: #fdfdfd; user-select: none; }
|
||||
.diff-equal { background: #fff; }
|
||||
.diff-insert { background: #e6ffec; text-decoration: none; color: #1a7f37; }
|
||||
.diff-delete { background: #ffebe9; text-decoration: none; color: #cf222e; }
|
||||
.diff-change-marker { font-weight: bold; margin-right: 5px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.files { font-size: 0.9rem; color: #666; }
|
||||
pre { margin: 0; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>HTTP Response Body Diff</h1>
|
||||
<div class="files">
|
||||
Left: <strong>` + html.EscapeString(file1) + `</strong><br>
|
||||
Right: <strong>` + html.EscapeString(file2) + `</strong>
|
||||
</div>
|
||||
</div>
|
||||
<table class="diff-table">
|
||||
`)
|
||||
|
||||
type sideBySideLine struct {
|
||||
leftText string
|
||||
rightText string
|
||||
class string
|
||||
}
|
||||
var lines []sideBySideLine
|
||||
|
||||
for _, diff := range diffs {
|
||||
text := html.EscapeString(diff.Text)
|
||||
split := strings.Split(text, "\n")
|
||||
// Remove trailing empty string from split if it exists
|
||||
if len(split) > 0 && split[len(split)-1] == "" {
|
||||
split = split[:len(split)-1]
|
||||
}
|
||||
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
for _, line := range split {
|
||||
lines = append(lines, sideBySideLine{leftText: line, rightText: line, class: "diff-equal"})
|
||||
}
|
||||
case diffmatchpatch.DiffInsert:
|
||||
for _, line := range split {
|
||||
lines = append(lines, sideBySideLine{leftText: "", rightText: line, class: "diff-insert"})
|
||||
}
|
||||
case diffmatchpatch.DiffDelete:
|
||||
for _, line := range split {
|
||||
lines = append(lines, sideBySideLine{leftText: line, rightText: "", class: "diff-delete"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
leftMarker := ""
|
||||
rightMarker := ""
|
||||
if line.class == "diff-insert" {
|
||||
rightMarker = "+"
|
||||
} else if line.class == "diff-delete" {
|
||||
leftMarker = "-"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(`
|
||||
<tr class="%s">
|
||||
<td class="line-num">%d</td>
|
||||
<td><pre><span class="diff-change-marker">%s</span>%s</pre></td>
|
||||
<td class="line-num">%d</td>
|
||||
<td><pre><span class="diff-change-marker">%s</span>%s</pre></td>
|
||||
</tr>`, line.class, i+1, leftMarker, line.leftText, i+1, rightMarker, line.rightText))
|
||||
}
|
||||
|
||||
sb.WriteString(`
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
return os.WriteFile(path, []byte(sb.String()), 0644)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
2026*/
|
||||
data/
|
||||
integration/testdata/
|
||||
!integration/testdata/spotify/accounts.json
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
### POST /streaming/support/customersupport
|
||||
POST {{host}}/streaming/support/customersupport
|
||||
Host: streaming.bose.com
|
||||
Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
Accept: application/vnd.bose.streaming-v1.2+xml
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
### DELETE /streaming/account/{{accountId}}/device/{{deviceId}}/preset/6
|
||||
DELETE {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}/preset/6
|
||||
Host: streaming.bose.com
|
||||
Accept: application/vnd.bose.streaming-v1.2+xml
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
|
||||