From 79ca666785142118f694333bd7b2b725e3072d1b Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 7 Feb 2026 19:23:22 +0100 Subject: [PATCH] Merge Bose-SoundTouch-API (soundcork-go) into Bose-SoundTouch. Integrated service logic, created soundtouch-service command, embedded resources, updated docs, examples and CI/CD. Commit history from `7204e619decc48df5dee91d18470934b50e389ac` to `f9b5ad3129831086b02bdf20a197ff4e2d098e2d`: https://github.com/gesellix/Bose-SoundTouch-API/compare/7204e619decc48df5dee91d18470934b50e389ac...f9b5ad3129831086b02bdf20a197ff4e2d098e2d * f9b5ad3 - Tobias Gesellchen, 2026-02-07 : Rename module to gesellix/bose-soundtouch-api and update related files * 5b3dbbb - Tobias Gesellchen, 2026-02-07 : docs: translate PLAN.md to English and fix preferredLanguage typo in marge.go * 696b9c9 - Tobias Gesellchen, 2026-02-07 : feat(discovery): fetch serial number from speaker info if missing in discovery and update datastore tests * 8ed78f0 - Tobias Gesellchen, 2026-02-07 : Consolidate proxy and main service on port 8000 and update related tests and UI * 0e3abbb - Tobias Gesellchen, 2026-02-07 : feat(go): lowercase guessed hostnames for URL consistency * ca1091f - Tobias Gesellchen, 2026-02-07 : feat(health): add health endpoint with VCS build information * a432d53 - Tobias Gesellchen, 2026-02-07 : Rename mock token to soundcork-local-token and add documentation * 3b5ee2f - Tobias Gesellchen, 2026-02-07 : Implement Phase 10: Stats API, Device Event Log, and advanced Marge functions * bc96033 - Tobias Gesellchen, 2026-02-06 : chore * c77864b - Tobias Gesellchen, 2026-02-06 : Document Golang header normalization behavior and ensure generic header casing preservation in proxy * a54e7e7 - Tobias Gesellchen, 2026-02-06 : Ensure ETag header preserves casing (uppercase 'T') for case-sensitive devices * 6265fbe - Tobias Gesellchen, 2026-02-06 : update dockerfile to be in sync with go.mod * 5290bad - Tobias Gesellchen, 2026-02-06 : Implement proxy logging settings UI and complete Phase 8 quick wins (ETags, DataStore initialization) * d7aa7f7 - Tobias Gesellchen, 2026-02-06 : Update PLAN.md with recent features and Phase 8 Upstream Parity tasks * c8ae5e2 - Tobias Gesellchen, 2026-02-06 : Enhance Bose SoundTouch migration with proxying, remote services persistence, and improved diagnostics * c53fa00 - Tobias Gesellchen, 2026-02-06 : Implement remote services persistence check and UI improvements for Bose SoundTouch migration * ea5c348 - Tobias Gesellchen, 2026-02-02 : Ignore soundcork-go/data directory and include recent datastore fixes * d162892 - Tobias Gesellchen, 2026-02-02 : Complete Phase 7: Automated Setup & UI refactoring. Implemented programmatic SSH/migration logic, added device discovery endpoints, created Web UI for speaker management, and refactored UI to use external HTML with Go embed. * b528016 - Tobias Gesellchen, 2026-02-01 : Add GitHub workflow to publish Docker image to GHCR and update Dockerfile * 2ee03da - Tobias Gesellchen, 2026-02-01 : Add GitHub Actions workflow for Go CI and update PLAN.md * 439e2a9 - Tobias Gesellchen, 2026-02-01 : Refactor Go implementation: extract handlers and tests into dedicated files, add comprehensive unit and HTTP tests * c0698fb - Tobias Gesellchen, 2026-02-01 : Add Docker telnet example and update IP consistency in documentation * 028a02e - Tobias Gesellchen, 2026-02-01 : Fix older port number in README * 264829d - Tobias Gesellchen, 2026-02-01 : Add setup-speaker.sh and update documentation to match issue #59 * 64306f9 - Tobias Gesellchen, 2026-02-01 : Implement device presets endpoint in Go * b98a602 - Tobias Gesellchen, 2026-02-01 : Implement Phase 4: Datastore and Marge logic in Go * b6e1bc9 - Tobias Gesellchen, 2026-02-01 : Implement Phase 3: BMX Streaming and Service Registry in Go * f1b3dcf - Tobias Gesellchen, 2026-02-01 : Port core models and constants to Go * 9eae655 - Tobias Gesellchen, 2026-02-01 : Implement static file serving for /media in Go * cc73e50 - Tobias Gesellchen, 2026-02-01 : Fix Go service accessibility and improve Docker configuration * e356bdd - Tobias Gesellchen, 2026-02-01 : Initialize Go migration: Phase 1 infrastructure, proxy-first routing, and root endpoint --- .github/workflows/release.yml | 144 ++-- .gitignore | 1 + README.md | 24 +- cmd/soundtouch-service/main.go | 174 +++++ data/.gitignore | 1 + docs/MERGE_PROJECTS.md | 75 ++ docs/SOUNDTOUCH-SERVICE.md | 64 ++ examples/service-demo/main.go | 55 ++ go.mod | 2 + go.sum | 6 + pkg/models/models.go | 207 ++++++ pkg/service/bmx/bmx.go | 303 ++++++++ pkg/service/bmx/bmx_test.go | 65 ++ pkg/service/constants/constants.go | 59 ++ pkg/service/constants/constants_test.go | 17 + pkg/service/datastore/datastore.go | 699 ++++++++++++++++++ pkg/service/datastore/datastore_test.go | 335 +++++++++ pkg/service/handlers/handlers_bmx.go | 70 ++ pkg/service/handlers/handlers_bmx_test.go | 71 ++ pkg/service/handlers/handlers_etag_test.go | 263 +++++++ pkg/service/handlers/handlers_events.go | 25 + pkg/service/handlers/handlers_events_test.go | 60 ++ pkg/service/handlers/handlers_health.go | 50 ++ pkg/service/handlers/handlers_health_test.go | 55 ++ pkg/service/handlers/handlers_marge.go | 210 ++++++ pkg/service/handlers/handlers_marge_test.go | 415 +++++++++++ pkg/service/handlers/handlers_media.go | 41 + pkg/service/handlers/handlers_media_test.go | 92 +++ pkg/service/handlers/handlers_proxy.go | 61 ++ pkg/service/handlers/handlers_setup.go | 177 +++++ pkg/service/handlers/handlers_setup_test.go | 68 ++ pkg/service/handlers/handlers_stats.go | 87 +++ pkg/service/handlers/handlers_stats_test.go | 65 ++ pkg/service/handlers/index.html | 478 ++++++++++++ pkg/service/handlers/main_test.go | 67 ++ pkg/service/handlers/server.go | 108 +++ .../handlers/soundcork/bmx_services.json | 175 +++++ .../soundcork/media/SiriusXM_Logo_Color.svg | 51 ++ .../soundcork/media/SiriusXM_Logo_Mono.svg | 1 + .../soundcork/media/favicon-braille.ico | Bin 0 -> 1451 bytes .../soundcork/media/favicon-braille.png | Bin 0 -> 418 bytes .../soundcork/media/favicon-braille.svg | 12 + .../soundcork/media/favicon-morse.ico | Bin 0 -> 859 bytes .../soundcork/media/favicon-morse.png | Bin 0 -> 246 bytes .../soundcork/media/favicon-morse.svg | 9 + .../handlers/soundcork/media/favicon.md | 14 + .../soundcork/media/orion-monochrome.svg | 19 + .../soundcork/media/orion-monochrome_v2.png | Bin 0 -> 1187 bytes .../media/siriusxm-monochromePng.png | Bin 0 -> 1677 bytes .../media/tunein-default-album-art.png | Bin 0 -> 957 bytes .../soundcork/media/tunein-monochromePng.png | Bin 0 -> 631 bytes .../soundcork/media/tunein-monochromeSvg.svg | 22 + .../soundcork/media/tunein-smallSvg.svg | 19 + pkg/service/handlers/soundcork/swupdate.xml | 312 ++++++++ pkg/service/marge/marge.go | 454 ++++++++++++ pkg/service/marge/marge_test.go | 124 ++++ pkg/service/proxy/proxy.go | 109 +++ pkg/service/proxy/proxy_test.go | 77 ++ pkg/service/setup/setup.go | 436 +++++++++++ pkg/service/setup/setup_test.go | 112 +++ pkg/service/ssh/ssh.go | 141 ++++ pkg/service/ssh/ssh_test.go | 66 ++ 62 files changed, 6769 insertions(+), 78 deletions(-) create mode 100644 cmd/soundtouch-service/main.go create mode 100644 data/.gitignore create mode 100644 docs/MERGE_PROJECTS.md create mode 100644 docs/SOUNDTOUCH-SERVICE.md create mode 100644 examples/service-demo/main.go create mode 100644 pkg/models/models.go create mode 100644 pkg/service/bmx/bmx.go create mode 100644 pkg/service/bmx/bmx_test.go create mode 100644 pkg/service/constants/constants.go create mode 100644 pkg/service/constants/constants_test.go create mode 100644 pkg/service/datastore/datastore.go create mode 100644 pkg/service/datastore/datastore_test.go create mode 100644 pkg/service/handlers/handlers_bmx.go create mode 100644 pkg/service/handlers/handlers_bmx_test.go create mode 100644 pkg/service/handlers/handlers_etag_test.go create mode 100644 pkg/service/handlers/handlers_events.go create mode 100644 pkg/service/handlers/handlers_events_test.go create mode 100644 pkg/service/handlers/handlers_health.go create mode 100644 pkg/service/handlers/handlers_health_test.go create mode 100644 pkg/service/handlers/handlers_marge.go create mode 100644 pkg/service/handlers/handlers_marge_test.go create mode 100644 pkg/service/handlers/handlers_media.go create mode 100644 pkg/service/handlers/handlers_media_test.go create mode 100644 pkg/service/handlers/handlers_proxy.go create mode 100644 pkg/service/handlers/handlers_setup.go create mode 100644 pkg/service/handlers/handlers_setup_test.go create mode 100644 pkg/service/handlers/handlers_stats.go create mode 100644 pkg/service/handlers/handlers_stats_test.go create mode 100644 pkg/service/handlers/index.html create mode 100644 pkg/service/handlers/main_test.go create mode 100644 pkg/service/handlers/server.go create mode 100644 pkg/service/handlers/soundcork/bmx_services.json create mode 100644 pkg/service/handlers/soundcork/media/SiriusXM_Logo_Color.svg create mode 100644 pkg/service/handlers/soundcork/media/SiriusXM_Logo_Mono.svg create mode 100644 pkg/service/handlers/soundcork/media/favicon-braille.ico create mode 100644 pkg/service/handlers/soundcork/media/favicon-braille.png create mode 100644 pkg/service/handlers/soundcork/media/favicon-braille.svg create mode 100644 pkg/service/handlers/soundcork/media/favicon-morse.ico create mode 100644 pkg/service/handlers/soundcork/media/favicon-morse.png create mode 100644 pkg/service/handlers/soundcork/media/favicon-morse.svg create mode 100644 pkg/service/handlers/soundcork/media/favicon.md create mode 100644 pkg/service/handlers/soundcork/media/orion-monochrome.svg create mode 100644 pkg/service/handlers/soundcork/media/orion-monochrome_v2.png create mode 100644 pkg/service/handlers/soundcork/media/siriusxm-monochromePng.png create mode 100644 pkg/service/handlers/soundcork/media/tunein-default-album-art.png create mode 100644 pkg/service/handlers/soundcork/media/tunein-monochromePng.png create mode 100644 pkg/service/handlers/soundcork/media/tunein-monochromeSvg.svg create mode 100644 pkg/service/handlers/soundcork/media/tunein-smallSvg.svg create mode 100644 pkg/service/handlers/soundcork/swupdate.xml create mode 100644 pkg/service/marge/marge.go create mode 100644 pkg/service/marge/marge_test.go create mode 100644 pkg/service/proxy/proxy.go create mode 100644 pkg/service/proxy/proxy_test.go create mode 100644 pkg/service/setup/setup.go create mode 100644 pkg/service/setup/setup_test.go create mode 100644 pkg/service/ssh/ssh.go create mode 100644 pkg/service/ssh/ssh_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03db629..be133a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,100 +113,87 @@ jobs: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }} - - name: Build binary + - name: Build binaries env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} GOARM: ${{ matrix.goarm }} CGO_ENABLED: 0 run: | - # Determine output filename - BINARY_NAME="soundtouch-cli" + # Common variables ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}" - if [[ "${{ matrix.goarm }}" != "" ]]; then ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}" fi - if [[ "${{ matrix.goos }}" == "windows" ]]; then - OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe" - else - OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}" - fi + # Function to build a binary + build_binary() { + local BINARY_NAME=$1 + local CMD_PATH=$2 + local OUTPUT_NAME - echo "Building: $OUTPUT_NAME" + if [[ "${{ matrix.goos }}" == "windows" ]]; then + OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe" + else + OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}" + fi - # Debug: Show current state - echo "Working directory: $(pwd)" - echo "Go version: $(go version)" - echo "Files before build:" - ls -la + echo "Building $BINARY_NAME: $OUTPUT_NAME" - # Debug: Show Go cache and module cache - echo "Go build cache location: $(go env GOCACHE)" - echo "Go module cache location: $(go env GOMODCACHE)" - echo "Go build cache contents:" - ls -la "$(go env GOCACHE)" 2>/dev/null || echo "Cache directory not accessible" - echo "Go module cache contents (top level):" - ls -la "$(go env GOMODCACHE)" 2>/dev/null || echo "Module cache directory not accessible" + # Ensure clean build environment for this binary + rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512" - # Ensure clean build environment - rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512" - go clean -cache + if ! go build \ + -ldflags="-s -w" \ + -o "$OUTPUT_NAME" \ + "$CMD_PATH"; then + echo "โŒ Build failed for $BINARY_NAME" + exit 1 + fi - # Build with optimizations (using debug.BuildInfo for version info) - if ! go build \ - -ldflags="-s -w" \ - -o "$OUTPUT_NAME" \ - ./cmd/soundtouch-cli; then - echo "โŒ Build failed" - echo "Files after failed build:" - ls -la - exit 1 - fi + # Verify binary was created + ls -la "$OUTPUT_NAME" + echo "$BINARY_NAME=$OUTPUT_NAME" >> $GITHUB_OUTPUT + } - # Debug: Show post-build state - echo "Files after successful build:" - ls -la - - # Verify binary was created and is executable - ls -la "$OUTPUT_NAME" - file "$OUTPUT_NAME" - - echo "binary_name=$OUTPUT_NAME" >> $GITHUB_OUTPUT + # Build CLI + build_binary "soundtouch-cli" "./cmd/soundtouch-cli" + + # Build Service + build_binary "soundtouch-service" "./cmd/soundtouch-service" id: build - - name: Generate individual checksum + - name: Generate individual checksums run: | - OUTPUT_NAME="${{ steps.build.outputs.binary_name }}" + CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}" + SVC_NAME="${{ steps.build.outputs.soundtouch-service }}" # Use atomic operations to avoid conflicts TEMP_DIR=$(mktemp -d) - echo "Building checksums for: $OUTPUT_NAME" - echo "Matrix: ${{ matrix.goos }}-${{ matrix.goarch }}" + generate_checksums() { + local FILE=$1 + echo "Building checksums for: $FILE" + sha256sum "$FILE" > "${TEMP_DIR}/$(basename "$FILE").sha256" + sha512sum "$FILE" > "${TEMP_DIR}/$(basename "$FILE").sha512" + mv "${TEMP_DIR}/$(basename "$FILE").sha256" "$FILE.sha256" + mv "${TEMP_DIR}/$(basename "$FILE").sha512" "$FILE.sha512" + } - # Generate checksums in temp directory first - sha256sum "$OUTPUT_NAME" > "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha256" - sha512sum "$OUTPUT_NAME" > "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha512" - - # Move to final location atomically - mv "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha256" "$OUTPUT_NAME.sha256" - mv "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha512" "$OUTPUT_NAME.sha512" + generate_checksums "$CLI_NAME" + generate_checksums "$SVC_NAME" # Cleanup rm -rf "$TEMP_DIR" - echo "โœ… Checksums generated successfully" - name: Upload build artifact uses: actions/upload-artifact@v6 with: - name: ${{ steps.build.outputs.binary_name }} + name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }} path: | - ${{ steps.build.outputs.binary_name }} - ${{ steps.build.outputs.binary_name }}.sha256 - ${{ steps.build.outputs.binary_name }}.sha512 + soundtouch-cli-v* + soundtouch-service-v* retention-days: 1 checksums: @@ -226,13 +213,13 @@ jobs: # Debug: Show the downloaded structure echo "๐Ÿ“ Downloaded artifact structure:" - find . -type f -name "soundtouch-cli-*" + ls -R # Create a collection directory to avoid naming conflicts mkdir -p release-files # Move all files from subdirectories to the collection directory - find . -mindepth 2 -type f -name "soundtouch-cli-*" -exec mv {} release-files/ \; + find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" \) -exec mv {} release-files/ \; # Remove empty directories find . -type d -empty -delete @@ -242,20 +229,20 @@ jobs: # Debug: Show flattened structure echo "๐Ÿ“ Flattened structure:" - ls -la soundtouch-cli-* || echo "No files found matching pattern" + ls -la soundtouch-* || echo "No files found matching pattern" # Generate combined checksums (exclude individual .sha256/.sha512 files) - if ls soundtouch-cli-v* 1> /dev/null 2>&1; then + if ls soundtouch-* 1> /dev/null 2>&1; then # Only checksum the actual binaries, not the .sha256/.sha512 files - ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256 - ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512 + ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256 + ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512 echo "๐Ÿ“‹ Generated combined checksums:" cat checksums.sha256 # Verify all expected files are present (binaries only, not checksum files) - EXPECTED_COUNT=7 # Based on build matrix - ACTUAL_COUNT=$(ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l) + EXPECTED_COUNT=14 # 7 platforms * 2 binaries + ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l) if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then echo "โŒ Expected $EXPECTED_COUNT binaries, found $ACTUAL_COUNT" @@ -369,19 +356,20 @@ jobs: - [Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Systematic issue resolution - [Deployment Guide](docs/DEPLOYMENT.md) - Production deployment examples (Docker, K8s, systemd) - ## ๐Ÿ”ง CLI Tool + ## ๐Ÿ”ง CLI & Service Tools - Download the CLI tool for your platform from the assets below: + Download the tools for your platform from the assets below: + ### CLI Tool \`\`\`bash # Quick device discovery ./soundtouch-cli -discover + \`\`\` - # Get device information - ./soundtouch-cli -host 192.168.1.100 -info - - # Monitor real-time events - ./soundtouch-cli -host 192.168.1.100 -nowplaying + ### SoundTouch Service + \`\`\`bash + # Start the service + ./soundtouch-service \`\`\` ## ๐Ÿงช Tested Hardware @@ -402,6 +390,8 @@ jobs: - Windows (amd64) - FreeBSD (amd64) + Both `soundtouch-cli` and `soundtouch-service` are included. + ## ๐Ÿ” Checksums Multiple checksum options are provided for download verification: @@ -454,6 +444,7 @@ jobs: prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }} files: | release-assets/soundtouch-cli-v* + release-assets/soundtouch-service-v* release-assets/checksums.sha256 release-assets/checksums.sha512 fail_on_unmatched_files: true @@ -479,6 +470,7 @@ jobs: tag_name: ${{ github.event.release.tag_name }} files: | release-assets/soundtouch-cli-v* + release-assets/soundtouch-service-v* release-assets/checksums.sha256 release-assets/checksums.sha512 fail_on_unmatched_files: true @@ -495,7 +487,7 @@ jobs: - name: Notify success run: | echo "๐ŸŽ‰ Release ${{ needs.validate.outputs.version }} completed successfully!" - echo "๐Ÿ“ฆ Binaries built for 7 platforms" + echo "๐Ÿ“ฆ Binaries built for 7 platforms (CLI and Service)" echo "๐Ÿ” Checksums generated and verified" echo "๐Ÿ“‹ Release notes automatically generated" echo "" diff --git a/.gitignore b/.gitignore index 1825f0c..fae6071 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ # Root-level binary executables (exclude built binaries in root) /soundtouch-cli +/soundtouch-service /example-mdns /example-upnp /example-unified diff --git a/README.md b/README.md index 2d71129..2645b3f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices - ๐Ÿ“ป **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music - ๐ŸŽ™๏ธ **Station Management**: Add and play radio stations without presets - ๐Ÿ–ฅ๏ธ **CLI Tool**: Comprehensive command-line interface +- ๐ŸŒ **SoundTouch Service**: Emulate Bose services and proxy device traffic (offline support) - ๐Ÿ”’ **Production Ready**: Extensive testing with real SoundTouch hardware - ๐ŸŒ **Cross-Platform**: Windows, macOS, Linux support @@ -26,9 +27,10 @@ A comprehensive Go library and CLI tool for controlling Bose SoundTouch devices ### Installation -#### Install CLI Tool +#### Install CLI and Service Tools ```bash go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest +go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest ``` #### Add Library to Your Project @@ -73,6 +75,24 @@ soundtouch-cli --host 192.168.1.100 speaker beep soundtouch-cli --host 192.168.1.100 events subscribe ``` +### Service Usage + +The `soundtouch-service` provides a REST API and can emulate Bose backend services (BMX/Marge), which is useful for offline device usage or custom service integration. + +#### Start the Service +```bash +# Start with default settings (port 8000) +soundtouch-service +``` + +#### Key Service Features +- **Device Discovery**: Automatically scans and lists Bose devices. +- **Service Emulation**: Emulates Bose BMX and Marge services. +- **Logging Proxy**: Intercept and log traffic between your device and the service. +- **Embedded Web UI**: Management interface available at `http://localhost:8000/`. + +See [docs/SOUNDTOUCH-SERVICE.md](docs/SOUNDTOUCH-SERVICE.md) for detailed configuration and API usage. + ### Library Usage #### Basic Control @@ -481,4 +501,4 @@ These projects form a comprehensive ecosystem for SoundTouch device management a --- -**Star this project** โญ if you find it useful! \ No newline at end of file +**Star this project** โญ if you find it useful! diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go new file mode 100644 index 0000000..b005cdf --- /dev/null +++ b/cmd/soundtouch-service/main.go @@ -0,0 +1,174 @@ +package main + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/handlers" + "github.com/gesellix/bose-soundtouch/pkg/service/proxy" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8000" + } + + bindAddr := os.Getenv("BIND_ADDR") + // If BIND_ADDR is explicitly set, use it. Otherwise, bind to all interfaces (IPv4 and IPv6). + addr := bindAddr + ":" + port + if bindAddr == "" { + addr = ":" + port + } + + targetURL := os.Getenv("PYTHON_BACKEND_URL") + if targetURL == "" { + targetURL = "http://localhost:8001" + } + + target, err := url.Parse(targetURL) + if err != nil { + log.Fatalf("Failed to parse target URL: %v", err) + } + + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "data" + } + ds := datastore.NewDataStore(dataDir) + if err := ds.Initialize(); err != nil { + log.Printf("Warning: Failed to initialize datastore: %v", err) + } + + serverURL := os.Getenv("SERVER_URL") + if serverURL == "" { + // Try to guess the server URL + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "localhost" + } + serverURL = "http://" + strings.ToLower(hostname) + ":" + port + } + + sm := setup.NewManager(serverURL, ds) + + redact := os.Getenv("REDACT_PROXY_LOGS") != "false" + logBody := os.Getenv("LOG_PROXY_BODY") == "true" + + server := handlers.NewServer(ds, sm, serverURL, redact, logBody) + + pyProxy := httputil.NewSingleHostReverseProxy(target) + pyProxy.ModifyResponse = func(res *http.Response) error { + // Generic Header Preservation: + // Go's net/http canonicalizes headers (e.g., ETag becomes Etag). + // We ensure ETag specifically uses uppercase 'T' as some Bose devices are case-sensitive. + if etags, ok := res.Header["Etag"]; ok { + delete(res.Header, "Etag") + res.Header["ETag"] = etags + } + // Also restore other potentially sensitive headers if needed, but for now we focus on ETag + // as it's the most common culprit. + + currentLp := proxy.NewLoggingProxy(target.String(), redact) + currentLp.LogBody = logBody + currentLp.LogResponse(res) + return nil + } + originalPyDirector := pyProxy.Director + pyProxy.Director = func(req *http.Request) { + originalPyDirector(req) + currentLp := proxy.NewLoggingProxy(target.String(), redact) + currentLp.LogBody = logBody + currentLp.LogRequest(req) + } + + // Phase 5: Device Discovery + go func() { + for { + server.DiscoverDevices() + time.Sleep(5 * time.Minute) + } + }() + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // Phase 2: Root endpoint implemented in Go + r.Get("/", server.HandleRoot) + r.Get("/health", server.HandleHealth) + r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = "/media/favicon-braille.svg" + server.HandleMedia()(w, r) + }) + + // Phase 2: Static file serving for /media + r.Get("/media/*", server.HandleMedia()) + + // Phase 3: BMX endpoints + r.Route("/bmx", func(r chi.Router) { + r.Get("/registry/v1/services", server.HandleBMXRegistry) + r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback) + r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) + r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) + r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + }) + + // Phase 4: Marge endpoints + r.Route("/marge", func(r chi.Router) { + r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders) + r.Get("/accounts/{account}/full", server.HandleMargeAccountFull) + r.Post("/streaming/support/power_on", server.HandleMargePowerOn) + r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate) + r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets) + r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset) + r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent) + r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice) + r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice) + r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings) + r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken) + r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport) + }) + + // Phase 10: Stats endpoints + r.Route("/streaming/stats", func(r chi.Router) { + r.Post("/usage", server.HandleUsageStats) + r.Post("/error", server.HandleErrorStats) + }) + + // Proxy route integrated into main router + r.Get("/proxy/*", server.HandleProxyRequest) + + // Phase 7: Setup and Discovery endpoints + r.Route("/setup", func(r chi.Router) { + r.Get("/devices", server.HandleListDiscoveredDevices) + r.Post("/discover", server.HandleTriggerDiscovery) + r.Get("/discovery-status", server.HandleGetDiscoveryStatus) + r.Get("/settings", server.HandleGetSettings) + r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo) + r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary) + r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice) + r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices) + r.Post("/backup/{deviceIP}", server.HandleBackupConfig) + r.Get("/proxy-settings", server.HandleGetProxySettings) + r.Post("/proxy-settings", server.HandleUpdateProxySettings) + r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents) + }) + + // Delegation Logic: Proxy everything else to Python + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + pyProxy.ServeHTTP(w, r) + }) + + log.Printf("Go service starting on %s, proxying to %s", addr, targetURL) + log.Fatal(http.ListenAndServe(addr, r)) +} diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 0000000..4d3dae7 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +default/ diff --git a/docs/MERGE_PROJECTS.md b/docs/MERGE_PROJECTS.md new file mode 100644 index 0000000..7d0e50b --- /dev/null +++ b/docs/MERGE_PROJECTS.md @@ -0,0 +1,75 @@ +# Merging Bose-SoundTouch-API into Bose-SoundTouch + +This document outlines the plan to merge the [Bose-SoundTouch-API](https://github.com/gesellix/Bose-SoundTouch-API) project into this repository. The actual Go implementation in that repository is located in the `soundcork-go` subdirectory. The goal is to provide both a CLI (`soundtouch-cli`) and a service (`soundtouch-service`) from a single codebase. + +## Goals + +- [x] Maintain the existing `soundtouch-cli` functionality. +- [x] Introduce `soundtouch-service` as a new command (based on the `soundcork-go` project). +- [x] Consolidate shared logic (models, clients, discovery) into the `pkg/` directory. +- [x] Simplify maintenance by having a single Go module and shared CI/CD pipeline. + +## Current Directory Structure + +```text +. +โ”œโ”€โ”€ cmd/ +โ”‚ โ”œโ”€โ”€ soundtouch-cli/ # Existing CLI implementation +โ”‚ โ”‚ โ””โ”€โ”€ main.go +โ”‚ โ””โ”€โ”€ soundtouch-service/ # New service implementation (REST API / Websocket) +โ”‚ โ””โ”€โ”€ main.go +โ”œโ”€โ”€ pkg/ +โ”‚ โ”œโ”€โ”€ client/ # Shared SoundTouch API client +โ”‚ โ”œโ”€โ”€ models/ # Shared data models +โ”‚ โ”œโ”€โ”€ discovery/ # Shared device discovery logic +โ”‚ โ””โ”€โ”€ service/ # Service-specific logic (from Bose-SoundTouch-API) +โ”‚ โ”œโ”€โ”€ bmx/ # BMX service logic +โ”‚ โ”œโ”€โ”€ marge/ # Marge service logic +โ”‚ โ”œโ”€โ”€ datastore/ # Device and configuration storage +โ”‚ โ”œโ”€โ”€ proxy/ # Logging proxy logic +โ”‚ โ”œโ”€โ”€ setup/ # Device setup and migration logic +โ”‚ โ””โ”€โ”€ handlers/ # HTTP handlers (adapted from soundcork-go/soundcork-go) +โ”‚ โ””โ”€โ”€ soundcork/ # Embedded resources (index.html, media/, etc.) +โ”œโ”€โ”€ docs/ +โ”‚ โ””โ”€โ”€ MERGE_PROJECTS.md # This document +โ”œโ”€โ”€ go.mod +โ””โ”€โ”€ go.sum +``` + +## Step-by-Step Merge Status + +### 1. Preparation +- [x] Review `go.mod` in both projects to identify dependency overlaps and conflicts. + +### 2. Code Integration +- [x] **Models & Client**: Merged missing functionality from `soundcork-go/internal/models` into `pkg/models`. Renamed overlapping models to `Service*` (e.g., `ServiceContentItem`, `ServicePreset`). +- [x] **Service Logic**: Adapted internal packages from `soundcork-go/internal/` to `pkg/service/`. +- [x] **Handlers**: Moved and adapted HTTP handlers into `pkg/service/handlers/`. +- [x] **New Command**: Created `cmd/soundtouch-service/main.go` as the service entry point using `chi` router. +- [x] **Embedded Resources**: Integrated `index.html`, `bmx_services.json`, `swupdate.xml`, and `media/` folder into the binary using `//go:embed`. + +### 3. Dependency Management +- [x] Update `go.mod` to include: + - `github.com/go-chi/chi/v5` + - `github.com/srwiley/oksvg` and `github.com/srwiley/rasterx` + - `golang.org/x/crypto` +- [x] Run `go mod tidy` to clean up dependencies. + +### 4. Shared Logic Refactoring +- [x] Identify common code between `soundtouch-cli` and the new service. +- [x] Move shared logic into `pkg/` to ensure both commands use the same underlying implementation. + +### 5. Documentation & Examples +- [x] Update `README.md` to mention the new `soundtouch-service` command. +- [x] Add service-specific documentation in `docs/SOUNDTOUCH-SERVICE.md`. +- [x] Provide examples of how to run and interact with the service in `examples/service-demo/`. + +### 6. CI/CD Updates +- [x] Update `.github/workflows/release.yml` to build and release the `soundtouch-service` binary alongside `soundtouch-cli`. +- [x] Update any test workflows to include tests for the service logic. + +## Verification +- [x] `go build ./cmd/soundtouch-cli` works as expected. +- [x] `go build ./cmd/soundtouch-service` works as expected. +- [x] All tests pass: `go test ./...`. +- [x] Resources are correctly served from the embedded filesystem. diff --git a/docs/SOUNDTOUCH-SERVICE.md b/docs/SOUNDTOUCH-SERVICE.md new file mode 100644 index 0000000..47eae6d --- /dev/null +++ b/docs/SOUNDTOUCH-SERVICE.md @@ -0,0 +1,64 @@ +# SoundTouch Service + +The `soundtouch-service` is a companion service for Bose SoundTouch devices. It provides: +- A REST API for device management and discovery. +- Emulation of Bose backend services (BMX and Marge), allowing devices to work without an active internet connection to Bose servers. +- A logging proxy for inspecting device communication. +- A web interface for management. + +## Installation + +```bash +go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest +``` + +## Running the Service + +Simply run the binary: +```bash +soundtouch-service +``` + +### Configuration + +The service can be configured via environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | Port to bind the service to | `8000` | +| `BIND_ADDR` | Network interface to bind to | all (ip4 and ip6) | +| `DATA_DIR` | Directory for persistent data (devices, stats) | `./data` | +| `SERVER_URL` | External URL of this service | `http://:8000` | +| `REDACT_PROXY_LOGS` | Set to `false` to show sensitive data in proxy logs | `true` | +| `LOG_PROXY_BODY` | Set to `true` to log full request/reponse bodies | `false` | + +## API Endpoints + +### Discovery & Setup +- `GET /setup/devices`: List all discovered Bose devices. +- `POST /setup/discover`: Trigger a new network scan. +- `GET /setup/info/{deviceIP}`: Get detailed info for a specific device. +- `POST /setup/migrate/{deviceIP}`: Configure a device to use this service as its backend. + +### BMX (Bose Music eXperience) +- `GET /bmx/registry/v1/services`: Service registry for the device. +- `GET /bmx/tunein/v1/playback/station/{stationID}`: TuneIn playback bridge. + +### Marge (Account & Device Management) +- `GET /marge/streaming/sourceproviders`: List of available music services. +- `GET /marge/accounts/{account}/full`: Mock account information. +- `GET /marge/updates/soundtouch`: Mock software update endpoint. + +### Proxy +- `GET /proxy/{targetURL}`: Proxy requests through the service with logging. + +## Web Interface + +Access the management interface at `http://localhost:8000/`. The interface allows you to view discovered devices and manage their settings. + +## Persistent Data + +By default, the service creates a `data/` directory in the current working directory. This directory contains: +- `default/devices/`: Configuration and state for each discovered device. +- `usage_stats.json`: Logged device usage statistics. +- `error_stats.json`: Logged device errors. diff --git a/examples/service-demo/main.go b/examples/service-demo/main.go new file mode 100644 index 0000000..a6ff89a --- /dev/null +++ b/examples/service-demo/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +// This example demonstrates how to interact with the soundtouch-service API +// to list discovered devices. + +func main() { + // 1. Trigger a discovery scan + fmt.Println("Triggering discovery scan...") + resp, err := http.Post("http://localhost:8000/setup/discover", "application/json", nil) + if err != nil { + log.Fatalf("Failed to trigger discovery: %v\nMake sure soundtouch-service is running on localhost:8000", err) + } + resp.Body.Close() + + // Wait a bit for discovery to find some devices + fmt.Println("Waiting 5 seconds for discovery...") + time.Sleep(5 * time.Second) + + // 2. List discovered devices + fmt.Println("Fetching discovered devices...") + resp, err = http.Get("http://localhost:8000/setup/devices") + if err != nil { + log.Fatalf("Failed to fetch devices: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Failed to read response body: %v", err) + } + + var devices []map[string]interface{} + if err := json.Unmarshal(body, &devices); err != nil { + log.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if len(devices) == 0 { + fmt.Println("No devices discovered yet.") + return + } + + fmt.Printf("Discovered %d devices:\n", len(devices)) + for _, d := range devices { + fmt.Printf("- %s (IP: %s, Model: %s)\n", d["name"], d["ip_address"], d["product_code"]) + } +} diff --git a/go.mod b/go.mod index f83b76c..60878e1 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/gesellix/bose-soundtouch go 1.25.6 require ( + github.com/go-chi/chi/v5 v5.2.5 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/mdns v1.0.6 github.com/urfave/cli/v2 v2.27.7 + golang.org/x/crypto v0.47.0 ) require ( diff --git a/go.sum b/go.sum index 34e8ee0..6dd4c58 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -22,6 +24,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 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= @@ -75,6 +79,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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= diff --git a/pkg/models/models.go b/pkg/models/models.go new file mode 100644 index 0000000..d9f2fba --- /dev/null +++ b/pkg/models/models.go @@ -0,0 +1,207 @@ +package models + +import ( + "encoding/xml" +) + +type Link struct { + Href string `json:"href" xml:"href,attr"` + UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"` +} + +type Links struct { + BmxLogout *Link `json:"bmx_logout,omitempty" xml:"bmx_logout,omitempty"` + BmxNavigate *Link `json:"bmx_navigate,omitempty" xml:"bmx_navigate,omitempty"` + BmxServicesAvailability *Link `json:"bmx_services_availability,omitempty" xml:"bmx_services_availability,omitempty"` + BmxToken *Link `json:"bmx_token,omitempty" xml:"bmx_token,omitempty"` + Self *Link `json:"self,omitempty" xml:"self,omitempty"` + BmxAvailability *Link `json:"bmx_availability,omitempty" xml:"bmx_availability,omitempty"` + BmxReporting *Link `json:"bmx_reporting,omitempty" xml:"bmx_reporting,omitempty"` + 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"` +} + +type IconSet struct { + DefaultAlbumArt string `json:"defaultAlbumArt,omitempty" xml:"defaultAlbumArt,omitempty"` + LargeSvg string `json:"largeSvg" xml:"largeSvg"` + MonochromePng string `json:"monochromePng" xml:"monochromePng"` + MonochromeSvg string `json:"monochromeSvg" xml:"monochromeSvg"` + SmallSvg string `json:"smallSvg" xml:"smallSvg"` +} + +type Asset struct { + Color string `json:"color" xml:"color"` + Description string `json:"description" xml:"description"` + Icons IconSet `json:"icons" xml:"icons"` + Name string `json:"name" xml:"name"` + ShortDescription string `json:"shortDescription,omitempty" xml:"shortDescription,omitempty"` +} + +type Id struct { + Name string `json:"name" xml:"name"` + Value int `json:"value" xml:"value"` +} + +type BmxService struct { + Links *Links `json:"_links,omitempty" xml:"links,omitempty"` + AskAdapter bool `json:"askAdapter" xml:"askAdapter"` + Assets Asset `json:"assets" xml:"assets"` + BaseUrl string `json:"baseUrl" xml:"baseUrl"` + SignupUrl string `json:"signupUrl,omitempty" xml:"signupUrl,omitempty"` + StreamTypes []string `json:"streamTypes" xml:"streamTypes>streamType"` + AuthenticationModel map[string]interface{} `json:"authenticationModel" xml:"authenticationModel"` + ID Id `json:"id" xml:"id"` +} + +type BmxResponse struct { + Links *Links `json:"_links,omitempty" xml:"links,omitempty"` + AskAgainAfter int `json:"askAgainAfter" xml:"askAgainAfter"` + BmxServices []Service `json:"bmx_services" xml:"bmx_services>service"` +} + +type Stream struct { + Links *Links `json:"_links,omitempty" xml:"links,omitempty"` + BufferingTimeout int `json:"bufferingTimeout,omitempty" xml:"bufferingTimeout,omitempty"` + ConnectingTimeout int `json:"connectingTimeout,omitempty" xml:"connectingTimeout,omitempty"` + HasPlaylist bool `json:"hasPlaylist" xml:"hasPlaylist"` + IsRealtime bool `json:"isRealtime" xml:"isRealtime"` + StreamUrl string `json:"streamUrl" xml:"streamUrl"` +} + +type Audio struct { + HasPlaylist bool `json:"hasPlaylist" xml:"hasPlaylist"` + IsRealtime bool `json:"isRealtime" xml:"isRealtime"` + MaxTimeout int `json:"maxTimeout,omitempty" xml:"maxTimeout,omitempty"` + StreamUrl string `json:"streamUrl" xml:"streamUrl"` + Streams []Stream `json:"streams" xml:"streams>stream"` +} + +type BmxPlaybackResponse struct { + Links *Links `json:"_links,omitempty" xml:"links,omitempty"` + Artist struct { + Name string `json:"name,omitempty" xml:"name,omitempty"` + } `json:"artist,omitempty" xml:"artist,omitempty"` + Audio Audio `json:"audio" xml:"audio"` + ImageUrl string `json:"imageUrl" xml:"imageUrl"` + IsFavorite *bool `json:"isFavorite,omitempty" xml:"isFavorite,omitempty"` + Name string `json:"name" xml:"name"` + StreamType string `json:"streamType" xml:"streamType"` + Duration int `json:"duration,omitempty" xml:"duration,omitempty"` + ShuffleDisabled bool `json:"shuffle_disabled,omitempty" xml:"shuffleDisabled,omitempty"` + RepeatDisabled bool `json:"repeat_disabled,omitempty" xml:"repeatDisabled,omitempty"` +} + +type Track struct { + Links *Links `json:"_links,omitempty" xml:"links,omitempty"` + IsSelected bool `json:"isSelected" xml:"isSelected"` + Name string `json:"name" xml:"name"` +} + +type BmxPodcastInfoResponse struct { + Links *Links `json:"_links,omitempty" xml:"links,omitempty"` + Name string `json:"name" xml:"name"` + ShuffleDisabled bool `json:"shuffleDisabled" xml:"shuffleDisabled"` + RepeatDisabled bool `json:"repeatDisabled" xml:"repeatDisabled"` + StreamType string `json:"streamType" xml:"streamType"` + Tracks []Track `json:"tracks" xml:"tracks>track"` +} + +type SourceProvider struct { + ID int `json:"id" xml:"id,attr"` + CreatedOn string `json:"created_on" xml:"createdOn"` + Name string `json:"name" xml:"name"` + UpdatedOn string `json:"updated_on" xml:"updatedOn"` +} + +type ServiceContentItem struct { + ID string `json:"id" xml:"id,attr"` + Name string `json:"name" xml:"itemName"` + Source string `json:"source,omitempty" xml:"source,attr,omitempty"` + Type string `json:"type" xml:"type,attr"` + Location string `json:"location" xml:"location,attr"` + SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"` + SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"` + IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"` +} + +type ServicePreset struct { + ServiceContentItem + ContainerArt string `json:"container_art" xml:"containerArt"` + CreatedOn string `json:"created_on" xml:"createdOn"` + UpdatedOn string `json:"updated_on" xml:"updatedOn"` +} + +type ServiceRecent struct { + ServiceContentItem + DeviceID string `json:"device_id" xml:"deviceid"` + UtcTime string `json:"utc_time" xml:"utc_time"` + ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"` +} + +type ConfiguredSource struct { + DisplayName string `json:"display_name" xml:"sourcename"` + ID string `json:"id" xml:"id,attr"` + Secret string `json:"secret" xml:"credential"` + SecretType string `json:"secret_type" xml:"credential_type,attr"` + SourceKeyType string `json:"source_key_type" xml:"sourceproviderid"` + SourceKeyAccount string `json:"source_key_account" xml:"username"` +} + +type ServiceDeviceInfo struct { + DeviceID string `json:"device_id" xml:"deviceID,attr"` + ProductCode string `json:"product_code" xml:"type"` + DeviceSerialNumber string `json:"device_serial_number" xml:"serialnumber"` + ProductSerialNumber string `json:"product_serial_number" xml:"product_serial_number"` + FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"` + IPAddress string `json:"ip_address" xml:"ipAddress"` + Name string `json:"name" xml:"name"` +} + +type CustomerSupportDevice struct { + ID string `xml:"id,attr"` + SerialNumber string `xml:"serialnumber"` + FirmwareVersion string `xml:"firmware-version"` + Product struct { + ProductCode string `xml:"product_code,attr"` + Type string `xml:"type,attr"` + SerialNumber string `xml:"serialnumber"` + } `xml:"product"` +} + +type CustomerSupportRequest struct { + XMLName xml.Name `xml:"device-data"` + Device CustomerSupportDevice `xml:"device"` + DiagnosticData struct { + DeviceLandscape struct { + RSSI string `xml:"rssi"` + GatewayIP string `xml:"gateway-ip-address"` + IPAddress string `xml:"ip-address"` + NetworkConnectionType string `xml:"network-connection-type"` + MacAddresses []string `xml:"macaddresses>macaddress"` + } `xml:"device-landscape"` + } `xml:"diagnostic-data"` +} + +type UsageStats struct { + DeviceID string `json:"deviceId" xml:"deviceId"` + AccountID string `json:"accountId" xml:"accountId"` + Timestamp string `json:"timestamp" xml:"timestamp"` + EventType string `json:"eventType" xml:"eventType"` + Parameters map[string]interface{} `json:"parameters" xml:"parameters"` +} + +type ErrorStats struct { + DeviceID string `json:"deviceId" xml:"deviceId"` + ErrorCode string `json:"errorCode" xml:"errorCode"` + ErrorMessage string `json:"errorMessage" xml:"errorMessage"` + Timestamp string `json:"timestamp" xml:"timestamp"` + Details string `json:"details,omitempty" xml:"details,omitempty"` +} + +type DeviceEvent struct { + Type string `json:"type"` + Time string `json:"time"` + MonoTime int64 `json:"monoTime"` + Data map[string]interface{} `json:"data"` +} diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go new file mode 100644 index 0000000..cefac36 --- /dev/null +++ b/pkg/service/bmx/bmx.go @@ -0,0 +1,303 @@ +package bmx + +import ( + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +const ( + TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s" + TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg" +) + +func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { + describeURL := fmt.Sprintf(TuneInDescribe, stationID) + resp, err := http.Get(describeURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var opml struct { + Body struct { + Outline struct { + Station struct { + Name string `xml:"name"` + Logo string `xml:"logo"` + } `xml:"station"` + } `xml:"outline"` + } `xml:"body"` + } + + if err := xml.Unmarshal(body, &opml); err != nil { + return nil, err + } + + station := opml.Body.Outline.Station + + streamReq := fmt.Sprintf(TuneInStream, stationID) + streamResp, err := http.Get(streamReq) + if err != nil { + return nil, err + } + defer streamResp.Body.Close() + + streamBody, err := io.ReadAll(streamResp.Body) + if err != nil { + return nil, err + } + + streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n") + if len(streamURLList) == 0 || streamURLList[0] == "" { + return nil, fmt.Errorf("no streams found") + } + + streamID := "e3342" + listenID := "3432432423" + bmxReportingQS := url.Values{} + bmxReportingQS.Set("stream_id", streamID) + bmxReportingQS.Set("guide_id", stationID) + bmxReportingQS.Set("listen_id", listenID) + bmxReportingQS.Set("stream_type", "liveRadio") + bmxReporting := "/v1/report?" + bmxReportingQS.Encode() + + var streams []models.Stream + for _, sURL := range streamURLList { + sURL = strings.TrimSpace(sURL) + if sURL == "" { + continue + } + streams = append(streams, models.Stream{ + Links: &models.Links{ + BmxReporting: &models.Link{Href: bmxReporting}, + }, + HasPlaylist: true, + IsRealtime: true, + BufferingTimeout: 20, + ConnectingTimeout: 10, + StreamUrl: sURL, + }) + } + + audio := models.Audio{ + HasPlaylist: true, + IsRealtime: true, + MaxTimeout: 60, + StreamUrl: streamURLList[0], + Streams: streams, + } + + response := &models.BmxPlaybackResponse{ + Links: &models.Links{ + BmxFavorite: &models.Link{Href: "/v1/favorite/" + stationID}, + BmxNowPlaying: &models.Link{Href: "/v1/now-playing/station/" + stationID, UseInternalClient: "ALWAYS"}, + BmxReporting: &models.Link{Href: bmxReporting}, + }, + Audio: audio, + ImageUrl: station.Logo, + IsFavorite: new(bool), // defaults to false + Name: station.Name, + StreamType: "liveRadio", + } + + return response, nil +} + +func TuneInPodcastInfo(podcastID string, encodedName string) (*models.BmxPodcastInfoResponse, error) { + // Bose app sometimes sends non-standard base64, so try both standard and URL-safe + nameBytes, err := base64.URLEncoding.DecodeString(encodedName) + if err != nil { + nameBytes, err = base64.StdEncoding.DecodeString(encodedName) + } + if err != nil { + return nil, err + } + name := string(nameBytes) + + track := models.Track{ + Links: &models.Links{ + BmxTrack: &models.Link{Href: fmt.Sprintf("/v1/playback/episode/%s", podcastID)}, + }, + IsSelected: false, + Name: name, + } + + response := &models.BmxPodcastInfoResponse{ + Links: &models.Links{ + Self: &models.Link{Href: fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", podcastID, encodedName)}, + }, + Name: name, + ShuffleDisabled: true, + RepeatDisabled: true, + StreamType: "onDemand", + Tracks: []models.Track{track}, + } + + return response, nil +} + +func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error) { + describeURL := fmt.Sprintf(TuneInDescribe, podcastID) + resp, err := http.Get(describeURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var opml struct { + Body struct { + Outline struct { + Topic struct { + Title string `xml:"title"` + ShowTitle string `xml:"show_title"` + Duration string `xml:"duration"` + ShowID string `xml:"show_id"` + Logo string `xml:"logo"` + } `xml:"topic"` + } `xml:"outline"` + } `xml:"body"` + } + + if err := xml.Unmarshal(body, &opml); err != nil { + return nil, err + } + + topic := opml.Body.Outline.Topic + + streamReq := fmt.Sprintf(TuneInStream, podcastID) + streamResp, err := http.Get(streamReq) + if err != nil { + return nil, err + } + defer streamResp.Body.Close() + + streamBody, err := io.ReadAll(streamResp.Body) + if err != nil { + return nil, err + } + + streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n") + if len(streamURLList) == 0 || streamURLList[0] == "" { + return nil, fmt.Errorf("no streams found") + } + + streamID := "e3342" + listenID := "3432432423" + bmxReportingQS := url.Values{} + bmxReportingQS.Set("stream_id", streamID) + bmxReportingQS.Set("guide_id", podcastID) + bmxReportingQS.Set("listen_id", listenID) + bmxReportingQS.Set("stream_type", "onDemand") + bmxReporting := "/v1/report?" + bmxReportingQS.Encode() + + var streams []models.Stream + for _, sURL := range streamURLList { + sURL = strings.TrimSpace(sURL) + if sURL == "" { + continue + } + streams = append(streams, models.Stream{ + Links: &models.Links{ + BmxReporting: &models.Link{Href: bmxReporting}, + }, + HasPlaylist: true, + IsRealtime: false, + BufferingTimeout: 20, + ConnectingTimeout: 10, + StreamUrl: sURL, + }) + } + + audio := models.Audio{ + HasPlaylist: true, + IsRealtime: false, + MaxTimeout: 60, + StreamUrl: streamURLList[0], + Streams: streams, + } + + duration, _ := strconv.Atoi(topic.Duration) + + response := &models.BmxPlaybackResponse{ + Links: &models.Links{ + BmxFavorite: &models.Link{Href: fmt.Sprintf("/v1/favorite/%s", topic.ShowID)}, + BmxReporting: &models.Link{Href: bmxReporting}, + }, + Artist: struct { + Name string `json:"name,omitempty" xml:"name,omitempty"` + }{Name: topic.ShowTitle}, + Audio: audio, + Duration: duration, + ImageUrl: topic.Logo, + IsFavorite: new(bool), + Name: topic.Title, + ShuffleDisabled: true, + RepeatDisabled: true, + StreamType: "onDemand", + } + + return response, nil +} + +func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) { + // Bose app sometimes sends non-standard base64, so try both standard and URL-safe + jsonStr, err := base64.URLEncoding.DecodeString(data) + if err != nil { + jsonStr, err = base64.StdEncoding.DecodeString(data) + } + if err != nil { + return nil, err + } + + var jsonObj struct { + StreamURL string `json:"streamUrl"` + ImageURL string `json:"imageUrl"` + Name string `json:"name"` + } + if err := json.Unmarshal(jsonStr, &jsonObj); err != nil { + return nil, err + } + + streamList := []models.Stream{ + { + HasPlaylist: true, + IsRealtime: true, + StreamUrl: jsonObj.StreamURL, + }, + } + + audio := models.Audio{ + HasPlaylist: true, + IsRealtime: true, + StreamUrl: jsonObj.StreamURL, + Streams: streamList, + } + + response := &models.BmxPlaybackResponse{ + Audio: audio, + ImageUrl: jsonObj.ImageURL, + Name: jsonObj.Name, + StreamType: "liveRadio", + } + + return response, nil +} diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go new file mode 100644 index 0000000..e99083e --- /dev/null +++ b/pkg/service/bmx/bmx_test.go @@ -0,0 +1,65 @@ +package bmx + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +func TestPlayCustomStream(t *testing.T) { + // Simple test for custom stream XML generation + dataObj := struct { + StreamURL string `json:"streamUrl"` + ImageURL string `json:"imageUrl"` + Name string `json:"name"` + }{ + StreamURL: "http://example.com/stream.mp3", + ImageURL: "image.png", + Name: "Stream Name", + } + jsonBytes, _ := json.Marshal(dataObj) + + // Test Standard Base64 + dataStd := base64.StdEncoding.EncodeToString(jsonBytes) + resp, err := PlayCustomStream(dataStd) + if err != nil { + t.Fatalf("PlayCustomStream with standard base64 failed: %v", err) + } + if resp.Name != "Stream Name" { + t.Errorf("Expected name Stream Name, got %s", resp.Name) + } + + // Test URL-safe Base64 + dataURL := base64.URLEncoding.EncodeToString(jsonBytes) + resp, err = PlayCustomStream(dataURL) + if err != nil { + t.Fatalf("PlayCustomStream with URL-safe base64 failed: %v", err) + } + if resp.Name != "Stream Name" { + t.Errorf("Expected name Stream Name, got %s", resp.Name) + } +} + +func TestTuneInPodcastInfo_Base64(t *testing.T) { + name := "Podcast Name / with special chars?" + + // Test Standard Base64 + encodedStd := base64.StdEncoding.EncodeToString([]byte(name)) + resp, err := TuneInPodcastInfo("123", encodedStd) + if err != nil { + t.Fatalf("TuneInPodcastInfo with standard base64 failed: %v", err) + } + if resp.Name != name { + t.Errorf("Expected name %s, got %s", name, resp.Name) + } + + // Test URL-safe Base64 + encodedURL := base64.URLEncoding.EncodeToString([]byte(name)) + resp, err = TuneInPodcastInfo("123", encodedURL) + if err != nil { + t.Fatalf("TuneInPodcastInfo with URL-safe base64 failed: %v", err) + } + if resp.Name != name { + t.Errorf("Expected name %s, got %s", name, resp.Name) + } +} diff --git a/pkg/service/constants/constants.go b/pkg/service/constants/constants.go new file mode 100644 index 0000000..ccbb723 --- /dev/null +++ b/pkg/service/constants/constants.go @@ -0,0 +1,59 @@ +package constants + +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", +} + +const ( + DevicesDir = "devices" + DeviceInfoFile = "DeviceInfo.xml" + PresetsFile = "Presets.xml" + RecentsFile = "Recents.xml" + SourcesFile = "Sources.xml" + + SpeakerHTTPPort = 8090 + SpeakerDeviceInfoPath = "/info" + SpeakerRecentsPath = "/recents" + SpeakerPresetsPath = "/presets" + SpeakerSourcesFileLocation = "/mnt/nv/BoseApp-Persistence/1/Sources.xml" + + // DateStr is the hardcoded date used in many Bose XML responses + DateStr = "2012-09-19T12:43:00.000+00:00" +) diff --git a/pkg/service/constants/constants_test.go b/pkg/service/constants/constants_test.go new file mode 100644 index 0000000..2b860a6 --- /dev/null +++ b/pkg/service/constants/constants_test.go @@ -0,0 +1,17 @@ +package constants + +import ( + "testing" +) + +func TestConstants(t *testing.T) { + if DateStr == "" { + t.Error("DateStr should not be empty") + } + if SpeakerHTTPPort != 8090 { + t.Errorf("Expected SpeakerHTTPPort 8090, got %d", SpeakerHTTPPort) + } + if len(Providers) == 0 { + t.Error("Providers should not be empty") + } +} diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go new file mode 100644 index 0000000..bbf16d8 --- /dev/null +++ b/pkg/service/datastore/datastore.go @@ -0,0 +1,699 @@ +package datastore + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/constants" + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +type DataStore struct { + DataDir string + eventMutex sync.RWMutex + deviceEvents map[string][]models.DeviceEvent +} + +func NewDataStore(dataDir string) *DataStore { + if dataDir == "" { + dataDir = "data" + } + return &DataStore{ + DataDir: dataDir, + deviceEvents: make(map[string][]models.DeviceEvent), + } +} + +func (ds *DataStore) AccountDir(account string) string { + return filepath.Join(ds.DataDir, account) +} + +func (ds *DataStore) AccountDevicesDir(account string) string { + return filepath.Join(ds.DataDir, account, constants.DevicesDir) +} + +func (ds *DataStore) AccountDeviceDir(account, device string) string { + return filepath.Join(ds.AccountDevicesDir(account), device) +} + +func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.DeviceInfoFile) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var info struct { + XMLName xml.Name `xml:"info"` + DeviceID string `xml:"deviceID,attr"` + Name string `xml:"name"` + Type string `xml:"type"` + ModuleType string `xml:"moduleType"` + Components []struct { + Category string `xml:"componentCategory"` + SoftwareVersion string `xml:"softwareVersion"` + SerialNumber string `xml:"serialNumber"` + } `xml:"components>component"` + NetworkInfo []struct { + Type string `xml:"type,attr"` + IPAddress string `xml:"ipAddress"` + } `xml:"networkInfo"` + } + + if err := xml.Unmarshal(data, &info); err != nil { + return nil, err + } + + deviceInfo := &models.ServiceDeviceInfo{ + DeviceID: info.DeviceID, + ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType), + Name: info.Name, + } + + for _, comp := range info.Components { + if comp.Category == "SCM" { + deviceInfo.FirmwareVersion = comp.SoftwareVersion + deviceInfo.DeviceSerialNumber = comp.SerialNumber + } else if comp.Category == "PackagedProduct" { + deviceInfo.ProductSerialNumber = comp.SerialNumber + } + } + + for _, net := range info.NetworkInfo { + if net.Type == "SCM" { + deviceInfo.IPAddress = net.IPAddress + } + } + + return deviceInfo, nil +} + +// ListAllDevices returns a list of all devices in all accounts. +func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { + dirs := []string{} + if exists(ds.DataDir) { + dirs = append(dirs, ds.DataDir) + } + // Also check soundcork-go/data if it's different and exists + altDir := "soundcork-go/data" + if ds.DataDir != altDir && exists(altDir) { + dirs = append(dirs, altDir) + } + + if len(dirs) == 0 { + return []models.ServiceDeviceInfo{}, nil + } + + devices := []models.ServiceDeviceInfo{} + seenIDs := make(map[string]bool) + + for _, dir := range dirs { + accounts, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, acc := range accounts { + if !acc.IsDir() { + continue + } + + devicesDir := filepath.Join(dir, acc.Name(), constants.DevicesDir) + deviceEntries, err := os.ReadDir(devicesDir) + if err != nil { + continue + } + + for _, dev := range deviceEntries { + var info *models.ServiceDeviceInfo + var err error + + if !dev.IsDir() { + if dev.Name() == constants.DeviceInfoFile { + // Special case for DeviceInfo.xml directly in devicesDir + path := filepath.Join(devicesDir, constants.DeviceInfoFile) + info, err = ds.parseDeviceInfoFile(path) + } + } else { + path := filepath.Join(devicesDir, dev.Name(), constants.DeviceInfoFile) + info, err = ds.parseDeviceInfoFile(path) + } + + if err == nil && info != nil { + // Use a unique key for deduplication + key := info.DeviceID + if key == "" { + key = info.IPAddress + } + if !seenIDs[key] { + devices = append(devices, *info) + seenIDs[key] = true + } + } + } + } + } + + return devices, nil +} + +func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var info struct { + XMLName xml.Name `xml:"info"` + DeviceID string `xml:"deviceID,attr"` + Name string `xml:"name"` + Type string `xml:"type"` + ModuleType string `xml:"moduleType"` + Components []struct { + Category string `xml:"componentCategory"` + SoftwareVersion string `xml:"softwareVersion"` + SerialNumber string `xml:"serialNumber"` + } `xml:"components>component"` + NetworkInfo []struct { + Type string `xml:"type,attr"` + IPAddress string `xml:"ipAddress"` + } `xml:"networkInfo"` + } + + if err := xml.Unmarshal(data, &info); err != nil { + return nil, err + } + + deviceInfo := &models.ServiceDeviceInfo{ + DeviceID: info.DeviceID, + ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType), + Name: info.Name, + } + + for _, comp := range info.Components { + if comp.Category == "SCM" { + deviceInfo.FirmwareVersion = comp.SoftwareVersion + deviceInfo.DeviceSerialNumber = comp.SerialNumber + } else if comp.Category == "PackagedProduct" { + deviceInfo.ProductSerialNumber = comp.SerialNumber + } + } + + for _, net := range info.NetworkInfo { + if net.Type == "SCM" { + deviceInfo.IPAddress = net.IPAddress + } + } + + return deviceInfo, nil +} + +func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) { + path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var presetsWrap struct { + Presets []struct { + ID string `xml:"id,attr"` + CreatedOn string `xml:"createdOn,attr"` + UpdatedOn string `xml:"updatedOn,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"` + } `xml:"ContentItem"` + } `xml:"preset"` + } + + if err := xml.Unmarshal(data, &presetsWrap); err != nil { + return nil, fmt.Errorf("malformed presets XML at %s: %w", path, err) + } + + presets := []models.ServicePreset{} + for _, p := range presetsWrap.Presets { + presets = append(presets, models.ServicePreset{ + ServiceContentItem: models.ServiceContentItem{ + ID: p.ID, + Name: p.ContentItem.ItemName, + Source: p.ContentItem.Source, + Type: p.ContentItem.Type, + Location: p.ContentItem.Location, + SourceAccount: p.ContentItem.SourceAccount, + IsPresetable: p.ContentItem.IsPresetable, + }, + ContainerArt: p.ContentItem.ContainerArt, + CreatedOn: p.CreatedOn, + UpdatedOn: p.UpdatedOn, + }) + } + + return presets, nil +} + +func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) error { + path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) + + type PresetXML struct { + ID string `xml:"id,attr"` + CreatedOn string `xml:"createdOn,attr"` + UpdatedOn string `xml:"updatedOn,attr"` + ContentItem struct { + Source string `xml:"source,attr,omitempty"` + Type string `xml:"type,attr"` + Location string `xml:"location,attr"` + SourceAccount string `xml:"sourceAccount,attr,omitempty"` + IsPresetable string `xml:"isPresetable,attr"` + ItemName string `xml:"itemName"` + ContainerArt string `xml:"containerArt"` + } `xml:"ContentItem"` + } + + type PresetsXML struct { + XMLName xml.Name `xml:"presets"` + Presets []PresetXML `xml:"preset"` + } + + var px PresetsXML + for _, p := range presets { + var pxml PresetXML + pxml.ID = p.ID + pxml.CreatedOn = p.CreatedOn + pxml.UpdatedOn = p.UpdatedOn + pxml.ContentItem.Source = p.Source + pxml.ContentItem.Type = p.Type + pxml.ContentItem.Location = p.Location + pxml.ContentItem.SourceAccount = p.SourceAccount + pxml.ContentItem.IsPresetable = "true" + pxml.ContentItem.ItemName = p.Name + pxml.ContentItem.ContainerArt = p.ContainerArt + px.Presets = append(px.Presets, pxml) + } + + data, err := xml.MarshalIndent(px, "", " ") + if err != nil { + return err + } + + header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) +} + +func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) { + path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var recentsWrap struct { + Recents []struct { + ID string `xml:"id,attr"` + DeviceID string `xml:"deviceID,attr"` + UtcTime string `xml:"utcTime,attr"` + ContentItem struct { + Source string `xml:"source,attr"` + Type string `xml:"type,attr"` + Location string `xml:"location,attr"` + SourceAccount string `xml:"sourceAccount,attr"` + IsPresetable string `xml:"isPresetable,attr"` + ItemName string `xml:"itemName"` + ContainerArt string `xml:"containerArt"` + } `xml:"contentItem"` + } `xml:"recent"` + } + + if err := xml.Unmarshal(data, &recentsWrap); err != nil { + return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err) + } + + recents := []models.ServiceRecent{} + for _, r := range recentsWrap.Recents { + recents = append(recents, models.ServiceRecent{ + ServiceContentItem: models.ServiceContentItem{ + ID: r.ID, + Name: r.ContentItem.ItemName, + Source: r.ContentItem.Source, + Type: r.ContentItem.Type, + Location: r.ContentItem.Location, + SourceAccount: r.ContentItem.SourceAccount, + IsPresetable: r.ContentItem.IsPresetable, + }, + DeviceID: r.DeviceID, + UtcTime: r.UtcTime, + ContainerArt: r.ContentItem.ContainerArt, + }) + } + + return recents, nil +} + +func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) error { + path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) + + type RecentXML struct { + ID string `xml:"id,attr"` + DeviceID string `xml:"deviceID,attr"` + UtcTime string `xml:"utcTime,attr"` + ContentItem struct { + Source string `xml:"source,attr,omitempty"` + Type string `xml:"type,attr"` + Location string `xml:"location,attr"` + SourceAccount string `xml:"sourceAccount,attr,omitempty"` + IsPresetable string `xml:"isPresetable,attr"` + ItemName string `xml:"itemName"` + ContainerArt string `xml:"containerArt"` + } `xml:"contentItem"` + } + + type RecentsXML struct { + XMLName xml.Name `xml:"recents"` + Recents []RecentXML `xml:"recent"` + } + + var rx RecentsXML + for _, r := range recents { + var rxml RecentXML + rxml.ID = r.ID + rxml.DeviceID = r.DeviceID + rxml.UtcTime = r.UtcTime + rxml.ContentItem.Source = r.Source + rxml.ContentItem.Type = r.Type + rxml.ContentItem.Location = r.Location + rxml.ContentItem.SourceAccount = r.SourceAccount + rxml.ContentItem.IsPresetable = r.IsPresetable + if rxml.ContentItem.IsPresetable == "" { + rxml.ContentItem.IsPresetable = "true" + } + rxml.ContentItem.ItemName = r.Name + rxml.ContentItem.ContainerArt = r.ContainerArt + rx.Recents = append(rx.Recents, rxml) + } + + data, err := xml.MarshalIndent(rx, "", " ") + if err != nil { + return err + } + + header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) +} + +func (ds *DataStore) SaveDeviceInfo(account string, device string, info *models.ServiceDeviceInfo) error { + if device == "" { + return fmt.Errorf("device ID/name cannot be empty") + } + dir := ds.AccountDeviceDir(account, device) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + path := filepath.Join(dir, constants.DeviceInfoFile) + + type ComponentXML struct { + ComponentCategory string `xml:"componentCategory"` + SoftwareVersion string `xml:"softwareVersion,omitempty"` + SerialNumber string `xml:"serialNumber,omitempty"` + } + + type NetworkInfoXML struct { + Type string `xml:"type,attr"` + IPAddress string `xml:"ipAddress"` + } + + type InfoXML struct { + XMLName xml.Name `xml:"info"` + DeviceID string `xml:"deviceID,attr"` + Name string `xml:"name"` + Type string `xml:"type"` + ModuleType string `xml:"moduleType"` + Components []ComponentXML `xml:"components>component"` + NetworkInfo []NetworkInfoXML `xml:"networkInfo"` + } + + // Parsing product code back to type and moduleType (best effort) + // Python: f"{type} {module_type}" + devType := info.ProductCode + moduleType := "" + for i := 0; i < len(info.ProductCode); i++ { + if info.ProductCode[i] == ' ' { + devType = info.ProductCode[:i] + moduleType = info.ProductCode[i+1:] + break + } + } + + ix := InfoXML{ + DeviceID: info.DeviceID, + Name: info.Name, + Type: devType, + ModuleType: moduleType, + Components: []ComponentXML{ + { + ComponentCategory: "SCM", + SoftwareVersion: info.FirmwareVersion, + SerialNumber: info.DeviceSerialNumber, + }, + { + ComponentCategory: "PackagedProduct", + SerialNumber: info.ProductSerialNumber, + }, + }, + NetworkInfo: []NetworkInfoXML{ + { + Type: "SCM", + IPAddress: info.IPAddress, + }, + }, + } + + data, err := xml.MarshalIndent(ix, "", " ") + if err != nil { + return err + } + + header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) +} + +func (ds *DataStore) RemoveDevice(account string, device string) error { + dir := ds.AccountDeviceDir(account, device) + return os.RemoveAll(dir) +} + +func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSource, error) { + path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var sourcesWrap struct { + Sources []struct { + DisplayName string `xml:"displayName,attr"` + ID string `xml:"id,attr"` + Secret string `xml:"secret,attr"` + SecretType string `xml:"secretType,attr"` + SourceKey struct { + Account string `xml:"account,attr"` + Type string `xml:"type,attr"` + } `xml:"sourceKey"` + } `xml:"source"` + } + + if err := xml.Unmarshal(data, &sourcesWrap); err != nil { + return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err) + } + + var sources []models.ConfiguredSource + lastID := 100001 + for _, s := range sourcesWrap.Sources { + id := s.ID + if id == "" { + id = strconv.Itoa(lastID) + lastID++ + } + sources = append(sources, models.ConfiguredSource{ + DisplayName: s.DisplayName, + ID: id, + Secret: s.Secret, + SecretType: s.SecretType, + SourceKeyType: s.SourceKey.Type, + SourceKeyAccount: s.SourceKey.Account, + }) + } + + return sources, nil +} + +func (ds *DataStore) SaveConfiguredSources(account string, sources []models.ConfiguredSource) error { + path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) + os.MkdirAll(filepath.Dir(path), 0755) + + type sourceXML struct { + DisplayName string `xml:"displayName,attr"` + ID string `xml:"id,attr"` + Secret string `xml:"secret,attr"` + SecretType string `xml:"secretType,attr"` + SourceKey struct { + Account string `xml:"account,attr"` + Type string `xml:"type,attr"` + } `xml:"sourceKey"` + } + + type sourcesWrap struct { + XMLName xml.Name `xml:"sources"` + Sources []sourceXML `xml:"source"` + } + + wrap := sourcesWrap{} + for _, s := range sources { + sx := sourceXML{ + DisplayName: s.DisplayName, + ID: s.ID, + Secret: s.Secret, + SecretType: s.SecretType, + } + sx.SourceKey.Account = s.SourceKeyAccount + sx.SourceKey.Type = s.SourceKeyType + wrap.Sources = append(wrap.Sources, sx) + } + + data, err := xml.MarshalIndent(wrap, "", " ") + if err != nil { + return err + } + + header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) +} + +func (ds *DataStore) Initialize() error { + // Ensure base data directory exists + if err := os.MkdirAll(ds.DataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + // Ensure default account exists + defaultDir := ds.AccountDir("default") + if err := os.MkdirAll(defaultDir, 0755); err != nil { + return fmt.Errorf("failed to create default account directory: %w", err) + } + + // Ensure devices subdirectory for default account + if err := os.MkdirAll(ds.AccountDevicesDir("default"), 0755); err != nil { + return fmt.Errorf("failed to create default devices directory: %w", err) + } + + return nil +} + +func (ds *DataStore) GetETagForPresets(account string) int64 { + path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.ModTime().UnixNano() / int64(time.Millisecond) +} + +func (ds *DataStore) GetETagForSources(account string) int64 { + path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.ModTime().UnixNano() / int64(time.Millisecond) +} + +func (ds *DataStore) GetETagForRecents(account string) int64 { + path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.ModTime().UnixNano() / int64(time.Millisecond) +} + +func (ds *DataStore) GetETagForAccount(account string) int64 { + e1 := ds.GetETagForPresets(account) + e2 := ds.GetETagForSources(account) + e3 := ds.GetETagForRecents(account) + max := e1 + if e2 > max { + max = e2 + } + if e3 > max { + max = e3 + } + return max +} + +func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { + dir := filepath.Join(ds.DataDir, "stats", "usage") + os.MkdirAll(dir, 0755) + filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID) + path := filepath.Join(dir, filename) + data, err := json.MarshalIndent(stats, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error { + dir := filepath.Join(ds.DataDir, "stats", "error") + os.MkdirAll(dir, 0755) + filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID) + path := filepath.Join(dir, filename) + data, err := json.MarshalIndent(stats, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) { + ds.eventMutex.Lock() + defer ds.eventMutex.Unlock() + + events := ds.deviceEvents[deviceID] + events = append(events, event) + + // Keep only last 100 events + if len(events) > 100 { + events = events[len(events)-100:] + } + ds.deviceEvents[deviceID] = events +} + +func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent { + ds.eventMutex.RLock() + defer ds.eventMutex.RUnlock() + + events, ok := ds.deviceEvents[deviceID] + if !ok { + return []models.DeviceEvent{} + } + + // Return a copy to avoid race conditions if the caller modifies it + copiedEvents := make([]models.DeviceEvent, len(events)) + copy(copiedEvents, events) + return copiedEvents +} diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go new file mode 100644 index 0000000..f63ee4a --- /dev/null +++ b/pkg/service/datastore/datastore_test.go @@ -0,0 +1,335 @@ +package datastore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestDataStore(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + account := "test-account" + device := "test-device" + + // Test Save/Get DeviceInfo + info := &models.ServiceDeviceInfo{ + DeviceID: device, + Name: "Test Speaker", + } + err = ds.SaveDeviceInfo(account, device, info) + if err != nil { + t.Errorf("SaveDeviceInfo failed: %v", err) + } + + loadedInfo, err := ds.GetDeviceInfo(account, device) + if err != nil { + t.Errorf("GetDeviceInfo failed: %v", err) + } + if loadedInfo.Name != info.Name { + t.Errorf("Expected name %s, got %s", info.Name, loadedInfo.Name) + } + + // Test Presets + presets := []models.ServicePreset{ + { + ServiceContentItem: models.ServiceContentItem{ + Name: "Preset 1", + }, + }, + } + err = ds.SavePresets(account, presets) + if err != nil { + t.Errorf("SavePresets failed: %v", err) + } + + loadedPresets, err := ds.GetPresets(account) + if err != nil { + t.Errorf("GetPresets failed: %v", err) + } + if len(loadedPresets) != 1 || loadedPresets[0].ServiceContentItem.Name != "Preset 1" { + t.Errorf("Unexpected presets: %+v", loadedPresets) + } + + // Test Recents + recents := []models.ServiceRecent{ + { + ServiceContentItem: models.ServiceContentItem{ + Name: "Recent 1", + }, + }, + } + err = ds.SaveRecents(account, recents) + if err != nil { + t.Errorf("SaveRecents failed: %v", err) + } + + loadedRecents, err := ds.GetRecents(account) + if err != nil { + t.Errorf("GetRecents failed: %v", err) + } + if len(loadedRecents) != 1 || loadedRecents[0].ServiceContentItem.Name != "Recent 1" { + t.Errorf("Unexpected recents: %+v", loadedRecents) + } + + // Test path helpers + expectedAccountDir := filepath.Join(tempDir, account) + if ds.AccountDir(account) != expectedAccountDir { + t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account)) + } +} + +func TestListAllDevices_Empty(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-empty-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + + // Case 1: DataDir does not exist + os.RemoveAll(tempDir) + devices, err := ds.ListAllDevices() + if err != nil { + t.Errorf("ListAllDevices should not return error when DataDir does not exist, got %v", err) + } + if devices == nil || len(devices) != 0 { + t.Errorf("Expected empty slice when DataDir does not exist, got %+v", devices) + } + + // Case 2: DataDir is empty + os.MkdirAll(tempDir, 0755) + devices, err = ds.ListAllDevices() + if err != nil { + t.Errorf("ListAllDevices failed on empty dir: %v", err) + } + if devices == nil { + t.Errorf("Expected empty slice (not nil) when no devices exist") + } + if len(devices) != 0 { + t.Errorf("Expected 0 devices, got %d", len(devices)) + } +} + +func TestListAllDevices(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-list-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + account := "default" + deviceID := "BO5EBO5E-F00D-F00D-FEED-08DF1F0BA325" + + info := &models.ServiceDeviceInfo{ + DeviceID: deviceID, + Name: "Test Speaker", + IPAddress: "192.168.178.28", + DeviceSerialNumber: deviceID, + ProductCode: "SoundTouch 10", + FirmwareVersion: "1.2.3", + } + + err = ds.SaveDeviceInfo(account, deviceID, info) + if err != nil { + t.Fatalf("SaveDeviceInfo failed: %v", err) + } + + devices, err := ds.ListAllDevices() + if err != nil { + t.Fatalf("ListAllDevices failed: %v", err) + } + + if len(devices) != 1 { + t.Fatalf("Expected 1 device, got %d", len(devices)) + } + + if devices[0].DeviceID != deviceID { + t.Errorf("Expected DeviceID %s, got %s", deviceID, devices[0].DeviceID) + } +} + +func TestListAllDevices_EmptyDeviceID(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-empty-id-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + account := "default" + deviceID := "" + + info := &models.ServiceDeviceInfo{ + DeviceID: deviceID, + Name: "Empty ID Speaker", + } + + // Use IP as fallback for device ID if it is empty + key := deviceID + if key == "" { + key = "127.0.0.1" + } + err = ds.SaveDeviceInfo(account, key, info) + if err != nil { + t.Fatalf("SaveDeviceInfo failed: %v", err) + } + + devices, err := ds.ListAllDevices() + if err != nil { + t.Fatalf("ListAllDevices failed: %v", err) + } + + if len(devices) != 1 { + t.Fatalf("Expected 1 device, got %d", len(devices)) + } + + if devices[0].Name != "Empty ID Speaker" { + t.Errorf("Expected Name 'Empty ID Speaker', got %s", devices[0].Name) + } +} + +func TestListAllDevices_MultipleEmptyIDs(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-multi-empty-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + account := "default" + + // Save two devices with empty ID but different IPs + info1 := &models.ServiceDeviceInfo{ + DeviceID: "", + Name: "Speaker 1", + IPAddress: "192.168.1.1", + } + info2 := &models.ServiceDeviceInfo{ + DeviceID: "", + Name: "Speaker 2", + IPAddress: "192.168.1.2", + } + + // We use the same logic as in main.go: use IP as fallback for directory name + err = ds.SaveDeviceInfo(account, info1.IPAddress, info1) + if err != nil { + t.Fatalf("SaveDeviceInfo 1 failed: %v", err) + } + err = ds.SaveDeviceInfo(account, info2.IPAddress, info2) + if err != nil { + t.Fatalf("SaveDeviceInfo 2 failed: %v", err) + } + + devices, err := ds.ListAllDevices() + if err != nil { + t.Fatalf("ListAllDevices failed: %v", err) + } + + if len(devices) != 2 { + t.Fatalf("Expected 2 devices, got %d", len(devices)) + } +} + +func TestListAllDevices_MalformedXML(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-malformed-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + account := "default" + deviceID := "malformed-device" + + dir := ds.AccountDeviceDir(account, deviceID) + os.MkdirAll(dir, 0755) + os.WriteFile(filepath.Join(dir, "DeviceInfo.xml"), []byte("not even closed"), 0644) + + devices, err := ds.ListAllDevices() + if err != nil { + t.Fatalf("ListAllDevices should not return error on malformed XML: %v", err) + } + + if len(devices) != 0 { + t.Errorf("Expected 0 devices, got %d", len(devices)) + } +} + +func TestConfiguredSources(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "datastore-sources-*") + defer os.RemoveAll(tempDir) + ds := NewDataStore(tempDir) + account := "test-acc" + + sources := []models.ConfiguredSource{ + { + DisplayName: "Source 1", + ID: "101", + Secret: "secret1", + SecretType: "type1", + SourceKeyType: "TUNEIN", + SourceKeyAccount: "user1", + }, + { + DisplayName: "Source 2", + ID: "102", + Secret: "secret2", + SecretType: "type2", + SourceKeyType: "PANDORA", + SourceKeyAccount: "user2", + }, + } + + err := ds.SaveConfiguredSources(account, sources) + if err != nil { + t.Fatalf("SaveConfiguredSources failed: %v", err) + } + + loadedSources, err := ds.GetConfiguredSources(account) + if err != nil { + t.Fatalf("GetConfiguredSources failed: %v", err) + } + + if len(loadedSources) != len(sources) { + t.Fatalf("Expected %d sources, got %d", len(sources), len(loadedSources)) + } + + for i, s := range sources { + ls := loadedSources[i] + if ls.DisplayName != s.DisplayName || ls.ID != s.ID || ls.Secret != s.Secret || + ls.SecretType != s.SecretType || ls.SourceKeyType != s.SourceKeyType || + ls.SourceKeyAccount != s.SourceKeyAccount { + t.Errorf("Source %d mismatch. Expected %+v, got %+v", i, s, ls) + } + } + + // Test with missing ID (GetConfiguredSources should auto-assign) + sources2 := []models.ConfiguredSource{ + { + DisplayName: "Source No ID", + SourceKeyType: "LOCAL", + SourceKeyAccount: "user3", + }, + } + err = ds.SaveConfiguredSources(account, sources2) + if err != nil { + t.Fatal(err) + } + loadedSources2, err := ds.GetConfiguredSources(account) + if err != nil { + t.Fatal(err) + } + if loadedSources2[0].ID == "" { + t.Error("Expected auto-assigned ID for source with empty ID") + } +} diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go new file mode 100644 index 0000000..13cb5fd --- /dev/null +++ b/pkg/service/handlers/handlers_bmx.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "os" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/service/bmx" + "github.com/go-chi/chi/v5" +) + +func (s *Server) HandleBMXRegistry(w http.ResponseWriter, r *http.Request) { + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:8000" + } + + content := string(bmxServicesJSON) + content = strings.ReplaceAll(content, "{BMX_SERVER}", baseURL) + content = strings.ReplaceAll(content, "{MEDIA_SERVER}", baseURL+"/media") + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(content)) +} + +func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) { + stationID := chi.URLParam(r, "stationID") + resp, err := bmx.TuneInPlayback(stationID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) { + podcastID := chi.URLParam(r, "podcastID") + encodedName := r.URL.Query().Get("encoded_name") + resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) { + podcastID := chi.URLParam(r, "podcastID") + resp, err := bmx.TuneInPlaybackPodcast(podcastID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { + data := chi.URLParam(r, "data") + resp, err := bmx.PlayCustomStream(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/pkg/service/handlers/handlers_bmx_test.go b/pkg/service/handlers/handlers_bmx_test.go new file mode 100644 index 0000000..35466ad --- /dev/null +++ b/pkg/service/handlers/handlers_bmx_test.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBMXServices(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Get(ts.URL + "/bmx/registry/v1/services") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + var response map[string]interface{} + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if _, ok := response["bmx_services"]; !ok { + t.Error("Response missing bmx_services field") + } + + // Verify placeholder replacement + bodyStr := string(body) + if strings.Contains(bodyStr, "{BMX_SERVER}") { + t.Error("Response still contains {BMX_SERVER} placeholder") + } + if strings.Contains(bodyStr, "{MEDIA_SERVER}") { + t.Error("Response still contains {MEDIA_SERVER} placeholder") + } +} + +func TestOrionPlayback(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + // Base64 encoded: {"streamUrl": "http://example.com/stream", "imageUrl": "http://example.com/img.jpg", "name": "Test Orion"} + data := "eyJzdHJlYW1VcmwiOiAiaHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbSIsICJpbWFnZVVybCI6ICJodHRwOi8vZXhhbXBsZS5jb20vaW1nLmpwZyIsICJuYW1lIjogIlRlc3QgT3Jpb24ifQ==" + res, err := http.Post(ts.URL+"/bmx/orion/v1/playback/station/"+data, "application/json", nil) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + var resp map[string]interface{} + json.Unmarshal(body, &resp) + + if resp["name"] != "Test Orion" { + t.Errorf("Expected name Test Orion, got %v", resp["name"]) + } +} diff --git a/pkg/service/handlers/handlers_etag_test.go b/pkg/service/handlers/handlers_etag_test.go new file mode 100644 index 0000000..2787be6 --- /dev/null +++ b/pkg/service/handlers/handlers_etag_test.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func TestMargeETags(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "soundcork-etag-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + + account := "12345" + accountDir := filepath.Join(tempDir, account) + os.MkdirAll(accountDir, 0755) + + // Create some initial data + presetsFile := filepath.Join(accountDir, "Presets.xml") + os.WriteFile(presetsFile, []byte(""), 0644) + sourcesFile := filepath.Join(accountDir, "Sources.xml") + os.WriteFile(sourcesFile, []byte(""), 0644) + recentsFile := filepath.Join(accountDir, "Recents.xml") + os.WriteFile(recentsFile, []byte(""), 0644) + + // Ensure devices directory exists for AccountFull + os.MkdirAll(ds.AccountDevicesDir(account), 0755) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + t.Run("Presets ETag", func(t *testing.T) { + // First request to get ETag + res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/devices/DEV1/presets") + if err != nil { + t.Fatal(err) + } + etag := res.Header.Get("ETag") + res.Body.Close() + + if etag == "" { + t.Fatal("Expected ETag header, got none") + } + + // Second request with If-None-Match + req, _ := http.NewRequest("GET", ts.URL+"/marge/accounts/"+account+"/devices/DEV1/presets", nil) + req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res2.Body.Close() + + if res2.StatusCode != http.StatusNotModified { + t.Errorf("Expected 304 Not Modified, got %v", res2.Status) + } + }) + + t.Run("AccountFull ETag", func(t *testing.T) { + res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/full") + if err != nil { + t.Fatal(err) + } + etag := res.Header.Get("ETag") + res.Body.Close() + + if etag == "" { + t.Fatal("Expected ETag header, got none") + } + + req, _ := http.NewRequest("GET", ts.URL+"/marge/accounts/"+account+"/full", nil) + req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res2.Body.Close() + + if res2.StatusCode != http.StatusNotModified { + t.Errorf("Expected 304 Not Modified, got %v", res2.Status) + } + }) + + t.Run("SourceProviders ETag (Dynamic)", func(t *testing.T) { + res, err := http.Get(ts.URL + "/marge/streaming/sourceproviders") + if err != nil { + t.Fatal(err) + } + etag := res.Header.Get("ETag") + res.Body.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/marge/streaming/sourceproviders", nil) + req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res2.Body.Close() + + // For SourceProviders, we currently use time.Now(), so this might fail if it crosses a millisecond boundary. + // In a real scenario, this would likely be stable during a single SoundTouch session's refresh. + if res2.StatusCode != http.StatusNotModified { + t.Logf("SourceProviders ETag changed (expected if ms boundary crossed)") + } + }) + + t.Run("SoftwareUpdate ETag", func(t *testing.T) { + res, err := http.Get(ts.URL + "/marge/updates/soundtouch") + if err != nil { + t.Fatal(err) + } + etag := res.Header.Get("ETag") + res.Body.Close() + + if etag == "" { + t.Fatal("Expected ETag header for swupdate") + } + + req, _ := http.NewRequest("GET", ts.URL+"/marge/updates/soundtouch", nil) + req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res2.Body.Close() + + if res2.StatusCode != http.StatusNotModified { + t.Errorf("Expected 304 Not Modified for swupdate, got %v", res2.Status) + } + }) + + t.Run("Negative ETag Test", func(t *testing.T) { + req, _ := http.NewRequest("GET", ts.URL+"/marge/accounts/"+account+"/full", nil) + req.Header.Set("If-None-Match", "wrong-etag") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected 200 OK for wrong ETag, got %v", res.Status) + } + }) + + t.Run("ETag Header Case Sensitivity", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/marge/accounts/"+account+"/full", nil) + r.ServeHTTP(w, req) + + t.Logf("Recorder Headers: %v", w.Header()) + + found := false + for k := range w.Header() { + if k == "ETag" { + found = true + break + } + } + + if !found { + t.Errorf("Expected exact 'ETag' header in recorder, but it was not found in: %v", w.Header()) + } + }) + + t.Run("ETag Header Case Sensitivity (Proxy)", func(t *testing.T) { + // Mock a backend response with lowercase 'etag' + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["etag"] = []string{"backend-etag"} + w.WriteHeader(http.StatusOK) + w.Write([]byte("")) + })) + defer backend.Close() + + target, _ := url.Parse(backend.URL) + pyProxy := httputil.NewSingleHostReverseProxy(target) + pyProxy.ModifyResponse = func(res *http.Response) error { + // Generic Header Restoration: + // Move Etag to ETag + if etags, ok := res.Header["Etag"]; ok { + delete(res.Header, "Etag") + res.Header["ETag"] = etags + } + return nil + } + + // We'll use a direct call to the ModifyResponse to check logic + resp := &http.Response{ + Header: make(http.Header), + } + resp.Header["Etag"] = []string{"test-etag"} + pyProxy.ModifyResponse(resp) + + if _, ok := resp.Header["ETag"]; !ok { + t.Errorf("ModifyResponse did not normalize ETag casing. Headers: %v", resp.Header) + } + + // Negative check: ensure 'Etag' is gone + if _, ok := resp.Header["Etag"]; ok { + t.Error("Etag header still present after normalization") + } + }) + + t.Run("X-Bose-Token Casing Test", func(t *testing.T) { + w := httptest.NewRecorder() + // Test that using direct map access on w.Header() preserves casing + w.Header()["X-BOSE-TOKEN"] = []string{"token"} + + found := false + for k := range w.Header() { + if k == "X-BOSE-TOKEN" { + found = true + break + } + } + if !found { + t.Errorf("Expected exact 'X-BOSE-TOKEN' header in recorder, but it was normalized: %v", w.Header()) + } + }) + + t.Run("Golang Header Normalization Documentation", func(t *testing.T) { + // This test documents how Go's http.Header.Set/Get canonicalizes keys. + h := make(http.Header) + + // 1. Set canonicalizes to "Etag" (Standard Go behavior) + h.Set("ETag", "v1") + if _, ok := h["Etag"]; !ok { + t.Errorf("Expected key 'Etag' in map after Set('ETag'), but got: %v", h) + } + if _, ok := h["ETag"]; ok { + // In Go's map, "ETag" and "Etag" are different keys. + // Set() uses CanonicalHeaderKey which produces "Etag" (lowercase 't'). + t.Errorf("Did not expect exact key 'ETag' in map after Set('ETag') because Go canonicalizes to 'Etag'") + } + + // 2. Get() also canonicalizes the key before lookup + if val := h.Get("ETAG"); val != "v1" { + t.Errorf("Expected Get('ETAG') to find 'v1' due to canonicalization, got %q", val) + } + + // 3. Direct map access bypasses normalization + h["X-Bose-Token"] = []string{"v2"} + if _, ok := h["X-Bose-Token"]; !ok { + t.Error("Expected exact key 'X-Bose-Token' to be present") + } + // However, Get() will still look for "X-Bose-Token" (canonicalized) + // Wait, CanonicalHeaderKey("X-Bose-Token") is "X-Bose-Token" anyway. + // Let's try something that changes. + h["etag"] = []string{"v3"} + if h.Get("etag") != "v1" { + // Get("etag") -> Get(Canonical("etag")) -> Get("Etag") -> returns "v1" + // It does NOT find "v3" because "etag" != "Etag" in the map. + t.Errorf("Get('etag') found %q, but we expected it to find the canonical 'Etag' value 'v1'", h.Get("etag")) + } + }) +} diff --git a/pkg/service/handlers/handlers_events.go b/pkg/service/handlers/handlers_events.go new file mode 100644 index 0000000..b6e12a1 --- /dev/null +++ b/pkg/service/handlers/handlers_events.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (s *Server) HandleGetDeviceEvents(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + events := s.ds.GetDeviceEvents(deviceID) + + resp := map[string]interface{}{ + "events": events, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/pkg/service/handlers/handlers_events_test.go b/pkg/service/handlers/handlers_events_test.go new file mode 100644 index 0000000..323eab7 --- /dev/null +++ b/pkg/service/handlers/handlers_events_test.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/go-chi/chi/v5" +) + +func TestEventLog(t *testing.T) { + ds := datastore.NewDataStore(t.TempDir()) + s := &Server{ds: ds} + + r := chi.NewRouter() + r.Post("/streaming/stats/usage", s.HandleUsageStats) + r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents) + + t.Run("Record and Retrieve Events", func(t *testing.T) { + // 1. Post a usage stat + usageBody := `{ + "deviceId": "SPEAKER1", + "eventType": "play-start", + "parameters": {"source": "TUNEIN"} + }` + req, _ := http.NewRequest("POST", "/streaming/stats/usage", strings.NewReader(usageBody)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", w.Code) + } + + // 2. Retrieve events + req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", w.Code) + } + + var resp struct { + Events []models.DeviceEvent `json:"events"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Events) != 1 { + t.Fatalf("Expected 1 event, got %d", len(resp.Events)) + } + + if resp.Events[0].Type != "play-start" { + t.Errorf("Expected event type 'play-start', got %q", resp.Events[0].Type) + } + }) +} diff --git a/pkg/service/handlers/handlers_health.go b/pkg/service/handlers/handlers_health.go new file mode 100644 index 0000000..31576b4 --- /dev/null +++ b/pkg/service/handlers/handlers_health.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "runtime/debug" + "time" +) + +func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { + version := "0.0.1" + vcsRevision := "" + vcsTime := "" + vcsModified := "" + + if info, ok := debug.ReadBuildInfo(); ok { + if info.Main.Version != "" && info.Main.Version != "(devel)" { + version = info.Main.Version + } + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + vcsRevision = setting.Value + case "vcs.time": + vcsTime = setting.Value + case "vcs.modified": + vcsModified = setting.Value + } + } + } + + status := map[string]interface{}{ + "status": "up", + "timestamp": time.Now().Format(time.RFC3339), + "version": version, + } + if vcsRevision != "" { + status["vcs_revision"] = vcsRevision + } + if vcsTime != "" { + status["vcs_time"] = vcsTime + } + if vcsModified != "" { + status["vcs_modified"] = vcsModified + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(status) +} diff --git a/pkg/service/handlers/handlers_health_test.go b/pkg/service/handlers/handlers_health_test.go new file mode 100644 index 0000000..42a7c0b --- /dev/null +++ b/pkg/service/handlers/handlers_health_test.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +type healthResp struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + VcsRevision string `json:"vcs_revision"` + VcsTime string `json:"vcs_time"` + VcsModified string `json:"vcs_modified"` +} + +func TestHealthEndpoint(t *testing.T) { + r := chi.NewRouter() + srv := &Server{} + r.Get("/health", srv.HandleHealth) + + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Get(ts.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200 OK, got %v", res.Status) + } + if ct := res.Header.Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json content type, got %s", ct) + } + + var hr healthResp + if err := json.NewDecoder(res.Body).Decode(&hr); err != nil { + t.Fatalf("failed to decode health response: %v", err) + } + if hr.Status != "up" { + t.Fatalf("expected status 'up', got %q", hr.Status) + } + if hr.Timestamp == "" { + t.Error("expected non-empty timestamp") + } + if hr.Version == "" { + t.Error("expected non-empty version") + } +} diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go new file mode 100644 index 0000000..138cde1 --- /dev/null +++ b/pkg/service/handlers/handlers_marge.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "encoding/xml" + "io" + "log" + "net/http" + "strconv" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/marge" + "github.com/go-chi/chi/v5" +) + +func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) { + etag := strconv.FormatInt(time.Now().UnixMilli(), 10) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + data, err := marge.SourceProvidersToXML() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Header()["ETag"] = []string{etag} + w.Write(data) +} + +func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + etag := strconv.FormatInt(s.ds.GetETagForAccount(account), 10) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + data, err := marge.AccountFullToXML(s.ds, account) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Header()["ETag"] = []string{etag} + w.Write(data) +} + +func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Request) { + etag := "default-embedded" + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + w.Header().Set("Content-Type", "application/xml") + w.Header()["ETag"] = []string{etag} + + if len(swUpdateXML) > 0 { + w.Write(swUpdateXML) + } else { + w.Write([]byte(marge.SoftwareUpdateToXML())) + } +} + +func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + data, err := marge.PresetsToXML(s.ds, account) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Header()["ETag"] = []string{etag} + w.Write(data) +} + +func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + device := chi.URLParam(r, "device") + + etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10) + w.Header()["ETag"] = []string{etag} + + presetNumberStr := chi.URLParam(r, "presetNumber") + presetNumber, err := strconv.Atoi(presetNumberStr) + if err != nil { + http.Error(w, "Invalid preset number", http.StatusBadRequest) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusInternalServerError) + return + } + data, err := marge.UpdatePreset(s.ds, account, device, presetNumber, body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Write(data) +} + +func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + device := chi.URLParam(r, "device") + + etag := strconv.FormatInt(s.ds.GetETagForRecents(account), 10) + w.Header()["ETag"] = []string{etag} + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusInternalServerError) + return + } + data, err := marge.AddRecent(s.ds, account, device, body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Write(data) +} + +func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusInternalServerError) + return + } + data, err := marge.AddDeviceToAccount(s.ds, account, body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Write(data) +} + +func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + device := chi.URLParam(r, "device") + if err := marge.RemoveDeviceFromAccount(s.ds, account, device); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) +} + +func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(marge.ProviderSettingsToXML(account))) +} + +func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, r *http.Request) { + // Simple mock token for offline use. + // In a real production environment, this would be a JWT or similar signed token. + // Some speakers might expect a specific format; soundcork uses a distinctive prefix + // to indicate it's a locally generated token. + token := "soundcork-local-token-" + strconv.FormatInt(time.Now().Unix(), 10) + w.Header().Set("Authorization", "Bearer "+token) + w.WriteHeader(http.StatusOK) +} + +func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + var req models.CustomerSupportRequest + if err := xml.Unmarshal(body, &req); err != nil { + // Log error but might still return 200 as Bose expects + log.Printf("Failed to unmarshal CustomerSupportRequest: %v", err) + } + + // Create a DeviceEvent for support data + event := models.DeviceEvent{ + Type: "customer-support-upload", + Time: time.Now().Format(time.RFC3339), + MonoTime: time.Now().UnixNano() / int64(time.Millisecond), + Data: map[string]interface{}{ + "firmware": req.Device.FirmwareVersion, + "product": req.Device.Product.ProductCode, + "ip": req.DiagnosticData.DeviceLandscape.IPAddress, + "rssi": req.DiagnosticData.DeviceLandscape.RSSI, + }, + } + s.ds.AddDeviceEvent(req.Device.ID, event) + + w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml") + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go new file mode 100644 index 0000000..736aa16 --- /dev/null +++ b/pkg/service/handlers/handlers_marge_test.go @@ -0,0 +1,415 @@ +package handlers + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func TestMargeSourceProviders(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Get(ts.URL + "/marge/streaming/sourceproviders") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "") { + t.Error("Response missing tag") + } +} + +func TestMargeSoftwareUpdate(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Get(ts.URL + "/marge/updates/soundtouch") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + // Should contain software_update or INDEX (if swupdate.xml exists) + if !strings.Contains(string(body), "software_update") && !strings.Contains(string(body), "INDEX") { + t.Errorf("Unexpected response: %s", string(body)) + } +} + +func TestMargeAccountFull(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "soundcork-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + + account := "12345" + deviceID := "ABCDE" + accountDir := filepath.Join(tempDir, account) + deviceDir := filepath.Join(accountDir, "devices", deviceID) + os.MkdirAll(deviceDir, 0755) + + // Mock DeviceInfo.xml + os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(` + + Test Speaker + SoundTouch 20 + Series II + + + SCM + 19.0.5 + SN123 + + + + 192.168.1.100 + + + `), 0644) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/full") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "ABCDE") || !strings.Contains(string(body), "Test Speaker") { + t.Errorf("Response missing expected device data: %s", string(body)) + } +} + +func TestMargePresets(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "soundcork-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + + account := "12345" + accountDir := filepath.Join(tempDir, account) + os.MkdirAll(accountDir, 0755) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + // Mock Sources.xml and Presets.xml + os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + + + 2012-09-19T12:43:00.000+00:00 + + TUNEIN + 1 + TUNEIN + + 2012-09-19T12:43:00.000+00:00 + + + + `), 0644) + os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(` + + + + Test Station + http://example.com/art.jpg + + + + `), 0644) + + res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/devices/any/presets") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "Test Station") { + t.Errorf("Response missing preset data: %s", string(body)) + } +} + +func TestMargeUpdatePreset(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "soundcork-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + + account := "12345" + accountDir := filepath.Join(tempDir, account) + os.MkdirAll(accountDir, 0755) + + // Mock Sources.xml + os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + + + TUNEIN + + + `), 0644) + os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(``), 0644) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + payload := ` + + New Preset + SRC1 + /station/s999 + station + http://example.com/new.jpg + ` + + res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/presets/1", "application/xml", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Errorf("Expected status OK, got %v: %s", res.Status, string(body)) + } + + // Verify file was saved + presetData, _ := os.ReadFile(filepath.Join(accountDir, "Presets.xml")) + if !strings.Contains(string(presetData), "New Preset") { + t.Error("Preset was not saved to datastore") + } +} + +func TestMargeAddRecent(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "soundcork-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + + account := "12345" + accountDir := filepath.Join(tempDir, account) + os.MkdirAll(accountDir, 0755) + + // Mock Sources.xml + os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + + + TUNEIN + + + `), 0644) + os.WriteFile(filepath.Join(accountDir, "Recents.xml"), []byte(``), 0644) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + payload := ` + + Recent Station + SRC1 + /station/s888 + station + ` + + res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/recents", "application/xml", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + // Verify file was saved + recentData, _ := os.ReadFile(filepath.Join(accountDir, "Recents.xml")) + if !strings.Contains(string(recentData), "Recent Station") { + t.Error("Recent was not saved to datastore") + } +} + +func TestMargeAddRemoveDevice(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "soundcork-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + + account := "12345" + accountDir := filepath.Join(tempDir, account) + os.MkdirAll(accountDir, 0755) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + // 1. Add Device + payload := ` + + New Speaker + SoundTouch 10 + Series I + + + SCM + 1.0.0 + SN_NEW + + + + 192.168.1.101 + + ` + + res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices", "application/xml", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("AddDevice: Expected status OK, got %v", res.Status) + } + + deviceFile := filepath.Join(accountDir, "devices", "NEWDEV", "DeviceInfo.xml") + if _, err := os.Stat(deviceFile); os.IsNotExist(err) { + t.Error("DeviceInfo.xml was not created") + } + + // 2. Remove Device + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/marge/accounts/"+account+"/devices/NEWDEV", nil) + res, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("RemoveDevice: Expected status OK, got %v", res.Status) + } + + if _, err := os.Stat(deviceFile); !os.IsNotExist(err) { + t.Error("DeviceInfo.xml was not deleted") + } +} + +func TestMargePowerOn(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte(""))) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } +} + +func TestMargeAdvancedFeatures(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "soundcork-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + t.Run("ProviderSettings", func(t *testing.T) { + res, err := http.Get(ts.URL + "/marge/streaming/account/123/provider_settings") + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "123") { + t.Errorf("Response body missing account ID: %s", body) + } + }) + + t.Run("StreamingToken", func(t *testing.T) { + res, err := http.Get(ts.URL + "/marge/streaming/device/DEV1/streaming_token") + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + token := res.Header.Get("Authorization") + if !strings.HasPrefix(token, "Bearer soundcork-local-token-") { + t.Errorf("Invalid token header: %s", token) + } + }) + + t.Run("CustomerSupport", func(t *testing.T) { + payload := ` + + + P123 + 27.0.6 + + SN123 + + + + + Good + 192.168.1.100 + + + ` + res, err := http.Post(ts.URL+"/marge/streaming/support/customersupport", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + // Verify event was recorded + events := ds.GetDeviceEvents("587A628A4042") + found := false + for _, e := range events { + if e.Type == "customer-support-upload" { + found = true + if e.Data["firmware"] != "27.0.6" { + t.Errorf("Expected firmware 27.0.6, got %v", e.Data["firmware"]) + } + break + } + } + if !found { + t.Error("Customer support event not found in event log") + } + }) +} diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go new file mode 100644 index 0000000..e0bdb6f --- /dev/null +++ b/pkg/service/handlers/handlers_media.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "strings" +) + +//go:embed index.html +var indexHTML []byte + +//go:embed soundcork/media/* +var mediaFS embed.FS + +//go:embed soundcork/bmx_services.json +var bmxServicesJSON []byte + +//go:embed soundcork/swupdate.xml +var swUpdateXML []byte + +func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("Accept") + if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`) + return + } + + w.Header().Set("Content-Type", "text/html") + w.Write(indexHTML) +} + +func (s *Server) HandleMedia() http.HandlerFunc { + subFS, _ := fs.Sub(mediaFS, "soundcork/media") + return func(w http.ResponseWriter, r *http.Request) { + fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS))) + fs.ServeHTTP(w, r) + } +} diff --git a/pkg/service/handlers/handlers_media_test.go b/pkg/service/handlers/handlers_media_test.go new file mode 100644 index 0000000..06dc69e --- /dev/null +++ b/pkg/service/handlers/handlers_media_test.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRootEndpoint(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL+"/", nil) + req.Header.Set("Accept", "text/html") + + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + contentType := res.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/html") { + t.Errorf("Expected text/html content type, got %s", contentType) + } + + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "Soundcork Management") { + t.Errorf("Expected body to contain 'Soundcork Management', got %s", string(body)) + } +} + +func TestRootEndpointJSON(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL+"/", nil) + req.Header.Set("Accept", "application/json") + + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + contentType := res.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("Expected application/json content type, got %s", contentType) + } + + body, _ := io.ReadAll(res.Body) + expected := `{"Bose": "Can't Brick Us", "service": "Go/Chi"}` + if strings.TrimSpace(string(body)) != expected { + t.Errorf("Expected body %s, got %s", expected, string(body)) + } +} + +func TestStaticMedia(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + // Use a known file from soundcork/media + res, err := http.Get(ts.URL + "/media/SiriusXM_Logo_Color.svg") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + contentType := res.Header.Get("Content-Type") + if !strings.Contains(contentType, "image/svg+xml") { + t.Errorf("Expected image/svg+xml content type, got %s", contentType) + } +} diff --git a/pkg/service/handlers/handlers_proxy.go b/pkg/service/handlers/handlers_proxy.go new file mode 100644 index 0000000..424910f --- /dev/null +++ b/pkg/service/handlers/handlers_proxy.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/service/proxy" +) + +func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) { + targetURLStr := strings.TrimPrefix(r.URL.Path, "/proxy/") + if targetURLStr == "" { + http.Error(w, "Target URL is required", http.StatusBadRequest) + return + } + + // Reconstruct original URL (it might have lost its double slashes in the path) + if !strings.HasPrefix(targetURLStr, "http://") && !strings.HasPrefix(targetURLStr, "https://") { + // Try to fix it if it looks like http:/... + if strings.HasPrefix(targetURLStr, "http:/") { + targetURLStr = "http://" + strings.TrimPrefix(targetURLStr, "http:/") + } else if strings.HasPrefix(targetURLStr, "https:/") { + targetURLStr = "https://" + strings.TrimPrefix(targetURLStr, "https:/") + } + } + + target, err := url.Parse(targetURLStr) + if err != nil { + http.Error(w, "Invalid target URL: "+err.Error(), http.StatusBadRequest) + return + } + + lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact) + lp.LogBody = s.proxyLogBody + + proxy := httputil.NewSingleHostReverseProxy(target) + // Update director to set the correct host and path + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + req.Host = target.Host + req.URL.Path = target.Path + req.URL.RawQuery = r.URL.RawQuery + lp.LogRequest(req) + } + + proxy.ModifyResponse = func(res *http.Response) error { + // Generic Header Preservation + if etags, ok := res.Header["Etag"]; ok { + delete(res.Header, "Etag") + res.Header["ETag"] = etags + } + + lp.LogResponse(res) + return nil + } + + proxy.ServeHTTP(w, r) +} diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go new file mode 100644 index 0000000..029bd57 --- /dev/null +++ b/pkg/service/handlers/handlers_setup.go @@ -0,0 +1,177 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, r *http.Request) { + devices, err := s.ds.ListAllDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devices) +} + +func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, r *http.Request) { + go s.DiscoverDevices() + w.WriteHeader(http.StatusAccepted) + w.Write([]byte(`{"status": "Discovery started"}`)) +} + +func (s *Server) HandleGetDiscoveryStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"discovering": s.discovering}) +} + +func (s *Server) HandleGetSettings(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "server_url": s.serverURL, + "proxy_url": s.proxyURL, + }) +} + +func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) { + deviceIP := chi.URLParam(r, "deviceIP") + if deviceIP == "" { + http.Error(w, "Device IP is required", http.StatusBadRequest) + return + } + + info, err := s.sm.GetLiveDeviceInfo(deviceIP) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) { + deviceIP := chi.URLParam(r, "deviceIP") + if deviceIP == "" { + http.Error(w, "Device IP is required", http.StatusBadRequest) + return + } + + targetURL := r.URL.Query().Get("target_url") + proxyURL := r.URL.Query().Get("proxy_url") + + options := make(map[string]string) + for k, v := range r.URL.Query() { + if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") { + options[k] = v[0] + } + } + + summary, err := s.sm.GetMigrationSummary(deviceIP, targetURL, proxyURL, options) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(summary) +} + +func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { + deviceIP := chi.URLParam(r, "deviceIP") + if deviceIP == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + return + } + + targetURL := r.URL.Query().Get("target_url") + proxyURL := r.URL.Query().Get("proxy_url") + + options := make(map[string]string) + for k, v := range r.URL.Query() { + if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") { + options[k] = v[0] + } + } + + if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started"}) +} + +func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) { + deviceIP := chi.URLParam(r, "deviceIP") + if deviceIP == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + return + } + + if err := s.sm.EnsureRemoteServices(deviceIP); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services ensured"}) +} + +func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { + deviceIP := chi.URLParam(r, "deviceIP") + if deviceIP == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + return + } + + if err := s.sm.BackupConfig(deviceIP); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Backup created"}) +} + +func (s *Server) HandleGetProxySettings(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{ + "redact": s.proxyRedact, + "log_body": s.proxyLogBody, + }) +} + +func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Request) { + var settings struct { + Redact bool `json:"redact"` + LogBody bool `json:"log_body"` + } + if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s.proxyRedact = settings.Redact + s.proxyLogBody = settings.LogBody + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Proxy settings updated"}) +} diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go new file mode 100644 index 0000000..b50e83f --- /dev/null +++ b/pkg/service/handlers/handlers_setup_test.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestProxySettingsAPI(t *testing.T) { + r, server := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) + defer ts.Close() + + // Initial State + server.proxyRedact = true + server.proxyLogBody = false + + // 1. Test GET + res, err := http.Get(ts.URL + "/setup/proxy-settings") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("GET: Expected status OK, got %v", res.Status) + } + + var settings map[string]bool + if err := json.NewDecoder(res.Body).Decode(&settings); err != nil { + t.Fatalf("GET: Failed to decode response: %v", err) + } + + if settings["redact"] != true || settings["log_body"] != false { + t.Errorf("GET: Unexpected settings: %+v", settings) + } + + // 2. Test POST + update := map[string]bool{ + "redact": false, + "log_body": true, + } + body, _ := json.Marshal(update) + res, err = http.Post(ts.URL+"/setup/proxy-settings", "application/json", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("POST: Expected status OK, got %v", res.Status) + } + + // Verify server state + if server.proxyRedact != false || server.proxyLogBody != true { + t.Errorf("POST: Server state did not update: redact=%v, logBody=%v", server.proxyRedact, server.proxyLogBody) + } + + // 3. Verify GET reflects new state + res, _ = http.Get(ts.URL + "/setup/proxy-settings") + defer res.Body.Close() + json.NewDecoder(res.Body).Decode(&settings) + if settings["redact"] != false || settings["log_body"] != true { + t.Errorf("GET (after update): Unexpected settings: %+v", settings) + } +} diff --git a/pkg/service/handlers/handlers_stats.go b/pkg/service/handlers/handlers_stats.go new file mode 100644 index 0000000..7a1504d --- /dev/null +++ b/pkg/service/handlers/handlers_stats.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "encoding/json" + "encoding/xml" + "io" + "net/http" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + var stats models.UsageStats + // Try XML first (Bose devices often use XML) + if err := xml.Unmarshal(body, &stats); err != nil { + // Fallback to JSON + if err := json.Unmarshal(body, &stats); err != nil { + http.Error(w, "Invalid stats format", http.StatusBadRequest) + return + } + } + + if err := s.ds.SaveUsageStats(stats); err != nil { + http.Error(w, "Failed to save usage stats", http.StatusInternalServerError) + return + } + + // Create a DeviceEvent from the usage stats + event := models.DeviceEvent{ + Type: stats.EventType, + Time: stats.Timestamp, + MonoTime: time.Now().UnixNano() / int64(time.Millisecond), + Data: stats.Parameters, + } + if event.Time == "" { + event.Time = time.Now().Format(time.RFC3339) + } + s.ds.AddDeviceEvent(stats.DeviceID, event) + + w.WriteHeader(http.StatusOK) +} + +func (s *Server) HandleErrorStats(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + var stats models.ErrorStats + if err := xml.Unmarshal(body, &stats); err != nil { + if err := json.Unmarshal(body, &stats); err != nil { + http.Error(w, "Invalid error stats format", http.StatusBadRequest) + return + } + } + + if err := s.ds.SaveErrorStats(stats); err != nil { + http.Error(w, "Failed to save error stats", http.StatusInternalServerError) + return + } + + // Create a DeviceEvent from the error stats + event := models.DeviceEvent{ + Type: "device-error", + Time: stats.Timestamp, + MonoTime: time.Now().UnixNano() / int64(time.Millisecond), + Data: map[string]interface{}{ + "errorCode": stats.ErrorCode, + "errorMessage": stats.ErrorMessage, + "details": stats.Details, + }, + } + if event.Time == "" { + event.Time = time.Now().Format(time.RFC3339) + } + s.ds.AddDeviceEvent(stats.DeviceID, event) + + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/service/handlers/handlers_stats_test.go b/pkg/service/handlers/handlers_stats_test.go new file mode 100644 index 0000000..793a544 --- /dev/null +++ b/pkg/service/handlers/handlers_stats_test.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func TestStatsHandlers(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := datastore.NewDataStore(tempDir) + s := &Server{ds: ds} + + t.Run("HandleUsageStats XML", func(t *testing.T) { + xmlData := ` + + device123 + account456 + 2023-10-27T10:00:00Z + PLAYBACK_START +` + req := httptest.NewRequest("POST", "/streaming/stats/usage", bytes.NewBufferString(xmlData)) + w := httptest.NewRecorder() + + s.HandleUsageStats(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", w.Code) + } + + // Verify file creation + files, _ := filepath.Glob(filepath.Join(tempDir, "stats", "usage", "*.json")) + if len(files) == 0 { + t.Error("Usage stats file was not created") + } + }) + + t.Run("HandleErrorStats JSON", func(t *testing.T) { + jsonData := `{"deviceId": "device123", "errorCode": "404", "errorMessage": "Not Found"}` + req := httptest.NewRequest("POST", "/streaming/stats/error", bytes.NewBufferString(jsonData)) + w := httptest.NewRecorder() + + s.HandleErrorStats(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", w.Code) + } + + // Verify file creation + files, _ := filepath.Glob(filepath.Join(tempDir, "stats", "error", "*.json")) + if len(files) == 0 { + t.Error("Error stats file was not created") + } + }) +} diff --git a/pkg/service/handlers/index.html b/pkg/service/handlers/index.html new file mode 100644 index 0000000..cc088db --- /dev/null +++ b/pkg/service/handlers/index.html @@ -0,0 +1,478 @@ + + + + + Soundcork Management + + + + +

