Compare commits

...
2 Commits
Author SHA1 Message Date
Tobias GesellchenandJunie 717693e01f feat(sync): improve parity with upstream during data sync (#123)
- Enhance initial and full data synchronization to better align with
upstream services.
- Update data structures in 'pkg/models' to support missing fields
(e.g., SecretType for Spotify).
- Improve 'datastore' persistence logic for presets, recents, and
sources.
- Add comprehensive regression tests for sync and datastore operations.
- Update documentation on parity status and improvements.

Co-authored-by: Junie <junie@jetbrains.com>

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 00:01:41 +01:00
Tobias Gesellchen a0833c113c Add favicon-gen tool to generate PNG and ICO favicons from SVG sources (#22) 2026-03-21 13:18:16 +01:00
34 changed files with 2831 additions and 509 deletions
+9 -1
View File
@@ -20,6 +20,8 @@ EXAMPLE_UPNP_NAME=example-upnp
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
SCANNER_NAME=mdns-scanner
SCANNER_PATH=./cmd/$(SCANNER_NAME)
FAVICON_GEN_NAME=favicon-gen
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
BUILD_DIR=./build
# Version info
@@ -27,7 +29,7 @@ BUILD_DIR=./build
all: check build
build: build-cli build-service build-examples
build: build-cli build-service build-examples build-favicon-gen
build-cli:
@echo "Building $(BINARY_NAME)..."
@@ -48,6 +50,11 @@ build-examples:
@echo "Building $(SCANNER_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
build-favicon-gen:
@echo "Building $(FAVICON_GEN_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
build-all: build-linux build-darwin build-windows build-examples-all
build-linux:
@@ -226,6 +233,7 @@ help:
@echo " build - Build the CLI tool, service, and examples"
@echo " build-cli - Build only the CLI tool"
@echo " build-service - Build only the service"
@echo " build-favicon-gen - Build the favicon generator"
@echo " build-examples - Build only the example programs"
@echo " build-all - Build for all platforms"
@echo " test - Run tests"
+152
View File
@@ -0,0 +1,152 @@
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
package main
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
func main() {
mediaDir := "pkg/service/handlers/web/img"
files := []string{"favicon-braille", "favicon-morse"}
for _, name := range files {
svgPath := filepath.Join(mediaDir, name+".svg")
pngPath := filepath.Join(mediaDir, name+".png")
icoPath := filepath.Join(mediaDir, name+".ico")
fmt.Printf("Processing %s...\n", name)
// 1. Render SVG to PNG
img, err := renderSVG(svgPath, 32, 32)
if err != nil {
log.Fatalf("Failed to render %s: %v", svgPath, err)
}
f, err := os.Create(pngPath)
if err != nil {
log.Fatalf("Failed to create %s: %v", pngPath, err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
}
f.Close()
fmt.Printf("Created %s\n", pngPath)
// 2. Create ICO (containing multiple sizes)
sizes := []int{16, 32, 48}
var images []image.Image
for _, s := range sizes {
m, err := renderSVG(svgPath, s, s)
if err != nil {
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
}
images = append(images, m)
}
if err := writeICO(icoPath, images); err != nil {
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
}
fmt.Printf("Created %s\n", icoPath)
}
}
func renderSVG(path string, w, h int) (image.Image, error) {
in, err := os.Open(path)
if err != nil {
return nil, err
}
defer in.Close()
icon, err := oksvg.ReadIconStream(in)
if err != nil {
return nil, err
}
icon.SetTarget(0, 0, float64(w), float64(h))
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
dasher := rasterx.NewDasher(w, h, gv)
icon.Draw(dasher, 1.0)
return rgba, nil
}
// Simple ICO encoder that wraps PNGs
func writeICO(path string, images []image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
bw := bufio.NewWriter(f)
defer bw.Flush()
// ICONDIR header
// Reserved (2), Type (2), Count (2)
binary.Write(bw, binary.LittleEndian, uint16(0))
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
var pngData [][]byte
for _, img := range images {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
pngData = append(pngData, buf.Bytes())
}
offset := uint32(6 + len(images)*16)
for i, img := range images {
b := img.Bounds()
width := uint8(b.Dx())
if b.Dx() >= 256 {
width = 0
}
height := uint8(b.Dy())
if b.Dy() >= 256 {
height = 0
}
// ICONDIRENTRY
bw.WriteByte(width)
bw.WriteByte(height)
bw.WriteByte(0) // Color count
bw.WriteByte(0) // Reserved
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
binary.Write(bw, binary.LittleEndian, offset)
offset += uint32(len(pngData[i]))
}
for _, data := range pngData {
bw.Write(data)
}
return nil
}
+16 -7
View File
@@ -743,14 +743,23 @@ func setupRouter(server *handlers.Server) *chi.Mux {
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
})
})
+25 -23
View File
@@ -3,10 +3,19 @@
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
#### ✅ Completed Improvements (Marge Service)
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
* **Attribute-level Parity**:
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
@@ -37,19 +46,21 @@ This document summarizes the improvements made to the **Marge service** to impro
#### 🛠️ Open Issues and Next Steps
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
#### 1. BMX / TuneIn Playback Parity (Medium)
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
* **Mismatched Parameters**: Local reporting URLs use `listen_id=3432432423`, while upstream uses a different session-based ID.
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
#### 2. Presets and Recents Parity (Medium)
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
#### 2. `/full` Account Response Data Gaps (Medium)
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
#### 3. OAuth / Spotify Token Noise (Low/Medium)
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
@@ -68,19 +79,10 @@ Analysis of device reboot logs revealed several data requirements:
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
#### 7. Account Full Response (/full) Structural & Value Parity (In Progress)
Based on `_/diffs/diff7/`, several structural and value gaps remain in the `/full` account response:
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
Structural and value gaps in the `/full` account response have been addressed:
**Remaining Findings:**
* **Nested Source Inconsistency in Recents**: The `<source>` element within `<recent>` entries still frequently points to a generic fallback (ID `9330201`) instead of the specific source (e.g., Spotify ID `10863533`).
* Missing/empty `<username>` at the `<preset>` level.
* **Values**:
* **Empty Device `<name>`**: Locally, the device `<name>` is empty in the response even when available in the datastore or upstream.
* **Empty `<contentItemType>`**: Local responses have empty `<contentItemType>` in presets and recents, whereas upstream has `tracklisturl` or `stationurl`.
* `preferredLanguage` mismatch (`en` vs `de`).
**Next Implementation Steps (Proposals):**
1. **Fix Device `<name>` Population**: Investigate why `CreateAccountDevice` or `AccountFullToXML` is not correctly returning the device name even if it's synchronized.
2. **Refine Source Association in Recents**: Improve the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
3. **Populate `contentItemType`**: Update the internal models and `SyncFromAccountFull` to correctly extract, persist, and echo back `contentItemType` (e.g., `tracklisturl`).
4. **Handle Account Metadata**: Synchronize `preferredLanguage` from the upstream `/full` response to the local account state.
**Key Fixes:**
* **Structural**:
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
+41
View File
@@ -0,0 +1,41 @@
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|:---------------------|:-------------------------------------------------|:----------------------------------------------------------------------------------------|
| **Group Management** | Placeholder handlers (return `<group/>` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
## 3. Key Strengths of SoundCork
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
- **Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).
## 4. Suggested Implementation Steps for Bose-SoundTouch
### A. Implement Full Group Support (High Priority)
- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`.
- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones.
### B. Modularize BMX Registry (Medium Priority)
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
- Allow users to customize which mocked services are advertised to the speaker.
### C. Enhanced Source Management (Medium Priority)
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
### D. Basic Admin Web UI (Low Priority)
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
## 5. Summary
While our Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`), SoundCork provides better coverage of multi-device coordination (Groups) and service emulation (BMX) that we should adopt for a more complete offline experience.
+5
View File
@@ -57,6 +57,10 @@
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
## Parity Analysis
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
## Appendix (Other Documents)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
@@ -81,3 +85,4 @@
* [Power On Implementation Guide](power-on-implementation-guide.md)
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
+4
View File
@@ -8,6 +8,8 @@ require (
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.49.0
)
@@ -15,9 +17,11 @@ require (
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.37.0 // 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/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
+8
View File
@@ -13,6 +13,10 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
@@ -26,6 +30,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
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=
@@ -91,6 +97,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+293 -83
View File
@@ -137,8 +137,8 @@ type ServiceContentItem struct {
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"`
Location string `json:"location,omitempty" xml:"location,attr,omitempty"`
SourceAccount string `json:"source_account" xml:"sourceAccount,attr"`
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
}
@@ -146,6 +146,7 @@ type ServiceContentItem struct {
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
ID string `json:"id,omitempty" xml:"id,attr"`
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
@@ -158,35 +159,212 @@ type ServicePreset struct {
type ServiceRecent struct {
XMLName xml.Name `json:"-" xml:"recent"`
ServiceContentItem
DeviceID string `json:"device_id" xml:"deviceid,attr"`
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"`
ContentItem *struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
} `xml:"contentItem,omitempty"`
}
// UnmarshalXML implements the xml.Unmarshaler interface to handle both nested and flat formats.
func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}
type Alias struct {
XMLName xml.Name `xml:"recent"`
ServiceContentItem
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
ContainerArt string `xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
LastPlayedAt string `xml:"lastplayedat"`
ContentItem *ContentItem `xml:"contentItem,omitempty"`
}
var a Alias
if err := d.DecodeElement(&a, &start); err != nil {
return err
}
r.DeviceID = a.DeviceID
r.UtcTime = a.UtcTime
r.ID = a.ID
r.SourceID = a.SourceID
if r.SourceID == "" {
r.SourceID = a.SourceID
}
r.CreatedOn = a.CreatedOn
r.UpdatedOn = a.UpdatedOn
r.ContainerArt = a.ContainerArt
r.SourceConfig = a.SourceConfig
r.LastPlayedAt = a.LastPlayedAt
// Ensure the embedded ServiceContentItem.ID is populated from the attribute
r.ID = a.ID
if a.ContentItem != nil {
r.Source = a.ContentItem.Source
r.Type = a.ContentItem.Type
r.Location = a.ContentItem.Location
r.SourceAccount = a.ContentItem.SourceAccount
r.IsPresetable = a.ContentItem.IsPresetable
r.Name = a.ContentItem.ItemName
if a.ContentItem.ContainerArt != "" {
r.ContainerArt = a.ContentItem.ContainerArt
}
} else {
// Fallback for flat format: populate ContentItem fields from root fields
r.Source = a.Source
r.Type = a.Type
r.Location = a.Location
r.SourceAccount = a.SourceAccount
r.IsPresetable = a.IsPresetable
r.Name = a.Name
}
// Always ensure the nested struct is populated for MarshalXML
r.ContentItem = &struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}{
Source: r.Source,
Type: r.Type,
Location: r.Location,
SourceAccount: r.SourceAccount,
IsPresetable: r.IsPresetable,
ItemName: r.Name,
ContainerArt: r.ContainerArt,
}
return nil
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ServiceRecent.
func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}
type Alias struct {
XMLName xml.Name `xml:"recent"`
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
ContentItem ContentItem `xml:"contentItem"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
LastPlayedAt string `xml:"lastplayedat,omitempty"`
SourceID string `xml:"sourceid,omitempty"`
Source *ConfiguredSource `xml:"source,omitempty"`
}
a := Alias{
DeviceID: r.DeviceID,
UtcTime: r.UtcTime,
ID: r.ID,
SourceID: r.SourceID,
ContentItem: ContentItem{
Source: r.Source,
Type: r.Type,
Location: r.Location,
SourceAccount: r.SourceAccount,
IsPresetable: r.IsPresetable,
ItemName: r.Name,
ContainerArt: r.ContainerArt,
},
CreatedOn: r.CreatedOn,
UpdatedOn: r.UpdatedOn,
LastPlayedAt: r.LastPlayedAt,
Source: r.SourceConfig,
}
if a.SourceID == "" && r.SourceID != "" {
a.SourceID = r.SourceID
}
if r.ContentItem != nil {
a.ContentItem.Source = r.ContentItem.Source
a.ContentItem.Type = r.ContentItem.Type
a.ContentItem.Location = r.ContentItem.Location
a.ContentItem.SourceAccount = r.ContentItem.SourceAccount
a.ContentItem.IsPresetable = r.ContentItem.IsPresetable
a.ContentItem.ItemName = r.ContentItem.ItemName
if r.ContentItem.ContainerArt != "" {
a.ContentItem.ContainerArt = r.ContentItem.ContainerArt
}
}
if a.Source == nil && r.SourceConfig != nil {
a.Source = r.SourceConfig
}
if a.ContentItem.IsPresetable == "" {
a.ContentItem.IsPresetable = "true"
}
start.Name.Local = "recent"
return e.EncodeElement(a, start)
}
// ConfiguredSource represents a configured media source with authentication details.
type ConfiguredSource struct {
XMLName xml.Name `json:"-" xml:"source"`
DisplayName string `json:"display_name" xml:"name"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"credential"`
SecretType string `json:"secret_type" xml:"credential_type,attr"`
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
ID string `json:"id" xml:"id,attr,omitempty"`
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"`
} `json:"source_key" xml:"sourceKey"`
Type string `xml:"type,attr,omitempty"`
// Parity fields
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid"`
Username string `json:"username,omitempty" xml:"username"`
SourceName string `json:"source_name,omitempty" xml:"sourcename"`
SourceSettings string `json:"-" xml:"sourceSettings"`
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,attr,omitempty"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,attr,omitempty"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid,attr,omitempty"`
Username string `json:"username,omitempty" xml:"-"`
SourceName string `json:"source_name,omitempty" xml:"-"`
Name string `json:"name,omitempty" xml:"-"`
SourceSettings string `json:"-" xml:"-"`
Status string `json:"status,omitempty" xml:"-"`
// Legacy fields for backward compatibility in code if needed,
// though it's better to update the code to use SourceKey.
@@ -196,20 +374,38 @@ type ConfiguredSource struct {
// 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),
type Alias struct {
DisplayName string `xml:"displayName,attr,omitempty"`
Secret string `xml:"secret,attr"`
SecretType string `xml:"secretType,attr"`
ID string `xml:"id,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
CreatedOn string `xml:"createdOn,attr,omitempty"`
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `xml:"sourceKey"`
}
a.Username = s.Username
a.SourceName = s.SourceName
// We want <sourceSettings/>
a.SourceSettings = ""
a := Alias{
DisplayName: s.DisplayName,
Secret: s.Secret,
SecretType: s.SecretType,
ID: s.ID,
Type: s.Type,
CreatedOn: s.CreatedOn,
UpdatedOn: s.UpdatedOn,
SourceProviderID: s.SourceProviderID,
}
a.SourceKey.Type = s.SourceKey.Type
a.SourceKey.Account = s.SourceKey.Account
start.Name.Local = "source"
// Important: Clear automatically generated attributes from the start element
// because we are using Alias to control attribute order and presence.
start.Attr = nil
return e.EncodeElement(a, start)
}
@@ -231,11 +427,18 @@ type ServiceDeviceInfo struct {
// ServiceComponent represents a hardware or software component of a device.
type ServiceComponent struct {
Type string `xml:"type,attr"`
Category string `xml:"category,attr,omitempty"`
SoftwareVersion string `xml:"firmware-version"`
SerialNumber string `xml:"serialnumber"`
Label string `xml:"componentlabel,omitempty"`
Type string `json:"type" xml:"type,attr"`
Category string `json:"category,omitempty" xml:"category,attr,omitempty"`
SoftwareVersion string `json:"firmware_version" xml:"firmware-version"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
Label string `json:"label,omitempty" xml:"componentlabel,omitempty"`
}
// ServiceAccountInfo represents account-level metadata.
type ServiceAccountInfo struct {
AccountID string `json:"account_id"`
PreferredLanguage string `json:"preferred_language"`
ProviderSettings []ProviderSetting `json:"provider_settings"`
}
// CustomerSupportDevice represents device information for customer support purposes.
@@ -361,45 +564,48 @@ type EmailAddressResponse struct {
// FullResponseSource represents a configured media source specifically for the /full response.
// It follows the specific XML structure and field order of the upstream /full response.
type FullResponseSource struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
CreatedOn string `xml:"createdOn"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
ID string `json:"id" xml:"id,attr"`
Type string `json:"type" xml:"type,attr"`
DisplayName string `json:"display_name,omitempty" xml:"displayName,attr,omitempty"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Credential struct {
Type string `json:"type" xml:"type,attr"`
Value string `json:"value" xml:",chardata"`
} `json:"credential" xml:"credential"`
Name string `json:"name" xml:"name"`
SourceProviderID string `json:"sourceproviderid" xml:"sourceproviderid"`
SourceName string `json:"source_name" xml:"sourcename"`
SourceSettings string `json:"source_settings" xml:"sourceSettings"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
SourceLabel string `json:"source_label" xml:"-"`
}
// FullResponsePreset represents a preset specifically for the /full response.
type FullResponsePreset struct {
ButtonNumber string `xml:"buttonNumber,attr"`
ContainerArt string `xml:"containerArt"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
Location string `xml:"location"`
Name string `xml:"name"`
Source FullResponseSource `xml:"source"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
ButtonNumber string `json:"button_number" xml:"buttonNumber,attr"`
ContainerArt string `json:"container_art" xml:"containerArt"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Location string `json:"location" xml:"location"`
Name string `json:"name" xml:"name"`
Source FullResponseSource `json:"source" xml:"source"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
}
// FullResponseRecent represents a recent item specifically for the /full response.
type FullResponseRecent struct {
ID string `xml:"id,attr"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
LastPlayedAt string `xml:"lastplayedat"`
Location string `xml:"location"`
Name string `xml:"name"`
Source FullResponseSource `xml:"source"`
SourceID string `xml:"sourceid"`
UpdatedOn string `xml:"updatedOn"`
ID string `json:"id" xml:"id,attr"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
CreatedOn string `json:"created_on" xml:"createdOn"`
LastPlayedAt string `json:"last_played_at" xml:"lastplayedat"`
Location string `json:"location" xml:"location"`
Name string `json:"name" xml:"name"`
Source FullResponseSource `json:"source" xml:"source"`
SourceID string `json:"source_id" xml:"sourceid"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// AccountFullResponse represents the complete account XML structure.
@@ -416,31 +622,35 @@ type AccountFullResponse struct {
// AccountDevice represents a device in the account response.
type AccountDevice struct {
DeviceID string `xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `xml:"attachedProduct"`
CreatedOn string `xml:"createdOn"`
FirmwareVersion string `xml:"firmwareVersion"`
IPAddress string `xml:"ipaddress"`
Name string `xml:"name"`
Presets []FullResponsePreset `xml:"presets>preset"`
Recents []FullResponseRecent `xml:"recents>recent"`
SerialNumber string `xml:"serialNumber"`
UpdatedOn string `xml:"updatedOn"`
DeviceID string `json:"device_id" xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
CreatedOn string `json:"created_on" xml:"createdOn"`
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
IPAddress string `json:"ip_address" xml:"ipaddress"`
Name string `json:"name" xml:"name"`
Presets []FullResponsePreset `json:"presets" xml:"presets>preset"`
ProductCode string `json:"product_code" xml:"-"`
Recents []FullResponseRecent `json:"recents" xml:"recents>recent"`
SerialNumber string `json:"serial_number" xml:"serialNumber"`
DeviceSerialNumber string `json:"device_serial_number,omitempty" xml:"-"`
MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty" xml:"-"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// AttachedProduct represents product information for a device.
type AttachedProduct struct {
ProductCode string `xml:"product_code,attr"`
Components []ServiceComponent `xml:"components>component"`
ProductLabel string `xml:"productlabel"`
SerialNumber string `xml:"serialnumber"`
UpdatedOn string `xml:"updatedOn"`
ProductCode string `json:"product_code" xml:"product_code,attr"`
Components []ServiceComponent `json:"components" xml:"components>component"`
ProductLabel string `json:"product_label" xml:"productlabel"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// ProviderSetting represents a single provider setting.
type ProviderSetting struct {
BoseID string `xml:"boseId"`
KeyName string `xml:"keyName"`
Value string `xml:"value"`
ProviderID string `xml:"providerId"`
BoseID string `json:"bose_id" xml:"boseId"`
KeyName string `json:"key_name" xml:"keyName"`
Value string `json:"value" xml:"value"`
ProviderID string `json:"provider_id" xml:"providerId"`
}
+84 -39
View File
@@ -5,51 +5,96 @@ package constants
type SourceProvider struct {
ID int
Name string
Label 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"},
{ID: 1, Name: "PANDORA", Label: "Pandora", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 2, Name: "INTERNET_RADIO", Label: "Internet Radio", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 3, Name: "OFF", Label: "Off", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
{ID: 4, Name: "LOCAL", Label: "Local", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 5, Name: "AIRPLAY", Label: "AirPlay", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 6, Name: "CURRATED_RADIO", Label: "Curated Radio", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 7, Name: "STORED_MUSIC", Label: "Stored Music", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 8, Name: "SLAVE_SOURCE", Label: "Slave Source", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 9, Name: "AUX", Label: "Aux", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 10, Name: "RECOMMENDED_INTERNET_RADIO", Label: "Recommended Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 11, Name: "LOCAL_INTERNET_RADIO", Label: "Local Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 12, Name: "GLOBAL_INTERNET_RADIO", Label: "Global Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 13, Name: "HELLO", Label: "Hello", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
{ID: 14, Name: "DEEZER", Label: "Deezer", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 15, Name: "SPOTIFY", Label: "Spotify", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 16, Name: "IHEART", Label: "iHeartRadio", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 17, Name: "SIRIUSXM", Label: "SiriusXM", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 18, Name: "GOOGLE_PLAY_MUSIC", Label: "Google Play Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 19, Name: "QQMUSIC", Label: "QQMusic", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 20, Name: "AMAZON", Label: "Amazon Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 21, Name: "LOCAL_MUSIC", Label: "Local Music Library", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
{ID: 22, Name: "WBMX", Label: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 23, Name: "SOUNDCLOUD", Label: "SoundCloud", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 24, Name: "TIDAL", Label: "Tidal", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 25, Name: "TUNEIN", Label: "TuneIn Radio", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 26, Name: "QPLAY", Label: "QPlay", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
{ID: 27, Name: "JUKE", Label: "Juke", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 28, Name: "BBC", Label: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 29, Name: "DARFM", Label: "DAR.fm", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 30, Name: "7DIGITAL", Label: "7digital", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 31, Name: "SAAVN", Label: "Saavn", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 32, Name: "RDIO", Label: "Rdio", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 33, Name: "PHONE_MUSIC", Label: "Phone Music", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
{ID: 34, Name: "ALEXA", Label: "Amazon Alexa", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
{ID: 35, Name: "RADIOPLAYER", Label: "Radioplayer", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
{ID: 36, Name: "RADIO.COM", Label: "Radio.com", CreatedOn: "2019-05-28T18:21:41.000+00:00", UpdatedOn: "2019-05-28T18:21:41.000+00:00"},
{ID: 37, Name: "RADIO_COM", Label: "Radio.com", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
{ID: 38, Name: "SIRIUSXM_EVEREST", Label: "SiriusXM Everest", CreatedOn: "2019-11-25T18:00:33.000+00:00", UpdatedOn: "2019-11-25T18:00:33.000+00:00"},
{ID: 39, Name: "RADIO_BROWSER", Label: "Radio Browser", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
}
// GetSourceLabel returns a user-friendly label for a source type.
func GetSourceLabel(sourceType string) string {
for _, provider := range StaticProviders {
if provider.Name == sourceType {
return provider.Label
}
}
switch sourceType {
case "BLUETOOTH":
return "Bluetooth"
case "BMX":
return "BMX"
case "NOTIFICATION":
return "Notifications"
case "TUNEIN":
return "TuneIn Radio"
case "SPOTIFY":
return "Spotify"
case "IHEART":
return "iHeartRadio"
case "AMAZON":
return "Amazon Music"
case "DEEZER":
return "Deezer"
case "SIRIUSXM":
return "SiriusXM"
case "TIDAL":
return "Tidal"
case "PANDORA":
return "Pandora"
case "AUX":
return "Aux"
case "AUX_IN":
return "AUX IN"
case "INTERNET_RADIO":
return "Internet Radio"
case "LOCAL_INTERNET_RADIO":
return "Local Internet Radio"
default:
return sourceType
}
}
// Providers lists known source provider identifiers used by Bose SoundTouch.
+170 -53
View File
@@ -105,6 +105,45 @@ func (ds *DataStore) safeJoin(elem ...string) string {
return base
}
// SafeJoin returns a safe joined path relative to the datastore base directory.
func (ds *DataStore) SafeJoin(elem ...string) string {
return ds.safeJoin(elem...)
}
// ListAccounts returns a list of all account IDs (directories in the data root).
func (ds *DataStore) ListAccounts() ([]string, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
// Account data is stored in 'accounts' subdirectory within the data root.
accountsDir := filepath.Join(ds.baseDir, "accounts")
if !exists(accountsDir) {
return []string{"default"}, nil
}
entries, err := os.ReadDir(accountsDir)
if err != nil {
return nil, err
}
accounts := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() {
// Basic filter to ignore common hidden/system dirs
if entry.Name() != ".git" && entry.Name() != "logs" {
accounts = append(accounts, entry.Name())
}
}
}
if len(accounts) == 0 {
accounts = append(accounts, "default")
}
return accounts, nil
}
// AccountDir returns the directory path for a specific account.
func (ds *DataStore) AccountDir(account string) string {
return ds.safeJoin("accounts", account)
@@ -199,6 +238,12 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
}
for _, comp := range info.Components {
deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
switch comp.Category {
case "SCM":
deviceInfo.FirmwareVersion = comp.SoftwareVersion
@@ -459,15 +504,15 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
CreatedOn string `xml:"createdOn,attr"`
UpdatedOn string `xml:"updatedOn,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContentItemType string `xml:"contentItemType"`
ContainerArt string `xml:"containerArt"`
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"`
Source *models.ConfiguredSource `xml:"source"`
} `xml:"preset"`
}
@@ -480,14 +525,10 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
for i := range presetsWrap.Presets {
p := &presetsWrap.Presets[i]
cit := p.ContentItem.ContentItemType
if cit == "" {
cit = p.ContentItem.Type
}
cit := p.ContentItem.Type
presets = append(presets, models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: p.ID,
Name: p.ContentItem.ItemName,
Source: p.ContentItem.Source,
Type: p.ContentItem.Type,
@@ -496,9 +537,12 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
IsPresetable: p.ContentItem.IsPresetable,
ContentItemType: cit,
},
ID: p.ID,
ButtonNumber: p.ID,
ContainerArt: p.ContentItem.ContainerArt,
CreatedOn: p.CreatedOn,
UpdatedOn: p.UpdatedOn,
SourceConfig: p.Source,
})
}
@@ -511,21 +555,24 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
defer ds.fileMutex.Unlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
type PresetXML struct {
ID string `xml:"id,attr"`
CreatedOn string `xml:"createdOn,attr"`
UpdatedOn string `xml:"updatedOn,attr"`
ContentItem struct {
Source string `xml:"source,attr,omitempty"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContentItemType string `xml:"contentItemType"`
ContainerArt string `xml:"containerArt"`
Source string `xml:"source,attr,omitempty"`
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"`
Source *models.ConfiguredSource `xml:"source,omitempty"`
}
type PresetsXML struct {
@@ -540,7 +587,11 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
var pxml PresetXML
pxml.ID = p.ID
pxml.ID = p.ButtonNumber
if pxml.ID == "" {
pxml.ID = p.ID
}
pxml.CreatedOn = p.CreatedOn
pxml.UpdatedOn = p.UpdatedOn
pxml.ContentItem.Source = p.Source
@@ -549,8 +600,8 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
pxml.ContentItem.SourceAccount = p.SourceAccount
pxml.ContentItem.IsPresetable = "true"
pxml.ContentItem.ItemName = p.Name
pxml.ContentItem.ContentItemType = p.ContentItemType
pxml.ContentItem.ContainerArt = p.ContainerArt
pxml.Source = p.SourceConfig
px.Presets = append(px.Presets, pxml)
}
@@ -592,6 +643,7 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
for i := range recents {
r := &recents[i]
if id, err := strconv.Atoi(r.ID); err == nil {
if id > maxID {
maxID = id
@@ -619,7 +671,12 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
dir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
path := filepath.Join(dir, constants.RecentsFile)
type RecentsXML struct {
XMLName xml.Name `xml:"recents"`
@@ -730,11 +787,27 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
}
ix := InfoXML{
DeviceID: info.DeviceID,
Name: info.Name,
Type: devType,
ModuleType: moduleType,
Components: []ComponentXML{
DeviceID: info.DeviceID,
Name: info.Name,
Type: devType,
ModuleType: moduleType,
DiscoveryMethod: info.DiscoveryMethod,
}
if ix.DiscoveryMethod == "" {
ix.DiscoveryMethod = "sync_full"
}
for _, comp := range info.Components {
ix.Components = append(ix.Components, ComponentXML{
ComponentCategory: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
}
if len(ix.Components) == 0 {
ix.Components = []ComponentXML{
{
ComponentCategory: "SCM",
SoftwareVersion: info.FirmwareVersion,
@@ -744,15 +817,15 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
ComponentCategory: "PackagedProduct",
SerialNumber: info.ProductSerialNumber,
},
}
}
ix.NetworkInfo = []NetworkInfoXML{
{
Type: "SCM",
IPAddress: info.IPAddress,
MacAddress: info.MacAddress,
},
NetworkInfo: []NetworkInfoXML{
{
Type: "SCM",
IPAddress: info.IPAddress,
MacAddress: info.MacAddress,
},
},
DiscoveryMethod: info.DiscoveryMethod,
}
data, err := xml.MarshalIndent(ix, "", " ")
@@ -765,6 +838,51 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return os.WriteFile(path, append(header, data...), 0644)
}
// SaveAccountInfo saves account-level metadata to the datastore.
func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccountInfo) error {
if ds == nil || ds.DataDir == "" || accountID == "" {
return nil
}
dir := ds.AccountDir(accountID)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
path := filepath.Join(dir, "account.json")
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// GetAccountInfo retrieves account-level metadata from the datastore.
func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInfo, error) {
if ds == nil || ds.DataDir == "" || accountID == "" {
return &models.ServiceAccountInfo{AccountID: accountID}, nil
}
path := filepath.Join(ds.AccountDir(accountID), "account.json")
if !exists(path) {
return &models.ServiceAccountInfo{AccountID: accountID}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var info models.ServiceAccountInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, err
}
return &info, nil
}
// RemoveDevice removes a device and all its data from the specified account.
func (ds *DataStore) RemoveDevice(account, device string) error {
ds.fileMutex.Lock()
@@ -800,27 +918,26 @@ 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]
// Ensure SourceKey values are prioritized for legacy fields
if s.SourceKey.Type != "" {
s.SourceKeyType = s.SourceKey.Type
}
if s.SourceKey.Account != "" {
s.SourceKeyAccount = s.SourceKey.Account
}
// Ensure Type is populated from SourceKey if missing
if s.Type == "" && s.SourceKey.Type != "" {
s.Type = s.SourceKey.Type
}
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
}
return sourcesWrap.Sources, nil
+2 -2
View File
@@ -15,8 +15,8 @@ func TestSaveDeviceInfo_MergesName(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "3230304"
device := "A81B6A536A98"
account := "1234567"
device := "001122334455"
// 1. Initial save with name
info1 := &models.ServiceDeviceInfo{
@@ -0,0 +1,194 @@
package datastore
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSavePresets_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-format-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "test-playlist",
Source: "SPOTIFY",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
SourceAccount: "test-user",
IsPresetable: "true",
},
ID: "1",
ButtonNumber: "1",
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
CreatedOn: "1719128436",
UpdatedOn: "1728740382",
},
}
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Fatalf("SavePresets failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Presets.xml: %v", err)
}
xmlContent := string(data)
// Check for correct id attribute
if !strings.Contains(xmlContent, `id="1"`) {
t.Errorf("Presets.xml missing correct id attribute, got: %s", xmlContent)
}
// Check that contentItemType is NOT present (as requested in previous issues)
if strings.Contains(xmlContent, "contentItemType") {
t.Errorf("Presets.xml should not contain contentItemType tag, got: %s", xmlContent)
}
// Verify unmarshaling still works
loadedPresets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets failed: %v", err)
}
if len(loadedPresets) != 1 {
t.Fatalf("Expected 1 preset, got %d", len(loadedPresets))
}
if loadedPresets[0].ID != "1" {
t.Errorf("Expected ID 1, got %s", loadedPresets[0].ID)
}
if loadedPresets[0].ContentItemType != "tracklisturl" {
t.Errorf("Expected ContentItemType to be tracklisturl, got %s", loadedPresets[0].ContentItemType)
}
}
func TestSavePresets_PreservesID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-id-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "test-acc"
device := "test-dev"
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 1",
},
ID: "1",
ButtonNumber: "1",
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 2",
},
ID: "2",
// ButtonNumber is empty, should fall back to ID
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 3",
},
ButtonNumber: "3",
// ID is empty, should use ButtonNumber
},
}
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Fatalf("SavePresets failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Presets.xml: %v", err)
}
xmlContent := string(data)
if !strings.Contains(xmlContent, `id="1"`) {
t.Errorf("Expected id=\"1\", got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `id="2"`) {
t.Errorf("Expected id=\"2\", got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `id="3"`) {
t.Errorf("Expected id=\"3\", got: %s", xmlContent)
}
// Now check if GetPresets loads them correctly
loaded, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets failed: %v", err)
}
if len(loaded) != 3 {
t.Fatalf("Expected 3 presets, got %d", len(loaded))
}
for i, p := range loaded {
expectedID := strconv.Itoa(i + 1)
if p.ID != expectedID {
t.Errorf("At index %d, expected ID %s, got %s", i, expectedID, p.ID)
}
if p.ButtonNumber != expectedID {
t.Errorf("At index %d, expected ButtonNumber %s, got %s", i, expectedID, p.ButtonNumber)
}
}
}
func TestPresetsXML_NoID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-noid-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "No ID Preset",
},
},
}
err = ds.SavePresets("acc", "dev", presets)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(ds.AccountDeviceDir("acc", "dev"), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `id=""`) {
t.Errorf("Expected empty id attribute, got: %s", string(data))
}
}
@@ -0,0 +1,113 @@
package datastore
import (
"encoding/xml"
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveRecents_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore_recents_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "test-account"
device := "test-device"
recents := []models.ServiceRecent{
{
ServiceContentItem: models.ServiceContentItem{
ID: "2567119953",
Name: "The National",
Source: "SPOTIFY",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C",
SourceAccount: "test-user",
IsPresetable: "true",
},
DeviceID: "001122334455",
UtcTime: "1771666755",
},
}
if err := ds.SaveRecents(account, device, recents); err != nil {
t.Fatalf("SaveRecents failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Recents.xml")
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Recents.xml: %v", err)
}
expectedXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent deviceID="001122334455" utcTime="1771666755" id="2567119953">
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C" sourceAccount="test-user" isPresetable="true">
<itemName>The National</itemName>
</contentItem>
</recent>
</recents>`
// Normalize whitespace for comparison by unmarshaling both
var expected, actual struct {
XMLName xml.Name `xml:"recents"`
Recents []struct {
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
} `xml:"contentItem"`
} `xml:"recent"`
}
if err := xml.Unmarshal([]byte(expectedXML), &expected); err != nil {
t.Fatalf("Failed to unmarshal expected XML: %v", err)
}
if err := xml.Unmarshal(content, &actual); err != nil {
t.Fatalf("Failed to unmarshal actual XML: %v", err)
}
if len(actual.Recents) != 1 {
t.Fatalf("Expected 1 recent, got %d", len(actual.Recents))
}
r := actual.Recents[0]
if r.ID != "2567119953" || r.DeviceID != "001122334455" || r.UtcTime != "1771666755" {
t.Errorf("Attributes mismatch: %+v", r)
}
if r.ContentItem.ItemName != "The National" || r.ContentItem.Source != "SPOTIFY" {
t.Errorf("ContentItem mismatch: %+v", r.ContentItem)
}
if r.ContentItem.IsPresetable != "true" {
t.Errorf("IsPresetable mismatch: got %s, expected true", r.ContentItem.IsPresetable)
}
// Now test Round-trip (GetRecents)
loadedRecents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("GetRecents failed: %v", err)
}
if len(loadedRecents) != 1 {
t.Fatalf("Expected 1 loaded recent, got %d", len(loadedRecents))
}
lr := loadedRecents[0]
if lr.ID != "2567119953" || lr.Name != "The National" || lr.Source != "SPOTIFY" || lr.SourceAccount != "test-user" {
t.Errorf("Loaded recent mismatch: %+v", lr)
}
}
@@ -0,0 +1,96 @@
package datastore
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveSources_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-sources-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
sources := []models.ConfiguredSource{
{
DisplayName: "AUX IN",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "AUX", Account: "AUX"},
},
{
SecretType: "token",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "INTERNET_RADIO", Account: ""},
},
{
DisplayName: "user@example.com",
Secret: "dummy-token-spotify",
SecretType: "token_version_3",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "SPOTIFY", Account: "test-user"},
},
}
err = ds.SaveConfiguredSources(account, device, sources)
if err != nil {
t.Fatalf("SaveConfiguredSources failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Sources.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Sources.xml: %v", err)
}
xmlContent := string(data)
// Check for correct attributes in first source
if !strings.Contains(xmlContent, `<source displayName="AUX IN" secret="" secretType="">`) {
t.Errorf("First source missing expected attributes. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX" />`) &&
!strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX"></sourceKey>`) {
t.Errorf("First sourceKey incorrect. Got: %s", xmlContent)
}
// Check for third source (Spotify)
if !strings.Contains(xmlContent, `displayName="user@example.com"`) {
t.Errorf("Spotify source missing displayName. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `secretType="token_version_3"`) {
t.Errorf("Spotify source missing secretType. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user" />`) &&
!strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user"></sourceKey>`) {
t.Errorf("Spotify sourceKey incorrect. Got: %s", xmlContent)
}
// Negative checks for extra tags
if strings.Contains(xmlContent, "<sourcename>") {
t.Errorf("Sources.xml should not contain <sourcename> tag")
}
if strings.Contains(xmlContent, "<username>") {
t.Errorf("Sources.xml should not contain <username> tag")
}
if strings.Contains(xmlContent, "<name>") {
t.Errorf("Sources.xml should not contain <name> tag")
}
if strings.Contains(xmlContent, "<sourceSettings>") {
t.Errorf("Sources.xml should not contain <sourceSettings> tag")
}
}
@@ -0,0 +1,271 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/go-chi/chi/v5"
)
// HandleMgmtAccountDetails returns full details for an account for the Web UI.
func (s *Server) HandleMgmtAccountDetails(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
// 1. Get account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
log.Printf("[Mgmt] Failed to get account info for %s: %v", accountID, err)
accountInfo = &models.ServiceAccountInfo{AccountID: accountID}
}
// 2. List all devices for this account
allDevices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Mgmt] Failed to list devices: %v", err)
}
accountDevices := make([]deviceDetail, 0)
for i := range allDevices {
d := &allDevices[i]
if d.AccountID != accountID {
continue
}
detail := s.getDeviceDetail(accountID, d)
accountDevices = append(accountDevices, detail)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"account": accountInfo,
"devices": accountDevices,
}); err != nil {
log.Printf("[Mgmt] Failed to encode account details: %v", err)
}
}
type deviceDetail struct {
models.AccountDevice
Presets []models.FullResponsePreset `json:"presets,omitempty"`
Recents []models.FullResponseRecent `json:"recents,omitempty"`
Sources []models.FullResponseSource `json:"sources,omitempty"`
Components []models.ServiceComponent `json:"components,omitempty"`
}
func (s *Server) getDeviceDetail(accountID string, d *models.ServiceDeviceInfo) deviceDetail {
detail := deviceDetail{
AccountDevice: models.AccountDevice{
DeviceID: d.DeviceID,
FirmwareVersion: d.FirmwareVersion,
IPAddress: d.IPAddress,
Name: d.Name,
ProductCode: d.ProductCode,
SerialNumber: d.DeviceSerialNumber,
DeviceSerialNumber: d.DeviceSerialNumber,
MacAddress: d.MacAddress,
DiscoveryMethod: d.DiscoveryMethod,
},
}
// We also have AttachedProduct which has Components
detail.AttachedProduct = &models.AttachedProduct{
SerialNumber: d.DeviceSerialNumber,
ProductCode: d.ProductCode,
ProductLabel: d.Name,
Components: d.Components,
}
detail.Components = d.Components
// Fetch sources
var configuredSources []models.ConfiguredSource
if sources, err := s.ds.GetConfiguredSources(accountID, d.DeviceID); err == nil {
configuredSources = sources
for j := range sources {
fs := mapToFullResponseSource(&sources[j])
if fs.Type == "" && fs.Name == "" && fs.DisplayName == "" {
log.Printf("[Mgmt] Skipping empty source for device %s", d.DeviceID)
continue
}
detail.Sources = append(detail.Sources, fs)
}
}
// Fetch presets
if presets, err := s.ds.GetPresets(accountID, d.DeviceID); err == nil {
for j := range presets {
detail.Presets = append(detail.Presets, mapToFullResponsePreset(&presets[j], configuredSources))
}
}
detail.AccountDevice.Presets = detail.Presets
// Fetch recents
if recents, err := s.ds.GetRecents(accountID, d.DeviceID); err == nil {
for j := range recents {
detail.Recents = append(detail.Recents, mapToFullResponseRecent(&recents[j], configuredSources))
}
}
detail.AccountDevice.Recents = detail.Recents
return detail
}
func mapToFullResponseSource(src *models.ConfiguredSource) models.FullResponseSource {
fs := models.FullResponseSource{
ID: src.ID,
Type: src.Type,
DisplayName: src.DisplayName,
Name: src.DisplayName,
Username: src.Username,
SourceName: src.SourceName,
SourceProviderID: src.SourceProviderID,
CreatedOn: src.CreatedOn,
UpdatedOn: src.UpdatedOn,
Account: src.SourceKey.Account,
SourceLabel: constants.GetSourceLabel(src.Type),
SourceSettings: src.SourceSettings,
}
fs.Credential.Value = src.Secret
fs.Credential.Type = src.SecretType
// Provide fallback for Name and SourceName if missing
switch {
case fs.Name != "":
// Name already set to DisplayName
case fs.SourceLabel != "":
fs.Name = fs.SourceLabel
default:
fs.Name = fs.Type
}
if fs.SourceName == "" {
fs.SourceName = fs.Name
}
return fs
}
func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models.ConfiguredSource) models.FullResponsePreset {
fp := models.FullResponsePreset{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: p.CreatedOn,
Location: p.Location,
Name: p.Name,
UpdatedOn: p.UpdatedOn,
}
if fp.Name == "" {
fp.Name = p.Name
}
if fp.CreatedOn == "" && p.CreatedOn != "" {
fp.CreatedOn = p.CreatedOn
}
if p.SourceConfig != nil {
fp.Source = mapToFullResponseSource(p.SourceConfig)
} else {
// Attempt to find matching source in configuredSources
found := false
for k := range configuredSources {
src := &configuredSources[k]
if src.SourceKey.Type == p.Source && (src.SourceKey.Account == p.SourceAccount || p.SourceAccount == "") {
fp.Source = mapToFullResponseSource(src)
found = true
break
}
}
if !found && p.Source != "" {
// Create a dummy source for UI purposes if not found in configured sources
dummy := &models.ConfiguredSource{
Type: p.Source,
}
dummy.SourceKey.Type = p.Source
dummy.SourceKey.Account = p.SourceAccount
fp.Source = mapToFullResponseSource(dummy)
}
}
return fp
}
func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models.ConfiguredSource) models.FullResponseRecent {
fr := models.FullResponseRecent{
ID: r.ID,
ContentItemType: r.ContentItemType,
CreatedOn: r.CreatedOn,
LastPlayedAt: r.LastPlayedAt,
Location: r.Location,
Name: r.Name,
SourceID: r.SourceID,
UpdatedOn: r.UpdatedOn,
}
if fr.Name == "" {
fr.Name = r.Name
}
if fr.CreatedOn == "" && r.CreatedOn != "" {
fr.CreatedOn = r.CreatedOn
} else if fr.CreatedOn == "" && r.UtcTime != "" {
fr.CreatedOn = r.UtcTime
}
if r.SourceConfig != nil {
fr.Source = mapToFullResponseSource(r.SourceConfig)
} else {
// Attempt to find matching source in configuredSources
found := false
for k := range configuredSources {
src := &configuredSources[k]
if src.SourceKey.Type == r.Source && (src.SourceKey.Account == r.SourceAccount || r.SourceAccount == "") {
fr.Source = mapToFullResponseSource(src)
found = true
break
}
}
if !found && r.Source != "" {
// Create a dummy source for UI purposes if not found in configured sources
dummy := &models.ConfiguredSource{
Type: r.Source,
}
dummy.SourceKey.Type = r.Source
dummy.SourceKey.Account = r.SourceAccount
fr.Source = mapToFullResponseSource(dummy)
}
}
return fr
}
// HandleMgmtListAccounts returns a list of all account IDs in the datastore.
func (s *Server) HandleMgmtListAccounts(w http.ResponseWriter, _ *http.Request) {
accounts, err := s.ds.ListAccounts()
if err != nil {
log.Printf("[Mgmt] Failed to list accounts: %v", err)
accounts = []string{"default"}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"accounts": accounts,
}); err != nil {
log.Printf("[Mgmt] Failed to encode accounts: %v", err)
}
}
@@ -0,0 +1,148 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
tempBaseDir := "mgmt_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
deviceID := "001122334455"
// Setup a device with a recent item that has utcTime and name in ContentItem
deviceDir := ds.AccountDeviceDir(accountID, deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatal(err)
}
recentsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recents>
<recent id="2538285498" utcTime="1690000000">
<contentItem source="INTERNET_RADIO" type="stationurl">
<itemName>For Your Darkest Days</itemName>
</contentItem>
</recent>
</recents>`
err = os.WriteFile(deviceDir+"/Recents.xml", []byte(recentsXML), 0644)
if err != nil {
t.Fatal(err)
}
// Also need a device info file to be listed
deviceInfo := models.ServiceDeviceInfo{
AccountID: accountID,
DeviceID: deviceID,
Name: "Test Device",
}
err = ds.SaveDeviceInfo(accountID, deviceID, &deviceInfo)
if err != nil {
t.Fatal(err)
}
server := &Server{ds: ds}
r := chi.NewRouter()
r.Get("/mgmt/accounts/{accountId}", server.HandleMgmtAccountDetails)
req := httptest.NewRequest("GET", "/mgmt/accounts/1234567", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response struct {
Devices []struct {
Recents []models.FullResponseRecent `json:"recents"`
} `json:"devices"`
}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(response.Devices) == 0 {
t.Fatal("Expected at least one device")
}
recents := response.Devices[0].Recents
if len(recents) == 0 {
t.Fatal("Expected one recent item")
}
r0 := recents[0]
if r0.Name != "For Your Darkest Days" {
t.Errorf("Expected recent name 'For Your Darkest Days', got '%s'", r0.Name)
}
if r0.CreatedOn != "1690000000" {
t.Errorf("Expected recent created_on '1690000000' (from utcTime), got '%s'", r0.CreatedOn)
}
// Test Preset mapping as well
presetsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<presets>
<preset id="1" createdOn="1690000001">
<ContentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
<itemName>test-playlist</itemName>
</ContentItem>
</preset>
</presets>`
err = os.WriteFile(deviceDir+"/Presets.xml", []byte(presetsXML), 0644)
if err != nil {
t.Fatal(err)
}
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req)
var response2 struct {
Devices []struct {
Presets []models.FullResponsePreset `json:"presets"`
} `json:"devices"`
}
err = json.Unmarshal(w2.Body.Bytes(), &response2)
if err != nil {
t.Fatal(err)
}
if len(response2.Devices[0].Presets) == 0 {
t.Fatal("Expected one preset")
}
p0 := response2.Devices[0].Presets[0]
if p0.Name != "test-playlist" {
t.Errorf("Expected preset name 'test-playlist', got '%s'", p0.Name)
}
if p0.CreatedOn != "1690000001" {
t.Errorf("Expected preset created_on '1690000001', got '%s'", p0.CreatedOn)
}
// Verify ButtonNumber/ID handling
if p0.ButtonNumber != "1" {
t.Errorf("Expected button_number '1', got '%s'", p0.ButtonNumber)
}
}
+5 -5
View File
@@ -722,7 +722,7 @@ func TestMargePowerOn(t *testing.T) {
})
t.Run("FullBody", func(t *testing.T) {
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="A81B6A536A98"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>A81B6A536A98</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="001122334455"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>001122334455</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
@@ -745,13 +745,13 @@ func TestMargePowerOn(t *testing.T) {
ts2 := httptest.NewServer(r)
defer ts2.Close()
deviceID := "A81B6A536A98"
deviceID := "001122334455"
serialNumber := "I6332527703739342000020"
firmware := "27.0.6.46330"
productCode := "SoundTouch 10 sm2"
productSerial := "069231P63364828AE"
ipAddress := "192.168.1.100"
macAddress := "A81B6A536A98"
macAddress := "001122334455"
payload := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?>
<device-data>
@@ -943,8 +943,8 @@ func TestMargeAdvancedFeatures(t *testing.T) {
})
t.Run("AddRecent_Reproduction", func(t *testing.T) {
account := "3230304"
device := "A81B6A536A98"
account := "1234567"
device := "001122334455"
// Setup sources for this device
deviceDir := ds.AccountDeviceDir(account, device)
@@ -23,10 +23,10 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
defer os.RemoveAll(tempDir)
// Mock device info response (real-world example)
deviceInfoXML := `<info deviceID="A81B6A536A98">
deviceInfoXML := `<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<margeAccountUUID>1234567</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
@@ -41,7 +41,7 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
<networkInfo type="SMSC">
@@ -98,8 +98,8 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
srv.handleDiscoveredDevice(discoveredDevice)
// 3. Verify the device was saved with MAC address as deviceID
expectedDeviceID := "A81B6A536A98" // MAC address from /info
expectedAccountID := "3230304" // From margeAccountUUID
expectedDeviceID := "001122334455" // MAC address from /info
expectedAccountID := "1234567" // From margeAccountUUID
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
if err != nil {
@@ -135,8 +135,8 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
t.Errorf("Expected productCode 'SoundTouch 10 sm2', got '%s'", deviceInfo.ProductCode)
}
if deviceInfo.MacAddress != "A81B6A536A98" {
t.Errorf("Expected macAddress 'A81B6A536A98', got '%s'", deviceInfo.MacAddress)
if deviceInfo.MacAddress != "001122334455" {
t.Errorf("Expected macAddress '001122334455', got '%s'", deviceInfo.MacAddress)
}
if deviceInfo.DeviceSerialNumber != "I6332527703739342000020" {
@@ -193,7 +193,7 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
// Verify MAC address in networkInfo
macFound := false
for _, net := range savedXML.NetworkInfo {
if net.Type == "SCM" && net.MacAddress == "A81B6A536A98" {
if net.Type == "SCM" && net.MacAddress == "001122334455" {
macFound = true
break
}
@@ -212,14 +212,14 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
}
// 7. Test MAC address resolution
resolvedDir := ds.AccountDeviceDir(expectedAccountID, "A81B6A536A98") // Use MAC as device lookup
resolvedDir := ds.AccountDeviceDir(expectedAccountID, "001122334455") // Use MAC as device lookup
expectedResolvedDir := ds.AccountDeviceDir(expectedAccountID, expectedDeviceID)
if resolvedDir != expectedResolvedDir {
t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedResolvedDir, resolvedDir)
} else {
t.Logf("\n5. MAC address resolution verified:")
t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory")
t.Logf(" MAC '001122334455' resolves to correct device directory")
}
t.Logf("\n✅ MAC-based device discovery integration test passed!")
@@ -241,7 +241,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
accountID := "3230304"
accountID := "1234567"
// 1. Create an existing device entry using IP address (old style)
oldDeviceID := "192.168.1.100"
@@ -281,10 +281,10 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
t.Logf(" Test presets saved: %d", len(testPresets))
// 2. Mock the same device now providing proper /info response
deviceInfoXML := `<info deviceID="A81B6A536A98">
deviceInfoXML := `<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<margeAccountUUID>1234567</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
@@ -293,7 +293,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
</component>
</components>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
@@ -326,7 +326,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
srv.handleDiscoveredDevice(discoveredDevice)
// 5. Verify new device exists with MAC as deviceID
newDeviceID := "A81B6A536A98"
newDeviceID := "001122334455"
newInfo, err := ds.GetDeviceInfo(accountID, newDeviceID)
if err != nil {
t.Fatalf("Failed to get migrated device info: %v", err)
@@ -23,9 +23,9 @@ func TestMacMappingIntegration_HTTPHandler(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Setup test data (same as the issue description)
accountID := "3230304"
accountID := "1234567"
serialNumber := "I6332527703739342000020"
macAddress := "A81B6A536A98"
macAddress := "001122334455"
// Create directory structure using serial number
deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
@@ -265,8 +265,8 @@ func TestMacMappingDebug(t *testing.T) {
serial string
mac string
}{
{"3230304", "I6332527703739342000020", "A81B6A536A98"},
{"3230304", "J1234567890123456789012", "B92C7B647BA9"},
{"1234567", "I6332527703739342000020", "001122334455"},
{"1234567", "J1234567890123456789012", "B92C7B647BA9"},
{"5678901", "K9876543210987654321098", "C03D8C758CAA"},
}
@@ -19,8 +19,8 @@ func TestParityMismatchReproduction_New(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
@@ -35,7 +35,7 @@ func TestParityMismatchReproduction_New(t *testing.T) {
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<credential type="token">dummy-token-base64</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
@@ -77,23 +77,18 @@ func TestParityMismatchReproduction_New(t *testing.T) {
}
// 3. SourceProviderID learned (25)
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
t.Errorf("SourceProviderID was not learned from POST, expected 25. Body: %s", bodyStr)
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("SourceProviderID was not learned from POST, expected 25 in attribute. 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)
if !strings.Contains(bodyStr, `secret="dummy-token-base64"`) {
t.Errorf("Secret was not learned from POST in attribute. 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)
if !strings.Contains(bodyStr, `createdOn="2017-07-20T16:43:48.000+00:00"`) {
t.Errorf("Source CreatedOn was not learned from POST in attribute. Body: %s", bodyStr)
}
})
@@ -107,11 +102,8 @@ func TestParityMismatchReproduction_New(t *testing.T) {
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)
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("GET /recents missing learned sourceproviderid 25 in attribute. Body: %s", bodyStr)
}
})
}
@@ -20,8 +20,8 @@ func TestParityMismatchReproduction_V2(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
@@ -36,7 +36,7 @@ func TestParityMismatchReproduction_V2(t *testing.T) {
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<credential type="token">dummy-token-base64</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
@@ -68,20 +68,12 @@ func TestParityMismatchReproduction_V2(t *testing.T) {
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, `sourceproviderid="25"`) {
t.Errorf("sourceproviderid mismatch. Expected 25 in attribute. 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, `secret="dummy-token-base64"`) {
t.Errorf("Secret value mismatch in attribute. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>") {
@@ -33,7 +33,7 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<credential type="token">dummy-token-base64</credential>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
@@ -42,8 +42,8 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
<sourceid>14774275</sourceid>
</recent>`
account := "3230304"
device := "A81B6A536A98"
account := "1234567"
device := "001122334455"
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) {
@@ -59,6 +59,7 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
t.Logf("POST /recent Response:\n%s\n", bodyStr)
if !strings.Contains(bodyStr, constants.XMLHeader) {
t.Error("Missing XML declaration with standalone=\"yes\"")
@@ -77,26 +78,17 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
// 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")
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("Source provider ID mismatch: expected 25 for TuneIn in attribute. Body: %s", bodyStr)
}
// 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")
if !strings.Contains(bodyStr, `secret="dummy-token-base64"`) {
t.Errorf("Secret value was not preserved in attribute. Body: %s", bodyStr)
}
// 6. Indentation check (2 spaces)
if !strings.Contains(bodyStr, "\n <contentItemType>") {
t.Error("Incorrect indentation: expected 2 spaces")
if !strings.Contains(bodyStr, "\n <contentItem source=\"TUNEIN\"") {
t.Errorf("Incorrect indentation for contentItem: expected 2 spaces. Body: %s", bodyStr)
}
})
@@ -113,12 +105,9 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
t.Logf("GET /recents Local Response:\n%s\n", bodyStr)
if !strings.Contains(bodyStr, `<sourceproviderid>25</sourceproviderid>`) {
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Error("Source provider ID missing in GET /recents")
}
if !strings.Contains(bodyStr, `<sourceSettings/>`) {
t.Error("sourceSettings should be self-closing in GET /recents")
}
})
}
+9 -14
View File
@@ -20,8 +20,8 @@ func TestMargeParityRegressions(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
@@ -44,7 +44,7 @@ func TestMargeParityRegressions(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST recent with Other source - sourcename should be empty", func(t *testing.T) {
t.Run("POST recent with Other source - displayName should be 'Other' in attribute", func(t *testing.T) {
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
@@ -68,23 +68,18 @@ func TestMargeParityRegressions(t *testing.T) {
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 displayName when it's "Other"
if !strings.Contains(bodyStr, `displayName="Other"`) {
t.Errorf("Expected displayName=\"Other\", but got: %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) {
t.Run("POST recent with named source - displayName should be preserved in attribute", func(t *testing.T) {
payload := `
<recent>
<contentItemType>track</contentItemType>
@@ -103,8 +98,8 @@ func TestMargeParityRegressions(t *testing.T) {
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)
if !strings.Contains(bodyStr, `displayName="My Spotify"`) {
t.Errorf("Expected displayName=\"My Spotify\", body: %s", bodyStr)
}
})
}
+2 -2
View File
@@ -23,8 +23,8 @@ func TestMargeRecentConsistencyAndIDParity(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
+28 -1
View File
@@ -36,6 +36,9 @@
<button class="tab-btn" onclick="openTab(event, 'tab-parity')">
6. Parity & Mirroring
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
7. Local Account
</button>
</div>
<!-- Tab 0: Overview -->
@@ -1214,7 +1217,7 @@
id="interaction-content"
style="
white-space: pre-wrap;
font-family: &quot;Courier New&quot;, Courier, monospace;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
margin: 0;
padding: 10px;
@@ -1434,6 +1437,30 @@
</div>
</div>
</div>
<!-- Tab 7: Local Account -->
<div id="tab-account" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Local Account Details</h2>
<div style="display: flex; gap: 10px; align-items: center;">
<label for="account-selector">Account:</label>
<select id="account-selector" onchange="fetchAccountDetails(this.value)">
<option value="default">Default</option>
</select>
<button onclick="fetchAccountDetails(document.getElementById('account-selector').value)">Refresh</button>
</div>
</div>
<div id="account-info-container" class="summary-box">
<h3>Account Overview</h3>
<div id="account-metadata">Loading...</div>
</div>
<div id="account-devices-container">
<h3>Connected Devices</h3>
<div id="account-devices-list">Select an account to view devices.</div>
</div>
</div>
</div>
<script src="/web/js/script.js"></script>
+158
View File
@@ -360,6 +360,10 @@ function openTab(evt, tabId) {
fetchParityMismatches();
}
if (tabId === "tab-account") {
fetchAccountList();
}
if (evt) {
evt.currentTarget.className += " active";
} else {
@@ -459,6 +463,160 @@ async function fetchVersion() {
}
}
async function fetchAccountList() {
try {
const response = await fetch("/mgmt/accounts");
if (!response.ok) return;
const data = await response.json();
const selector = document.getElementById("account-selector");
if (selector) {
selector.innerHTML = data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join("");
if (data.accounts.length > 0) {
fetchAccountDetails(selector.value);
}
}
} catch (error) {
console.error("Failed to fetch account list", error);
}
}
async function fetchAccountDetails(accountId) {
if (!accountId) return;
const metadataEl = document.getElementById("account-metadata");
const devicesEl = document.getElementById("account-devices-list");
if (metadataEl) metadataEl.innerHTML = "Loading...";
if (devicesEl) devicesEl.innerHTML = "Loading devices...";
try {
const response = await fetch(`/mgmt/accounts/${encodeURIComponent(accountId)}`);
if (!response.ok) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${response.statusText}</span>`;
return;
}
const data = await response.json();
// Render Metadata
if (metadataEl) {
metadataEl.innerHTML = `
<table style="width: 100%; font-size: 0.9em;">
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">${data.account.preferred_language || "Not set"}</td></tr>
<tr><td style="padding: 4px"><strong>Provider Settings:</strong></td><td style="padding: 4px">${data.account.provider_settings ? "Configured" : "None"}</td></tr>
</table>
`;
}
// Render Devices
if (devicesEl) {
if (!data.devices || data.devices.length === 0) {
devicesEl.innerHTML = "No devices found for this account.";
return;
}
devicesEl.innerHTML = data.devices.map(device => `
<div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
<div style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;" onclick="toggleInfo('device-details-${device.device_id}')">
<h4 style="margin: 0">${device.name || "Unnamed Device"} (${device.product_code})</h4>
<div style="font-size: 0.8em; color: #666">
${device.ip_address} | ${device.device_id} <span style="font-size: 1.2em; vertical-align: middle;">&#9662;</span>
</div>
</div>
<div id="device-details-${device.device_id}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
<div>
<h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
<div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
<strong>Serial:</strong> ${device.device_serial_number || device.serial_number || "N/A"}<br>
<strong>MAC:</strong> ${device.mac_address || "N/A"}<br>
<strong>Version:</strong> ${device.firmware_version || "N/A"}<br>
<strong>Discovery:</strong> ${device.discovery_method || "N/A"}
</div>
<h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
<ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
${device.components ? device.components.map(c => `<li><strong>${c.category || c.type || 'Component'}</strong>: ${c.firmware_version || 'N/A'} <br><small style="color:#777">S/N: ${c.serial_number || 'N/A'}</small></li>`).join("") : "<li>No components found</li>"}
</ul>
</div>
<div>
<h5 style="margin: 10px 0 5px 0">Presets (1-6)</h5>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px">
${Array.from({length: 6}, (_, i) => {
const p = device.presets ? device.presets.find(pr => pr.button_number == i + 1) : null;
let itemName = "Empty";
let sourceLabel = "";
if (p) {
itemName = p.name || (p.source ? (p.source.source_label || p.source.name || p.source.type) : "Unknown");
if (p.source) {
const s = p.source;
const name = s.source_label || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
const finalName = name || s.type || "Unknown Source";
if (finalName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
}
}
}
return `
<div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
<strong>#${i + 1}</strong>: ${itemName}${sourceLabel}
</div>
`;
}).join("")}
</div>
<h5 style="margin: 15px 0 5px 0">Recent Items</h5>
<div style="max-height: 150px; overflow-y: auto; font-size: 0.8em; border: 1px solid #eee; padding: 5px; border-radius: 4px">
<ul style="padding-left: 15px; margin: 0">
${device.recents ? device.recents.slice(0, 10).map(r => {
const name = r.name || (r.source ? (r.source.source_label || r.source.name || r.source.type) : "Unknown");
let sourceLabel = "";
if (r.source) {
const s = r.source;
const sName = s.source_label || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
const finalSName = sName || s.type || "Unknown Source";
if (finalSName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
}
}
return `<li>${name}${sourceLabel} <br><small style="color:#888">${r.created_on ? new Date(r.created_on * 1000).toLocaleString() : 'N/A'}</small></li>`;
}).join("") : "<li>No recents</li>"}
</ul>
</div>
</div>
</div>
<div style="margin-top: 15px; border-top: 1px dashed #ddd; padding-top: 10px">
<h5 style="margin: 0 0 5px 0">Configured Sources</h5>
<div style="display: flex; flex-wrap: wrap; gap: 5px">
${device.sources ? device.sources.filter(s => (s.source_label || s.source_name || s.name || s.type)).map(s => {
const sourceName = s.source_label || s.source_name || s.name || s.type;
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
const accountSuffix = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
return `
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
${sourceName}${usernameSuffix}${accountSuffix}
</span>
`;
}).join("") : "<small style='color:#999'>None</small>"}
</div>
</div>
</div>
</div>
`).join("");
}
} catch (error) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
console.error("Failed to fetch account details", error);
}
}
async function fetchInteractionStats() {
console.log("Fetching interaction stats...");
try {
+66 -95
View File
@@ -62,79 +62,12 @@ func SourceProvidersToXML() ([]byte, error) {
// ConfiguredSourceToXML converts a configured source to XML format.
func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) {
type SourceXML struct {
XMLName xml.Name `xml:"source"`
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
CreatedOn string `xml:"createdOn"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
providerID := cs.SourceProviderID
tokenType := "token"
if providerID == "" {
for _, p := range constants.StaticProviders {
if p.Name == cs.SourceKeyType {
providerID = strconv.Itoa(p.ID)
break
}
}
}
if cs.SourceKeyType == "SPOTIFY" {
tokenType = "token_version_3"
}
if providerID == "" {
providerID = "0"
}
createdOn := cs.CreatedOn
if createdOn == "" {
createdOn = DateStr
}
updatedOn := cs.UpdatedOn
if updatedOn == "" {
updatedOn = DateStr
}
sxml := SourceXML{
ID: cs.ID,
Type: "Audio",
CreatedOn: createdOn,
Name: cs.SourceKeyAccount,
SourceProviderID: providerID,
SourceName: cs.DisplayName,
SourceSettings: "",
UpdatedOn: updatedOn,
Username: cs.SourceKeyAccount,
}
if sxml.SourceName == "Other" || cs.SourceKeyType == "TUNEIN" {
sxml.SourceName = ""
}
sxml.Credential.Type = tokenType
sxml.Credential.Value = cs.Secret
data, err := xml.Marshal(sxml)
// Use the model's own MarshalXML for consistent output
data, err := xml.Marshal(cs)
if err != nil {
return nil, err
}
// Parity: use self-closing tags for empty SourceSettings
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
return data, nil
}
@@ -240,9 +173,17 @@ func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
}
// Find and prepare source
// Priority 1: sourceID match
// Priority 2: source and sourceAccount match
sourceID := p.SourceID
if sourceID == "" {
sourceID = p.SourceID
}
for j := range sources {
s := sources[j]
if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
if (sourceID != "" && s.ID == sourceID) ||
(s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
// Use a new variable to avoid pointer-to-iterator-variable bug
matchedSource := s
PrepareConfiguredSource(&matchedSource)
@@ -255,12 +196,12 @@ func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
pxml.Presets = append(pxml.Presets, p)
}
data, err := xml.Marshal(pxml)
data, err := xml.MarshalIndent(pxml, "", " ")
if err != nil {
return nil, err
}
return append([]byte(constants.XMLHeader), data...), nil
return append([]byte(constants.XMLHeader+"\n"), data...), nil
}
// RecentsToXML converts account recent items to XML format for Marge responses.
@@ -372,11 +313,20 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
UpdatedOn: DateStr,
}
if device.SerialNumber == "" && info.DeviceID != "" {
device.SerialNumber = info.DeviceID
}
if device.AttachedProduct.SerialNumber == "" && info.ProductSerialNumber != "" {
device.AttachedProduct.SerialNumber = info.ProductSerialNumber
} else if device.AttachedProduct.SerialNumber == "" && device.SerialNumber != "" {
device.AttachedProduct.SerialNumber = device.SerialNumber
}
if len(info.Components) > 0 {
for _, comp := range info.Components {
device.AttachedProduct.Components = append(device.AttachedProduct.Components, models.ServiceComponent{
Type: comp.Type,
Label: comp.Label,
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
@@ -397,6 +347,7 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
fullSource := models.FullResponseSource{
ID: s.ID,
Type: s.Type,
DisplayName: s.DisplayName,
CreatedOn: s.CreatedOn,
Name: s.SourceKeyAccount,
SourceProviderID: s.SourceProviderID,
@@ -456,7 +407,7 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C
}
fullPreset := models.FullResponsePreset{
ButtonNumber: p.ID,
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: p.CreatedOn,
@@ -535,7 +486,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
ID: account,
AccountStatus: "OK",
Mode: "global",
PreferredLanguage: "en",
PreferredLanguage: "de",
ProviderSettings: []models.ProviderSetting{
{
BoseID: account,
@@ -552,6 +503,16 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
},
}
if info, _ := ds.GetAccountInfo(account); info != nil {
if info.PreferredLanguage != "" {
resp.PreferredLanguage = info.PreferredLanguage
}
if len(info.ProviderSettings) > 0 {
resp.ProviderSettings = info.ProviderSettings
}
}
var lastDeviceID string
for _, entry := range entries {
@@ -635,14 +596,15 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
nowStr := strconv.FormatInt(time.Now().Unix(), 10)
presetObj := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: strconv.Itoa(presetNumber),
Name: newPresetElem.Name,
Source: matchingSrc.SourceKeyType,
Type: newPresetElem.ContentItemType,
Location: newPresetElem.Location,
SourceAccount: matchingSrc.SourceKeyAccount,
SourceID: newPresetElem.SourceID,
Name: newPresetElem.Name,
Source: matchingSrc.SourceKeyType,
Type: newPresetElem.ContentItemType,
Location: newPresetElem.Location,
SourceAccount: matchingSrc.SourceKeyAccount,
SourceID: newPresetElem.SourceID,
ContentItemType: newPresetElem.ContentItemType,
},
ID: strconv.Itoa(presetNumber),
ContainerArt: newPresetElem.ContainerArt,
CreatedOn: nowStr,
UpdatedOn: nowStr,
@@ -732,8 +694,11 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
}
// Ensure DisplayName and SourceName are consistent
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" && matchingSrc.DisplayName != "Other" {
matchingSrc.SourceName = matchingSrc.DisplayName
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
// Parity: for some services like TuneIn, sourcename should be empty
if matchingSrc.DisplayName != "TuneIn" && matchingSrc.DisplayName != "Other" {
matchingSrc.SourceName = matchingSrc.DisplayName
}
}
if matchingSrc.DisplayName == "" && matchingSrc.SourceName != "" {
@@ -770,8 +735,16 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
func createLearnedSource(sourceID, location, sourceName, credentialValue, sourceProviderID, createdOn, updatedOn string) *models.ConfiguredSource {
displayName := sourceName
if displayName == "" {
displayName = "Other"
// For TuneIn, we often see empty DisplayName/SourceName in recent items
// if it's already a known source or if it's a generic TuneIn request.
if displayName == "" && sourceID != "" {
// Try to deduce from sourceID if it looks like a known service
switch sourceID {
case "14774275": // TuneIn
displayName = "TuneIn"
case "Spotify":
displayName = "Spotify"
}
}
src := &models.ConfiguredSource{
@@ -873,18 +846,16 @@ func updateOrCreateRecent(recents []models.ServiceRecent, name string, matchingS
// Move to front
recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
break
return recentObj, recents
}
}
if recentObj == nil {
recentObj = createNewRecent(recents, name, matchingSrc, contentItemType, location, device, utcTime)
recentObj.UpdatedOn = FormatTime(time.Now())
recentObj = createNewRecent(recents, name, matchingSrc, contentItemType, location, device, utcTime)
recentObj.UpdatedOn = FormatTime(time.Now())
recents = append([]models.ServiceRecent{*recentObj}, recents...)
if len(recents) > 10 {
recents = recents[:10]
}
recents = append([]models.ServiceRecent{*recentObj}, recents...)
if len(recents) > 10 {
recents = recents[:10]
}
return recentObj, recents
+109 -86
View File
@@ -88,7 +88,7 @@ func TestAccountFullToXML_Structure(t *testing.T) {
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "3230304"
account := "1234567"
device := "08DF1F0BA325"
// 1. Setup Device Info with Components
@@ -100,20 +100,20 @@ func TestAccountFullToXML_Structure(t *testing.T) {
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",
Components: []models.ServiceComponent{
{
Category: "SMSC",
SoftwareVersion: "I2014101420409423",
SerialNumber: "08DF1F0BA32A",
},
{
Category: "LIGHTSWITCH",
SoftwareVersion: "1.2.3",
SerialNumber: "LS001",
},
},
}
_ = ds.SaveDeviceInfo(account, device, info)
// 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.
@@ -127,26 +127,27 @@ func TestAccountFullToXML_Structure(t *testing.T) {
// 2. Setup Sources
src := models.ConfiguredSource{
ID: "10863533",
DisplayName: "gesellix",
DisplayName: "test-user",
Type: "Audio",
Secret: "AQBtotl13...",
Secret: "dummy-token-spotify...",
SecretType: "token_version_3",
SourceName: "gesellix+spotify@gmail.com",
Username: "gesellix",
SourceName: "test-user+spotify@gmail.com",
Username: "test-user",
}
src.SourceKeyType = "SPOTIFY"
src.SourceKeyAccount = "gesellix"
src.SourceKeyAccount = "test-user"
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
// 3. Setup Presets
preset := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Jonas",
Name: "test-playlist",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
Source: "SPOTIFY",
},
ID: "1",
ButtonNumber: "1",
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
}
_ = ds.SavePresets(account, device, []models.ServicePreset{preset})
@@ -173,8 +174,11 @@ func TestAccountFullToXML_Structure(t *testing.T) {
// 6. Verify Structure
// Root and attributes
if !strings.Contains(xmlStr, `<account id="3230304">`) {
t.Errorf("Expected <account id=\"3230304\">, got %s", xmlStr)
if !strings.Contains(xmlStr, `<account id="1234567">`) {
t.Errorf("Expected <account id=\"1234567\">, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<preferredLanguage>de</preferredLanguage>`) {
t.Errorf("Expected <preferredLanguage>de</preferredLanguage>, got %s", xmlStr)
}
// Device structure
@@ -191,15 +195,29 @@ func TestAccountFullToXML_Structure(t *testing.T) {
t.Errorf("Expected <updatedOn> under device, got %s", xmlStr)
}
// Preset buttonNumber
if !strings.Contains(xmlStr, `<preset buttonNumber="1">`) {
t.Errorf("Expected <preset buttonNumber=\"1\">, 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)
t.Errorf("Expected productlabel, 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, `<component category="SMSC">`) && !strings.Contains(xmlStr, `category="SMSC"`) {
t.Errorf("Expected component with category SMSC, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<component category="LIGHTSWITCH">`) && !strings.Contains(xmlStr, `category="LIGHTSWITCH"`) {
t.Errorf("Expected component with category LIGHTSWITCH, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<firmware-version>1.2.3</firmware-version>`) {
t.Errorf("Expected firmware-version 1.2.3, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<serialnumber>08DF1F0BA325</serialnumber>`) {
t.Errorf("Expected <serialnumber>08DF1F0BA325</serialnumber> under attachedProduct, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<updatedOn>`) {
t.Errorf("Expected <updatedOn> under attachedProduct, got %s", xmlStr)
@@ -225,10 +243,13 @@ func TestAccountFullToXML_Structure(t *testing.T) {
}
// 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, `<source id="10863533" type="Audio" displayName="test-user">`) {
t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<credential type="token_version_3">AQBtotl13...</credential>`) {
if !strings.Contains(xmlStr, `<name>test-user</name>`) {
t.Errorf("Expected <name>test-user</name> under source, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<credential type="token_version_3">dummy-token-spotify...</credential>`) {
t.Errorf("Expected credential tag, got %s", xmlStr)
}
@@ -304,11 +325,11 @@ func TestRecentsXML_EmptyIDFix(t *testing.T) {
t.Fatalf("RecentsToXML failed: %v", err)
}
if strings.Contains(string(xmlData), `recent id=""`) {
if strings.Contains(string(xmlData), ` id=""`) {
t.Errorf("XML should not contain empty recent ID: %s", string(xmlData))
}
if !strings.Contains(string(xmlData), `recent id="1"`) {
if !strings.Contains(string(xmlData), `id="1"`) {
t.Errorf("XML should contain fixed numeric ID: %s", string(xmlData))
}
}
@@ -331,11 +352,14 @@ func TestRecentsToXML_SourceIncluded(t *testing.T) {
recents := []models.ServiceRecent{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Track",
SourceID: "100001",
Type: "tracklisturl",
Location: "/test",
ID: "1",
Name: "Test Track",
Source: "SPOTIFY",
SourceAccount: "test-user",
SourceID: "100001",
Type: "tracklisturl",
ContentItemType: "tracklisturl",
Location: "/test",
},
DeviceID: device,
UtcTime: "1708896000",
@@ -350,6 +374,10 @@ func TestRecentsToXML_SourceIncluded(t *testing.T) {
DisplayName: "Spotify",
SourceName: "Spotify",
Username: "testuser",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "SPOTIFY", Account: "test-user"},
},
}
_ = ds.SaveConfiguredSources(account, device, sources)
@@ -361,14 +389,20 @@ func TestRecentsToXML_SourceIncluded(t *testing.T) {
}
xmlStr := string(xmlData)
if !strings.Contains(xmlStr, "<source") {
t.Errorf("XML should contain <source> element: %s", xmlStr)
if !strings.Contains(xmlStr, "id=\"1\"") {
t.Errorf("XML should contain id=\"1\" for recent: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<sourcename>Spotify</sourcename>") {
t.Errorf("XML should contain <sourcename>Spotify</sourcename>: %s", xmlStr)
if !strings.Contains(xmlStr, "source=\"SPOTIFY\"") {
t.Errorf("XML should contain source=\"SPOTIFY\" attribute: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<username>testuser</username>") {
t.Errorf("XML should contain <username>testuser</username>: %s", xmlStr)
if !strings.Contains(xmlStr, "type=\"tracklisturl\"") {
t.Errorf("XML should contain type=\"tracklisturl\" attribute: %s", xmlStr)
}
if !strings.Contains(xmlStr, "location=\"/test\"") {
t.Errorf("XML should contain location=\"/test\" attribute: %s", xmlStr)
}
if !strings.Contains(xmlStr, "displayName=\"Spotify\"") {
t.Errorf("XML should contain displayName=\"Spotify\" in source attribute: %s", xmlStr)
}
}
@@ -390,12 +424,15 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Preset",
SourceID: "100001",
Type: "tracklisturl",
Location: "/test",
ID: "1",
Name: "Test Preset",
SourceID: "100001",
Source: "SPOTIFY",
SourceAccount: "testuser",
Type: "tracklisturl",
Location: "/test",
},
ID: "1",
},
}
_ = ds.SavePresets(account, device, presets)
@@ -405,10 +442,10 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
{
ID: "100001",
DisplayName: "Spotify",
SourceName: "Spotify",
Username: "testuser",
},
}
sources[0].SourceKey.Type = "SPOTIFY"
sources[0].SourceKey.Account = "testuser"
_ = ds.SaveConfiguredSources(account, device, sources)
// Fetch XML
@@ -421,8 +458,8 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
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, "displayName=\"Spotify\"") {
t.Errorf("XML should contain displayName=\"Spotify\" attribute: %s", xmlStr)
}
}
@@ -431,6 +468,7 @@ func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
ID: "101&202",
DisplayName: "Test & Source",
Secret: "key&value",
SecretType: "token",
}
src.SourceKeyAccount = "user&name"
@@ -438,36 +476,23 @@ func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
if !strings.Contains(xmlData, "id=\"101&amp;202\"") {
t.Errorf("ID not escaped in attribute: %s", xmlData)
}
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(xmlData, "displayName=\"Test &amp; Source\"") {
t.Errorf("DisplayName not escaped in attribute: %s", xmlData)
}
if !strings.Contains(xmlData, "<sourcename>Test &amp; Source</sourcename>") {
t.Errorf("DisplayName not escaped: %s", xmlData)
}
if !strings.Contains(xmlData, ">key&amp;value</credential>") {
t.Errorf("Secret not escaped: %s", xmlData)
if !strings.Contains(xmlData, "secret=\"key&amp;value\"") {
t.Errorf("Secret not escaped in attribute: %s", xmlData)
}
}
func TestGetConfiguredSourceXML_Parity(t *testing.T) {
t.Run("Other source should have empty sourcename", func(t *testing.T) {
t.Run("Other source should have displayName in attribute", 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)
if !strings.Contains(xmlData, "displayName=\"Other\"") {
t.Errorf("Expected displayName=\"Other\", got: %s", xmlData)
}
})
}
@@ -504,10 +529,10 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
// 2. Add an initial recent
sourceXML := []byte(`
<recent>
<name>Initial Station</name>
<contentItem source="TUNEIN" type="stationurl" location="station-1" sourceAccount="test-user">
<itemName>Initial Station</itemName>
</contentItem>
<sourceid>101</sourceid>
<location>station-1</location>
<contentItemType>station</contentItemType>
</recent>`)
_, err = AddRecent(ds, account, device, sourceXML)
@@ -535,16 +560,14 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
}
recents, _ = ds.GetRecents(account, device)
// AddRecent should have reused the existing one since location/source are the same
if len(recents) != 1 {
t.Errorf("Expected still 1 recent, got %d", len(recents))
}
// Verify that sourceid is present in recent response and is a sibling to source tag
if !strings.Contains(string(respXML), "<sourceid>101</sourceid>") {
t.Errorf("Expected sourceid in recent response: %s", string(respXML))
}
if strings.Contains(string(respXML), "<source id=\"101\" type=\"Audio\"><createdOn>2012-09-19T12:43:00.000+00:00</createdOn><credential type=\"token\">key&amp;value</credential><name>test-user</name><sourceid>101</sourceid>") {
t.Errorf("sourceid should not be inside source tag: %s", string(respXML))
// Verify that source id is present in recent response
if !strings.Contains(string(respXML), "id=\"101\"") {
t.Errorf("Expected source id in recent response: %s", string(respXML))
}
}
@@ -604,15 +627,15 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "3230304"
device := "A81B6A536A98"
account := "1234567"
device := "001122334455"
// Mimic the backup structure: accounts/3230304/devices/A81B6A536A98/DeviceInfo.xml
// Mimic the backup structure: accounts/1234567/devices/001122334455/DeviceInfo.xml
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
_ = os.MkdirAll(deviceDir, 0755)
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="A81B6A536A98">
<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch</type>
<moduleType>10 sm2</moduleType>
@@ -625,7 +648,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
</components>
<networkInfo type="SCM">
<ipAddress>192.168.178.35</ipAddress>
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
</networkInfo>
<discoveryMethod>sync_full</discoveryMethod>
</info>`
@@ -652,7 +675,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="Jonas" isPresetable="true" contentItemType="tracklisturl">
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="test-playlist" isPresetable="true" contentItemType="tracklisturl">
<containerArt>https://i.scdn.co/image/art</containerArt>
</ContentItem>
</preset>
@@ -670,7 +693,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
}
// 3. Test with empty name
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?><info deviceID="A81B6A536A98"><name></name></info>`), 0644)
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?><info deviceID="001122334455"><name></name></info>`), 0644)
fullXML2, _ := AccountFullToXML(ds, account)
if !strings.Contains(string(fullXML2), `<name/>`) {
t.Errorf("Expected <name/> for empty name, got %s", string(fullXML2))
+379 -16
View File
@@ -7,9 +7,81 @@ import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestReadSourcesWithEmptyDisplayName(t *testing.T) {
tempBaseDir := "repro_sources_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
accountID := "1234567"
deviceID := "001122334455"
// Create device directory
devDir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", deviceID)
err = os.MkdirAll(devDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create DeviceInfo.xml so ListAllDevices finds it
devInfo := `<info deviceID="` + deviceID + `"><name>Test Device</name></info>`
os.WriteFile(filepath.Join(devDir, "DeviceInfo.xml"), []byte(devInfo), 0644)
// Create Sources.xml with some empty displayNames (as provided in the issue)
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source displayName="AUX IN" id="" secret="" secretType="">
<sourceKey type="AUX" account="AUX"></sourceKey>
</source>
<source displayName="" id="" secret="" secretType="token">
<sourceKey type="INTERNET_RADIO" account=""></sourceKey>
</source>
<source displayName="" id="" secret="S1" secretType="token">
<sourceKey type="LOCAL_INTERNET_RADIO" account=""></sourceKey>
</source>
<source displayName="test-user+spotify@gmail.com" id="" secret="S2" secretType="token">
<sourceKey type="SPOTIFY" account="test-user"></sourceKey>
</source>
<source displayName="" id="" secret="S3" secretType="token">
<sourceKey type="TUNEIN" account=""></sourceKey>
</source>
</sources>`
os.WriteFile(filepath.Join(devDir, "Sources.xml"), []byte(sourcesXML), 0644)
ds := datastore.NewDataStore(tempBaseDir)
sources, err := ds.GetConfiguredSources(accountID, deviceID)
if err != nil {
t.Fatalf("Failed to get configured sources: %v", err)
}
if len(sources) != 5 {
t.Errorf("Expected 5 sources, got %d", len(sources))
}
for i, s := range sources {
t.Logf("Source %d: ID=%s, DisplayName=%s, Type=%s, SourceKeyType=%s, Account=%s", i, s.ID, s.DisplayName, s.Type, s.SourceKeyType, s.SourceKey.Account)
if s.SourceKeyType == "" {
t.Errorf("Source %d (%s) has empty SourceKeyType", i, s.DisplayName)
}
if s.Type == "SPOTIFY" && s.SourceKey.Account != "test-user" {
t.Errorf("Source %d (%s) expected account 'test-user', got '%s'", i, s.DisplayName, s.SourceKey.Account)
}
label := constants.GetSourceLabel(s.Type)
t.Logf(" Label: %s", label)
if label == "" && s.Type != "" {
t.Errorf("Source %d (%s) has empty label for type %s", i, s.DisplayName, s.Type)
}
}
}
func TestReproduceMissingName(t *testing.T) {
tempBaseDir := "repro_data"
err := os.MkdirAll(tempBaseDir, 0755)
@@ -18,11 +90,11 @@ func TestReproduceMissingName(t *testing.T) {
}
defer os.RemoveAll(tempBaseDir)
accountID := "3230304"
accountID := "1234567"
// Create device folders
// 08DF1F0BA325 (has name)
// A81B6A536A98 (missing name in full_local.xml)
// 001122334455 (missing name in full_local.xml)
// Device 1: 08DF1F0BA325
dev1Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "08DF1F0BA325")
@@ -48,14 +120,14 @@ func TestReproduceMissingName(t *testing.T) {
</info>`
os.WriteFile(filepath.Join(dev1Dir, "DeviceInfo.xml"), []byte(dev1Info), 0644)
// Device 2: A81B6A536A98 - MAC address ID in XML, name with special char or space?
dev2Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "A81B6A536A98")
// Device 2: 001122334455 - MAC address ID in XML, name with special char or space?
dev2Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "001122334455")
err = os.MkdirAll(dev2Dir, 0755)
if err != nil {
t.Fatal(err)
}
dev2Info := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="A81B6A536A98">
<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch</type>
<moduleType>10 sm2</moduleType>
@@ -72,7 +144,7 @@ func TestReproduceMissingName(t *testing.T) {
</components>
<networkInfo type="SCM">
<ipAddress>192.168.178.35</ipAddress>
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
</networkInfo>
<discoveryMethod>sync_full</discoveryMethod>
</info>`
@@ -103,28 +175,28 @@ func TestReproduceMissingName(t *testing.T) {
}
// Now test name preservation during sync
// Mock a response with empty name for A81B6A536A98
// Mock a response with empty name for 001122334455
for i := range resp.Devices {
if resp.Devices[i].DeviceID == "A81B6A536A98" {
if resp.Devices[i].DeviceID == "001122334455" {
resp.Devices[i].Name = ""
}
}
// Remove the account-specific device directory to force resolution to 'default'
os.RemoveAll(filepath.Join(tempBaseDir, "accounts", accountID, "devices", "A81B6A536A98"))
os.RemoveAll(filepath.Join(tempBaseDir, "accounts", accountID, "devices", "001122334455"))
// Create a duplicate directory in another place (e.g. 'st-go/data/accounts/default') with the CORRECT name
// This simulates a global entry that ds.ListAllDevices() should find
globalDevDir := filepath.Join("st-go", "data", "accounts", "default", "devices", "A81B6A536A98")
globalDevDir := filepath.Join("st-go", "data", "accounts", "default", "devices", "001122334455")
os.MkdirAll(globalDevDir, 0755)
defer os.RemoveAll("st-go")
globalDevInfo := `<info deviceID="A81B6A536A98"><name>Sound Machinechen</name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
globalDevInfo := `<info deviceID="001122334455"><name>Sound Machinechen</name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
os.WriteFile(filepath.Join(globalDevDir, "DeviceInfo.xml"), []byte(globalDevInfo), 0644)
// Create a directory in 'default' with EMPTY name (the one that GetDeviceInfo will pick up)
defaultDevDir := filepath.Join(tempBaseDir, "default", "devices", "A81B6A536A98")
defaultDevDir := filepath.Join(tempBaseDir, "default", "devices", "001122334455")
os.MkdirAll(defaultDevDir, 0755)
defaultDevInfo := `<info deviceID="A81B6A536A98"><name></name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
defaultDevInfo := `<info deviceID="001122334455"><name></name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
os.WriteFile(filepath.Join(defaultDevDir, "DeviceInfo.xml"), []byte(defaultDevInfo), 0644)
err = SyncFromAccountFull(ds, &resp)
@@ -133,7 +205,7 @@ func TestReproduceMissingName(t *testing.T) {
}
// Verify name was preserved
info, err := ds.GetDeviceInfo(accountID, "A81B6A536A98")
info, err := ds.GetDeviceInfo(accountID, "001122334455")
if err != nil {
t.Fatal(err)
}
@@ -163,7 +235,7 @@ func TestReproduceMissingName(t *testing.T) {
t.Error("Device 08DF1F0BA325 name should not be empty")
}
}
if d.DeviceID == "A81B6A536A98" || d.DeviceID == "I6332527703739342000020" {
if d.DeviceID == "001122334455" || d.DeviceID == "I6332527703739342000020" {
if d.Name != "" {
foundA8 = true
}
@@ -174,6 +246,297 @@ func TestReproduceMissingName(t *testing.T) {
t.Error("Device 08DF1F0BA325 not found in response")
}
if !foundA8 {
t.Error("Device A81B6A536A98 not found in response")
t.Error("Device 001122334455 not found in response")
}
}
func TestRecentItemsMissingSources(t *testing.T) {
tempBaseDir := "repro_recents_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
accountID := "1234567"
deviceID := "001122334455"
// Create device directory
devDir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", deviceID)
err = os.MkdirAll(devDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create Recents.xml with some entries that have missing sources or sparse data
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent id="1" deviceID="001122334455" utcTime="1771916458">
<contentItem source="INTERNET_RADIO" type="tracklisturl" location="/some/loc" isPresetable="true">
<itemName>For Your Darkest Days</itemName>
</contentItem>
</recent>
<recent id="2" deviceID="001122334455" utcTime="1771916459">
<contentItem source="SPOTIFY" type="tracklisturl" location="/spotify/loc" sourceAccount="test-user" isPresetable="true">
<itemName>Spotify Item</itemName>
</contentItem>
</recent>
</recents>`
os.WriteFile(filepath.Join(devDir, "Recents.xml"), []byte(recentsXML), 0644)
// Create Sources.xml with matching sources
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source displayName="" id="ir_source" type="INTERNET_RADIO">
<sourceKey type="INTERNET_RADIO" account=""></sourceKey>
</source>
<source displayName="test-user+spotify@gmail.com" id="spotify_source" type="SPOTIFY">
<sourceKey type="SPOTIFY" account="test-user"></sourceKey>
</source>
</sources>`
os.WriteFile(filepath.Join(devDir, "Sources.xml"), []byte(sourcesXML), 0644)
ds := datastore.NewDataStore(tempBaseDir)
recents, err := ds.GetRecents(accountID, deviceID)
if err != nil {
t.Fatalf("Failed to get recents: %v", err)
}
if len(recents) != 2 {
t.Errorf("Expected 2 recents, got %d", len(recents))
}
for _, r := range recents {
t.Logf("Recent: ID=%s, Name=%s, Source=%s, SourceAccount=%s", r.ID, r.Name, r.Source, r.SourceAccount)
if r.Name == "" {
t.Errorf("Recent %s has empty Name", r.ID)
}
if r.Source == "" {
t.Errorf("Recent %s has empty Source attribute", r.ID)
}
}
}
func TestSyncSourcesAttributes(t *testing.T) {
tempBaseDir := "sync_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
xmlData := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<account id="1234567">
<devices>
<device deviceid="08DF1F0BA325">
<presets>
<preset buttonNumber="1">
<name>test-playlist</name>
<source id="10863533" type="Audio">
<name>test-user</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>test-user+spotify@gmail.com</sourcename>
<username>test-user</username>
</source>
</preset>
</presets>
<recents>
<recent id="2538285498">
<contentItem source="SPOTIFY" type="tracklisturl" location="spotify:track:abc" sourceAccount="test-user" isPresetable="true">
<itemName>test-playlist</itemName>
</contentItem>
<source id="10863533" type="Audio">
<name>test-user</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>test-user+spotify@gmail.com</sourcename>
<username>test-user</username>
</source>
</recent>
</recents>
</device>
</devices>
<sources>
<source id="10863533" type="Audio">
<name>test-user</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>test-user+spotify@gmail.com</sourcename>
<username>test-user</username>
</source>
</sources>
</account>`
var resp models.AccountFullResponse
err = xml.Unmarshal([]byte(xmlData), &resp)
if err != nil {
t.Fatal(err)
}
// Verify unmarshaling of attributes
if resp.ID != "1234567" {
t.Errorf("Expected account ID 1234567, got %s", resp.ID)
}
if len(resp.Devices) == 0 {
t.Fatal("No devices found in unmarshaled response")
}
dev := resp.Devices[0]
if len(dev.Presets) == 0 {
t.Fatal("No presets found in unmarshaled response")
}
p := dev.Presets[0]
if p.Source.ID != "10863533" {
t.Errorf("Preset source ID not unmarshaled: expected 10863533, got '%s'", p.Source.ID)
}
if p.Source.Type != "Audio" {
t.Errorf("Preset source Type not unmarshaled: expected Audio, got '%s'", p.Source.Type)
}
err = SyncFromAccountFull(ds, &resp)
if err != nil {
t.Fatal(err)
}
// Check datastore
presets, err := ds.GetPresets("1234567", "08DF1F0BA325")
if err != nil {
t.Fatal(err)
}
if len(presets) == 0 {
t.Fatal("No presets found in datastore after sync")
}
lp := presets[0]
if lp.SourceConfig == nil {
t.Fatal("SourceConfig is nil for synced preset")
}
if lp.SourceConfig.ID != "10863533" {
t.Errorf("Synced preset source ID mismatch: expected 10863533, got '%s'", lp.SourceConfig.ID)
}
if lp.SourceConfig.Type != "Audio" {
t.Errorf("Synced preset source Type mismatch: expected Audio, got '%s'", lp.SourceConfig.Type)
}
recents, err := ds.GetRecents("1234567", "08DF1F0BA325")
if err != nil {
t.Fatal(err)
}
if len(recents) == 0 {
t.Fatal("No recents found in datastore after sync")
}
lr := recents[0]
if lr.SourceConfig == nil {
t.Fatal("SourceConfig is nil for synced recent")
}
if lr.SourceConfig.ID != "10863533" {
t.Errorf("Synced recent source ID mismatch: expected 10863533, got '%s'", lr.SourceConfig.ID)
}
if lr.SourceConfig.Type != "Audio" {
t.Errorf("Synced recent source Type mismatch: expected Audio, got '%s'", lr.SourceConfig.Type)
}
}
func TestSyncSourcesAggregation(t *testing.T) {
tempBaseDir := "sync_agg_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
xmlData := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<account id="agg_account">
<devices>
<device deviceid="dev1">
<presets>
<preset buttonNumber="1">
<name>Preset Source</name>
<source id="src_preset" type="Audio">
<name>Preset Source Name</name>
</source>
</preset>
</presets>
<recents>
<recent id="rec1">
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:s123" sourceAccount="" isPresetable="true">
<itemName>Recent Source</itemName>
</contentItem>
<source id="src_recent" type="Audio">
<name>Recent Source Name</name>
</source>
</recent>
</recents>
</device>
</devices>
<sources>
<source id="src_account" type="Audio">
<name>Account Source Name</name>
</source>
</sources>
</account>`
var resp models.AccountFullResponse
err = xml.Unmarshal([]byte(xmlData), &resp)
if err != nil {
t.Fatal(err)
}
err = SyncFromAccountFull(ds, &resp)
if err != nil {
t.Fatal(err)
}
// Check aggregated sources for dev1
sources, err := ds.GetConfiguredSources("agg_account", "dev1")
if err != nil {
t.Fatal(err)
}
// We expect 3 sources: src_account, src_preset, src_recent
if len(sources) != 3 {
t.Errorf("Expected 3 aggregated sources, got %d", len(sources))
for _, s := range sources {
t.Logf("Found source: ID=%s, Name=%s", s.ID, s.Name)
}
}
foundAccount := false
foundPreset := false
foundRecent := false
for _, s := range sources {
switch s.ID {
case "src_account":
foundAccount = true
case "src_preset":
foundPreset = true
case "src_recent":
foundRecent = true
}
}
if !foundAccount {
t.Error("Source 'src_account' not found in aggregated sources")
}
if !foundPreset {
t.Error("Source 'src_preset' not found in aggregated sources")
}
if !foundRecent {
t.Error("Source 'src_recent' not found in aggregated sources")
}
}
+70 -3
View File
@@ -17,6 +17,8 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
}
log.Printf("[SYNC] Starting synchronization for account %s", accountID)
// 0. Update Account Metadata
syncAccountInfo(ds, accountID, resp)
for i := range resp.Devices {
dev := &resp.Devices[i]
@@ -32,7 +34,7 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
syncDeviceInfo(ds, accountID, dev)
// 2. Update Configured Sources for this device
syncConfiguredSources(ds, accountID, deviceID, resp.Sources)
syncConfiguredSources(ds, accountID, deviceID, resp.Sources, dev)
// 3. Update Presets
syncPresets(ds, accountID, deviceID, dev.Presets)
@@ -46,6 +48,18 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
return nil
}
func syncAccountInfo(ds *datastore.DataStore, accountID string, resp *models.AccountFullResponse) {
info := &models.ServiceAccountInfo{
AccountID: accountID,
PreferredLanguage: resp.PreferredLanguage,
ProviderSettings: resp.ProviderSettings,
}
if err := ds.SaveAccountInfo(accountID, info); err != nil {
log.Printf("[SYNC_ERR] Failed to save account info for %s: %v", accountID, err)
}
}
func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.AccountDevice) {
deviceID := dev.DeviceID
existingInfo, _ := ds.GetDeviceInfo(accountID, deviceID)
@@ -61,7 +75,15 @@ func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.Accou
}
if dev.AttachedProduct != nil {
info.ProductCode = dev.AttachedProduct.ProductCode
info.ProductSerialNumber = dev.AttachedProduct.SerialNumber
for _, comp := range dev.AttachedProduct.Components {
info.Components = append(info.Components, models.ServiceComponent{
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
}
}
// If the name is empty in the upstream response, try to preserve the local name
@@ -102,14 +124,46 @@ func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.Accou
}
}
func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string, sources []models.FullResponseSource) {
func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string, sources []models.FullResponseSource, dev *models.AccountDevice) {
// We'll use the account-level sources from the response as a base.
var deviceSources []models.ConfiguredSource
// Track seen sources to avoid duplicates
seen := make(map[string]bool)
// 1. Add sources from the account-level sources list
for i := range sources {
s := &sources[i]
if s.ID != "" && seen[s.ID] {
continue
}
dsrc := mapFullSourceToConfiguredSource(*s)
deviceSources = append(deviceSources, dsrc)
if s.ID != "" {
seen[s.ID] = true
}
}
// 2. Add sources from presets if they are not already in the list
for i := range dev.Presets {
p := &dev.Presets[i]
if p.Source.ID != "" && !seen[p.Source.ID] {
dsrc := mapFullSourceToConfiguredSource(p.Source)
deviceSources = append(deviceSources, dsrc)
seen[p.Source.ID] = true
}
}
// 3. Add sources from recents if they are not already in the list
for i := range dev.Recents {
r := &dev.Recents[i]
if r.Source.ID != "" && !seen[r.Source.ID] {
dsrc := mapFullSourceToConfiguredSource(r.Source)
deviceSources = append(deviceSources, dsrc)
seen[r.Source.ID] = true
}
}
if err := ds.SaveConfiguredSources(accountID, deviceID, deviceSources); err != nil {
@@ -132,6 +186,7 @@ func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSou
SourceAccount: p.Source.Username,
},
ButtonNumber: p.ButtonNumber,
ID: p.ButtonNumber,
CreatedOn: p.CreatedOn,
UpdatedOn: p.UpdatedOn,
ContainerArt: p.ContainerArt,
@@ -142,6 +197,7 @@ func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSou
UpdatedOn: p.Source.UpdatedOn,
SourceName: p.Source.SourceName,
DisplayName: p.Source.Name,
Name: p.Source.Name,
SourceProviderID: p.Source.SourceProviderID,
Secret: p.Source.Credential.Value,
SecretType: p.Source.Credential.Type,
@@ -181,6 +237,7 @@ func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSou
UpdatedOn: r.Source.UpdatedOn,
SourceName: r.Source.SourceName,
DisplayName: r.Source.Name,
Name: r.Source.Name,
SourceProviderID: r.Source.SourceProviderID,
Secret: r.Source.Credential.Value,
SecretType: r.Source.Credential.Type,
@@ -202,13 +259,23 @@ func mapFullSourceToConfiguredSource(s models.FullResponseSource) models.Configu
CreatedOn: s.CreatedOn,
UpdatedOn: s.UpdatedOn,
SourceName: s.SourceName,
DisplayName: s.Name,
DisplayName: s.DisplayName,
Name: s.Name,
SourceProviderID: s.SourceProviderID,
Secret: s.Credential.Value,
SecretType: s.Credential.Type,
Username: s.Username,
SourceSettings: s.SourceSettings,
}
if dsrc.DisplayName == "" {
dsrc.DisplayName = s.Name
}
if dsrc.Name == "" {
dsrc.Name = s.DisplayName
}
dsrc.SourceKey.Type = s.Type
dsrc.SourceKey.Account = s.Username
+7 -2
View File
@@ -117,7 +117,12 @@ func TestSyncFromAccountFull(t *testing.T) {
if err != nil {
t.Errorf("Failed to get sources: %v", err)
}
if len(sources) != 1 {
t.Errorf("Expected 1 source, got %d", len(sources))
// Now we aggregate sources from Account + Preset + Recent.
// Account has TUNEIN.
// Preset has TUNEIN (same ID, so deduplicated).
// Recent has SPOTIFY (new ID, so added).
// Total expected: 2
if len(sources) != 2 {
t.Errorf("Expected 2 sources (aggregated), got %d", len(sources))
}
}
+15 -2
View File
@@ -2119,8 +2119,11 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
}
log.Printf("[SYNC] Syncing presets for %s", deviceIP)
resp, err := m.HTTPGet(presetsURL)
if err != nil {
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", deviceIP, err)
return
}
@@ -2159,6 +2162,8 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
SourceID: "", // Preset doesn't have SourceID in ContentItem usually
IsPresetable: strconv.FormatBool(p.ContentItem.IsPresetable),
},
ID: strconv.Itoa(p.ID),
ButtonNumber: strconv.Itoa(p.ID),
ContainerArt: p.ContentItem.ContainerArt,
CreatedOn: createdOn,
UpdatedOn: updatedOn,
@@ -2255,9 +2260,17 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{
DisplayName: s.DisplayName,
ID: s.Source,
SecretType: string(s.Status),
Secret: "",
SecretType: "",
}
if s.Status == "READY" {
cs.SecretType = "token"
}
if s.Source == "SPOTIFY" {
cs.SecretType = "token_version_3"
}
cs.SourceKey.Type = s.Source
cs.SourceKey.Account = s.SourceAccount
// Also set legacy fields for now
+304
View File
@@ -0,0 +1,304 @@
package setup
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestSyncPresets_PreservesID(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/presets":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" sourceAccount="test-user" isPresetable="true">
<itemName>test-playlist</itemName>
<containerArt>https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b</containerArt>
</ContentItem>
</preset>
<preset id="6" createdOn="1585502139" updatedOn="1769977469">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s213886" isPresetable="true">
<itemName>WDR 2 Rheinland</itemName>
<containerArt>https://cdn-radiotime-logos.tunein.com/s213886g.png</containerArt>
</ContentItem>
</preset>
</presets>`)
case "/info":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<nowPlaying deviceID="001122334455" source="STANDBY" />`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// Since HTTPGet is private, we'll rely on NewManager setting it to http.Get,
// which will work fine with our httptest server.
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncPresets(deviceIP, accountID, deviceID)
// 5. Verify the saved file
presetFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Presets.xml")
data, err := os.ReadFile(presetFile)
if err != nil {
t.Fatalf("Failed to read saved presets file: %v", err)
}
content := string(data)
if !strings.Contains(content, `id="1"`) {
t.Errorf("Saved XML missing id=\"1\":\n%s", content)
}
if !strings.Contains(content, `id="6"`) {
t.Errorf("Saved XML missing id=\"6\":\n%s", content)
}
}
func TestSyncPresets_PreservesEmptySourceAccount(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/presets" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="6" createdOn="1585502139" updatedOn="1769977469">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s213886" sourceAccount="" isPresetable="true">
<itemName>WDR 2 Rheinland</itemName>
<containerArt>https://cdn-radiotime-logos.tunein.com/s213886g.png</containerArt>
</ContentItem>
</preset>
</presets>`)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-test-sourceaccount-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncPresets(deviceIP, accountID, deviceID)
// 5. Verify the saved file
presetFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Presets.xml")
data, err := os.ReadFile(presetFile)
if err != nil {
t.Fatalf("Failed to read saved presets file: %v", err)
}
content := string(data)
if !strings.Contains(content, `sourceAccount=""`) {
t.Errorf("Saved XML missing sourceAccount=\"\":\n%s", content)
}
}
func TestSyncRecents_PreservesID(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/recents":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent deviceID="001122334455" utcTime="1719128436" id="101">
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" sourceAccount="test-user" isPresetable="true">
<itemName>test-playlist</itemName>
<containerArt>https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b</containerArt>
</contentItem>
</recent>
</recents>`)
case "/info":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<nowPlaying deviceID="001122334455" source="STANDBY" />`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-recents-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncRecents(deviceIP, accountID, deviceID)
// 5. Verify the saved file
recentFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Recents.xml")
data, err := os.ReadFile(recentFile)
if err != nil {
t.Fatalf("Failed to read saved recents file: %v", err)
}
content := string(data)
if !strings.Contains(content, `id="101"`) {
t.Errorf("Saved XML missing id=\"101\":\n%s", content)
}
}
func TestSyncRecents_PreservesEmptySourceAccount(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/recents" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent deviceID="001122334455" utcTime="1719128436" id="101">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s213886" sourceAccount="" isPresetable="true">
<itemName>WDR 2 Rheinland</itemName>
<containerArt>https://cdn-radiotime-logos.tunein.com/s213886g.png</containerArt>
</contentItem>
</recent>
</recents>`)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-recents-test-sourceaccount-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncRecents(deviceIP, accountID, deviceID)
// 5. Verify the saved file
recentFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Recents.xml")
data, err := os.ReadFile(recentFile)
if err != nil {
t.Fatalf("Failed to read saved recents file: %v", err)
}
content := string(data)
if !strings.Contains(content, `sourceAccount=""`) {
t.Errorf("Saved XML missing sourceAccount=\"\":\n%s", content)
}
}
func TestSyncSources_Format(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="001122334455">
<name>Test Device</name>
<type>SoundTouch 10</type>
<margeAccountUUID>1234567</margeAccountUUID>
</info>`)
case "/sources":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<sources deviceID="001122334455">
<sourceItem source="AUX" status="READY" isLocal="true" multiroomallowed="true">AUX IN</sourceItem>
<sourceItem source="SPOTIFY" sourceAccount="test-user" status="READY" isLocal="false" multiroomallowed="true">test-user</sourceItem>
</sources>`)
case "/presets", "/recents":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<root></root>`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-sources-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
err = m.SyncDeviceData(deviceIP)
if err != nil {
t.Fatalf("SyncDeviceData failed: %v", err)
}
// 5. Verify the saved file
sourceFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Sources.xml")
data, err := os.ReadFile(sourceFile)
if err != nil {
t.Fatalf("Failed to read saved sources file at %s: %v", sourceFile, err)
}
content := string(data)
if !strings.Contains(content, `<source displayName="AUX IN" secret="" secretType="token">`) {
t.Errorf("Saved XML missing or incorrect AUX source:\n%s", content)
}
if !strings.Contains(content, `<source displayName="test-user" secret="" secretType="token_version_3">`) {
t.Errorf("Saved XML missing or incorrect Spotify source:\n%s", content)
}
if strings.Contains(content, "<sourcename>") {
t.Errorf("Saved XML contains legacy tags:\n%s", content)
}
}