Compare commits

..
4 Commits
Author SHA1 Message Date
Tobias Gesellchen 49ec2d58db wip 2026-03-07 19:51:19 +01:00
Tobias Gesellchen c78664ee59 wip 2026-03-07 19:51:19 +01:00
Tobias Gesellchen c17ba0e839 Add Stockholm Mini (3)
This is also a refactoring of our api paths
2026-03-07 19:51:19 +01:00
Tobias Gesellchen 17f052308f wip 2026-03-07 19:51:19 +01:00
46 changed files with 2176 additions and 3571 deletions
+3 -3
View File
@@ -256,11 +256,11 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -268,7 +268,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
+3 -3
View File
@@ -492,10 +492,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -503,7 +503,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
+1
View File
@@ -78,6 +78,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
- **🔧 Device Migration**: Seamlessly transition devices to local control
- **🌐 Web Management UI**: Easy browser-based setup and management
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
+38 -5
View File
@@ -753,10 +753,32 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/proxy/*", server.HandleProxyRequest)
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Get("/devices/{deviceId}/info", server.HandleGetStockholmDeviceInfo)
r.Post("/devices/{deviceId}/key/{key}", server.HandleDeviceKey)
r.Post("/devices/{deviceId}/volume/{level}", server.HandleDeviceVolume)
// Stockholm Mini app
r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini"))))
r.Route("/devices", func(r chi.Router) {
r.Get("/", server.HandleListDiscoveredDevices)
r.Post("/", server.HandleAddManualDevice)
r.Route("/{deviceId}", func(r chi.Router) {
r.Delete("/", server.HandleRemoveDevice)
r.Get("/events", server.HandleGetDeviceEvents)
r.Get("/info", server.HandleGetDeviceInfo)
r.Get("/ws", server.HandleDeviceWebSocket)
r.Post("/key/{key}", server.HandleDeviceKey)
r.Post("/volume/{level}", server.HandleDeviceVolume)
r.Post("/reboot", server.HandleRebootDevice)
})
})
r.Get("/version", server.HandleGetVersionInfo)
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
@@ -777,7 +799,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
@@ -791,7 +812,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Route("/devices/{deviceId}", func(r chi.Router) {
r.Get("/summary", server.HandleGetMigrationSummary)
r.Post("/migrate", server.HandleMigrateDevice)
r.Post("/revert", server.HandleRevertMigration)
r.Post("/trust-ca", server.HandleTrustCACert)
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
r.Post("/backup", server.HandleBackupConfig)
r.Post("/sync", server.HandleInitialSync)
r.Post("/test-connection", server.HandleTestConnection)
r.Post("/test-hosts", server.HandleTestHostsRedirection)
r.Post("/test-dns", server.HandleTestDNSRedirection)
})
})
r.NotFound(server.HandleNotFound)
-62
View File
@@ -1,62 +0,0 @@
### Overview of Recent Improvements and Next Steps
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
#### ✅ Completed Improvements (Marge Service)
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
* **Credential Preservation**: Improved `AddRecent` to correctly extract and echo back base64 tokens/credentials provided in the incoming request, improving source learning.
* **XML Formatting Parity**:
* Added `standalone="yes"` to the XML declaration for all Marge responses, including `recent`, `presets`, `full account`, `software update`, and `sourceproviders`.
* Enforced self-closing `<sourceSettings/>` tags for parity.
* Standardized date formatting to UTC with milliseconds (`.000+00:00`).
* Fixed casing for `/streaming/sourceproviders`: Root element is `<sourceProviders>`, but child elements are `<sourceprovider>` (all lowercase), matching upstream behavior.
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
---
#### 🛠️ Open Issues and Next Steps
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
#### 1. BMX / TuneIn Playback Parity (Medium)
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
* **Mismatched Parameters**: Local reporting URLs use `listen_id=3432432423`, while upstream uses a different session-based ID.
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
#### 2. Presets and Recents Parity (Medium)
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
#### 3. OAuth / Spotify Token Noise (Low/Medium)
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
* **The Issue**: This creates "noise" in your parity reports that isn't actually a bug.
* **Action**: Update the parity detection logic (or the handler) to selectively ignore the `access_token` field while still verifying that the rest of the JSON structure (expires_in, scope, token_type) matches.
#### 4. Large IDs for Other Models (Medium)
While we fixed IDs for `recents`, other models like `presets` or `sources` might still use small auto-incrementing integers.
* **Action**: Evaluate if other endpoints should also transition to the timestamp-based ID schema to further reduce diff noise.
#### 5. Improved Data Persistence (Continuous)
Continue the "learning" approach for other services. For example, if we see a new `sourceproviderid` in a Spotify or TuneIn request, we should ensure it is stored and reused.
#### 6. Local Reboot & Device State Management (Continuous)
Analysis of device reboot logs revealed several data requirements:
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
+1 -1
View File
@@ -119,7 +119,7 @@ soundtouch-service
```go
// Build custom applications on top of local services
client := &http.Client{}
resp, _ := client.Get("http://localhost:8000/setup/devices")
resp, _ := client.Get("http://localhost:8000/devices")
```
### Privacy-Conscious Users
+1 -1
View File
@@ -53,6 +53,7 @@
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
@@ -80,4 +81,3 @@
* [Device Lifecycle Summary](device-lifecycle-summary.md)
* [Power On Implementation Guide](power-on-implementation-guide.md)
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
+52
View File
@@ -0,0 +1,52 @@
### Stockholm App Analysis Report
#### 1. Overview
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
- **Key Libraries**:
- **jQuery**: Core DOM manipulation and event handling.
- **iScroll**: Used for smooth scrolling in lists and carousels.
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
#### 2. Directory Structure
- `js/`: Core application logic.
- `app/`: Main application entry point (`app.js`).
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
- `views/`: UI view templates and logic.
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
- `json/`: Configuration files and static data.
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
- `sourceFeatures.json`: Capability mapping for different sources.
- `setup/`: Onboarding and initial device setup logic.
- `lang/`: Localization files for multi-language support.
#### 3. Communication Architecture
The app uses several communication channels to interact with the SoundTouch ecosystem:
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
#### 4. Key Features & Functionality
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
- **Preset Management**: Browsing and setting presets directly from the UI.
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
- **Onboarding**: A dedicated setup flow for new devices.
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
#### 5. Integration Opportunities for Bose-SoundTouch Project
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
#### 6. Conclusion
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
+44 -17
View File
@@ -212,23 +212,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
```bash
# Get migration summary first
curl http://localhost:8000/setup/migration-summary/192.168.1.100
curl http://localhost:8000/setup/devices/192.168.1.100/summary
# Perform migration
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
# Verify migration status
curl http://localhost:8000/setup/devices
curl http://localhost:8000/devices
```
#### Advanced Migration Options
```bash
# Migration with proxy fallback for original services
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
# Migration with custom target URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
```
### Post-Migration Verification
@@ -237,13 +237,13 @@ After migration, verify the device is working correctly:
```bash
# Check device status
curl http://localhost:8000/setup/devices
curl http://localhost:8000/devices
# Test preset functionality
curl "http://192.168.1.100:8090/presets"
# Monitor device events (if needed)
curl "http://localhost:8000/events/192.168.1.100"
curl "http://localhost:8000/devices/08DF1F0BA325/events"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
@@ -347,7 +347,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor
### Discovery & Setup
#### `GET /setup/devices`
#### `GET /devices`
Lists all discovered SoundTouch devices with their current status.
**Response:**
@@ -368,10 +368,10 @@ Lists all discovered SoundTouch devices with their current status.
#### `POST /setup/discover`
Triggers immediate network device discovery.
#### `GET /setup/info/{deviceIP}`
#### `GET /devices/{deviceIP}/info`
Gets detailed device information and configuration.
#### `GET /setup/migration-summary/{deviceIP}`
#### `GET /setup/devices/{deviceIP}/summary`
Analyzes device configuration and provides migration preview.
**Response:**
@@ -388,7 +388,7 @@ Analyzes device configuration and provides migration preview.
}
```
#### `POST /setup/migrate/{deviceIP}`
#### `POST /setup/devices/{deviceIP}/migrate`
Migrates device to use local services.
**Query Parameters:**
@@ -399,6 +399,33 @@ Migrates device to use local services.
- `sw_update`: Set to "original" to proxy update requests (optional)
- `bmx`: Set to "original" to proxy BMX requests (optional)
#### `POST /setup/devices/{deviceIP}/revert`
Reverts device to Bose cloud defaults.
#### `POST /setup/devices/{deviceIP}/trust-ca`
Injects the AfterTouch root CA into the device's trust store.
#### `POST /setup/devices/{deviceIP}/sync`
Syncs presets and recents from the device to local storage.
#### `POST /setup/devices/{deviceIP}/backup`
Creates a backup of the current device configuration.
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
Enables persistent SSH/remote services on the device.
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
Removes persistent SSH/remote services from the device.
#### `POST /setup/devices/{deviceIP}/test-connection`
Tests HTTPS connection from device to service.
#### `POST /setup/devices/{deviceIP}/test-hosts`
Tests /etc/hosts redirection on the device.
#### `POST /setup/devices/{deviceIP}/test-dns`
Tests DNS redirection on the device.
### BMX Services (Bose Media eXchange)
#### `GET /bmx/registry/v1/services`
@@ -658,13 +685,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
### Setup API
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
- `POST /setup/devices`: Manually add a device by IP.
- `GET /devices`: List all known (auto-discovered and manual) devices.
- `POST /devices`: Manually add a device by IP.
- `POST /setup/discover`: Trigger a new network discovery scan.
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
@@ -811,7 +838,7 @@ soundtouch:
name: "Living Room Speaker"
rest:
- resource: "http://localhost:8000/setup/devices"
- resource: "http://localhost:8000/devices"
scan_interval: 60
sensor:
- name: "SoundTouch Devices"
+6 -6
View File
@@ -9,15 +9,15 @@ require (
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.48.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+14 -14
View File
@@ -24,16 +24,16 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -44,8 +44,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -53,8 +53,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -67,8 +67,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -79,8 +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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -98,6 +98,6 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+7
View File
@@ -356,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
}
attempt++
// Check if device is reachable before attempting full WS connection to reduce log noise
if err := ws.client.Ping(); err != nil {
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
continue
}
ws.logger.Printf("Reconnection attempt %d", attempt)
if err := ws.connectWithConfig(config); err != nil {
+22 -149
View File
@@ -132,61 +132,42 @@ type SourceProvider struct {
// ServiceContentItem represents a media content item with source and location details.
type ServiceContentItem struct {
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name"`
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
Type string `json:"type" xml:"type,attr"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
Location string `json:"location" xml:"location"`
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"`
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"`
}
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
Username string `json:"-" xml:"username,omitempty"`
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// ServiceRecent represents recently played media content.
type ServiceRecent struct {
XMLName xml.Name `json:"-" xml:"recent"`
ServiceContentItem
DeviceID string `json:"device_id" xml:"deviceid,attr"`
UtcTime string `json:"utc_time" xml:"utcTime,attr"`
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat"`
DeviceID string `json:"device_id" xml:"deviceid"`
UtcTime string `json:"utc_time" xml:"utc_time"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
}
// ConfiguredSource represents a configured media source with authentication details.
type ConfiguredSource struct {
XMLName xml.Name `json:"-" xml:"source"`
DisplayName string `json:"display_name" xml:"name"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"credential"`
SecretType string `json:"secret_type" xml:"credential_type,attr"`
DisplayName string `json:"display_name" xml:"displayName,attr"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"secret,attr"`
SecretType string `json:"secret_type" xml:"secretType,attr"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `json:"source_key" xml:"source_key"`
Type string `xml:"type,attr"`
// Parity fields
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid"`
Username string `json:"username,omitempty" xml:"username"`
SourceName string `json:"source_name,omitempty" xml:"sourcename"`
SourceSettings string `json:"-" xml:"sourceSettings"`
} `json:"source_key" xml:"sourceKey"`
// Legacy fields for backward compatibility in code if needed,
// though it's better to update the code to use SourceKey.
@@ -194,26 +175,6 @@ type ConfiguredSource struct {
SourceKeyAccount string `json:"source_key_account" xml:"-"`
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type Alias ConfiguredSource
a := struct {
Alias
Username string `xml:"username"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
}{
Alias: Alias(s),
}
a.Username = s.Username
a.SourceName = s.SourceName
// We want <sourceSettings/>
a.SourceSettings = ""
return e.EncodeElement(a, start)
}
// ServiceDeviceInfo represents information about a SoundTouch device.
type ServiceDeviceInfo struct {
DeviceID string `json:"device_id" xml:"deviceID,attr"`
@@ -232,10 +193,9 @@ type ServiceDeviceInfo struct {
// ServiceComponent represents a hardware or software component of a device.
type ServiceComponent struct {
Type string `xml:"type,attr"`
Category string `xml:"category,attr,omitempty"`
SoftwareVersion string `xml:"firmware-version"`
SerialNumber string `xml:"serialnumber"`
Label string `xml:"componentlabel,omitempty"`
Category string `xml:"category,attr"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
}
// CustomerSupportDevice represents device information for customer support purposes.
@@ -357,90 +317,3 @@ type EmailAddressResponse struct {
XMLName xml.Name `xml:"emailAddress"`
Email string `xml:",chardata"`
}
// FullResponseSource represents a configured media source specifically for the /full response.
// It follows the specific XML structure and field order of the upstream /full response.
type FullResponseSource struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
CreatedOn string `xml:"createdOn"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
// FullResponsePreset represents a preset specifically for the /full response.
type FullResponsePreset struct {
ButtonNumber string `xml:"buttonNumber,attr"`
ContainerArt string `xml:"containerArt"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
Location string `xml:"location"`
Name string `xml:"name"`
Source FullResponseSource `xml:"source"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
// FullResponseRecent represents a recent item specifically for the /full response.
type FullResponseRecent struct {
ID string `xml:"id,attr"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
LastPlayedAt string `xml:"lastplayedat"`
Location string `xml:"location"`
Name string `xml:"name"`
Source FullResponseSource `xml:"source"`
SourceID string `xml:"sourceid"`
UpdatedOn string `xml:"updatedOn"`
}
// AccountFullResponse represents the complete account XML structure.
type AccountFullResponse struct {
XMLName xml.Name `xml:"account"`
ID string `xml:"id,attr"`
AccountStatus string `xml:"accountStatus"`
Devices []AccountDevice `xml:"devices>device"`
Mode string `xml:"mode"`
PreferredLanguage string `xml:"preferredLanguage"`
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
Sources []FullResponseSource `xml:"sources>source"`
}
// AccountDevice represents a device in the account response.
type AccountDevice struct {
DeviceID string `xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `xml:"attachedProduct"`
CreatedOn string `xml:"createdOn"`
FirmwareVersion string `xml:"firmwareVersion"`
IPAddress string `xml:"ipaddress"`
Name string `xml:"name"`
Presets []FullResponsePreset `xml:"presets>preset"`
Recents []FullResponseRecent `xml:"recents>recent"`
SerialNumber string `xml:"serialNumber"`
UpdatedOn string `xml:"updatedOn"`
}
// AttachedProduct represents product information for a device.
type AttachedProduct struct {
ProductCode string `xml:"product_code,attr"`
Components []ServiceComponent `xml:"components>component"`
ProductLabel string `xml:"productlabel"`
SerialNumber string `xml:"serialNumber"`
UpdatedOn string `xml:"updatedOn"`
}
// ProviderSetting represents a single provider setting.
type ProviderSetting struct {
BoseID string `xml:"boseId"`
KeyName string `xml:"keyName"`
Value string `xml:"value"`
ProviderID string `xml:"providerId"`
}
-54
View File
@@ -1,57 +1,6 @@
// Package constants defines file names, directories, and common values used by the service layer.
package constants
// SourceProvider represents a media source provider configuration.
type SourceProvider struct {
ID int
Name string
CreatedOn string
UpdatedOn string
}
// StaticProviders lists known source provider identifiers with their metadata.
var StaticProviders = []SourceProvider{
{ID: 1, Name: "PANDORA", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 2, Name: "INTERNET_RADIO", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 3, Name: "OFF", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
{ID: 4, Name: "LOCAL", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 5, Name: "AIRPLAY", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 6, Name: "CURRATED_RADIO", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 7, Name: "STORED_MUSIC", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 8, Name: "SLAVE_SOURCE", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 9, Name: "AUX", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 10, Name: "RECOMMENDED_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 11, Name: "LOCAL_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 12, Name: "GLOBAL_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 13, Name: "HELLO", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
{ID: 14, Name: "DEEZER", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 15, Name: "SPOTIFY", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 16, Name: "IHEART", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 17, Name: "SIRIUSXM", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 18, Name: "GOOGLE_PLAY_MUSIC", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 19, Name: "QQMUSIC", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 20, Name: "AMAZON", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 21, Name: "LOCAL_MUSIC", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
{ID: 22, Name: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 23, Name: "SOUNDCLOUD", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 24, Name: "TIDAL", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 25, Name: "TUNEIN", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 26, Name: "QPLAY", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
{ID: 27, Name: "JUKE", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 28, Name: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 29, Name: "DARFM", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 30, Name: "7DIGITAL", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 31, Name: "SAAVN", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 32, Name: "RDIO", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 33, Name: "PHONE_MUSIC", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
{ID: 34, Name: "ALEXA", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
{ID: 35, Name: "RADIOPLAYER", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
{ID: 36, Name: "RADIO.COM", CreatedOn: "2019-05-28T18:21:41.000+00:00", UpdatedOn: "2019-05-28T18:21:41.000+00:00"},
{ID: 37, Name: "RADIO_COM", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
{ID: 38, Name: "SIRIUSXM_EVEREST", CreatedOn: "2019-11-25T18:00:33.000+00:00", UpdatedOn: "2019-11-25T18:00:33.000+00:00"},
{ID: 39, Name: "RADIO_BROWSER", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
}
// Providers lists known source provider identifiers used by Bose SoundTouch.
var Providers = []string{
"PANDORA",
@@ -111,7 +60,4 @@ const (
// DateStr is the hardcoded date used in many Bose XML responses
DateStr = "2012-09-19T12:43:00.000+00:00"
// XMLHeader is the standard XML declaration for Bose SoundTouch responses
XMLHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
)
+75 -26
View File
@@ -437,27 +437,53 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
return nil, err
}
type RecentsXML struct {
XMLName xml.Name `xml:"recents"`
Recents []models.ServiceRecent `xml:"recent"`
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"`
}
var recentsWrap RecentsXML
if err := xml.Unmarshal(data, &recentsWrap); err != nil {
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
}
recents := recentsWrap.Recents
recents := []models.ServiceRecent{}
maxID := 0
for i := range recents {
r := &recents[i]
for i := range recentsWrap.Recents {
r := &recentsWrap.Recents[i]
if id, err := strconv.Atoi(r.ID); err == nil {
if id > maxID {
maxID = id
}
}
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,
})
}
// Ensure all recents have unique numeric IDs
@@ -475,16 +501,52 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), 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 []models.ServiceRecent `xml:"recent"`
XMLName xml.Name `xml:"recents"`
Recents []RecentXML `xml:"recent"`
}
wrap := RecentsXML{
Recents: recents,
var rx RecentsXML
for i := range recents {
r := &recents[i]
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(wrap, "", " ")
data, err := xml.MarshalIndent(rx, "", " ")
if err != nil {
return err
}
@@ -608,24 +670,11 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
}
// Helper struct for unmarshaling with displayName
var sourcesWithDisplayName struct {
Sources []struct {
DisplayName string `xml:"displayName,attr"`
} `xml:"source"`
}
_ = xml.Unmarshal(data, &sourcesWithDisplayName)
for i := range sourcesWrap.Sources {
s := &sourcesWrap.Sources[i]
if s.ID == "" {
s.ID = strconv.Itoa(100001 + i)
}
if s.DisplayName == "" && i < len(sourcesWithDisplayName.Sources) {
s.DisplayName = sourcesWithDisplayName.Sources[i].DisplayName
}
// Sync legacy fields
s.SourceKeyType = s.SourceKey.Type
s.SourceKeyAccount = s.SourceKey.Account
+2 -2
View File
@@ -18,7 +18,7 @@ func TestEventLog(t *testing.T) {
r := chi.NewRouter()
r.Post("/streaming/stats/usage", s.HandleUsageStats)
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
t.Run("Record and Retrieve Events", func(t *testing.T) {
// 1. Post a usage stat
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
}
// 2. Retrieve events
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
+2 -34
View File
@@ -10,7 +10,6 @@ import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/go-chi/chi/v5"
)
@@ -86,37 +85,6 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
// Persist device details provided in the power_on request
if deviceID != "" && s.ds != nil {
// Use "default" account if not found or if the device is not yet mapped to an account.
// In a real scenario, this might be resolved differently if we already have the account info.
accountID := "default"
if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil && existing.AccountID != "" {
accountID = existing.AccountID
}
macAddress := ""
if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 0 {
macAddress = req.DiagnosticData.DeviceLandscape.MacAddresses[0]
}
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: accountID,
ProductCode: req.Device.Product.ProductCode,
DeviceSerialNumber: req.Device.SerialNumber,
ProductSerialNumber: req.Device.Product.SerialNumber,
FirmwareVersion: req.Device.FirmwareVersion,
IPAddress: deviceIP,
MacAddress: macAddress,
DiscoveryMethod: "power_on",
}
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("[Marge] Failed to save device info for %s: %v", deviceID, err)
}
}
if deviceIP != "" {
go s.PrimeDeviceWithSpotify(deviceIP)
} else {
@@ -402,7 +370,7 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header().Set("Authorization", bearerToken.GetAuthHeader())
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader))
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
@@ -411,7 +379,7 @@ func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request)
// Native firmware expects vnd.bose.streaming content type
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><group/>`))
}
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
@@ -2,7 +2,6 @@ package handlers
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -732,90 +731,6 @@ func TestMargePowerOn(t *testing.T) {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("Persistence", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts2 := httptest.NewServer(r)
defer ts2.Close()
deviceID := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
firmware := "27.0.6.46330"
productCode := "SoundTouch 10 sm2"
productSerial := "069231P63364828AE"
ipAddress := "192.168.1.100"
macAddress := "A81B6A536A98"
payload := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?>
<device-data>
<device id="%s">
<serialnumber>%s</serialnumber>
<firmware-version>%s</firmware-version>
<product product_code="%s" type="5">
<serialnumber>%s</serialnumber>
</product>
</device>
<diagnostic-data>
<device-landscape>
<rssi>Excellent</rssi>
<gateway-ip-address>192.168.1.1</gateway-ip-address>
<macaddresses>
<macaddress>%s</macaddress>
</macaddresses>
<ip-address>%s</ip-address>
<network-connection-type>Wireless</network-connection-type>
</device-landscape>
</diagnostic-data>
</device-data>`, deviceID, serialNumber, firmware, productCode, productSerial, macAddress, ipAddress)
res, err := http.Post(ts2.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
// Verify data in datastore
info, err := ds.GetDeviceInfo("default", deviceID)
if err != nil {
t.Fatalf("Failed to get device info: %v", err)
}
if info.DeviceID != deviceID {
t.Errorf("Expected DeviceID %s, got %s", deviceID, info.DeviceID)
}
if info.DeviceSerialNumber != serialNumber {
t.Errorf("Expected SerialNumber %s, got %s", serialNumber, info.DeviceSerialNumber)
}
if info.FirmwareVersion != firmware {
t.Errorf("Expected Firmware %s, got %s", firmware, info.FirmwareVersion)
}
if info.ProductCode != productCode {
t.Errorf("Expected ProductCode %s, got %s", productCode, info.ProductCode)
}
if info.ProductSerialNumber != productSerial {
t.Errorf("Expected ProductSerialNumber %s, got %s", productSerial, info.ProductSerialNumber)
}
if info.IPAddress != ipAddress {
t.Errorf("Expected IPAddress %s, got %s", ipAddress, info.IPAddress)
}
if info.MacAddress != macAddress {
t.Errorf("Expected MacAddress %s, got %s", macAddress, info.MacAddress)
}
if info.DiscoveryMethod != "power_on" {
t.Errorf("Expected DiscoveryMethod power_on, got %s", info.DiscoveryMethod)
}
})
}
func TestMargeAdvancedFeatures(t *testing.T) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
//go:embed web/index.html
var indexHTML []byte
//go:embed web/css/* web/js/*
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
var webFS embed.FS
//go:embed static/media/*
+79 -8
View File
@@ -103,33 +103,104 @@ func TestStaticWeb(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// 1. Test CSS
res, err := http.Get(ts.URL + "/web/css/style.css")
// 1. Test Migration UI CSS
res, err := http.Get(ts.URL + "/web/migration/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("CSS: Expected status OK, got %v", res.Status)
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 2. Test JS
res, err = http.Get(ts.URL + "/web/js/script.js")
// 2. Test Migration UI JS
res, err = http.Get(ts.URL + "/web/migration/script.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("JS: Expected status OK, got %v", res.Status)
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test Migration UI Index
res, err = http.Get(ts.URL + "/web/migration/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 4. Test Stockholm Mini
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 5. Test Stockholm Mini CSS
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 6. Test Shared CSS
res, err = http.Get(ts.URL + "/web/shared/common.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 7. Test Shared JS
res, err = http.Get(ts.URL + "/web/shared/common.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test diff.min.js
+23 -20
View File
@@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
}
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Migrate: Expected output field in response")
}
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("TrustCA: Expected output field in response")
}
// 4. Test POST /setup/reboot/{deviceIP}
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
// 4. Test POST /devices/{deviceIP}/reboot
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Reboot: Expected output field in response")
}
// 5. Test POST /setup/remove-remote-services/{deviceIP}
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -329,17 +329,20 @@ func TestRemoveDevice(t *testing.T) {
_ = ds.Initialize()
// Setup a dummy device in the datastore
account := "test-account"
account := "acc1"
deviceID := "TEST-DEVICE-ID"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
t.Fatalf("Failed to create device info file: %v", err)
// Register device in datastore so HandleRemoveDevice works
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: account,
IPAddress: "192.168.1.100",
})
// Verify directory exists where datastore expects it
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if _, err := os.Stat(deviceDir); err != nil {
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
}
r, _ := setupRouter("http://localhost:8001", ds)
@@ -347,7 +350,7 @@ func TestRemoveDevice(t *testing.T) {
defer ts.Close()
// 1. Verify device exists
res, err := http.Get(ts.URL + "/setup/devices")
res, err := http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
@@ -370,7 +373,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 2. Remove device
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
if err != nil {
t.Fatal(err)
}
@@ -385,7 +388,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 3. Verify device is gone
res, err = http.Get(ts.URL + "/setup/devices")
res, err = http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
+124
View File
@@ -0,0 +1,124 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
// HandleGetStockholmDeviceInfo returns live information for a device.
func (s *Server) HandleGetStockholmDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
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")
// Include IP address in both snake_case and camelCase for frontend compatibility
type deviceInfoResponse struct {
*setup.DeviceInfoXML `json:",inline"`
IPAddress string `json:"ip_address"`
IPAddressCamel string `json:"ipAddress,omitempty"`
}
resp := deviceInfoResponse{
DeviceInfoXML: info,
IPAddress: deviceIP,
IPAddressCamel: deviceIP,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleDeviceKey sends a key command to a device.
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
key := chi.URLParam(r, "key")
if deviceID == "" || key == "" {
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SendKey(key)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// HandleDeviceVolume sets the volume level for a device.
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
levelStr := chi.URLParam(r, "level")
if deviceID == "" || levelStr == "" {
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
level, err := strconv.Atoi(levelStr)
if err != nil {
http.Error(w, "Invalid volume level", http.StatusBadRequest)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SetVolume(level)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+203
View File
@@ -0,0 +1,203 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
const (
pongWait = 40 * time.Second
pingPeriod = 20 * time.Second // must be less than pongWait
)
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Upgrade the HTTP connection to a WebSocket for the browser
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Create a SoundTouch WebSocket client for the target device
c := client.NewClientFromHost(deviceIP)
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
// Channel-based write pump per Gorilla best practices
sendCh := make(chan []byte, 64) // buffer to smooth bursts
closeCh := make(chan struct{})
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
enqueue := func(v interface{}) {
b, err := json.Marshal(v)
if err != nil {
return
}
select {
case sendCh <- b:
default:
// drop to protect connection under burst
}
}
// Reader: we don't expect messages from the browser; just keep the
// connection alive by processing control frames and detect close.
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
go func() {
defer func() {
close(closeCh)
_ = wsClient.Disconnect()
_ = conn.Close()
}()
for {
mt, _, err := conn.ReadMessage()
if err != nil {
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
return
}
if mt == websocket.CloseMessage {
return
}
}
}()
// Writer: single writer goroutine handles JSON writes and ping keepalive
go func() {
pingTicker := time.NewTicker(pingPeriod)
defer func() {
pingTicker.Stop()
_ = conn.Close()
}()
for {
select {
case msg, ok := <-sendCh:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-pingTicker.C:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-closeCh:
return
}
}
}()
// Forward typed events with a simple envelope into the send queue
wsClient.SetHandlers(&models.WebSocketEventHandlers{
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
},
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
},
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
},
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
},
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
},
OnBassUpdated: func(e *models.BassUpdatedEvent) {
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
},
OnUnknownEvent: func(event *models.WebSocketEvent) {
bytes, _ := json.Marshal(event)
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
},
OnSpecialMessage: func(msg *models.SpecialMessage) {
enqueue(map[string]interface{}{"type": "special", "payload": msg})
},
})
// Add a separate goroutine to monitor the device connection status
go func() {
wsClient.Wait()
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
_ = conn.Close()
}()
// Connect to the device WebSocket
if err := wsClient.Connect(); err != nil {
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
return
}
// Optional: send an initial snapshot for convenience
go func() {
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
return
}
// Supplement with volume and now playing
c := client.NewClientFromHost(deviceIP)
payload := map[string]interface{}{
"deviceID": info.DeviceID,
"name": info.Name,
"type": info.Type,
//"maccAddress": info.MaccAddress,
"serialNumber": info.SerialNumber,
"softwareVersion": info.SoftwareVer,
// Provide IP in both styles for frontend robustness
"ip_address": deviceIP,
"ipAddress": deviceIP,
}
if vol, err := c.GetVolume(); err == nil {
payload["volume"] = vol
// Also add at top level for flatter frontend parsing
payload["actualVolume"] = vol.ActualVolume
}
if np, err := c.GetNowPlaying(); err == nil {
payload["nowPlaying"] = np
}
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
}()
}
+45 -2
View File
@@ -91,12 +91,31 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
// Setup Devices for tests
r.Route("/devices", func(r chi.Router) {
r.Get("/", server.HandleListDiscoveredDevices)
r.Post("/", server.HandleAddManualDevice)
r.Route("/{deviceId}", func(r chi.Router) {
r.Delete("/", server.HandleRemoveDevice)
r.Get("/events", server.HandleGetDeviceEvents)
r.Get("/info", server.HandleGetDeviceInfo)
r.Get("/ws", server.HandleDeviceWebSocket)
r.Post("/key/{key}", server.HandleDeviceKey)
r.Post("/volume/{level}", server.HandleDeviceVolume)
r.Post("/reboot", server.HandleRebootDevice)
})
})
r.Get("/version", server.HandleGetVersionInfo)
// Setup Setup for tests
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
@@ -108,6 +127,30 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Route("/devices/{deviceId}", func(r chi.Router) {
r.Get("/summary", server.HandleGetMigrationSummary)
r.Post("/migrate", server.HandleMigrateDevice)
r.Post("/revert", server.HandleRevertMigration)
r.Post("/trust-ca", server.HandleTrustCACert)
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
r.Post("/backup", server.HandleBackupConfig)
r.Post("/sync", server.HandleInitialSync)
r.Post("/test-connection", server.HandleTestConnection)
r.Post("/test-hosts", server.HandleTestHostsRedirection)
r.Post("/test-dns", server.HandleTestDNSRedirection)
})
})
r.NotFound(server.HandleNotFound)
+3 -60
View File
@@ -322,26 +322,14 @@ func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseR
reasons = append(reasons, fmt.Sprintf("Content-Type mismatch: local %s, upstream %s", localCT, upstreamCT))
}
// Compare bodies
// Basic body comparison (could be improved with XML semantic diff)
localBody := local.body.Bytes()
upstreamBody := upstream.body.Bytes()
if !bytes.Equal(localBody, upstreamBody) {
// If both are XML, try a whitespace-insensitive comparison
isXML := (strings.Contains(localCT, "/xml") || strings.Contains(localCT, "+xml")) &&
(strings.Contains(upstreamCT, "/xml") || strings.Contains(upstreamCT, "+xml"))
mismatch = true
if isXML {
if !s.compareXMLWhitespaceInsensitive(localBody, upstreamBody) {
mismatch = true
reasons = append(reasons, "Body content mismatch (XML)")
}
} else {
mismatch = true
reasons = append(reasons, "Body content mismatch")
}
reasons = append(reasons, "Body content mismatch")
}
if mismatch {
@@ -350,51 +338,6 @@ func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseR
}
}
// compareXMLWhitespaceInsensitive compares two XML bodies ignoring whitespace between elements.
func (s *Server) compareXMLWhitespaceInsensitive(local, upstream []byte) bool {
clean := func(b []byte) string {
s := string(b)
// Remove XML declaration for easier comparison
if strings.HasPrefix(s, "<?xml") {
if idx := strings.Index(s, "?>"); idx != -1 {
s = s[idx+2:]
}
}
// Normalize whitespace:
// 1. Remove all whitespace between elements (i.e., between > and <)
// 2. Trim surrounding whitespace
var result strings.Builder
inTag := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '<':
inTag = true
result.WriteByte(c)
case c == '>':
inTag = false
result.WriteByte(c)
case inTag:
result.WriteByte(c)
default:
// We are between tags, only add if not whitespace
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
result.WriteByte(c)
}
}
}
return strings.TrimSpace(result.String())
}
return clean(local) == clean(upstream)
}
func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorResponseRecorder, reasons []string) {
record := map[string]interface{}{
"timestamp": time.Now().Format(time.RFC3339),
@@ -1,118 +0,0 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestParityMismatchReproduction_New(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-parity-reproduce-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// Upstream example payload for POST /recent
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
<username></username>
</source>
<sourceid>14774275</sourceid>
<updatedOn>2026-03-14T12:50:14.221+00:00</updatedOn>
</recent>`
t.Run("POST /recent should learn source details and respond with parity", func(t *testing.T) {
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Fatalf("Expected status 201, got %d", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
fmt.Printf("[DEBUG_LOG] Response Body:\n%s\n", bodyStr)
// Verification points:
// 1. Standalone="yes"
if !strings.Contains(bodyStr, `standalone="yes"`) {
t.Errorf("Missing standalone=\"yes\"")
}
// 2. Millisecond precision in dates
if !strings.Contains(bodyStr, ".000+00:00") && !strings.Contains(bodyStr, ".221+00:00") {
// Note: FormatTime always uses .000+00:00 for now, but it's acceptable.
// The key is it MUST have milliseconds and +00:00 offset.
t.Errorf("Date format mismatch, expected .000+00:00. Body: %s", bodyStr)
}
// 3. SourceProviderID learned (25)
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
t.Errorf("SourceProviderID was not learned from POST, expected 25. Body: %s", bodyStr)
}
// 4. Credential learned
if !strings.Contains(bodyStr, "eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=") {
t.Errorf("Credential was not learned from POST. Body: %s", bodyStr)
}
// 5. SourceSettings self-closing
if !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("SourceSettings should be self-closing <sourceSettings/>. Body: %s", bodyStr)
}
// 6. Source CreatedOn/UpdatedOn learned
if !strings.Contains(bodyStr, "<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>") {
t.Errorf("Source CreatedOn was not learned from POST. Body: %s", bodyStr)
}
})
t.Run("Subsequent GET /recents should also show learned source details", func(t *testing.T) {
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
t.Errorf("GET /recents missing learned sourceproviderid 25. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("GET /recents missing self-closing sourceSettings. Body: %s", bodyStr)
}
})
}
@@ -1,91 +0,0 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestParityMismatchReproduction_V2(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-parity-repro-v2-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST /recent parity with upstream example", func(t *testing.T) {
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings></sourceSettings>
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
<username></username>
</source>
<sourceid>14774275</sourceid>
</recent>`
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.HasPrefix(bodyStr, constants.XMLHeader) {
t.Errorf("Missing or incorrect XML declaration: %s", bodyStr)
}
if !strings.Contains(bodyStr, `id="`) {
t.Errorf("Missing recent id attribute")
}
if !strings.Contains(bodyStr, ".000+00:00") {
t.Errorf("Date format mismatch. Expected .000+00:00. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
t.Errorf("sourceproviderid mismatch. Expected 25. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=") {
t.Errorf("Credential value mismatch. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("sourceSettings should be self-closing <sourceSettings/>. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourcename></sourcename>") {
t.Errorf("sourcename should be empty. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>") {
t.Errorf("lastplayedat mismatch. Body: %s", bodyStr)
}
})
}
@@ -1,144 +0,0 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestParityMismatchReproduction_V3(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "marge-test")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// Upstream input for POST /recent (extracted from user description)
// We'll use the same source metadata as provided in the upstream response
// to see if we can "learn" it and echo it back correctly.
requestBody := `
<recent>
<contentItemType>stationurl</contentItemType>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
</source>
<sourceid>14774275</sourceid>
</recent>`
account := "3230304"
device := "A81B6A536A98"
url := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device)
t.Run("POST /recent and check parity", func(t *testing.T) {
res, err := http.Post(url, "application/xml", strings.NewReader(requestBody))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Errorf("Expected status 201, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, constants.XMLHeader) {
t.Error("Missing XML declaration with standalone=\"yes\"")
}
// 2. Large ID (YYMMDDxxx format)
prefix := time.Now().UTC().Format("060102")
if !strings.Contains(bodyStr, fmt.Sprintf(`id="%s`, prefix)) {
t.Errorf("Recent ID missing expected prefix %s. Body: %s", prefix, bodyStr)
}
// 3. Date Formatting (.000+00:00)
if !strings.Contains(bodyStr, `.000+00:00`) {
t.Error("Dates are missing milliseconds or incorrect offset")
}
// 4. Source Learning
// Check for provider ID 25
if !strings.Contains(bodyStr, `<sourceproviderid>25</sourceproviderid>`) {
t.Error("Source provider ID mismatch: expected 25 for TuneIn")
}
// Check for credential
if !strings.Contains(bodyStr, `eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=`) {
t.Error("Credential value was not preserved")
}
// Check for empty sourcename
if !strings.Contains(bodyStr, `<sourcename></sourcename>`) {
t.Error("sourcename should be empty for TuneIn")
}
// 5. Self-closing SourceSettings
if !strings.Contains(bodyStr, `<sourceSettings/>`) {
t.Error("sourceSettings should be self-closing")
}
// 6. Indentation check (2 spaces)
if !strings.Contains(bodyStr, "\n <contentItemType>") {
t.Error("Incorrect indentation: expected 2 spaces")
}
})
t.Run("Verify GET /recents consistency", func(t *testing.T) {
recentsUrl := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device)
res, err := http.Get(recentsUrl)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
fmt.Printf("[DEBUG_LOG] GET /recents Local Response:\n%s\n", bodyStr)
if !strings.Contains(bodyStr, `<sourceproviderid>25</sourceproviderid>`) {
t.Error("Source provider ID missing in GET /recents")
}
if !strings.Contains(bodyStr, `<sourceSettings/>`) {
t.Error("sourceSettings should be self-closing in GET /recents")
}
})
}
func TestXMLWhitespaceInsensitivity(t *testing.T) {
s := &Server{}
local := []byte(constants.XMLHeader + `
<recent id="123">
<name>Test</name>
</recent>`)
upstream := []byte(constants.XMLHeader + `
<recent id="123">
<name>Test</name>
</recent>`)
if !s.compareXMLWhitespaceInsensitive(local, upstream) {
t.Error("compareXMLWhitespaceInsensitive failed for simple whitespace difference")
}
upstreamNoSpaces := []byte(constants.XMLHeader + `<recent id="123"><name>Test</name></recent>`)
if !s.compareXMLWhitespaceInsensitive(local, upstreamNoSpaces) {
t.Error("compareXMLWhitespaceInsensitive failed for no-whitespace upstream")
}
}
@@ -1,110 +0,0 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMargeParityRegressions(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-parity-regressions-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
// Mock Sources.xml matching the upstream example (source id 14774275)
// One with "Other" and one with a specific name.
sourcesXML := `
<sources>
<source id="14774275" displayName="Other" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
<source id="SPOT1" displayName="My Spotify" secret="token123" secretType="Audio">
<sourceKey type="SPOTIFY" account="user123"/>
</source>
</sources>`
os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte("<recents></recents>"), 0644)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST recent with Other source - sourcename should be empty", func(t *testing.T) {
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<sourceid>14774275</sourceid>
</recent>`
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
// Check for standalone="yes"
if !strings.Contains(bodyStr, `standalone="yes"`) {
t.Errorf("Response missing standalone=\"yes\"")
}
// Check for empty sourcename when it's "Other"
if !strings.Contains(bodyStr, "<sourcename></sourcename>") && !strings.Contains(bodyStr, "<sourcename/>") {
t.Errorf("Expected empty sourcename for 'Other' source, but got something else or missing. Body: %s", bodyStr)
}
// Check for date format (should have .000+00:00)
if !strings.Contains(bodyStr, ".000+00:00") {
t.Errorf("Response date format mismatch, expected .000+00:00. Body: %s", bodyStr)
}
// Check for sourceSettings presence
if !strings.Contains(bodyStr, "<sourceSettings>") && !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("Response missing sourceSettings element. Body: %s", bodyStr)
}
})
t.Run("POST recent with named source - sourcename should be preserved", func(t *testing.T) {
payload := `
<recent>
<contentItemType>track</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>spotify:track:123</location>
<name>Test Song</name>
<sourceid>SPOT1</sourceid>
</recent>`
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "<sourcename>My Spotify</sourcename>") {
t.Errorf("Expected sourcename 'My Spotify', body: %s", bodyStr)
}
})
}
-143
View File
@@ -1,143 +0,0 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMargeRecentConsistencyAndIDParity(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-recent-parity-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST recent creates consistent IDs and persists unknown sources", func(t *testing.T) {
payload := `
<recent>
<contentItemType>tracklisturl</contentItemType>
<lastplayedat>2026-03-14T21:33:22.000+00:00</lastplayedat>
<location>/playback/container/c3BvdGlmeTphbGJ1bTo3RjUwdWg3b0dpdG1BRVNjUktWNnBE</location>
<name>Terminal Caribe</name>
<sourceid>10863533</sourceid>
</recent>`
// 1. POST /recent
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Fatalf("Expected status 201, got %d", res.StatusCode)
}
postBody, _ := io.ReadAll(res.Body)
postBodyStr := string(postBody)
// Verify ID format: YYMMDDXXX (9 digits)
// Today's prefix:
prefix := time.Now().UTC().Format("060102")
idPattern := fmt.Sprintf(`id="%s`, prefix)
if !strings.Contains(postBodyStr, idPattern) {
t.Errorf("Response ID missing expected prefix %s. Body: %s", prefix, postBodyStr)
}
// Extract ID
startIdx := strings.Index(postBodyStr, `id="`) + 4
endIdx := strings.Index(postBodyStr[startIdx:], `"`) + startIdx
recentID := postBodyStr[startIdx:endIdx]
idInt, err := strconv.Atoi(recentID)
if err != nil {
t.Errorf("Recent ID is not an integer: %s", recentID)
} else if idInt > 2147483647 {
t.Errorf("Recent ID exceeds 32-bit signed integer range: %d", idInt)
}
// 2. GET /recents
res2, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
if err != nil {
t.Fatal(err)
}
defer res2.Body.Close()
getRecentsBody, _ := io.ReadAll(res2.Body)
getRecentsStr := string(getRecentsBody)
// 3. Verify consistency
// Use a whitespace-insensitive comparison
clean := func(s string) string {
if strings.HasPrefix(s, "<?xml") {
if idx := strings.Index(s, "?>"); idx != -1 {
s = s[idx+2:]
}
}
var result strings.Builder
inTag := false
for i := 0; i < len(s); i++ {
c := s[i]
if c == '<' {
inTag = true
result.WriteByte(c)
} else if c == '>' {
inTag = false
result.WriteByte(c)
} else if inTag {
result.WriteByte(c)
} else {
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
result.WriteByte(c)
}
}
}
return strings.TrimSpace(result.String())
}
if !strings.Contains(clean(getRecentsStr), clean(postBodyStr)) {
t.Errorf("GET /recents does not contain the same XML as POST /recent response.\nPOST: %s\nGET: %s", postBodyStr, getRecentsStr)
}
// 4. Verify source persistence
// Check if source 10863533 was learned and is now in Sources.xml
sources, err := ds.GetConfiguredSources(account, deviceID)
if err != nil {
t.Errorf("Failed to get configured sources: %v", err)
}
found := false
for _, s := range sources {
if s.ID == "10863533" {
found = true
if s.SourceKeyType != "SPOTIFY" {
t.Errorf("Learned source should be SPOTIFY based on location, got %s", s.SourceKeyType)
}
break
}
}
if !found {
t.Errorf("Source 10863533 was not learned and persisted")
}
})
}
+20 -4
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"fmt"
"io"
"log"
"net"
"net/http"
)
@@ -12,7 +13,7 @@ import (
// RecordMiddleware returns a middleware that records "self" requests and responses.
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil || !s.recordEnabled {
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
next.ServeHTTP(w, r)
return
}
@@ -52,6 +53,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
// Create a response object for the recorder
res := rw.getRecordedResponse(r)
if res.StatusCode >= 400 {
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
}
if res.Body != nil {
defer func() { _ = res.Body.Close() }()
}
@@ -65,8 +70,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
type responseWriter struct {
http.ResponseWriter
statusCode int
body *bytes.Buffer
statusCode int
body *bytes.Buffer
wroteHeader bool
}
func (rw *responseWriter) Header() http.Header {
@@ -74,18 +80,28 @@ func (rw *responseWriter) Header() http.Header {
}
func (rw *responseWriter) WriteHeader(code int) {
if rw.wroteHeader {
return
}
rw.statusCode = code
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.wroteHeader {
rw.WriteHeader(http.StatusOK)
}
rw.body.Write(b)
return rw.ResponseWriter.Write(b)
}
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
statusCode := rw.statusCode
if statusCode == 0 {
if !rw.wroteHeader && statusCode == 0 {
statusCode = http.StatusOK
}
+20
View File
@@ -840,3 +840,23 @@ func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
return "", fmt.Errorf("device not found: %s", deviceID)
}
// lookupIP resolves a deviceId to its last known device IP.
func (s *Server) lookupIP(deviceId string) (string, error) {
devices, err := s.ds.ListAllDevices()
if err != nil {
return "", err
}
for i := range devices {
if devices[i].DeviceID == deviceId {
if devices[i].IPAddress == "" {
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
}
return devices[i].IPAddress, nil
}
}
return "", fmt.Errorf("deviceId %s not found", deviceId)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,476 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>AfterTouch (SoundTouch Toolkit)</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="../shared/common.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>AfterTouch</h1>
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
<p style="margin-bottom: 20px;"><a href="/">&larr; Back to selection</a></p>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
</div>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to AfterTouch</h2>
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Prerequisite: Enable SSH</strong><br>
Migration requires SSH access. To enable it:
<ol style="margin-top: 5px; margin-bottom: 5px;">
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
</ol>
<strong>Verify connection:</strong>
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@&lt;SPEAKER-IP&gt;</code> (no password).</li>
</ul>
</div>
<ol class="guide-steps">
<li>
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
</li>
<li>
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
Ensure your speakers are powered on and connected to the same network.
</li>
<li>
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
</li>
<li>
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
</li>
<li>
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
</li>
</ol>
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</div>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
rather than <code>localhost</code>.
</p>
<div style="margin-bottom: 20px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="soundcork-url">Soundcork URL:</label>
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
</div>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
<strong>DNS Discovery:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label for="dns-upstream">Upstream DNS:</label>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
</label>
</div>
</div>
</div>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
<button onclick="addManualDevice()">Add Device</button>
</div>
</div>
<!-- Tab 3: Data Sync -->
<div id="tab-sync" class="tab-content">
<h2>Initial Data Sync</h2>
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
<div class="device-selection">
<label for="sync-device-list">Device:</label>
<select id="sync-device-list">
<option value="">-- Select a device --</option>
</select>
<button id="sync-now-btn">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" style="margin-top: 20px; display: none;">
<h3>Sync Results</h3>
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
<!-- Tab 4: Migration -->
<div id="tab-migration" class="tab-content">
<h2>Device Migration</h2>
<div class="device-selection">
<label for="migration-device-list">Device:</label>
<select id="migration-device-list" onchange="showSummary(this.value)">
<option value="">-- Select a device --</option>
</select>
</div>
<div id="status" class="status"></div>
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
<h3>Command Output</h3>
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px;">
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
</div>
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
<strong>Preliminary /etc/hosts Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
<div style="margin-top: 10px;">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px;">
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
</div>
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
<strong>Preliminary DNS Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
<div style="margin-top: 10px;">
Domain: <code>aftertouch.test</code>
</div>
<div style="margin-top: 10px;">
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
</div>
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
</select>
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
</div>
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Current /etc/resolv.conf</span>
<pre id="current-resolv-content"></pre>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (AfterTouch)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/resolv.conf Hook</span>
<pre id="planned-resolv"></pre>
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
</div>
</div>
</div>
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions & Device Events</h2>
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: 10px;">
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
</div>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2;">
<h3>Sessions</h3>
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">Browse Recordings</h3>
</div>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()">
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
</select>
</div>
<div>
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
</div>
<button onclick="fetchInteractions()">Apply Filters</button>
</div>
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">#</th>
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Method</th>
<th style="padding: 8px;">Path</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">DNS Discoveries</h3>
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
</div>
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Hostname</th>
<th style="padding: 8px;">Last Seen</th>
<th style="padding: 8px; text-align: center;">Queries</th>
<th style="padding: 8px; text-align: center;">Bose?</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Last Client IP</th>
</tr>
</thead>
<tbody id="dns-discoveries-list">
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
</div>
</div>
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Type</th>
<th style="padding: 8px;">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="../shared/common.js"></script>
<script src="script.js"></script>
<footer style="margin-top: 50px;">
<span id="version-info">AfterTouch</span>
</footer>
</body>
</html>
@@ -251,8 +251,9 @@ async function updateSettings() {
async function fetchDevices() {
try {
const response = await fetch("/setup/devices");
const response = await fetch("/devices");
const devices = await response.json();
window._knownDevices = devices; // Store globally for easy lookup
const container = document.getElementById("device-list");
const syncSelector = document.getElementById("sync-device-list");
const migrationSelector = document.getElementById("migration-device-list");
@@ -430,7 +431,7 @@ async function startSync() {
log.innerHTML = "";
try {
const response = await fetch("/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/sync", {method: "POST"},);
if (response.ok) {
status.style.backgroundColor = "#dfd";
status.textContent = "✅ Sync completed successfully for " + display + "!";
@@ -927,7 +928,7 @@ async function fetchDeviceEvents(deviceId) {
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
try {
const response = await fetch(`/setup/devices/${deviceId}/events`);
const response = await fetch(`/devices/${deviceId}/events`);
const data = await response.json();
const events = data.events;
@@ -1146,7 +1147,7 @@ async function addManualDevice() {
}
try {
const response = await fetch("/setup/devices", {
const response = await fetch("/devices", {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ip: ip}),
});
@@ -1168,7 +1169,7 @@ async function removeDevice(deviceId, name) {
}
try {
const response = await fetch(`/setup/devices/${deviceId}`, {
const response = await fetch(`/devices/${deviceId}`, {
method: "DELETE",
});
@@ -1214,7 +1215,7 @@ async function pollDiscoveryStatus() {
async function updateDeviceInfo(deviceId, ip) {
try {
const response = await fetch("/setup/info/" + encodeURIComponent(deviceId));
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/info");
if (!response.ok) return;
const info = await response.json();
@@ -1274,7 +1275,7 @@ async function showSummary(deviceId) {
}
try {
const response = await fetch("/setup/summary/" + encodeURIComponent(deviceId) + query,);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/summary" + query,);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
@@ -1459,7 +1460,7 @@ async function revert(deviceId, ip) {
statusDiv.innerHTML = "Reverting " + display + " to defaults...";
try {
const response = await fetch("/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/revert", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1491,7 +1492,7 @@ async function reboot(deviceId, ip) {
statusDiv.innerHTML = "Rebooting " + display + "...";
try {
const response = await fetch("/setup/reboot/" + encodeURIComponent(deviceId), {method: "POST"},);
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/reboot", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1537,7 +1538,7 @@ async function migrate(deviceId, ip) {
}
try {
const response = await fetch("/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/migrate" + query, {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1549,6 +1550,7 @@ async function migrate(deviceId, ip) {
rebootBtn.style.display = "inline-block";
rebootBtn.disabled = false;
rebootBtn.style.border = "2px solid #000";
rebootBtn.onclick = () => reboot(deviceId);
// Re-show summary but with prominence on reboot
summaryDiv.style.display = "block";
@@ -1574,7 +1576,7 @@ async function trustCA(deviceId, ip) {
statusDiv.innerHTML = "Injecting Root CA into shared trust store on " + display + "...";
try {
const response = await fetch("/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/trust-ca", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1606,7 +1608,7 @@ async function ensureRemoteServices(deviceId, ip) {
statusDiv.innerHTML = "Ensuring remote services for " + display + "...";
try {
const response = await fetch("/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/ensure-remote-services", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1640,7 +1642,7 @@ async function removeRemoteServices(deviceId, ip) {
statusDiv.innerHTML = "Removing remote services for " + display + "...";
try {
const response = await fetch("/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/remove-remote-services", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1668,7 +1670,7 @@ async function backupConfig(deviceId, ip) {
statusDiv.innerHTML = "Creating backup for " + display + "...";
try {
const response = await fetch("/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/backup", {method: "POST"},);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
@@ -1697,7 +1699,7 @@ async function testConnection(deviceId, useExplicitCA) {
try {
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
const response = await fetch(`/setup/devices/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
const result = await response.json();
if (result.ok) {
@@ -1725,7 +1727,7 @@ async function testHostsRedirection(deviceId) {
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-hosts${query}`, {method: "POST"},);
const result = await response.json();
if (result.ok) {
@@ -1753,7 +1755,7 @@ async function testDNSRedirection(deviceId) {
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-dns${query}`, {method: "POST"},);
const result = await response.json();
if (result.ok) {
@@ -0,0 +1,16 @@
footer {
margin-top: 50px;
padding: 20px;
font-size: 0.8em;
color: #666;
text-align: center;
}
#version-info a {
color: inherit;
text-decoration: none;
}
#version-info a:hover {
text-decoration: underline;
}
+23
View File
@@ -0,0 +1,23 @@
async function fetchVersion() {
try {
const response = await fetch('/version');
const data = await response.json();
const info = document.getElementById('version-info');
if (info && data.version) {
const version = data.version;
const commit = data.commit;
const isDirty = version.includes('dirty');
const releaseUrl = isDirty
? 'https://github.com/gesellix/Bose-SoundTouch/releases'
: `https://github.com/gesellix/Bose-SoundTouch/releases/tag/v${version}`;
const commitUrl = `https://github.com/gesellix/Bose-SoundTouch/commit/${commit}`;
const projectUrl = 'https://gesellix.github.io/Bose-SoundTouch/';
info.innerHTML = `<a href="${projectUrl}" target="_blank" style="color: inherit; text-decoration: none;">AfterTouch</a> ` +
`<a href="${releaseUrl}" target="_blank" style="color: inherit;">${version}</a> ` +
`(<a href="${commitUrl}" target="_blank" style="color: inherit;">${commit}</a>) - ${data.date}`;
}
} catch (error) {
console.error('Failed to fetch version info', error);
}
}
@@ -0,0 +1,456 @@
async function fetchDevices() {
try {
const response = await fetch('/devices');
const devices = await response.json();
const container = document.getElementById('device-list');
const seen = new Set();
if (devices.length === 0) {
container.innerHTML = '<p>No devices found. Ensure they are on the same network.</p>';
return;
}
devices.forEach(device => {
seen.add(device.device_id);
const existing = document.getElementById(`device-${device.device_id}`);
if (existing) {
// Update product code/IP if changed, but keep title if we already have a better name
const title = existing.querySelector('.device-title');
if (title && (!title.textContent || title.textContent === 'Unknown Device' || title.textContent.startsWith('SoundTouch-'))) {
title.textContent = device.name || 'Unknown Device';
}
const subtitle = existing.querySelector('.device-subtitle span');
if (subtitle) {
const currentSubtitle = subtitle.textContent || '';
const parts = currentSubtitle.split(' | ');
const currentType = parts.length > 1 ? parts[1].trim() : '';
const newType = device.product_code || 'Unknown';
// Don't downgrade type if we already have a specific one
const isGeneric = !currentType || currentType === 'Unknown' || currentType === 'N/A';
const displayType = isGeneric ? newType : currentType;
subtitle.textContent = `${device.ip_address} | ${displayType}`;
}
const details = existing.querySelector(`#details-${device.device_id}`);
if (details) {
const idField = details.querySelector('p:nth-child(1) code');
if (idField) {
const currentId = idField.textContent;
// Don't overwrite with serial if we have a real deviceID (usually hex)
if (!currentId || currentId === 'N/A' || currentId === device.device_serial_number) {
idField.textContent = device.device_id || 'N/A';
}
}
const firmwareField = details.querySelector('p:nth-child(2) code');
if (firmwareField) {
const cur = firmwareField.textContent;
if (!cur || cur === 'N/A' || cur === '0.0.0') {
firmwareField.textContent = device.firmware_version || 'N/A';
}
}
const serialField = details.querySelector('p:nth-child(3) code');
if (serialField && (!serialField.textContent || serialField.textContent === 'N/A')) {
serialField.textContent = device.device_serial_number || 'N/A';
}
}
// Ensure WS is open
openDeviceWebSocket(device.device_id);
return;
}
const card = document.createElement('div');
card.className = 'device-card';
card.id = `device-${device.device_id}`;
card.innerHTML = `
<div class="device-info">
<div class="device-header">
<div>
<div class="device-title-row">
<h2 class="device-title">${device.name || 'Unknown Device'}</h2>
<button class="info-toggle" title="More info" onclick="toggleDetails('${device.device_id}')">i</button>
</div>
<p class="device-subtitle">
<span>${device.ip_address} | ${device.product_code}</span>
</p>
</div>
<button class="power-icon" title="Power" aria-label="Power" onclick="control('${device.device_id}', 'POWER')">&#xE17E;</button>
</div>
<div class="device-details" id="details-${device.device_id}">
<p>ID: <code>${device.device_id}</code></p>
<p>Firmware: <code>${device.firmware_version || 'N/A'}</code></p>
<p>Serial: <code>${device.device_serial_number || 'N/A'}</code></p>
<p>Discovery: <code>${device.discovery_method || 'N/A'}</code></p>
</div>
</div>
<div class="now-playing" id="np-${device.device_id}">
<p><em>Loading playback status...</em></p>
</div>
<div class="controls">
<button class="primary" onclick="control('${device.device_id}', 'PLAY')">Play</button>
<button class="primary" onclick="control('${device.device_id}', 'PAUSE')">Pause</button>
<button onclick="control('${device.device_id}', 'PREV_TRACK')">Prev</button>
<button onclick="control('${device.device_id}', 'NEXT_TRACK')">Next</button>
</div>
<div class="volume-container">
<span>Vol:</span>
<input id="vol-${device.device_id}" type="range" min="0" max="100"
oninput="onVolumeInput('${device.device_id}', this)"
onmousedown="startAdjust('${device.device_id}')" ontouchstart="startAdjust('${device.device_id}')"
onmouseup="endAdjust('${device.device_id}')" ontouchend="endAdjust('${device.device_id}')">
</div>
`;
container.appendChild(card);
updateNowPlaying(device.device_id);
updateVolume(device.device_id);
openDeviceWebSocket(device.device_id);
});
// Remove cards for devices that no longer exist
Array.from(container.children).forEach(child => {
const id = child.id?.replace('device-', '');
if (id && !seen.has(id)) {
container.removeChild(child);
}
});
} catch (error) {
console.error('Failed to fetch devices', error);
document.getElementById('device-list').innerHTML = '<p>Error loading devices.</p>';
}
}
async function updateNowPlaying(deviceId) {
try {
const response = await fetch(`/devices/${deviceId}/info`);
if (!response.ok) return;
const info = await response.json();
// Update device name and type if available (live info is more accurate than discovery)
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && info.name) {
title.textContent = info.name;
}
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
if (subtitle && info.type) {
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
}
// Update firmware version if available
const details = document.getElementById(`details-${deviceId}`);
if (details) {
if (info.deviceID) {
const idField = details.querySelector('p:nth-child(1) code');
if (idField) idField.textContent = info.deviceID;
}
if (info.softwareVersion) {
const firmwareField = details.querySelector('p:nth-child(2) code');
if (firmwareField) firmwareField.textContent = info.softwareVersion;
}
if (info.serialNumber) {
const serialField = details.querySelector('p:nth-child(3) code');
if (serialField) serialField.textContent = info.serialNumber;
}
}
const npContainer = document.getElementById(`np-${deviceId}`);
if (npContainer && info.nowPlaying) {
const np = info.nowPlaying;
const source = np.source || np.Source;
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
if (powerIcon) {
if (source === 'STANDBY') {
powerIcon.classList.add('off');
powerIcon.classList.remove('on');
} else {
powerIcon.classList.remove('off');
powerIcon.classList.add('on');
}
}
if (source === 'STANDBY') {
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} else {
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
const artist = np.artist || np.Artist || 'Unknown Artist';
const album = np.album || np.Album || 'Unknown Album';
const art = np.Art || np.art || {};
const artStatus = art.ArtImageStatus || art.artImageStatus;
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
npContainer.innerHTML = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
} catch (error) {
console.warn('Failed to fetch now playing for ' + deviceId, error);
}
}
async function updateVolume(deviceId) {
try {
const response = await fetch(`/devices/${deviceId}/info`);
if (!response.ok) return;
const info = await response.json();
const slider = document.getElementById(`vol-${deviceId}`);
if (slider && info.volume && typeof info.volume.actualvolume === 'number' && !adjusting[deviceId]) {
slider.value = String(info.volume.actualvolume);
}
} catch (error) {
console.warn('Failed to fetch volume for ' + deviceId, error);
}
}
async function control(deviceId, key) {
let deviceName = deviceId;
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && title.textContent) {
deviceName = title.textContent;
}
try {
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/key/${encodeURIComponent(key)}`, {
method: 'POST'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
} catch (error) {
console.error('Control failed', error);
alert(`Failed to send ${key} to ${deviceName}: ${error.message}`);
}
}
async function setVolume(deviceId, level) {
let deviceName = deviceId;
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && title.textContent) {
deviceName = title.textContent;
}
try {
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/volume/${encodeURIComponent(level)}`, {
method: 'POST'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
} catch (error) {
console.error('Set volume failed', error);
alert(`Failed to set volume on ${deviceName} to ${level}: ${error.message}`);
}
}
// Volume interaction helpers to avoid UI jumping while dragging
const adjusting = {};
const volumeTimers = {};
function startAdjust(deviceId) {
adjusting[deviceId] = true;
}
function endAdjust(deviceId) {
// Small delay to let the device send back its volume update
setTimeout(() => { adjusting[deviceId] = false; }, 300);
}
function onVolumeInput(deviceId, el) {
startAdjust(deviceId);
const level = el.value;
// Debounce network calls per device
if (volumeTimers[deviceId]) {
clearTimeout(volumeTimers[deviceId]);
}
volumeTimers[deviceId] = setTimeout(() => {
setVolume(deviceId, level);
endAdjust(deviceId);
}, 150);
}
let deviceSockets = {};
function openDeviceWebSocket(deviceId) {
const key = `${deviceId}`;
try {
const existing = deviceSockets[key];
if (existing) {
// Reuse an already healthy connection instead of tearing it down every refresh
if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) {
return;
}
try { existing.close(); } catch (_) {}
}
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${proto}://${location.host}/devices/${encodeURIComponent(deviceId)}/ws`;
const ws = new WebSocket(wsUrl);
deviceSockets[key] = ws;
ws.onopen = () => {
// console.log('WS connected for', deviceId);
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
const type = msg.type;
const payload = msg.payload || {};
if (type === 'nowPlayingUpdated') {
const e = payload;
const np = e.NowPlaying || e.nowPlaying || {};
const source = np.source || np.Source;
// Also try to update name/type if they are present in the event (sometimes events carry device info)
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && e.name) {
title.textContent = e.name;
}
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
if (subtitle && e.type) {
subtitle.textContent = `${e.ipAddress || e.ip_address || 'N/A'} | ${e.type}`;
}
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
if (powerIcon) {
if (source === 'STANDBY') {
powerIcon.classList.add('off');
powerIcon.classList.remove('on');
} else {
powerIcon.classList.remove('off');
powerIcon.classList.add('on');
}
}
const npContainer = document.getElementById(`np-${deviceId}`);
if (npContainer) {
if (source === 'STANDBY') {
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} else {
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
const artist = np.artist || np.Artist || 'Unknown Artist';
const album = np.album || np.Album || 'Unknown Album';
const art = np.Art || np.art || {};
const artStatus = art.ArtImageStatus || art.artImageStatus;
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
npContainer.innerHTML = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
} else if (type === 'volumeUpdated') {
const e = payload;
const vol = (e.Volume && (typeof e.Volume.actualvolume === 'number' ? e.Volume.actualvolume : (typeof e.Volume.actual === 'number' ? e.Volume.actual : e.Volume.target))) ||
(e.volume && (typeof e.volume.actualvolume === 'number' ? e.volume.actualvolume : (typeof e.volume.actual === 'number' ? e.volume.actual : e.volume.target)));
const slider = document.getElementById(`vol-${deviceId}`);
if (slider && typeof vol === 'number' && !adjusting[deviceId]) {
slider.value = String(vol);
}
} else if (type === 'snapshotInfo') {
const info = payload || {};
// Update name and type from snapshot
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && info.name) {
title.textContent = info.name;
}
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
if (subtitle && info.type) {
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
}
// Update firmware and ID from snapshot if available
const details = document.getElementById(`details-${deviceId}`);
if (details) {
if (info.deviceID) {
const idField = details.querySelector('p:nth-child(1) code');
if (idField) idField.textContent = info.deviceID;
}
if (info.softwareVersion) {
const firmwareField = details.querySelector('p:nth-child(2) code');
if (firmwareField) firmwareField.textContent = info.softwareVersion;
}
if (info.serialNumber) {
const serialField = details.querySelector('p:nth-child(3) code');
if (serialField) serialField.textContent = info.serialNumber;
}
}
if (info.nowPlaying) {
const np = info.nowPlaying;
const source = np.source || np.Source;
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
if (powerIcon) {
if (source === 'STANDBY') {
powerIcon.classList.add('off');
powerIcon.classList.remove('on');
} else {
powerIcon.classList.remove('off');
powerIcon.classList.add('on');
}
}
const npContainer = document.getElementById(`np-${deviceId}`);
if (npContainer) {
if (source === 'STANDBY') {
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} else {
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
const artist = np.artist || np.Artist || 'Unknown Artist';
const album = np.album || np.Album || 'Unknown Album';
const art = np.Art || np.art || {};
const artStatus = art.ArtImageStatus || art.artImageStatus;
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
npContainer.innerHTML = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
}
const vol = info.actualVolume || (info.volume && (typeof info.volume.actualvolume === 'number' ? info.volume.actualvolume : (typeof info.volume.actual === 'number' ? info.volume.actual : null)));
const slider = document.getElementById(`vol-${deviceId}`);
if (slider && typeof vol === 'number' && !adjusting[deviceId]) slider.value = String(vol);
}
} catch (err) {
// console.warn('Bad WS message', err);
}
};
ws.onerror = () => {
// console.warn('WS error for', ip);
};
ws.onclose = () => {
// Try to reconnect after a delay
setTimeout(() => {
if (deviceSockets[key] === ws) {
delete deviceSockets[key];
}
openDeviceWebSocket(deviceId);
}, 3000);
};
} catch (e) {
// console.warn('Failed to open WS for', deviceId, e);
}
}
function toggleDetails(deviceId) {
const el = document.getElementById(`details-${deviceId}`);
if (el) {
el.classList.toggle('visible');
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchDevices();
fetchVersion();
setInterval(fetchDevices, 30000);
});
Binary file not shown.
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Stockholm Mini - Reverse Engineered</title>
<link rel="stylesheet" href="../shared/common.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Stockholm Mini</h1>
<p style="margin-top: -10px; font-style: italic; color: #666; font-size: 0.9rem;">A minimal reverse-engineered SoundTouch controller.</p>
<p style="margin-bottom: 20px;"><a href="/" style="color: #00bcd4; text-decoration: none; font-size: 0.9rem;">&larr; Back to selection</a></p>
<div id="device-list"></div>
</div>
<footer style="margin-top: 50px;">
<span id="version-info">AfterTouch</span>
</footer>
<script src="../shared/common.js"></script>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,40 @@
@font-face {
font-family: 'bose';
src: url('bose.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #121212; color: #e0e0e0; margin: 0; padding: 20px; }
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; }
.device-card { background: #1e1e1e; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.device-info h2 { margin-top: 0; color: #00bcd4; margin-bottom: 0; }
.device-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.device-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.device-title { margin: 0; font-size: 1.5rem; line-height: 1.2; }
.device-subtitle { color: #888; font-size: 0.85rem; margin: 0; display: flex; align-items: center; }
.info-toggle { background: none; color: #555; padding: 0; width: 1.15rem; height: 1.15rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #444; border-radius: 50%; font-size: 0.7rem; font-style: italic; cursor: pointer; line-height: 1; transition: all 0.2s; flex-shrink: 0; }
.info-toggle:hover { color: #aaa; border-color: #666; background: #2a2a2a; }
.device-details { display: none; margin-top: 10px; font-size: 0.8rem; background: #252525; padding: 10px; border-radius: 4px; color: #aaa; border-left: 2px solid #00bcd4; }
.device-details.visible { display: block; }
.device-details p { margin: 4px 0; }
.device-details code { color: #ccc; }
.controls { display: flex; gap: 10px; margin-top: 20px; }
button { background: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
button:hover { background: #444; }
button.primary { background: #00bcd4; color: #000; font-weight: bold; }
button.primary:hover { background: #00acc1; }
.power-icon { font-family: bose, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.25rem; line-height: 1; height: 2.25rem; width: 2.25rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 50%; color: #00bcd4; border: 1px solid #00bcd4; }
.power-icon:hover { background: #3a3a3a; }
.power-icon.off { color: #666; border-color: #444; background: #1a1a1a; }
.power-icon.on { background: #00bcd4; color: #000; border-color: #00bcd4; }
.power-icon.on:hover { background: #00acc1; }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; background: #333; margin-left: 10px; }
.now-playing { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; display: flex; gap: 15px; align-items: center; min-height: 80px; }
.now-playing-info { flex-grow: 1; }
.album-art { width: 80px; height: 80px; border-radius: 4px; background: #2a2a2a; flex-shrink: 0; object-fit: cover; box-shadow: 0 2px 4px rgba(0,0,0,0.5); }
.album-art[src=""] { display: none; }
.volume-container { margin-top: 15px; display: flex; align-items: center; gap: 10px; }
input[type=range] { flex-grow: 1; }
#device-list:empty::after { content: "Searching for devices..."; color: #666; font-style: italic; }
+223 -644
View File
File diff suppressed because it is too large Load Diff
+9 -314
View File
@@ -45,20 +45,6 @@ func TestMargeXML(t *testing.T) {
t.Errorf("Expected <sourceProviders>, got %s", string(xmlData))
}
// Verify RADIO_BROWSER is in the list
if !strings.Contains(string(xmlData), "RADIO_BROWSER") {
t.Errorf("Expected RADIO_BROWSER in XML")
}
// Verify a known static provider has correct createdOn
// SPOTIFY (ID 15) should have 2014-03-17T15:30:27.000+00:00
if !strings.Contains(string(xmlData), `id="15"`) {
t.Errorf("Expected Spotify ID 15 in XML, got %s", string(xmlData))
}
if !strings.Contains(string(xmlData), `<createdOn>2014-03-17T15:30:27.000+00:00</createdOn>`) {
t.Errorf("Expected Spotify createdOn 2014-03-17T15:30:27.000+00:00 in XML")
}
// Test AccountFullToXML
fullXML, err := AccountFullToXML(ds, account)
if err != nil {
@@ -80,161 +66,6 @@ func TestMargeXML(t *testing.T) {
}
}
func TestAccountFullToXML_Structure(t *testing.T) {
tempDir, err := os.MkdirTemp("", "marge-test-structure-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "3230304"
device := "08DF1F0BA325"
// 1. Setup Device Info with Components
info := &models.ServiceDeviceInfo{
DeviceID: device,
Name: "A Sound Machine",
ProductCode: "SoundTouch 20",
DeviceSerialNumber: device,
ProductSerialNumber: "066802942560222AE",
FirmwareVersion: "27.0.6.46330.5043500",
IPAddress: "192.168.178.28",
}
_ = ds.SaveDeviceInfo(account, device, info)
// Since SaveDeviceInfo is limited, we'll manually add the SMSC component
// because CreateAccountDevice expects it in info.Components
info, _ = ds.GetDeviceInfo(account, device)
info.Components = []models.ServiceComponent{
{
Type: "SMSC",
SoftwareVersion: "I2014101420409423",
SerialNumber: "08DF1F0BA32A",
Label: "SMSC",
},
}
// We'll mock the CreateAccountDevice call or just rely on the fact that
// info.Components will be used if we could save it.
// But ds.SaveDeviceInfo doesn't save arbitrary components.
// Let's modify CreateAccountDevice to be more flexible or fix the test by
// manually creating the AccountDevice if needed, but the goal is to test AccountFullToXML.
// Actually, CreateAccountDevice calls ds.GetDeviceInfo.
// Let's just fix the test to not expect SMSC if it's not supported by datastore yet,
// OR fix datastore.
// For now, I'll adjust the test to expect what's actually produced.
// 2. Setup Sources
src := models.ConfiguredSource{
ID: "10863533",
DisplayName: "gesellix",
Type: "Audio",
Secret: "AQBtotl13...",
SecretType: "token_version_3",
SourceName: "gesellix+spotify@gmail.com",
Username: "gesellix",
}
src.SourceKeyType = "SPOTIFY"
src.SourceKeyAccount = "gesellix"
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
// 3. Setup Presets
preset := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Jonas",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
Source: "SPOTIFY",
},
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
}
_ = ds.SavePresets(account, device, []models.ServicePreset{preset})
// 4. Setup Recents
recent := models.ServiceRecent{
ServiceContentItem: models.ServiceContentItem{
Name: "Billie Eilish - bad guy",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDoxV2dKT3EyWktYU1BTRGxDdWI1NERV",
Source: "SPOTIFY",
},
LastPlayedAt: "2026-02-24T07:02:24.000+00:00",
}
_ = ds.SaveRecents(account, device, []models.ServiceRecent{recent})
// 5. Generate XML
fullXML, err := AccountFullToXML(ds, account)
if err != nil {
t.Fatalf("AccountFullToXML failed: %v", err)
}
xmlStr := string(fullXML)
// 6. Verify Structure
// Root and attributes
if !strings.Contains(xmlStr, `<account id="3230304">`) {
t.Errorf("Expected <account id=\"3230304\">, got %s", xmlStr)
}
// Device structure
if !strings.Contains(xmlStr, `<device deviceid="08DF1F0BA325">`) {
t.Errorf("Expected device attribute deviceid, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<serialNumber>08DF1F0BA325</serialNumber>`) {
t.Errorf("Expected <serialNumber>08DF1F0BA325</serialNumber> under device, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<updatedOn>`) {
t.Errorf("Expected <updatedOn> under device, got %s", xmlStr)
}
// AttachedProduct and Components
if !strings.Contains(xmlStr, `<attachedProduct product_code="SoundTouch 20">`) {
t.Errorf("Expected attachedProduct with product_code, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<productlabel>SoundTouch 20</productlabel>`) {
t.Errorf("Expected productlabel SoundTouch 20, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<serialNumber>066802942560222AE</serialNumber>`) {
t.Errorf("Expected <serialNumber>066802942560222AE</serialNumber> under attachedProduct, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<updatedOn>`) {
t.Errorf("Expected <updatedOn> under attachedProduct, got %s", xmlStr)
}
// Presets and Recents nesting
if !strings.Contains(xmlStr, `<presets><preset buttonNumber="1">`) {
t.Errorf("Expected preset tag with buttonNumber, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<contentItemType>tracklisturl</contentItemType>`) {
t.Errorf("Expected contentItemType tracklisturl, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<recents><recent id="1">`) {
t.Errorf("Expected recent tag with id, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<contentItemType>tracklisturl</contentItemType>`) {
t.Errorf("Expected contentItemType tracklisturl in recents, got %s", xmlStr)
}
// Provider Settings
if !strings.Contains(xmlStr, `<providerSettings><providerSetting>`) {
t.Errorf("Expected <providerSettings><providerSetting>, got %s", xmlStr)
}
// Global Sources
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio">`) {
t.Errorf("Expected source tag with attributes, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<credential type="token_version_3">AQBtotl13...</credential>`) {
t.Errorf("Expected credential tag, got %s", xmlStr)
}
// Check for self-closing tags (parity check)
if !strings.Contains(xmlStr, `<sourceSettings/>`) {
t.Errorf("Expected self-closing <sourceSettings/>, got %s", xmlStr)
}
}
func TestEscapeXML(t *testing.T) {
input := "Antenne Chillout & Other"
expected := "Antenne Chillout &amp; Other"
@@ -310,119 +141,6 @@ func TestRecentsXML_EmptyIDFix(t *testing.T) {
}
}
func TestRecentsToXML_SourceIncluded(t *testing.T) {
tempDir, err := os.MkdirTemp("", "marge-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "test-acc"
device := "test-dev"
deviceDir := ds.AccountDeviceDir(account, device)
_ = os.MkdirAll(deviceDir, 0755)
// Create a Recents.xml with a reference to a source
recents := []models.ServiceRecent{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Track",
SourceID: "100001",
Type: "tracklisturl",
Location: "/test",
},
DeviceID: device,
UtcTime: "1708896000",
},
}
_ = ds.SaveRecents(account, device, recents)
// Create a Sources.xml with the SPOTIFY source
sources := []models.ConfiguredSource{
{
ID: "100001",
DisplayName: "Spotify",
SourceName: "Spotify",
Username: "testuser",
},
}
_ = ds.SaveConfiguredSources(account, device, sources)
// Fetch XML
xmlData, err := RecentsToXML(ds, account, device)
if err != nil {
t.Fatalf("RecentsToXML failed: %v", err)
}
xmlStr := string(xmlData)
if !strings.Contains(xmlStr, "<source") {
t.Errorf("XML should contain <source> element: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<sourcename>Spotify</sourcename>") {
t.Errorf("XML should contain <sourcename>Spotify</sourcename>: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<username>testuser</username>") {
t.Errorf("XML should contain <username>testuser</username>: %s", xmlStr)
}
}
func TestPresetsToXML_SourceIncluded(t *testing.T) {
tempDir, err := os.MkdirTemp("", "marge-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "test-acc"
device := "test-dev"
deviceDir := ds.AccountDeviceDir(account, device)
_ = os.MkdirAll(deviceDir, 0755)
// Create a Presets.xml with a reference to a source
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Preset",
SourceID: "100001",
Type: "tracklisturl",
Location: "/test",
},
},
}
_ = ds.SavePresets(account, device, presets)
// Create a Sources.xml with the source
sources := []models.ConfiguredSource{
{
ID: "100001",
DisplayName: "Spotify",
SourceName: "Spotify",
Username: "testuser",
},
}
_ = ds.SaveConfiguredSources(account, device, sources)
// Fetch XML
xmlData, err := PresetsToXML(ds, account, device)
if err != nil {
t.Fatalf("PresetsToXML failed: %v", err)
}
xmlStr := string(xmlData)
if !strings.Contains(xmlStr, "<source") {
t.Errorf("XML should contain <source> element: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<sourcename>Spotify</sourcename>") {
t.Errorf("XML should contain <sourcename>Spotify</sourcename>: %s", xmlStr)
}
}
func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
src := models.ConfiguredSource{
ID: "101&202",
@@ -431,44 +149,21 @@ func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
}
src.SourceKeyAccount = "user&name"
xmlData := GetConfiguredSourceXML(src)
if !strings.Contains(xmlData, "id=\"101&amp;202\"") {
t.Errorf("ID not escaped in attribute: %s", xmlData)
xml := GetConfiguredSourceXML(src)
if !strings.Contains(xml, "id=\"101&amp;202\"") {
t.Errorf("ID not escaped in attribute: %s", xml)
}
if strings.Contains(xmlData, "<sourceid>101&amp;202</sourceid>") {
t.Errorf("ID should not be escaped in sourceid tag inside source tag anymore: %s", xmlData)
if strings.Contains(xml, "<sourceid>101&amp;202</sourceid>") {
t.Errorf("ID should not be escaped in sourceid tag inside source tag anymore: %s", xml)
}
if !strings.Contains(xmlData, "<sourcename>Test &amp; Source</sourcename>") {
t.Errorf("DisplayName not escaped: %s", xmlData)
if !strings.Contains(xml, "<sourcename>Test &amp; Source</sourcename>") {
t.Errorf("DisplayName not escaped: %s", xml)
}
if !strings.Contains(xmlData, ">key&amp;value</credential>") {
t.Errorf("Secret not escaped: %s", xmlData)
if !strings.Contains(xml, ">key&amp;value</credential>") {
t.Errorf("Secret not escaped: %s", xml)
}
}
func TestGetConfiguredSourceXML_Parity(t *testing.T) {
t.Run("Other source should have empty sourcename", func(t *testing.T) {
src := models.ConfiguredSource{
ID: "14774275",
DisplayName: "Other",
}
xmlData := GetConfiguredSourceXML(src)
if !strings.Contains(xmlData, "<sourcename></sourcename>") && !strings.Contains(xmlData, "<sourcename/>") {
t.Errorf("Expected empty sourcename for 'Other', got: %s", xmlData)
}
})
t.Run("sourceSettings should be present", func(t *testing.T) {
src := models.ConfiguredSource{
ID: "14774275",
}
xmlData := GetConfiguredSourceXML(src)
if !strings.Contains(xmlData, "<sourceSettings>") && !strings.Contains(xmlData, "<sourceSettings/>") {
t.Errorf("Expected sourceSettings, got: %s", xmlData)
}
})
}
func TestAddRecent_TimestampPreservation(t *testing.T) {
tempDir, err := os.MkdirTemp("", "marge-test-*")
if err != nil {
+14
View File
@@ -139,6 +139,10 @@ type DeviceInfoXML struct {
} `xml:"networkInfo" json:"networkInfo"`
SoftwareVer string `xml:"-" json:"softwareVersion"`
SerialNumber string `xml:"-" json:"serialNumber"`
// Enriched fields (not part of device /info XML)
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
Volume *models.Volume `json:"volume,omitempty"`
}
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
@@ -186,6 +190,16 @@ func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) e
}
}
// Enrich with live now playing and volume via device API (best-effort)
//c := client.NewClientFromHost(deviceIP)
//if vol, err := c.GetVolume(); err == nil {
// infoXML.Volume = vol
//}
//
//if np, err := c.GetNowPlaying(); err == nil {
// infoXML.NowPlaying = np
//}
return nil
}