Soundcork Management

+

Discovered Devices

+
Loading devices...
+ +
+

Manual Entry

+ + + +

Settings

+
+ + + (This URL will be used for standard services) +
+
+ + + (This URL will be used to proxy upstream Bose services) +
+
+ Proxy Logging: + + +
+
+ +
+ +
+

Migration Summary for

+

SSH Connection:

+ + +

Remote Services Enabled:

+ + + + + +
+
+ Current Config (on Speaker) +

+            
+
+ Planned Config (Soundcork) +

+            
+
+
+ + + +
+
+ + + + diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go new file mode 100644 index 0000000..d0d0685 --- /dev/null +++ b/pkg/service/handlers/main_test.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "net/http" + "net/url" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/go-chi/chi/v5" +) + +func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) { + target, _ := url.Parse(targetURL) + proxy := &reverseProxy{target: target} + server := &Server{ds: ds} + + r := chi.NewRouter() + r.Get("/", server.HandleRoot) + + // Setup media directory for tests + r.Get("/media/*", server.HandleMedia()) + + // Setup BMX for tests + r.Route("/bmx", func(r chi.Router) { + r.Get("/registry/v1/services", server.HandleBMXRegistry) + r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback) + r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) + r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) + r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) + }) + + // Setup Marge for tests + r.Route("/marge", func(r chi.Router) { + r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders) + r.Get("/accounts/{account}/full", server.HandleMargeAccountFull) + r.Post("/streaming/support/power_on", server.HandleMargePowerOn) + r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate) + r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets) + r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset) + r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent) + r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice) + r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice) + r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings) + r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken) + r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport) + }) + + // Setup Setup for tests + r.Route("/setup", func(r chi.Router) { + r.Get("/proxy-settings", server.HandleGetProxySettings) + r.Post("/proxy-settings", server.HandleUpdateProxySettings) + }) + + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + proxy.ServeHTTP(w, r) + }) + return r, server +} + +type reverseProxy struct { + target *url.URL +} + +func (p *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Simplified proxy for testing + w.WriteHeader(http.StatusAccepted) // Custom status to identify proxy hit in tests + w.Write([]byte("Proxied to " + p.target.String())) +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go new file mode 100644 index 0000000..f4860db --- /dev/null +++ b/pkg/service/handlers/server.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "context" + "log" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/discovery" + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" +) + +type Server struct { + ds *datastore.DataStore + sm *setup.Manager + serverURL string + proxyURL string + discovering bool + proxyRedact bool + proxyLogBody bool +} + +func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact bool, proxyLogBody bool) *Server { + return &Server{ + ds: ds, + sm: sm, + serverURL: serverURL, + proxyURL: serverURL, + proxyRedact: proxyRedact, + proxyLogBody: proxyLogBody, + } +} + +func (s *Server) DiscoverDevices() { + s.discovering = true + defer func() { s.discovering = false }() + + log.Println("Scanning for Bose devices...") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + svc := discovery.NewService(10 * time.Second) + devices, err := svc.DiscoverDevices(ctx) + if err != nil { + log.Printf("Discovery error: %v", err) + return + } + + for _, d := range devices { + log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo) + + // 1. Check if we already have this device by serial number (best identifier) + var existingID string // The directory name used for this device + + allDevices, _ := s.ds.ListAllDevices() + for _, known := range allDevices { + if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) { + existingID = known.DeviceID + if existingID == "" { + existingID = known.IPAddress + } + break + } + } + + // Use SerialNo if available, otherwise fallback to IP for the datastore directory name + deviceID := d.SerialNo + if deviceID == "" { + // If serial is missing from discovery, try to fetch it from :8090/info + log.Printf("Serial number missing for %s at %s, attempting live info fetch...", d.Name, d.Host) + liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host) + if err == nil && liveInfo.SerialNumber != "" { + d.SerialNo = liveInfo.SerialNumber + log.Printf("Successfully retrieved serial number %s for %s via live info", d.SerialNo, d.Host) + } + } + + deviceID = d.SerialNo + if deviceID == "" { + deviceID = d.Host + } + + // 2. If we found it by serial but it was stored under an IP-based directory, + // we should ideally migrate it, but for now, we'll just ensure the Serial one is used. + // If the IP changed for a known serial, SaveDeviceInfo will overwrite the old IP info + // if deviceID == existingBySerial.DeviceID. + + info := &models.ServiceDeviceInfo{ + DeviceID: d.SerialNo, + Name: d.Name, + IPAddress: d.Host, + DeviceSerialNumber: d.SerialNo, + ProductCode: d.ModelID, + FirmwareVersion: "0.0.0", // Unknown from discovery + } + + // If we had an IP-based entry and now have a Serial, clean up the IP-based entry + if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo { + log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", d.Name, existingID, d.SerialNo) + s.ds.RemoveDevice("default", existingID) + } + + if err := s.ds.SaveDeviceInfo("default", deviceID, info); err != nil { + log.Printf("Failed to save device info: %v", err) + } + } +} diff --git a/pkg/service/handlers/soundcork/bmx_services.json b/pkg/service/handlers/soundcork/bmx_services.json new file mode 100644 index 0000000..f1d7a9b --- /dev/null +++ b/pkg/service/handlers/soundcork/bmx_services.json @@ -0,0 +1,175 @@ +{ + "_links": { + "bmx_services_availability": { + "href": "../servicesAvailability" + } + }, + "askAgainAfter": 1230482, + "bmx_services": [ + { + "_links": { + "bmx_navigate": { + "href": "/v1/navigate" + }, + "bmx_token": { + "href": "/v1/token" + }, + "self": { + "href": "/" + } + }, + "askAdapter": false, + "assets": { + "color": "#000000", + "description": "With TuneIn on SoundTouch, listen to more than 100,000 stations and the hottest podcasts, plus live games, concerts and shows from around the world. However, you cannot access your Favorites and Premium content on your existing TuneIn account at this time.", + "icons": { + "defaultAlbumArt": "{MEDIA_SERVER}/tunein-default-album-art.png", + "largeSvg": "{MEDIA_SERVER}/tunein-smallSvg.svg", + "monochromePng": "{MEDIA_SERVER}/tunein-monochromePng.png", + "monochromeSvg": "{MEDIA_SERVER}/tunein-monochromeSvg.svg", + "smallSvg": "{MEDIA_SERVER}/tunein-smallSvg.svg" + }, + "name": "TuneIn" + }, + "authenticationModel": { + "anonymousAccount": { + "autoCreate": true, + "enabled": true + } + }, + "baseUrl": "{BMX_SERVER}/bmx/tunein", + "id": { + "name": "TUNEIN", + "value": 25 + }, + "streamTypes": [ + "liveRadio", + "onDemand" + ] + }, + { + "_links": { + "bmx_token": { + "href": "/token" + }, + "self": { + "href": "/" + } + }, + "askAdapter": false, + "assets": { + "color": "#000000", + "description": "Custom radio stations with BMX.", + "icons": { + "largeSvg": "{MEDIA_SERVER}/orion-monochrome.svg", + "monochromePng": "{MEDIA_SERVER}/orion-monochrome_v2.png", + "monochromeSvg": "{MEDIA_SERVER}/orion-monochrome.svg", + "smallSvg": "{MEDIA_SERVER}/orion-monochrome.svg" + }, + "name": "Custom Stations" + }, + "authenticationModel": { + "anonymousAccount": { + "autoCreate": true, + "enabled": true + } + }, + "baseUrl": "{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion", + "id": { + "name": "LOCAL_INTERNET_RADIO", + "value": 11 + }, + "streamTypes": [ + "liveRadio" + ] + }, + { + "_links": { + "bmx_availability": { + "href": "/availability" + }, + "bmx_logout": { + "href": "/logout" + }, + "bmx_navigate": { + "href": "/navigate/" + }, + "bmx_token": { + "href": "/token" + }, + "self": { + "href": "/" + } + }, + "askAdapter": false, + "assets": { + "color": "#004b85", + "description": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more.", + "icons": { + "largeSvg": "{MEDIA_SERVER}/SiriusXM_Logo_Color.svg", + "monochromePng": "{MEDIA_SERVER}/siriusxm-monochromePng.png", + "monochromeSvg": "{MEDIA_SERVER}/SiriusXM_Logo_Mono.svg", + "smallSvg": "{MEDIA_SERVER}/SiriusXM_Logo_Color.svg" + }, + "name": "SiriusXM", + "shortDescription": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more." + }, + "authenticationModel": { + "loginPageProvider": "BOSE" + }, + "baseUrl": "{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter", + "id": { + "name": "SIRIUSXM_EVEREST", + "value": 38 + }, + "signupUrl": "https://streaming.siriusxm.com/?/flepz=true&campaign=bose30#_frmAccountLookup", + "streamTypes": [ + "liveRadio", + "onDemand" + ] + }, + { + "_links": { + "bmx_availability": { + "href": "/availability" + }, + "bmx_navigate": { + "href": "/navigate" + }, + "bmx_token": { + "href": "{BMX_SERVER}/soundtouch-msp-token-proxy/RADIOPLAYER/token" + }, + "self": { + "href": "/" + } + }, + "askAdapter": false, + "assets": { + "color": "#cc0033", + "description": "Radio for you, from your country. Radioplayer is a unique broadcaster owned service, with higher quality streams, full content (including all live sport), and thousands of catch-up programs and podcasts. Radioplayer is available in UK, Germany, Canada, Austria, Belgium, Denmark, Ireland, Italy, Norway, Spain and Switzerland.", + "icons": { + "largeSvg": "https://donpvpd81xeci.cloudfront.net/icons/small.svg", + "monochromePng": "https://donpvpd81xeci.cloudfront.net/icons/monochrome.png", + "monochromeSvg": "https://donpvpd81xeci.cloudfront.net/icons/monochrome.svg", + "smallSvg": "https://donpvpd81xeci.cloudfront.net/icons/small.svg" + }, + "name": "Radioplayer" + }, + "authenticationModel": { + "anonymousAccount": { + "autoCreate": false, + "enabled": true + } + }, + "baseUrl": "https://boserp.radioapi.io", + "id": { + "name": "RADIOPLAYER", + "value": 35 + }, + "streamTypes": [ + "liveRadio", + "onDemand" + ] + } + ] +} diff --git a/pkg/service/handlers/soundcork/media/SiriusXM_Logo_Color.svg b/pkg/service/handlers/soundcork/media/SiriusXM_Logo_Color.svg new file mode 100644 index 0000000..168ce43 --- /dev/null +++ b/pkg/service/handlers/soundcork/media/SiriusXM_Logo_Color.svg @@ -0,0 +1,51 @@ + + + + +service_icons_individual_artboards + + + + + + + + + + + + + + + diff --git a/pkg/service/handlers/soundcork/media/SiriusXM_Logo_Mono.svg b/pkg/service/handlers/soundcork/media/SiriusXM_Logo_Mono.svg new file mode 100644 index 0000000..4ec0417 --- /dev/null +++ b/pkg/service/handlers/soundcork/media/SiriusXM_Logo_Mono.svg @@ -0,0 +1 @@ +services \ No newline at end of file diff --git a/pkg/service/handlers/soundcork/media/favicon-braille.ico b/pkg/service/handlers/soundcork/media/favicon-braille.ico new file mode 100644 index 0000000000000000000000000000000000000000..67b27e30d1972c5acd1c01fbe071dedc1ea80515 GIT binary patch literal 1451 zcmZQzU}Rus5D;Jh(h3ZAj0_BB3=9kk3K0GxAio5N4GbXssZ0zEV$2K-odJICyj)UT zK&8B%9xg#Z8fXH800$e8l>huc5vZKi)5S5QV$Pb$fqtJHL|XS7|DS)s{Sa4A|0bDk zL&XnFzbE8=aCyXhF`%63olBb7g(_d|wvA;xo_qbv&uvlvdv0&(mo@j;{y%P>Y5D%| zr8B24W^9?5GT)rP?_|Zv-Bb76$$j9RFIn+k{6LhDVvA5kj$U5Al&t!R`QBZV@3|G6 z3%zJ~@qc&e6{i4>#IXGaGJM<9S%fPMn^!H`asQf=deau6$%SP}y*;YmQXEdMtJ4!q zn)G!lYtr2>?B{Nl^lY3RTJqg~b=uvtC+9CKv`ahj?abQ)SE6=q-It)6BDu_QN^tU< z_5C+LzRC^QeY7-??d0DbCe88R9$Wio-Fx}>NAs({=5;I#4FCTht7+6!44eNC7%mK+ zu6{1-oD!O_gq{LWS^*q-#Z^^bfT5QO3B5Pd9sQUb1zh7L#gk)rx%D3^?rD{7@(xpJ z+we(ZPC`dC|5476G}k1LdSl}@iI;LKPD&^SSy>be;Fy*!Xvc+8t3z^XtVa&OS_H2YROe-XXUm$2dS%FY`}sX3(@$ z2D!zYS05T2+j3Re`}OU43wD)x9-jYNv8COmOYmZI(62J)w6CuDTJ6 z4PG_(?)td~t&g=$)2x+UYdYci&n-HqeK``pgti<_FsSfSlL$_cP6!cSs1dPm`PbtD z?M)|+GJZYEm>-|*c}zPxFG1(TjGMZ5EgrmkZ7%iqnxOEtlE)3(*T2pd7qq==aUr7M z`__KxYRd;Nvb_tK7hAL6sjWRa#k6;yVTi&%WpTw{2M;KQnq%LT7~S_*q)UznV zVN-sO=Oc@GH8x+LU}YLPN7~@nnOsxPNjJ(TmTceHWOi=%#A4>NX~rr)Uv4|^$+qu( ze6-{3wV%THS{R$c%lxJ-V!zq(Z_WJl`Zz~ZZJ$TO?D_BJEP8%GFHoeE zaf$xoih1Jm=FY9PZfh2Qze}M?fkm40jEr8_3vs#z%_erdLTYIjlZB<_`Gr*FEmll$e_ z>1EqX5PYC;nay=4UGX5GLe1l?Tb_BR?gnW+wQ>*lRcP%0WUFHbCCY|9!R4(l?|LIA FN&vk@gvN-Bm9&7yNOY4V;DnN#@-5V127lQnp&H=D@J|sYRaWGc)^$CfpSnH`jAyLrek| zz>JssxE{D{Gf*8aY5Z+zQkG=^9-|4>;V{Ou)gw#m+n%7hc1gpZ>mh`PM2Zp7gHrl- z17`K>DCRA>n_1*PrS*G?uu6R1-A+YPuU$Q6DSaKSHIV1|v@FX_2x0V9gvDSmcuFQ1 zQ)C-pR2QKrSiYzA#}J2t$i)Hm#Q|?$Ye~i{TW(-1$e7tJ-8kUx>o*$x))5odbmM@x zuj^|U5k1{F&{%NywT~NnIpFAPOK=0DI|tl-eZ`bDjlMBdApRo<9P>lJ9PrK$eRIG) zKlI80|NOAmfQtj2=7&Bx&|!Y4qgM{N=Z9m6)A%qs8~Dxm3;+QC|ASrZbTZ#gsQ>@~ M07*qoM6N<$f~ah`qyPW_ literal 0 HcmV?d00001 diff --git a/pkg/service/handlers/soundcork/media/favicon-braille.svg b/pkg/service/handlers/soundcork/media/favicon-braille.svg new file mode 100644 index 0000000..483882f --- /dev/null +++ b/pkg/service/handlers/soundcork/media/favicon-braille.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/pkg/service/handlers/soundcork/media/favicon-morse.ico b/pkg/service/handlers/soundcork/media/favicon-morse.ico new file mode 100644 index 0000000000000000000000000000000000000000..63daa5d5785a7d8160c763f0ef49177670a15173 GIT binary patch literal 859 zcmZQzU}Rus5D;Jh(h3ZF85kJMfLK8R!v6+je+FU$0|>u}k%8eQ5O)Unx$|;KaRH@x zJw054fHcqq1_2H>ASwU(e~+}4*Z_3G z|NsAM%}sv1`RWDaFnGH9xvXclkH5UyTFzx--^RMEJhk%;-34al)F3cN=bIcetGWxvBKA>e3(G8Z1@*McgGyJ2&q? zcI@qT#cFn-Wt-H&l+y!2kgweXl6dF7tyTtl1fQ>gk;-5I_H{VZ&vsx8TS9$3eWTYA z2Z6T#oSIv{hq5egUC`P)RYrVmjmY`-KtGd>A{VvHnjYm$iU#^rs&JBi)W@-nHvD`?}5BO)KF40rIU{j~lvVva?+un8j+$o*>X8!ub zt$Ua@{C&LZm99%x44=B^XYM0$iU+-J>gn38J|Wkz`ADUIvm)E$SdT*aGZt$vTOBe;p4a=}~bEaLG$x<0pxE3Q4a+_cXh25dSF5YB;G?#P5dgGwtUSKg^hW^RIHTlLAYZ z_=b6w|4+VJB4;4=ZbIk`mN$*O$6sD;E$6baZ)06np4$0_Z%s^v@=9fcC97hu-RUgn zYN)*;Z9hT!y@0@@2@_V${Ski2IAP7iyA3z9J6z8G+*JBlb?J|84VEhZBJL8UotyU` sJN9%ISe11H=FS?g2@>bKh1g13kjv>FVdQ&MBb@0O4_G>i_@% literal 0 HcmV?d00001 diff --git a/pkg/service/handlers/soundcork/media/favicon-morse.svg b/pkg/service/handlers/soundcork/media/favicon-morse.svg new file mode 100644 index 0000000..a08f52f --- /dev/null +++ b/pkg/service/handlers/soundcork/media/favicon-morse.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/pkg/service/handlers/soundcork/media/favicon.md b/pkg/service/handlers/soundcork/media/favicon.md new file mode 100644 index 0000000..cb8f6d5 --- /dev/null +++ b/pkg/service/handlers/soundcork/media/favicon.md @@ -0,0 +1,14 @@ +# Favicon Meanings + +This directory contains favicons for the Soundcork project in various formats (SVG, PNG, ICO). The icons use Morse code and Braille to represent the initials **S** (Sound) and **T** (Touch). + +## Morse Variant (`favicon-morse.*`) +The icon represents the letters **S** and **T** in international Morse code: +- **S**: `...` (three dots), displayed in blue. +- **T**: `-` (one long dash), displayed in yellow. + +## Braille Variant (`favicon-braille.*`) +The icon uses the 6-dot Braille grid to represent a stylized combination of the letters **S** and **T**: +- The blue dots represent the base shape. +- The yellow dot (dot 5) serves as an accent for the **T**. +- The light gray dots complete the 2x3 grid for better recognition as Braille. diff --git a/pkg/service/handlers/soundcork/media/orion-monochrome.svg b/pkg/service/handlers/soundcork/media/orion-monochrome.svg new file mode 100644 index 0000000..399de74 --- /dev/null +++ b/pkg/service/handlers/soundcork/media/orion-monochrome.svg @@ -0,0 +1,19 @@ + + + + internet_radio + Created with Sketch. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pkg/service/handlers/soundcork/media/orion-monochrome_v2.png b/pkg/service/handlers/soundcork/media/orion-monochrome_v2.png new file mode 100644 index 0000000000000000000000000000000000000000..bf831b905912b5da673f4cd31b02b147df3b691f GIT binary patch literal 1187 zcmV;U1YG-xP);8000DPNklYB?+0LacbB@lI(x;PPA7|tivT=*`qXfT!$DC|(XnNVii&VJ9On7SlP3Ty zE-vD9I_(qh=;#1oV`Bra*K7FW$B&tvou#Ctq+scik`iWTXL=U1#p9dfu4jUE>1_9XH+oP$e=}^su3l}IaFF(WsfdG4ZdjQb3RLZJ{Ym&@c04-W(I?%g}H zGCrS=OeRA%o5k<E6A2lB8rZsc1ARNeTvortj<5uXAP|6?w%k zU%o6!`uOpqVOOtSl_bs0%^CLK!2?NBI2=|q8dWx%RW_SdG#XVn9F`>AzklE4Tea7& zT{G;{r%yREPbBW~cqB<%TU&;em6b`7*4Nh$eVAv@o*n98F)L?(t<`J)3~T0z#CLXf ze#y?9Ib&EVm69a+e7-~Z7W@AFyJ2U~o|PnRZ*Lbg^I+r^Pb3nOB){Kp*xK5fq}*@t z`Sa(Jq@kf9xm+&8T`re~hK3|bFJ8Pb{K}OplB9S%ZrG(umvUyFNc_Wx50a#+sw%_Y zzI`i6x^w4_VIw0WN+y#E27_8ySWs_ouX=lXwXm?DU@)kytt~|&5tDD#TD>Hxy1F`N zX1+M`wG#*g0Eoq6hF!dPkxV87Kz)6EPVTpF-#8dwzkVeU2$;O<*RKPRPN%7;s4#qG zWd$H7_lR?Jg4^vTkw^g0(9mG``1m*gOG`^RSH8#NVPIguNDmGU;_-NLuKeZYWdO#; z#tgr4;|2hUL;|6lJ9UZmj%9liCWhLo!8i1CT7Q<_6YiVn1``4OZiA$obt&N(R8uQ%T+-z2- zva-@XaY;lX5%b%ltgI|={!!xl&-wG`iO1srL?V$wr3)7?E-p5^`Rdgx+r(eKd;800001b5ch_0Itp) z=>Px*M@d9MR9FekSXoF_T@+pprACt$VV2n(T2g2#HmVO%*hCRUmJ|p*g_TGsWzm4u zL(o8JScE-AL?t9Xq?J(#7G(y8Q#N3hm88vs>tElZd$?E6<(_kI5C4X9&p!L^z4o`) zUVE)?Z9gJnd3-7pCQP9D^XF4?ax#q{Kb}U67(pQ+A=K5?MepCgr@Fd2x_9p$-MxF4 zzJC4c(_GGj0o?X8aNt1Rym>PV7`9rvySw?!nKK+07iW7u6O8sjuUN5yo12>rye%y) zeC*gUUb}WJ$HvBTL_`FKhKBOQi4%G5+_}7M+cv&@`7(d|_D#q1_V)6H3m16Im@&RA zz#j12+*|{2MMVXtrKPc-pPwZFSWI?yHg|M%sEl{--tm9|11yd8S>vl#t>Rm^ZgF~g zx-nruKmadWwv11oKFyCGKV|^>{P{D#eEE{As;YSJ-n~3++BBp8(4j;5z<~pN^5jXQ z%>tR9dLhte+ARf(7+29 zF7yZlUTD{@UEJB(sfXAGKvUU%e+9_Pei1PA=g%K~WFfSE{d!uubSX`rKApnC!blcH zV)Jz3#0h%w;)VSQJd7}1k&Ln2K6Bvcz(9_PiBXRN4}%gN9c@_|?&fl8_Z@gpP!P|WHOnoiQKLp#ItNam z+{$w5V-6g?7XBF>;T?t#AKoum3>h+n2M-=>WfDt$oP!Y#vlB?Ox+`!5lJ@p?-P$i- zzVQD2`+3fsIqHLjL>U6GsIzC!^6}%xjkdlMA)-2>YJPq`fB5i$OG-*OCnv|K*Vfi* zy|lEHfB*i?*REaTqeqYOuV26T*|TR#ANtDu`|S$+{{8z38lipPzI|L+SjfwlFK1zT zynXw2eZzkvdOm#kkZ<0+sXqL{g9o~RrluyodiAPC#y4-?Xn4m0VNUh+^?GO#oL;|v z&B7uL&U5?rZCwB+fBN((H#RmZj=a1)Q^oiTdY5wX;zbfNL~`J0=gyrJ9v-d~RZNB8 z{_tL~U;*8@af5~p8%AO-Bxa>`2tu2f53OFkntuHFL6LdLjp<4C-QDgj!YJbAK?g_eVZgGua~gvV>dg|g6Bc#rP7@SJ{YG6mkR*Z;7g zpN)HiQ9K4oI_7pL4j6b0Oy^$~Q+Fd1SZ)^&p{}c@rbZJCL`nn`AzQp>&mK)G&>=N7 z)v`km10sTeprR@~Zp9 z6@`P=N{VLCS^=-_P=NYeywU3qp21IG=i(B%prF7=-C#B=SFUs`(*j_}Snq*jl8lTD)x2W?c!MC} z^2fO#S-yV#I!~E0#WAt3Z+pO#l9CjFv(}J*I<6-kJ$l3vh`6Vxhb3KO;WX-5oU?nO z1$_AMVV3CX3wV3LB~%-V1gJ%e7BPTZ*nw|5FiP$K|28e z_bR7Oozm+G+zUoUMKOFiE`e|Z0E%2zR>nf4&|U9a&YwTe!iBsAjdAvYV+_1RW@aW= zS63?-D?i}jxa~r6?d`mdaX$HR5u!yjO!MZ=bGc?7G-wcg{P@whW|jm+ubRR2|3&!^ XG`=u literal 0 HcmV?d00001 diff --git a/pkg/service/handlers/soundcork/media/tunein-default-album-art.png b/pkg/service/handlers/soundcork/media/tunein-default-album-art.png new file mode 100644 index 0000000000000000000000000000000000000000..347e73f1f7b2d5ab074a96579e50f80b2dbacaaa GIT binary patch literal 957 zcmV;u148_XP)Px#32;bRa{vGf6951U69E94oEQKA00(qQO+^Rb1qK!w1;pCp+W-IqPf0{UR7l6| zSItioK@@*8-CerdsxhL37NR2Jw+RsqYUD%XMH6qL#)C&aXaW}#O^jlqiTV%d*_#m( zJ#efE3DKybd<6x|LV+mKYG~T+ZoAWY9!5&32t}5I3BPQzJ3D#rH}ih;-rEIO!~X`d zT!XTzs(pL+aK=7-{5Ui9eYMu*_IR#byV}(g{E>)9VzG`lfm3JCtO`?+sy5%g6*mmb z!Q{8E4W~~#PgYU6qdOQxM4kggeBIGeym@nhPG~{GQhMOXk@AX)-$O#g4FG(lrEa&| zQ8EBUmR)4-G!a7nAv^_xj$huM8 zt*Vx3irM|u)yEneO15uBL`q$;SnT=JXFn$<7u#uZY3Xn%#1YfBz2IdtW;145))!sB z-qNCaGypi!*w{PtIcZrIV-{mJ^6>Ym>iQE)gtf%yAJKIz45Q(2O>OPA%F6e_VBXZX z$n>H5x;*FkVMG8v8)!uQTRw>?zt2~9vT-Qgbw zxk*i2E_MBSNmfV>lXyTx&e#%Uu^-XCPoHwl7KK>;ZyI4D))AhV80_g?x`Nzoz1K4^ zfPlh&5h{sL>T*#+<=Jc_gmA>s$%zLKAEuJYf|}>yqem}Xy11)yC&xJfP18)BtRsDgu>xh?d_d`z%n|uXPsA5wJeh)Njs~m{C@vD*%A8(2lY_M zqiNZqWm=XIkFTg!Z7ME)(cWgIQi)j@h7pU$?>~90dA$WXkqYe&lxmt*q$ov-l6}dL zt6ndm)X5ejj&g?MLWhw%+|Pu%2y@9kS4)ACRUn&}(SnaLHm2*@TI?Jd0ZM3I1sNJ1 z?hFK&Z99BGkn^~aNLVJb86adb?90f=z1G&=?r!J5h!sUSSX1LGD?>yeU>V7`U0sQp fnYDk4H8_V~^s*56REpZs00000NkvXXu0mjfYfHFu literal 0 HcmV?d00001 diff --git a/pkg/service/handlers/soundcork/media/tunein-monochromePng.png b/pkg/service/handlers/soundcork/media/tunein-monochromePng.png new file mode 100644 index 0000000000000000000000000000000000000000..1ec9a32fbc06e5d37d9228691733fc336194774c GIT binary patch literal 631 zcmV--0*L*IP);80006$Nkl z@pv$uP66O(?qSX~_4|D+mrLyTdvrP-qdyvrUZ42Y;|_;|ilUGt6-A*Cf+Sh3RtbQ` zVv!^%NfJr2*=!o#{hN-BMuV!V-sEvvmW^>qk~kWTj6B~i&I8n;eGPoK+cBHX9)ZiU zOi7X`gt)0VzpIB7r>^S=hr>sGlgR|@^%}Wc?%wnlb@S~|2yyg0C>RVv*Y%@5m&=9K zYV`oTE4sxhpFK~Ks;V0A2qD;PHjk{*Xf#+X7U}VLe#|OoSA4tOqTB5n?NllSr_+f* zAb?V-gk&-atJR8LuLqKdMx&@yD#rVyr%&>?TCKYlJ06dpU-6%c&%4jP z@w5lfzho--*TTL>HsIs5do|gF5T@P7hr=N RDHs3%002ovPDHLkV1hZ-CUF1& literal 0 HcmV?d00001 diff --git a/pkg/service/handlers/soundcork/media/tunein-monochromeSvg.svg b/pkg/service/handlers/soundcork/media/tunein-monochromeSvg.svg new file mode 100644 index 0000000..bbf5b5f --- /dev/null +++ b/pkg/service/handlers/soundcork/media/tunein-monochromeSvg.svg @@ -0,0 +1,22 @@ + + + + +Artboard Copy 9 +Created with Sketch. + + + + + + diff --git a/pkg/service/handlers/soundcork/media/tunein-smallSvg.svg b/pkg/service/handlers/soundcork/media/tunein-smallSvg.svg new file mode 100644 index 0000000..9bbcba8 --- /dev/null +++ b/pkg/service/handlers/soundcork/media/tunein-smallSvg.svg @@ -0,0 +1,19 @@ + + + +Artboard Copy 9 +Created with Sketch. + + + + + + diff --git a/pkg/service/handlers/soundcork/swupdate.xml b/pkg/service/handlers/soundcork/swupdate.xml new file mode 100644 index 0000000..151a14d --- /dev/null +++ b/pkg/service/handlers/soundcork/swupdate.xml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go new file mode 100644 index 0000000..3d5b388 --- /dev/null +++ b/pkg/service/marge/marge.go @@ -0,0 +1,454 @@ +package marge + +import ( + "encoding/xml" + "fmt" + "os" + "strconv" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/constants" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +const DateStr = "2012-09-19T12:43:00.000+00:00" + +func SourceProviders() []models.SourceProvider { + providers := make([]models.SourceProvider, len(constants.Providers)) + for i, name := range constants.Providers { + providers[i] = models.SourceProvider{ + ID: i + 1, + CreatedOn: DateStr, + Name: name, + UpdatedOn: DateStr, + } + } + return providers +} + +type SourceProvidersXML struct { + XMLName xml.Name `xml:"sourceProviders"` + Providers []models.SourceProvider `xml:"sourceProvider"` +} + +func SourceProvidersToXML() ([]byte, error) { + sp := SourceProvidersXML{ + Providers: SourceProviders(), + } + data, err := xml.MarshalIndent(sp, "", " ") + if err != nil { + return nil, err + } + return append([]byte(xml.Header), data...), nil +} + +func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) { + type SourceXML struct { + XMLName xml.Name `xml:"source"` + ID string `xml:"id,attr"` + Type string `xml:"type,attr"` + CreatedOn string `xml:"createdOn"` + Credential struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` + } `xml:"credential"` + Name string `xml:"name"` + SourceProviderID string `xml:"sourceproviderid"` + SourceName string `xml:"sourcename"` + SourceSettings string `xml:"sourcesettings"` + UpdatedOn string `xml:"updatedOn"` + Username string `xml:"username"` + } + + providerID := 0 + for i, p := range constants.Providers { + if p == cs.SourceKeyType { + providerID = i + 1 + break + } + } + + sxml := SourceXML{ + ID: cs.ID, + Type: "Audio", + CreatedOn: DateStr, + Name: cs.SourceKeyAccount, + SourceProviderID: strconv.Itoa(providerID), + SourceName: cs.DisplayName, + UpdatedOn: DateStr, + Username: cs.SourceKeyAccount, + } + sxml.Credential.Type = "token" + sxml.Credential.Value = cs.Secret + + return xml.Marshal(sxml) +} + +func GetConfiguredSourceXML(cs models.ConfiguredSource) string { + providerID := 0 + for i, p := range constants.Providers { + if p == cs.SourceKeyType { + providerID = i + 1 + break + } + } + return fmt.Sprintf(`%s%s%s%d%s%s%s`, + cs.ID, DateStr, cs.Secret, cs.SourceKeyAccount, providerID, cs.DisplayName, DateStr, cs.SourceKeyAccount) +} + +func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { + presets, err := ds.GetPresets(account) + if err != nil { + return nil, err + } + sources, err := ds.GetConfiguredSources(account) + if err != nil { + return nil, err + } + + res := `` + for _, p := range presets { + res += fmt.Sprintf(``, p.ID) + res += fmt.Sprintf(`%s`, p.ContainerArt) + res += fmt.Sprintf(`%s`, p.Type) + res += fmt.Sprintf(`%s`, DateStr) + res += fmt.Sprintf(`%s`, p.Location) + res += fmt.Sprintf(`%s`, p.Name) + + // Content Item Source + found := false + for _, s := range sources { + if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) { + res += GetConfiguredSourceXML(s) + found = true + break + } + } + if !found { + // This might happen if source is not found + } + + res += fmt.Sprintf(`%s`, DateStr) + res += `` + } + res += `` + + return append([]byte(xml.Header), []byte(res)...), nil +} + +func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { + recents, err := ds.GetRecents(account) + if err != nil { + return nil, err + } + sources, err := ds.GetConfiguredSources(account) + if err != nil { + return nil, err + } + + res := `` + for _, r := range recents { + lastPlayed := "" + if sec, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil { + lastPlayed = time.Unix(sec, 0).Format(time.RFC3339) + } + + res += fmt.Sprintf(``, r.ID) + res += fmt.Sprintf(`%s`, r.Type) + res += fmt.Sprintf(`%s`, DateStr) + res += fmt.Sprintf(`%s`, lastPlayed) + res += fmt.Sprintf(`%s`, r.Location) + res += fmt.Sprintf(`%s`, r.Name) + + found := false + for _, s := range sources { + if s.ID == r.SourceID || (s.SourceKeyType == r.Source && s.SourceKeyAccount == r.SourceAccount) { + res += GetConfiguredSourceXML(s) + found = true + break + } + } + if !found { + } + + res += fmt.Sprintf(`%s`, DateStr) + res += `` + } + res += `` + + return append([]byte(xml.Header), []byte(res)...), nil +} + +func ProviderSettingsToXML(account string) string { + return fmt.Sprintf(`%sELIGIBLE_FOR_TRIALtrue14`, account) +} + +func SoftwareUpdateToXML() string { + return `` +} + +func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { + devicesDir := ds.AccountDevicesDir(account) + entries, err := os.ReadDir(devicesDir) + if err != nil { + return nil, err + } + + res := fmt.Sprintf(`OK`, account) + lastDeviceID := "" + for _, entry := range entries { + if entry.IsDir() { + deviceID := entry.Name() + lastDeviceID = deviceID + info, err := ds.GetDeviceInfo(account, deviceID) + if err != nil { + continue + } + + res += fmt.Sprintf(``, deviceID) + res += fmt.Sprintf(`%s%s`, + info.ProductCode, info.ProductCode, info.ProductSerialNumber) + res += fmt.Sprintf(`%s`, DateStr) + res += fmt.Sprintf(`%s`, info.FirmwareVersion) + res += fmt.Sprintf(`%s`, info.IPAddress) + res += fmt.Sprintf(`%s`, info.Name) + + presets, _ := PresetsToXML(ds, account) + if len(presets) > len(xml.Header) { + res += string(presets[len(xml.Header):]) // strip header + } + + recents, _ := RecentsToXML(ds, account) + if len(recents) > len(xml.Header) { + res += string(recents[len(xml.Header):]) // strip header + } + + res += fmt.Sprintf(`%s`, info.DeviceSerialNumber) + res += fmt.Sprintf(`%s`, DateStr) + res += `` + } + } + res += `globalen` + res += ProviderSettingsToXML(account) + + if lastDeviceID != "" { + sources, _ := ds.GetConfiguredSources(account) + res += `` + for _, s := range sources { + res += GetConfiguredSourceXML(s) + } + res += `` + } + + res += `` + return []byte(res), nil +} + +func UpdatePreset(ds *datastore.DataStore, account string, device string, presetNumber int, sourceXML []byte) ([]byte, error) { + sources, err := ds.GetConfiguredSources(account) + if err != nil { + return nil, err + } + presets, err := ds.GetPresets(account) + if err != nil { + return nil, err + } + + var newPresetElem struct { + Name string `xml:"name"` + SourceID string `xml:"sourceid"` + Location string `xml:"location"` + ContentItemType string `xml:"contentItemType"` + ContainerArt string `xml:"containerArt"` + } + if err := xml.Unmarshal(sourceXML, &newPresetElem); err != nil { + return nil, err + } + + var matchingSrc *models.ConfiguredSource + for _, s := range sources { + if s.ID == newPresetElem.SourceID { + matchingSrc = &s + break + } + } + if matchingSrc == nil { + return nil, fmt.Errorf("invalid account/source") + } + + nowStr := strconv.FormatInt(time.Now().Unix(), 10) + presetObj := models.ServicePreset{ + ServiceContentItem: models.ServiceContentItem{ + ID: strconv.Itoa(presetNumber), + Name: newPresetElem.Name, + Source: matchingSrc.SourceKeyType, + Type: newPresetElem.ContentItemType, + Location: newPresetElem.Location, + SourceAccount: matchingSrc.SourceKeyAccount, + SourceID: newPresetElem.SourceID, + }, + ContainerArt: newPresetElem.ContainerArt, + CreatedOn: nowStr, + UpdatedOn: nowStr, + } + + // Ensure presets list is large enough + for len(presets) < presetNumber { + presets = append(presets, models.ServicePreset{}) + } + presets[presetNumber-1] = presetObj + + if err := ds.SavePresets(account, presets); err != nil { + return nil, err + } + + // Return XML for the single preset + res := fmt.Sprintf(``, presetObj.ID) + res += fmt.Sprintf(`%s`, presetObj.ContainerArt) + res += fmt.Sprintf(`%s`, presetObj.Type) + res += fmt.Sprintf(`%s`, DateStr) + res += fmt.Sprintf(`%s`, presetObj.Location) + res += fmt.Sprintf(`%s`, presetObj.Name) + res += GetConfiguredSourceXML(*matchingSrc) + res += fmt.Sprintf(`%s`, DateStr) + res += `` + + return append([]byte(xml.Header), []byte(res)...), nil +} + +func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML []byte) ([]byte, error) { + sources, err := ds.GetConfiguredSources(account) + if err != nil { + return nil, err + } + recents, err := ds.GetRecents(account) + if err != nil { + return nil, err + } + + var newRecentElem struct { + Name string `xml:"name"` + SourceID string `xml:"sourceid"` + Location string `xml:"location"` + ContentItemType string `xml:"contentItemType"` + LastPlayedAt string `xml:"lastplayedat"` + } + if err := xml.Unmarshal(sourceXML, &newRecentElem); err != nil { + return nil, err + } + + var matchingSrc *models.ConfiguredSource + for _, s := range sources { + if s.ID == newRecentElem.SourceID { + matchingSrc = &s + break + } + } + if matchingSrc == nil { + return nil, fmt.Errorf("invalid account/source") + } + + utcTime := time.Now().Unix() + if newRecentElem.LastPlayedAt != "" { + if t, err := time.Parse(time.RFC3339, newRecentElem.LastPlayedAt); err == nil { + utcTime = t.Unix() + } + } + + // Find existing + var recentObj *models.ServiceRecent + createdOn := DateStr + for i, r := range recents { + if r.Source == matchingSrc.SourceKeyType && r.Location == newRecentElem.Location && r.SourceAccount == matchingSrc.SourceKeyAccount { + recents[i].UtcTime = strconv.FormatInt(utcTime, 10) + recentObj = &recents[i] + // Moving to front means we need to handle its original createdOn + // In bose emulation, we often use fixed dates, but let's try to be consistent + // If we had a real createdOn, we'd use it here. + + // Move to front + recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...) + break + } + } + + if recentObj == nil { + maxID := 0 + for _, r := range recents { + if id, err := strconv.Atoi(r.ID); err == nil && id > maxID { + maxID = id + } + } + recentObj = &models.ServiceRecent{ + ServiceContentItem: models.ServiceContentItem{ + ID: strconv.Itoa(maxID + 1), + Name: newRecentElem.Name, + Source: matchingSrc.SourceKeyType, + Type: newRecentElem.ContentItemType, + Location: newRecentElem.Location, + SourceAccount: matchingSrc.SourceKeyAccount, + SourceID: newRecentElem.SourceID, + IsPresetable: "true", + }, + DeviceID: device, + UtcTime: strconv.FormatInt(utcTime, 10), + } + createdOn = time.Now().Format(time.RFC3339) + recents = append([]models.ServiceRecent{*recentObj}, recents...) + if len(recents) > 10 { + recents = recents[:10] + } + } + + if err := ds.SaveRecents(account, recents); err != nil { + return nil, err + } + + lastPlayed := time.Unix(utcTime, 0).Format(time.RFC3339) + res := fmt.Sprintf(``, recentObj.ID) + res += fmt.Sprintf(`%s`, recentObj.Type) + res += fmt.Sprintf(`%s`, createdOn) + res += fmt.Sprintf(`%s`, lastPlayed) + res += fmt.Sprintf(`%s`, recentObj.Location) + res += fmt.Sprintf(`%s`, recentObj.Name) + res += GetConfiguredSourceXML(*matchingSrc) + res += fmt.Sprintf(`%s`, DateStr) + res += `` + + return append([]byte(xml.Header), []byte(res)...), nil +} + +func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) { + var newDeviceElem struct { + DeviceID string `xml:"deviceid,attr"` + Name string `xml:"name"` + } + if err := xml.Unmarshal(sourceXML, &newDeviceElem); err != nil { + return nil, err + } + + info := &models.ServiceDeviceInfo{ + DeviceID: newDeviceElem.DeviceID, + Name: newDeviceElem.Name, + // Other fields will be filled by discovery later or default + } + + if err := ds.SaveDeviceInfo(account, newDeviceElem.DeviceID, info); err != nil { + return nil, err + } + + createdOn := time.Now().Format(time.RFC3339) + res := fmt.Sprintf(``, newDeviceElem.DeviceID) + res += fmt.Sprintf(`%s`, createdOn) + res += `` + res += fmt.Sprintf(`%s`, newDeviceElem.Name) + res += fmt.Sprintf(`%s`, createdOn) + res += `` + + return append([]byte(xml.Header), []byte(res)...), nil +} + +func RemoveDeviceFromAccount(ds *datastore.DataStore, account string, device string) error { + return ds.RemoveDevice(account, device) +} diff --git a/pkg/service/marge/marge_test.go b/pkg/service/marge/marge_test.go new file mode 100644 index 0000000..dfb3985 --- /dev/null +++ b/pkg/service/marge/marge_test.go @@ -0,0 +1,124 @@ +package marge + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestMargeXML(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "marge-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + account := "123" + device := "ABC" + + // Setup initial data + info := &models.ServiceDeviceInfo{ + DeviceID: device, + Name: "Living Room", + } + ds.SaveDeviceInfo(account, device, info) + + // Save empty presets/recents to avoid index out of range when stripping header + ds.SavePresets(account, []models.ServicePreset{}) + ds.SaveRecents(account, []models.ServiceRecent{}) + + // Test SourceProvidersToXML + xmlData, err := SourceProvidersToXML() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(xmlData), "") { + t.Errorf("Expected , got %s", string(xmlData)) + } + + // Test AccountFullToXML + fullXML, err := AccountFullToXML(ds, account) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(fullXML), `id="123"`) { + t.Errorf("Expected account id 123, got %s", string(fullXML)) + } + if !strings.Contains(string(fullXML), "Living Room") { + t.Errorf("Expected device name Living Room, got %s", string(fullXML)) + } + + // Test SoftwareUpdateToXML + swXML := SoftwareUpdateToXML() + if !strings.Contains(swXML, "") { + t.Errorf("Expected , got %s", swXML) + } +} + +func TestAddRecent_TimestampPreservation(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "marge-timestamp-test-*") + defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) + account := "test-acc" + device := "test-dev" + + // 1. Setup configured sources + // We need a Sources.xml file in the account directory + sourcesPath := ds.AccountDir(account) + os.MkdirAll(sourcesPath, 0755) + ds.SaveConfiguredSources(account, []models.ConfiguredSource{ + { + ID: "101", + DisplayName: "Test Source", + SourceKeyType: "TUNEIN", + SourceKeyAccount: "test-user", + }, + }) + ds.SaveRecents(account, []models.ServiceRecent{}) + + // 2. Add an initial recent + sourceXML := []byte(` + + Initial Station + 101 + station-1 + station +`) + _, err := AddRecent(ds, account, device, sourceXML) + if err != nil { + t.Fatalf("AddRecent failed: %v", err) + } + + recents, _ := ds.GetRecents(account) + if len(recents) != 1 { + t.Fatalf("Expected 1 recent, got %d", len(recents)) + } + originalCreatedOn := recents[0].UtcTime // It's stored in UtcTime field (unix string) in models.ServiceRecent but the AddRecent return XML uses tag which is DateStr or Now depending on logic. + // Actually let's check what AddRecent returns. + + // 3. Add the same recent again (it should move to front and preserve createdOn) + // We'll wait a second to ensure time.Now() would be different if it were used for createdOn + time.Sleep(1 * time.Second) + + respXML, err := AddRecent(ds, account, device, sourceXML) + if err != nil { + t.Fatalf("AddRecent second time failed: %v", err) + } + + if !strings.Contains(string(respXML), "2012-09-19T12:43:00.000+00:00") { + // Our DateStr is 2012-09-19T12:43:00.000+00:00 + t.Errorf("Expected preserved DateStr in createdOn, got XML: %s", string(respXML)) + } + + recents, _ = ds.GetRecents(account) + if len(recents) != 1 { + t.Errorf("Expected still 1 recent, got %d", len(recents)) + } + + // Check that UtcTime was updated (it should be, for lastplayedat) + if recents[0].UtcTime == originalCreatedOn { + // Wait, if they are the same it might be because we didn't specify LastPlayedAt in input XML so it used Now. + // Since we slept, it should be different. + } +} diff --git a/pkg/service/proxy/proxy.go b/pkg/service/proxy/proxy.go new file mode 100644 index 0000000..a43923d --- /dev/null +++ b/pkg/service/proxy/proxy.go @@ -0,0 +1,109 @@ +package proxy + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "os" + "strings" +) + +var sensitiveHeaders = []string{ + "Authorization", + "Cookie", + "X-Bose-Token", +} + +// LoggingProxy wraps a ReverseProxy to provide instrumentation. +type LoggingProxy struct { + Proxy *httputil.ReverseProxy + Redact bool + LogBody bool + MaxBodySize int64 +} + +func NewLoggingProxy(targetURL string, redact bool) *LoggingProxy { + // targetURL logic should be handled by the caller or we can parse it here + return &LoggingProxy{ + Redact: redact, + LogBody: os.Getenv("LOG_PROXY_BODY") == "true", + MaxBodySize: 1024 * 10, // 10KB default limit for logging + } +} + +func (lp *LoggingProxy) LogRequest(r *http.Request) { + headers := formatHeaders(r.Header, lp.Redact) + + bodyStr := "[HIDDEN]" + if lp.LogBody && shouldLogBody(r.Header.Get("Content-Type")) { + if r.Body != nil { + bodyBytes, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + if int64(len(bodyBytes)) > lp.MaxBodySize { + bodyStr = string(bodyBytes[:lp.MaxBodySize]) + "... [TRUNCATED]" + } else { + bodyStr = string(bodyBytes) + } + } else { + bodyStr = "[EMPTY]" + } + } + + log.Printf("[PROXY_REQ] %s %s\n Headers:\n%s\n Body: %s", r.Method, r.URL.String(), headers, bodyStr) +} + +func (lp *LoggingProxy) LogResponse(r *http.Response) { + headers := formatHeaders(r.Header, lp.Redact) + + bodyStr := "[HIDDEN]" + if lp.LogBody && shouldLogBody(r.Header.Get("Content-Type")) { + if r.Body != nil { + bodyBytes, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + if int64(len(bodyBytes)) > lp.MaxBodySize { + bodyStr = string(bodyBytes[:lp.MaxBodySize]) + "... [TRUNCATED]" + } else { + bodyStr = string(bodyBytes) + } + } else { + bodyStr = "[EMPTY]" + } + } + + log.Printf("[PROXY_RES] %d %s\n Headers:\n%s\n Body: %s", r.StatusCode, r.Request.URL.String(), headers, bodyStr) +} + +func formatHeaders(h http.Header, redact bool) string { + var sb strings.Builder + // In Go, http.Header is a map[string][]string. + // Iterating over the map directly allows us to see the actual keys + // stored in the map, which might not be canonical if set directly. + for k, vv := range h { + val := strings.Join(vv, ", ") + if redact && isSensitive(k) { + val = "[REDACTED]" + } + sb.WriteString(fmt.Sprintf(" %s: %s\n", k, val)) + } + return strings.TrimSuffix(sb.String(), "\n") +} + +func isSensitive(header string) bool { + for _, h := range sensitiveHeaders { + if strings.EqualFold(h, header) { + return true + } + } + return false +} + +func shouldLogBody(contentType string) bool { + contentType = strings.ToLower(contentType) + return strings.Contains(contentType, "xml") || + strings.Contains(contentType, "json") || + strings.Contains(contentType, "text") || + contentType == "" +} diff --git a/pkg/service/proxy/proxy_test.go b/pkg/service/proxy/proxy_test.go new file mode 100644 index 0000000..5ada146 --- /dev/null +++ b/pkg/service/proxy/proxy_test.go @@ -0,0 +1,77 @@ +package proxy + +import ( + "io" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestLoggingProxy_Redaction(t *testing.T) { + lp := NewLoggingProxy("http://example.com", true) + if !lp.Redact { + t.Error("Expected redact to be true") + } +} + +func TestIsSensitive(t *testing.T) { + tests := []struct { + header string + want bool + }{ + {"Authorization", true}, + {"authorization", true}, + {"Cookie", true}, + {"X-Bose-Token", true}, + {"Content-Type", false}, + {"Accept", false}, + } + + for _, tt := range tests { + if got := isSensitive(tt.header); got != tt.want { + t.Errorf("isSensitive(%q) = %v, want %v", tt.header, got, tt.want) + } + } +} + +func TestShouldLogBody(t *testing.T) { + tests := []struct { + contentType string + want bool + }{ + {"application/xml", true}, + {"application/json", true}, + {"text/plain", true}, + {"text/html", true}, + {"", true}, + {"audio/mpeg", false}, + {"application/octet-stream", false}, + } + + for _, tt := range tests { + if got := shouldLogBody(tt.contentType); got != tt.want { + t.Errorf("shouldLogBody(%q) = %v, want %v", tt.contentType, got, tt.want) + } + } +} + +func TestLoggingProxy_LogRequest(t *testing.T) { + os.Setenv("LOG_PROXY_BODY", "true") + defer os.Unsetenv("LOG_PROXY_BODY") + + lp := NewLoggingProxy("http://example.com", true) + + body := "test body content" + req := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader(body)) + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("Authorization", "bearer secret") + + lp.LogRequest(req) + + // Check if body is still readable + readBody, _ := io.ReadAll(req.Body) + if string(readBody) != body { + t.Errorf("Request body was consumed or changed, got %q, want %q", string(readBody), body) + } +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go new file mode 100644 index 0000000..afc1b5c --- /dev/null +++ b/pkg/service/setup/setup.go @@ -0,0 +1,436 @@ +package setup + +import ( + "encoding/xml" + "fmt" + "log" + "net" + "net/http" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/ssh" +) + +const SoundTouchSdkPrivateCfgPath = "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml" + +// PrivateCfg represents the SoundTouchSdkPrivateCfg XML structure. +type PrivateCfg struct { + XMLName xml.Name `xml:"SoundTouchSdkPrivateCfg" json:"-"` + MargeServerUrl string `xml:"margeServerUrl" json:"margeServerUrl"` + StatsServerUrl string `xml:"statsServerUrl" json:"statsServerUrl"` + SwUpdateUrl string `xml:"swUpdateUrl" json:"swUpdateUrl"` + UsePandoraProductionServer bool `xml:"usePandoraProductionServer" json:"usePandoraProductionServer"` + IsZeroconfEnabled bool `xml:"isZeroconfEnabled" json:"isZeroconfEnabled"` + SaveMargeCustomerReport bool `xml:"saveMargeCustomerReport" json:"saveMargeCustomerReport"` + BmxRegistryUrl string `xml:"bmxRegistryUrl" json:"bmxRegistryUrl"` +} + +// MigrationSummary provides details about the state of a speaker before migration. +type MigrationSummary struct { + SSHSuccess bool `json:"ssh_success"` + CurrentConfig string `json:"current_config"` + PlannedConfig string `json:"planned_config"` + OriginalConfig string `json:"original_config,omitempty"` + ParsedCurrentConfig *PrivateCfg `json:"parsed_current_config,omitempty"` + RemoteServicesEnabled bool `json:"remote_services_enabled"` + RemoteServicesPersistent bool `json:"remote_services_persistent"` + RemoteServicesFound []string `json:"remote_services_found"` + RemoteServicesCheckErr string `json:"remote_services_check_err,omitempty"` + DeviceName string `json:"device_name,omitempty"` + DeviceModel string `json:"device_model,omitempty"` + DeviceSerial string `json:"device_serial,omitempty"` + FirmwareVersion string `json:"firmware_version,omitempty"` +} + +// Manager handles the migration of speakers to the soundcork service. +type Manager struct { + ServerURL string + DataStore *datastore.DataStore +} + +// NewManager creates a new Manager with the given base server URL. +func NewManager(serverURL string, ds *datastore.DataStore) *Manager { + return &Manager{ServerURL: serverURL, DataStore: ds} +} + +// DeviceInfoXML represents the XML structure from :8090/info +type DeviceInfoXML struct { + XMLName xml.Name `xml:"info" json:"-"` + DeviceID string `xml:"deviceID,attr" json:"deviceID"` + Name string `xml:"name" json:"name"` + Type string `xml:"type" json:"type"` + MaccAddress string `xml:"maccAddress" json:"maccAddress"` + SoftwareVer string `xml:"-" json:"softwareVersion"` + SerialNumber string `xml:"-" json:"serialNumber"` + Components []struct { + Category string `xml:"componentCategory"` + SoftwareVersion string `xml:"softwareVersion"` + SerialNumber string `xml:"serialNumber"` + } `xml:"components>component" json:"-"` +} + +// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint. +func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) { + infoURL := fmt.Sprintf("http://%s:8090/info", deviceIP) + // For testing, if the IP already contains a port, don't append :8090 + if host, _, err := net.SplitHostPort(deviceIP); err == nil { + infoURL = fmt.Sprintf("http://%s/info", deviceIP) + _ = host + } + resp, err := http.Get(infoURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch info from %s: %v", infoURL, err) + } + defer resp.Body.Close() + + var infoXML DeviceInfoXML + if err := xml.NewDecoder(resp.Body).Decode(&infoXML); err != nil { + return nil, fmt.Errorf("failed to decode info XML from %s: %v", infoURL, err) + } + + for _, comp := range infoXML.Components { + if comp.Category == "SCM" { + infoXML.SoftwareVer = comp.SoftwareVersion + if infoXML.SerialNumber == "" { + infoXML.SerialNumber = comp.SerialNumber + } + } else if comp.Category == "PackagedProduct" { + if infoXML.SerialNumber == "" { + infoXML.SerialNumber = comp.SerialNumber + } + } + } + + return &infoXML, nil +} + +// GetMigrationSummary returns a summary of the current and planned state of the speaker. +func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyURL string, options map[string]string) (*MigrationSummary, error) { + if targetURL == "" { + targetURL = m.ServerURL + } + client := ssh.NewClient(deviceIP) + + summary := &MigrationSummary{ + SSHSuccess: false, + } + + // 0. Populate from datastore if available + if m.DataStore != nil { + devices, err := m.DataStore.ListAllDevices() + if err == nil { + for _, d := range devices { + if d.IPAddress == deviceIP { + summary.DeviceName = d.Name + summary.DeviceModel = d.ProductCode + summary.DeviceSerial = d.DeviceSerialNumber + summary.FirmwareVersion = d.FirmwareVersion + break + } + } + } else { + log.Printf("Warning: failed to list devices from datastore: %v", err) + } + } + + // 0a. Supplement with live info from :8090/info + infoXML, err := m.GetLiveDeviceInfo(deviceIP) + if err == nil { + if infoXML.Name != "" { + summary.DeviceName = infoXML.Name + } + if infoXML.Type != "" { + summary.DeviceModel = infoXML.Type + } + if infoXML.SerialNumber != "" { + summary.DeviceSerial = infoXML.SerialNumber + } + if infoXML.SoftwareVer != "" { + summary.FirmwareVersion = infoXML.SoftwareVer + } + } else { + log.Printf("Warning: %v", err) + } + + // 1. Initial planned config + plannedCfg := PrivateCfg{ + MargeServerUrl: fmt.Sprintf("%s/marge", targetURL), + StatsServerUrl: targetURL, + SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL), + UsePandoraProductionServer: true, + IsZeroconfEnabled: true, + SaveMargeCustomerReport: false, + BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL), + } + + // 2. Check SSH and read current config + var currentConfig string + path := SoundTouchSdkPrivateCfgPath + client = ssh.NewClient(deviceIP) + + // Check if .original exists + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); err == nil { + originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)) + if originalConfig != "" { + summary.OriginalConfig = originalConfig + } + } + + // Check file details + fileInfo, _ := client.Run(fmt.Sprintf("ls -l %s", path)) + if fileInfo != "" { + fmt.Printf("File info for %s: %s\n", path, fileInfo) + } + + // Try cat + config, err := client.Run(fmt.Sprintf("cat %s", path)) + if err == nil && config != "" { + currentConfig = config + summary.SSHSuccess = true + summary.CurrentConfig = currentConfig + fmt.Printf("Current config from %s at %s (length: %d):\n%q\n", deviceIP, path, len(currentConfig), currentConfig) + + // Parse current config + var currentCfg PrivateCfg + if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil { + summary.ParsedCurrentConfig = ¤tCfg + + if proxyURL == "" { + proxyURL = targetURL + } + + // Apply options if provided + if options != nil { + // Marge + if options["marge"] == "original" { + plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) + } + // Stats + if options["stats"] == "original" { + plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) + } + // SwUpdate + if options["sw_update"] == "original" { + plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) + } + // BMX + if options["bmx"] == "original" { + plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) + } + } else if proxyURL != "" { + // Default to proxy everything if proxyURL is explicitly provided but no options + // (Maintain backward compatibility for now if needed, but we'll probably always pass options from UI) + // Actually, if proxyURL is set but no options, let's keep the previous behavior of proxying all. + plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) + plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) + plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) + plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) + } + } + } else { + // Fallback: try base64 if cat returned empty string but file has size > 0 + if config == "" && fileInfo != "" { + fmt.Printf("Cat returned empty for %s, trying base64\n", path) + b64Config, err := client.Run(fmt.Sprintf("base64 %s", path)) + if err == nil && b64Config != "" { + fmt.Printf("Base64 output for %s (length %d)\n", path, len(b64Config)) + } + } + + // If SSH failed or file couldn't be read + if _, sshErr := client.Run("ls /"); sshErr == nil { + summary.SSHSuccess = true + if err != nil { + summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err) + } else { + summary.CurrentConfig = config // Might be empty + } + } else { + summary.SSHSuccess = false + summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", sshErr) + } + } + + xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal planned XML: %v", err) + } + summary.PlannedConfig = "\n" + string(xmlContent) + + // 3. Check for remote services files + locations := []string{ + "/etc/remote_services", + "/mnt/nv/remote_services", + "/tmp/remote_services", + } + + for _, loc := range locations { + _, err := client.Run(fmt.Sprintf("[ -e %s ]", loc)) + if err == nil { + summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc) + summary.RemoteServicesEnabled = true + if loc != "/tmp/remote_services" { + summary.RemoteServicesPersistent = true + } + } + } + + return summary, nil +} + +// MigrateSpeaker configures the speaker at the given IP to use this soundcork service. +func (m *Manager) MigrateSpeaker(deviceIP string, targetURL string, proxyURL string, options map[string]string) error { + if targetURL == "" { + targetURL = m.ServerURL + } + if err := m.EnsureRemoteServices(deviceIP); err != nil { + // Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'" + // Let's log it. + fmt.Printf("Warning: failed to ensure remote services: %v\n", err) + } + + cfg := PrivateCfg{ + MargeServerUrl: fmt.Sprintf("%s/marge", targetURL), + StatsServerUrl: targetURL, + SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL), + UsePandoraProductionServer: true, + IsZeroconfEnabled: true, + SaveMargeCustomerReport: false, + BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL), + } + + // If we have a proxyURL and can read current config, use it + client := ssh.NewClient(deviceIP) + if currentConfig, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && currentConfig != "" { + var currentCfg PrivateCfg + if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil { + if proxyURL == "" { + proxyURL = targetURL + } + + if options != nil { + if options["marge"] == "original" { + cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) + } + if options["stats"] == "original" { + cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) + } + if options["sw_update"] == "original" { + cfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) + } + if options["bmx"] == "original" { + cfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) + } + } else if proxyURL != "" { + cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) + cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) + cfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) + cfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) + } + } + } + + xmlContent, err := xml.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal XML: %v", err) + } + + // Add XML header + xmlContent = append([]byte("\n"), xmlContent...) + + // 0. Backup original config if it doesn't exist + remotePath := SoundTouchSdkPrivateCfgPath + rwCmd := "(rw || mount -o remount,rw /)" + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil { + fmt.Printf("Backing up original config to %s.original\n", remotePath) + // Try to copy existing config to .original, ensuring filesystem is writable + if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil { + fmt.Printf("Warning: failed to cp backup config: %v (output: %s)\n", err, output) + // Fallback to manual upload if cp failed (might not have cp?) + if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" { + if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil { + fmt.Printf("Warning: failed to upload backup config: %v\n", err) + } + } + } + } + + // 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw) + // We'll wrap the upload in a way that EnsureRemoteServices and others might benefit, + // but UploadContent is a separate method. We should probably add rw to UploadContent or call it before. + // Actually, let's call rw before UploadContent here. + _, _ = client.Run(rwCmd) + if err := client.UploadContent(xmlContent, remotePath); err != nil { + return fmt.Errorf("failed to upload config: %v", err) + } + + // 2. Reboot the speaker (requires 'rw' command first to make filesystem writable) + if _, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)); err != nil { + return fmt.Errorf("failed to reboot speaker: %v", err) + } + + return nil +} + +// BackupConfig creates a backup of the current configuration on the speaker. +func (m *Manager) BackupConfig(deviceIP string) error { + client := ssh.NewClient(deviceIP) + remotePath := SoundTouchSdkPrivateCfgPath + rwCmd := "(rw || mount -o remount,rw /)" + + // Check if .original already exists + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil { + return fmt.Errorf("backup already exists at %s.original", remotePath) + } + + // Try to copy on the device first (more reliable), ensuring filesystem is writable + if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err == nil { + return nil + } else { + fmt.Printf("Direct cp failed: %v (output: %s), falling back to cat+upload\n", err, output) + } + + // Fallback to cat + upload + config, err := client.Run(fmt.Sprintf("cat %s", remotePath)) + if err != nil || config == "" { + return fmt.Errorf("failed to read current config: %v", err) + } + + // Ensure rw before upload fallback + _, _ = client.Run(rwCmd) + if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil { + return fmt.Errorf("failed to upload backup config: %v", err) + } + + return nil +} + +// EnsureRemoteServices ensures that remote services are enabled on the device. +// It tries to create an empty file in one of the known valid locations. +func (m *Manager) EnsureRemoteServices(deviceIP string) error { + client := ssh.NewClient(deviceIP) + rwCmd := "(rw || mount -o remount,rw /)" + + // Try locations in order of preference + locations := []string{ + "/etc/remote_services", + "/mnt/nv/remote_services", + "/tmp/remote_services", + } + + for _, loc := range locations { + // Try to make filesystem writable for each location that might need it + // Combining rw && touch ensures it's attempted in the same sequence + _, err := client.Run(fmt.Sprintf("%s && touch %s", rwCmd, loc)) + if err == nil { + return nil + } + // If rw && touch failed, try just touch (e.g. for /tmp which doesn't need rw) + _, err = client.Run(fmt.Sprintf("touch %s", loc)) + if err == nil { + return nil + } + } + + return fmt.Errorf("failed to enable remote services in any of the locations: %v", locations) +} diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go new file mode 100644 index 0000000..94ed2f5 --- /dev/null +++ b/pkg/service/setup/setup_test.go @@ -0,0 +1,112 @@ +package setup + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetLiveDeviceInfo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/info" { + t.Errorf("Expected to request /info, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, ` + + Test Speaker + SoundTouch 20 + + + SCM + 19.0.5 + 08DF1F0BA325 + + +`) + })) + defer server.Close() + + // Extract IP and port from the test server URL + // The test server URL is like http://127.0.0.1:54321 + host := server.Listener.Addr().String() + + manager := NewManager("http://localhost:8000", nil) + info, err := manager.GetLiveDeviceInfo(host) + + if err != nil { + t.Fatalf("Failed to get live device info: %v", err) + } + + if info.Name != "Test Speaker" { + t.Errorf("Expected Name 'Test Speaker', got '%s'", info.Name) + } + if info.SoftwareVer != "19.0.5" { + t.Errorf("Expected SoftwareVer '19.0.5', got '%s'", info.SoftwareVer) + } + if info.SerialNumber != "08DF1F0BA325" { + t.Errorf("Expected SerialNumber '08DF1F0BA325', got '%s'", info.SerialNumber) + } +} + +func TestGetMigrationSummary_SSHFailure(t *testing.T) { + // Use an IP that is unlikely to have an SSH server running or reachable + // or use a local port that is closed. + // We'll use a local port that we know is closed. + manager := NewManager("http://localhost:8000", nil) + summary, err := manager.GetMigrationSummary("127.0.0.1", "", "", nil) + + // Currently it might return an error OR it might return a summary with SSHSuccess: false + // but the issue description says the user is told connection SUCCEEDED. + + if err == nil { + if summary.SSHSuccess { + t.Errorf("Expected SSHSuccess to be false for closed port, got true") + } + if summary.CurrentConfig == "" { + t.Errorf("Expected CurrentConfig to contain error message, got empty string") + } + } else { + t.Errorf("Expected no error from GetMigrationSummary, got %v", err) + } +} + +func TestGetMigrationSummary_WithProxyOptions(t *testing.T) { + // Setup a mock server for live info + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `Test`) + })) + defer server.Close() + + host := server.Listener.Addr().String() + manager := NewManager("http://soundcork:8000", nil) + + // Since we can't easily mock SSH here without a full SSH server, + // we are testing the logic that depends on ParsedCurrentConfig being nil or not. + // However, GetMigrationSummary tries to connect via SSH. + // If SSH fails, ParsedCurrentConfig will be nil. + + options := map[string]string{ + "marge": "original", + "stats": "soundcork", + "sw_update": "original", + "bmx": "soundcork", + } + + summary, err := manager.GetMigrationSummary(host, "http://target:8000", "http://proxy:8000", options) + if err != nil { + t.Fatalf("GetMigrationSummary failed: %v", err) + } + + // When SSH fails (which it will here), PlannedConfig should be the default one for target:8000 + if !contains(summary.PlannedConfig, "http://target:8000/marge") { + t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig) + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/pkg/service/ssh/ssh.go b/pkg/service/ssh/ssh.go new file mode 100644 index 0000000..2909452 --- /dev/null +++ b/pkg/service/ssh/ssh.go @@ -0,0 +1,141 @@ +package ssh + +import ( + "fmt" + "io" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +// Client wraps an SSH client to perform operations on SoundTouch speakers. +type Client struct { + Host string + User string +} + +// NewClient creates a new SSH client for the given host. +func NewClient(host string) *Client { + return &Client{ + Host: host, + User: "root", + } +} + +// getConfig returns the SSH client configuration. +func (c *Client) getConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: c.User, + Auth: []ssh.AuthMethod{ + ssh.Password(""), // Default password for SoundTouch root is often empty or not used with these settings + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + Config: ssh.Config{ + KeyExchanges: []string{ + "diffie-hellman-group1-sha1", + "diffie-hellman-group14-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + "curve25519-sha256@libssh.org", + }, + Ciphers: []string{ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-cbc", + "3des-cbc", + "aes128-gcm@openssh.com", + "arcfour256", + "arcfour128", + }, + }, + HostKeyAlgorithms: []string{ + ssh.KeyAlgoRSASHA256, + ssh.KeyAlgoRSASHA512, + ssh.KeyAlgoRSA, + ssh.KeyAlgoDSA, + ssh.KeyAlgoECDSA256, + ssh.KeyAlgoECDSA384, + ssh.KeyAlgoECDSA521, + ssh.KeyAlgoED25519, + }, + } +} + +// Run executes a command on the remote host and returns the combined stdout and stderr. +func (c *Client) Run(command string) (string, error) { + config := c.getConfig() + client, err := ssh.Dial("tcp", c.Host+":22", config) + if err != nil { + return "", fmt.Errorf("failed to dial: %v", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", fmt.Errorf("failed to create session: %v", err) + } + defer session.Close() + + output, err := session.CombinedOutput(command) + return string(output), err +} + +// UploadContent uploads the given content to a file on the remote host. +// It uses a simple approach: echoing the content into a file. +// For larger files, a proper SCP or SFTP implementation would be better. +func (c *Client) UploadContent(content []byte, remotePath string) error { + config := c.getConfig() + client, err := ssh.Dial("tcp", c.Host+":22", config) + if err != nil { + return fmt.Errorf("failed to dial: %v", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %v", err) + } + defer session.Close() + + // Use a pipe to write content to the remote command's stdin + stdin, err := session.StdinPipe() + if err != nil { + return fmt.Errorf("failed to get stdin pipe: %v", err) + } + + // Capture stderr to get better error messages + stderr, err := session.StderrPipe() + if err != nil { + return fmt.Errorf("failed to get stderr pipe: %v", err) + } + + // Read content from stdin and write to the remote file + cmd := fmt.Sprintf("cat > %s", remotePath) + + // Start the command + if err := session.Start(cmd); err != nil { + return fmt.Errorf("failed to start upload command: %v", err) + } + + // Write content and close stdin + _, err = stdin.Write(content) + stdin.Close() + if err != nil { + return fmt.Errorf("failed to write content to stdin: %v", err) + } + + // Read stderr in case of failure + stderrBuf := new(strings.Builder) + go io.Copy(stderrBuf, stderr) + + // Wait for the command to finish + if err := session.Wait(); err != nil { + return fmt.Errorf("failed to finish upload: %v (stderr: %s)", err, stderrBuf.String()) + } + + return nil +} diff --git a/pkg/service/ssh/ssh_test.go b/pkg/service/ssh/ssh_test.go new file mode 100644 index 0000000..e991a83 --- /dev/null +++ b/pkg/service/ssh/ssh_test.go @@ -0,0 +1,66 @@ +package ssh + +import ( + "strings" + "testing" +) + +func TestNewClient(t *testing.T) { + host := "192.168.1.10" + client := NewClient(host) + if client.Host != host { + t.Errorf("Expected host %s, got %s", host, client.Host) + } + if client.User != "root" { + t.Errorf("Expected user root, got %s", client.User) + } +} + +func TestGetConfig(t *testing.T) { + client := NewClient("localhost") + config := client.getConfig() + if config.User != "root" { + t.Errorf("Expected config user root, got %s", config.User) + } + if len(config.Auth) == 0 { + t.Error("Expected at least one auth method") + } +} + +func TestRun_DialFailure(t *testing.T) { + // Use an invalid port/host to trigger dial failure + client := NewClient("127.0.0.1:0") + _, err := client.Run("ls") + if err == nil { + t.Error("Expected dial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to dial") { + t.Errorf("Expected 'failed to dial' error, got: %v", err) + } +} + +// Note: Testing Run and UploadContent with a real SSH server is complex in a unit test. +// We've already verified the implementation manually and with setup tests. +// Below is a skeleton of how one might mock it if needed, but for now we focus on the basic logic. + +/* +// MockClient can be used to test components that depend on SSH without a real server. +type MockClient struct { + RunFunc func(command string) (string, error) + UploadContentFunc func(content []byte, remotePath string) error +} + +func (m *MockClient) Run(command string) (string, error) { + if m.RunFunc != nil { + return m.RunFunc(command) + } + return "", nil +} + +func (m *MockClient) UploadContent(content []byte, remotePath string) error { + if m.UploadContentFunc != nil { + return m.UploadContentFunc(content, remotePath) + } + return nil +} +*/