mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ec2d58db | ||
|
|
c78664ee59 | ||
|
|
c17ba0e839 | ||
|
|
17f052308f |
@@ -256,11 +256,11 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
|
||||
@@ -492,10 +492,10 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -503,7 +503,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
|
||||
@@ -17,7 +17,6 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- ⚡ **Real-time Events**: WebSocket connection for live device state monitoring
|
||||
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
|
||||
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
|
||||
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
|
||||
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
@@ -79,6 +78,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
@@ -107,7 +107,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
@@ -117,20 +117,20 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Get device information
|
||||
info, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Device: %s\n", info.Name)
|
||||
|
||||
|
||||
// Control playback
|
||||
err = c.Play()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Set volume
|
||||
err = c.SetVolume(50)
|
||||
if err != nil {
|
||||
@@ -148,7 +148,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
@@ -159,9 +159,9 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
for _, device := range devices {
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
device.Name, device.Host, device.Port)
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -185,13 +185,13 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Subscribe to device events
|
||||
events, err := c.SubscribeToEvents(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
for event := range events {
|
||||
switch e := event.(type) {
|
||||
case *models.NowPlayingUpdated:
|
||||
@@ -212,7 +212,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -222,21 +222,21 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Get current presets
|
||||
presets, err := c.GetPresets()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Printf("Found %d presets\n", len(presets.Preset))
|
||||
|
||||
|
||||
// Store currently playing content as preset 1
|
||||
err = c.StoreCurrentAsPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Store Spotify playlist as preset 2
|
||||
spotifyContent := &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
@@ -250,7 +250,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Store radio station as preset 3
|
||||
radioContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
@@ -263,13 +263,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Select preset 1
|
||||
err = c.SelectPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Preset management complete!")
|
||||
}
|
||||
```
|
||||
@@ -280,7 +280,7 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -290,7 +290,7 @@ func main() {
|
||||
Host: "192.168.1.100", // Master speaker
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Create a multiroom zone
|
||||
zone := &models.Zone{
|
||||
Master: "192.168.1.100",
|
||||
@@ -299,12 +299,12 @@ func main() {
|
||||
{IPAddress: "192.168.1.102"}, // Kitchen
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
err := master.SetZone(zone)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Multiroom zone created!")
|
||||
}
|
||||
```
|
||||
@@ -315,7 +315,7 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
@@ -324,13 +324,13 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Play Text-to-Speech message (language code "EN", "DE", etc.)
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Play audio content from URL
|
||||
err = c.PlayURL(
|
||||
"https://example.com/doorbell.mp3",
|
||||
@@ -343,13 +343,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Play notification beep
|
||||
err = c.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Notifications sent!")
|
||||
}
|
||||
```
|
||||
@@ -359,7 +359,7 @@ func main() {
|
||||
This library supports all Bose SoundTouch-compatible devices, including:
|
||||
|
||||
- SoundTouch 10, 20, 30 series
|
||||
- SoundTouch Portable
|
||||
- SoundTouch Portable
|
||||
- Wave SoundTouch music system
|
||||
- SoundTouch-enabled Bose speakers
|
||||
|
||||
@@ -520,7 +520,7 @@ This project builds upon the excellent work of several community projects:
|
||||
These projects together form a comprehensive ecosystem for SoundTouch device management:
|
||||
|
||||
- **This Project**: Go library + CLI + service for programmatic control and offline operation
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundTouch Plus**: Home Assistant integration with extensive device support
|
||||
- **ÜberBöse**: API research and advanced endpoint discovery
|
||||
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -253,61 +251,6 @@ func selectLocalInternetRadio(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
|
||||
func selectCustomRadio(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamURL := c.String("url")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
|
||||
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
|
||||
|
||||
params := url.Values{}
|
||||
if itemName != "" {
|
||||
params.Add("name", itemName)
|
||||
}
|
||||
|
||||
if containerArt != "" {
|
||||
params.Add("imageUrl", containerArt)
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
location += "?" + params.Encode()
|
||||
}
|
||||
|
||||
// Check LOCAL_INTERNET_RADIO availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
|
||||
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Station: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" URL: %s\n", streamURL)
|
||||
fmt.Printf(" Proxy: %s\n", location)
|
||||
|
||||
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select custom radio: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Custom radio stream selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectLocalMusic handles selecting LOCAL_MUSIC source
|
||||
func selectLocalMusic(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -924,34 +924,6 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "custom-radio",
|
||||
Usage: "Select custom radio stream via soundtouch-service",
|
||||
Action: selectCustomRadio,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "url",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Stream URL",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Station name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Station artwork URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "URL of the soundtouch-service (default: http://localhost:8080)",
|
||||
Value: "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "local-music",
|
||||
Usage: "Select local music content (LOCAL_MUSIC)",
|
||||
|
||||
@@ -235,7 +235,7 @@ func main() {
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
sm.MgmtUsername = config.mgmtUsername
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.migrationEnabled, config.migrationDryRun)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
@@ -663,7 +663,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
@@ -724,8 +723,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
})
|
||||
|
||||
r.Route("/oauth", func(r chi.Router) {
|
||||
r.Post("/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken)
|
||||
r.HandleFunc("/*", server.HandleBoseProxy)
|
||||
})
|
||||
|
||||
@@ -756,10 +753,32 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Get("/devices/{deviceId}/info", server.HandleGetStockholmDeviceInfo)
|
||||
r.Post("/devices/{deviceId}/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/devices/{deviceId}/volume/{level}", server.HandleDeviceVolume)
|
||||
|
||||
// Stockholm Mini app
|
||||
r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini"))))
|
||||
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
@@ -780,7 +799,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
@@ -794,7 +812,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -23,14 +23,6 @@ All content selection features from the [SoundTouch WebServices API Wiki](https:
|
||||
- Automatic defaults for missing parameters
|
||||
- **Use Cases**: Internet radio streams, proxy-based radio services
|
||||
|
||||
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
|
||||
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
|
||||
- **Features**:
|
||||
- Flexible stream URL encoding (Base64 or URL-escaped)
|
||||
- Dynamic generation of Bose-compatible playback JSON
|
||||
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
|
||||
- **Use Case**: Playing any internet radio URL without external proxy dependencies
|
||||
|
||||
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
|
||||
- **Requirements**: SoundTouch App Media Server running on a computer
|
||||
@@ -55,15 +47,6 @@ soundtouch-cli --host <device> source internet-radio \
|
||||
--artwork "https://example.com/art.png"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source custom-radio`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source custom-radio \
|
||||
--url "https://stream.example.com/radio" \
|
||||
--name "My Station" \
|
||||
--artwork "https://example.com/art.png" \
|
||||
--service-url "http://localhost:8080"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source local-music`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source local-music \
|
||||
@@ -118,7 +101,7 @@ Comprehensive test suites implemented for all new functionality:
|
||||
### Example Code
|
||||
Complete working example demonstrating:
|
||||
- LOCAL_INTERNET_RADIO with streamUrl proxy format
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_MUSIC content selection
|
||||
- STORED_MUSIC content selection
|
||||
- Generic ContentItem usage
|
||||
@@ -169,7 +152,7 @@ All convenience methods create properly structured `ContentItem` objects:
|
||||
Based on the wiki structure, these related features are also supported:
|
||||
|
||||
1. **LOCAL_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
3. **SPOTIFY**: ✅ Previously implemented
|
||||
4. **TUNEIN**: ✅ Previously implemented
|
||||
5. **BLUETOOTH**: ✅ Previously implemented
|
||||
@@ -186,7 +169,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "")
|
||||
// Direct ContentItem
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Type: "stationurl",
|
||||
Location: location,
|
||||
ItemName: "My Station",
|
||||
IsPresetable: true,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
### Overview of Recent Improvements and Next Steps
|
||||
|
||||
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
|
||||
|
||||
#### ✅ Completed Improvements (Marge Service)
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in 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.
|
||||
* **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.
|
||||
* **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.
|
||||
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
* **Credential Preservation**: Improved `AddRecent` to correctly extract and echo back base64 tokens/credentials provided in the incoming request, improving source learning.
|
||||
* **XML Formatting Parity**:
|
||||
* Added `standalone="yes"` to the XML declaration for all Marge responses, including `recent`, `presets`, `full account`, `software update`, and `sourceproviders`.
|
||||
* Enforced self-closing `<sourceSettings/>` tags for parity.
|
||||
* Standardized date formatting to UTC with milliseconds (`.000+00:00`).
|
||||
* Fixed casing for `/streaming/sourceproviders`: Root element is `<sourceProviders>`, but child elements are `<sourceprovider>` (all lowercase), matching upstream behavior.
|
||||
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
|
||||
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
|
||||
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
|
||||
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
|
||||
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
|
||||
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
|
||||
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
|
||||
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
|
||||
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
|
||||
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
|
||||
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
|
||||
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
|
||||
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
|
||||
|
||||
---
|
||||
|
||||
#### 🛠️ Open Issues and Next Steps
|
||||
|
||||
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
|
||||
|
||||
#### 1. BMX / TuneIn Playback Parity (Medium)
|
||||
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=3432432423`, while upstream uses a different session-based ID.
|
||||
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
|
||||
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
|
||||
|
||||
#### 2. Presets and Recents Parity (Medium)
|
||||
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
|
||||
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
|
||||
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
|
||||
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
|
||||
|
||||
#### 3. OAuth / Spotify Token Noise (Low/Medium)
|
||||
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
|
||||
* **The Issue**: This creates "noise" in your parity reports that isn't actually a bug.
|
||||
* **Action**: Update the parity detection logic (or the handler) to selectively ignore the `access_token` field while still verifying that the rest of the JSON structure (expires_in, scope, token_type) matches.
|
||||
|
||||
#### 4. Large IDs for Other Models (Medium)
|
||||
While we fixed IDs for `recents`, other models like `presets` or `sources` might still use small auto-incrementing integers.
|
||||
* **Action**: Evaluate if other endpoints should also transition to the timestamp-based ID schema to further reduce diff noise.
|
||||
|
||||
#### 5. Improved Data Persistence (Continuous)
|
||||
Continue the "learning" approach for other services. For example, if we see a new `sourceproviderid` in a Spotify or TuneIn request, we should ensure it is stored and reused.
|
||||
|
||||
#### 6. Local Reboot & Device State Management (Continuous)
|
||||
Analysis of device reboot logs revealed several data requirements:
|
||||
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
|
||||
#### 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:
|
||||
|
||||
**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.
|
||||
@@ -119,7 +119,7 @@ soundtouch-service
|
||||
```go
|
||||
// Build custom applications on top of local services
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Get("http://localhost:8000/setup/devices")
|
||||
resp, _ := client.Get("http://localhost:8000/devices")
|
||||
```
|
||||
|
||||
### Privacy-Conscious Users
|
||||
|
||||
+1
-1
@@ -53,6 +53,7 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
@@ -80,4 +81,3 @@
|
||||
* [Device Lifecycle Summary](device-lifecycle-summary.md)
|
||||
* [Power On Implementation Guide](power-on-implementation-guide.md)
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
### Stockholm App Analysis Report
|
||||
|
||||
#### 1. Overview
|
||||
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
|
||||
|
||||
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
|
||||
- **Key Libraries**:
|
||||
- **jQuery**: Core DOM manipulation and event handling.
|
||||
- **iScroll**: Used for smooth scrolling in lists and carousels.
|
||||
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
|
||||
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
|
||||
|
||||
#### 2. Directory Structure
|
||||
- `js/`: Core application logic.
|
||||
- `app/`: Main application entry point (`app.js`).
|
||||
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
|
||||
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
|
||||
- `views/`: UI view templates and logic.
|
||||
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
|
||||
- `json/`: Configuration files and static data.
|
||||
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
|
||||
- `sourceFeatures.json`: Capability mapping for different sources.
|
||||
- `setup/`: Onboarding and initial device setup logic.
|
||||
- `lang/`: Localization files for multi-language support.
|
||||
|
||||
#### 3. Communication Architecture
|
||||
The app uses several communication channels to interact with the SoundTouch ecosystem:
|
||||
|
||||
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
|
||||
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
|
||||
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
|
||||
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
|
||||
|
||||
#### 4. Key Features & Functionality
|
||||
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
|
||||
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
|
||||
- **Preset Management**: Browsing and setting presets directly from the UI.
|
||||
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
|
||||
- **Onboarding**: A dedicated setup flow for new devices.
|
||||
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
|
||||
|
||||
#### 5. Integration Opportunities for Bose-SoundTouch Project
|
||||
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
|
||||
|
||||
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
|
||||
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
|
||||
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
|
||||
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
|
||||
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
|
||||
|
||||
#### 6. Conclusion
|
||||
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
|
||||
@@ -394,9 +394,6 @@ soundtouch-cli --host <device> source spotify
|
||||
soundtouch-cli --host <device> source bluetooth
|
||||
soundtouch-cli --host <device> source aux
|
||||
|
||||
# Custom radio selection (via soundtouch-service)
|
||||
soundtouch-cli --host <device> source custom-radio --url <STREAM_URL> [--name <NAME>] [--artwork <ARTWORK>] [--service-url <SERVICE_URL>]
|
||||
|
||||
# Advanced content selection
|
||||
soundtouch-cli --host <device> source internet-radio --location <URL> [--name <NAME>]
|
||||
soundtouch-cli --host <device> source local-music --location <LOCATION> --account <ACCOUNT>
|
||||
@@ -485,7 +482,6 @@ soundtouch-cli --host 192.168.1.10 source compare
|
||||
| Command | Description | Requirements |
|
||||
|---------|-------------|--------------|
|
||||
| `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL |
|
||||
| `custom-radio` | Select custom radio stream via soundtouch-service | Stream URL and service URL |
|
||||
| `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server |
|
||||
| `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server |
|
||||
| `content` | Generic content selection (advanced) | Source and location |
|
||||
@@ -499,12 +495,6 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout"
|
||||
|
||||
# Using local soundtouch-service for custom streams
|
||||
soundtouch-cli --host 192.168.1.10 source custom-radio \
|
||||
--url "https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout" \
|
||||
--service-url "http://localhost:8080"
|
||||
```
|
||||
|
||||
#### Service Introspection
|
||||
@@ -1054,7 +1044,7 @@ soundtouch-cli --host 192.168.1.10 speaker beep
|
||||
|
||||
**Supported Languages for TTS:**
|
||||
- `EN` - English (default)
|
||||
- `DE` - German
|
||||
- `DE` - German
|
||||
- `ES` - Spanish
|
||||
- `FR` - French
|
||||
- `IT` - Italian
|
||||
@@ -1095,7 +1085,7 @@ soundtouch-cli --host <device> events subscribe [flags]
|
||||
|
||||
**Event Types:**
|
||||
- `nowPlaying` - Track changes, playback status
|
||||
- `volume` - Volume and mute changes
|
||||
- `volume` - Volume and mute changes
|
||||
- `connection` - Network connectivity status
|
||||
- `preset` - Preset configuration changes
|
||||
- `zone` - Multiroom zone changes
|
||||
|
||||
@@ -26,11 +26,10 @@ The service consists of several key components:
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
- **TuneIn Integration**: Direct playback of radio stations and podcasts
|
||||
- **Custom Streams**: Flexible playback of any internet radio URL via dynamic proxy
|
||||
- **Service Registry**: Media service discovery and configuration
|
||||
- **Playback Control**: Stream URL resolution and audio metadata
|
||||
|
||||
### Marge Services (Account & Device Management)
|
||||
### Marge Services (Account & Device Management)
|
||||
- **Account Management**: User account simulation and device association
|
||||
- **Preset Synchronization**: Cross-device preset storage and sync
|
||||
- **Recent Items**: Playback history tracking and management
|
||||
@@ -58,7 +57,7 @@ go build -o soundtouch-service ./cmd/soundtouch-service
|
||||
|
||||
### Docker Support
|
||||
|
||||
You can run the SoundTouch service using Docker or Docker Compose.
|
||||
You can run the SoundTouch service using Docker or Docker Compose.
|
||||
|
||||
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
|
||||
|
||||
@@ -213,23 +212,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/devices/192.168.1.100/summary
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
|
||||
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
```
|
||||
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
|
||||
```
|
||||
|
||||
### Post-Migration Verification
|
||||
@@ -238,13 +237,13 @@ After migration, verify the device is working correctly:
|
||||
|
||||
```bash
|
||||
# Check device status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
curl "http://localhost:8000/devices/08DF1F0BA325/events"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
@@ -348,7 +347,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor
|
||||
|
||||
### Discovery & Setup
|
||||
|
||||
#### `GET /setup/devices`
|
||||
#### `GET /devices`
|
||||
Lists all discovered SoundTouch devices with their current status.
|
||||
|
||||
**Response:**
|
||||
@@ -369,10 +368,10 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
#### `POST /setup/discover`
|
||||
Triggers immediate network device discovery.
|
||||
|
||||
#### `GET /setup/info/{deviceIP}`
|
||||
#### `GET /devices/{deviceIP}/info`
|
||||
Gets detailed device information and configuration.
|
||||
|
||||
#### `GET /setup/migration-summary/{deviceIP}`
|
||||
#### `GET /setup/devices/{deviceIP}/summary`
|
||||
Analyzes device configuration and provides migration preview.
|
||||
|
||||
**Response:**
|
||||
@@ -389,17 +388,44 @@ Analyzes device configuration and provides migration preview.
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /setup/migrate/{deviceIP}`
|
||||
#### `POST /setup/devices/{deviceIP}/migrate`
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url`: Custom service URL (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (optional)
|
||||
- `marge`: Set to "original" to proxy Marge requests (optional)
|
||||
- `stats`: Set to "original" to proxy stats requests (optional)
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/revert`
|
||||
Reverts device to Bose cloud defaults.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/trust-ca`
|
||||
Injects the AfterTouch root CA into the device's trust store.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/sync`
|
||||
Syncs presets and recents from the device to local storage.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/backup`
|
||||
Creates a backup of the current device configuration.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
|
||||
Enables persistent SSH/remote services on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
|
||||
Removes persistent SSH/remote services from the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-connection`
|
||||
Tests HTTPS connection from device to service.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-hosts`
|
||||
Tests /etc/hosts redirection on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-dns`
|
||||
Tests DNS redirection on the device.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
@@ -659,13 +685,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
|
||||
|
||||
### Setup API
|
||||
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /setup/devices`: Manually add a device by IP.
|
||||
- `GET /devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /devices`: Manually add a device by IP.
|
||||
- `POST /setup/discover`: Trigger a new network discovery scan.
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
|
||||
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
@@ -765,7 +791,7 @@ ls -la data/events/
|
||||
This service implementation is based on and inspired by several excellent community projects:
|
||||
|
||||
### SoundCork
|
||||
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
|
||||
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Gu and contributors
|
||||
- **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies.
|
||||
|
||||
@@ -797,7 +823,7 @@ func customBMXHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/custom/endpoint", customBMXHandler)
|
||||
r.Get("/bmx/custom/endpoint", customBMXHandler)
|
||||
http.ListenAndServe(":8000", r)
|
||||
}
|
||||
```
|
||||
@@ -810,9 +836,9 @@ soundtouch:
|
||||
- host: 192.168.1.100
|
||||
port: 8090
|
||||
name: "Living Room Speaker"
|
||||
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
- resource: "http://localhost:8000/devices"
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "SoundTouch Devices"
|
||||
|
||||
@@ -9,15 +9,15 @@ require (
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -24,16 +24,16 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -44,8 +44,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -53,8 +53,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -67,8 +67,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -79,8 +79,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -98,6 +98,6 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -356,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
}
|
||||
|
||||
attempt++
|
||||
|
||||
// Check if device is reachable before attempting full WS connection to reduce log noise
|
||||
if err := ws.client.Ping(); err != nil {
|
||||
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
ws.logger.Printf("Reconnection attempt %d", attempt)
|
||||
|
||||
if err := ws.connectWithConfig(config); err != nil {
|
||||
|
||||
+22
-149
@@ -132,61 +132,42 @@ type SourceProvider struct {
|
||||
|
||||
// ServiceContentItem represents a media content item with source and location details.
|
||||
type ServiceContentItem struct {
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
|
||||
Location string `json:"location" xml:"location"`
|
||||
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
|
||||
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Name string `json:"name" xml:"itemName"`
|
||||
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
Location string `json:"location" xml:"location,attr"`
|
||||
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
|
||||
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
|
||||
}
|
||||
|
||||
// ServicePreset represents a user-defined preset for quick access to media content.
|
||||
type ServicePreset struct {
|
||||
ServiceContentItem
|
||||
ContainerArt string `json:"container_art" xml:"containerArt"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
|
||||
Username string `json:"-" xml:"username,omitempty"`
|
||||
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
|
||||
ContainerArt string `json:"container_art" xml:"containerArt"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// ServiceRecent represents recently played media content.
|
||||
type ServiceRecent struct {
|
||||
XMLName xml.Name `json:"-" xml:"recent"`
|
||||
ServiceContentItem
|
||||
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
||||
UtcTime string `json:"utc_time" xml:"utcTime,attr"`
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
|
||||
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
|
||||
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
|
||||
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat"`
|
||||
DeviceID string `json:"device_id" xml:"deviceid"`
|
||||
UtcTime string `json:"utc_time" xml:"utc_time"`
|
||||
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
|
||||
}
|
||||
|
||||
// ConfiguredSource represents a configured media source with authentication details.
|
||||
type ConfiguredSource struct {
|
||||
XMLName xml.Name `json:"-" xml:"source"`
|
||||
DisplayName string `json:"display_name" xml:"name"`
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Secret string `json:"secret" xml:"credential"`
|
||||
SecretType string `json:"secret_type" xml:"credential_type,attr"`
|
||||
DisplayName string `json:"display_name" xml:"displayName,attr"`
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Secret string `json:"secret" xml:"secret,attr"`
|
||||
SecretType string `json:"secret_type" xml:"secretType,attr"`
|
||||
SourceKey struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
} `json:"source_key" xml:"source_key"`
|
||||
Type string `xml:"type,attr"`
|
||||
|
||||
// Parity fields
|
||||
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
|
||||
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
|
||||
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid"`
|
||||
Username string `json:"username,omitempty" xml:"username"`
|
||||
SourceName string `json:"source_name,omitempty" xml:"sourcename"`
|
||||
SourceSettings string `json:"-" xml:"sourceSettings"`
|
||||
} `json:"source_key" xml:"sourceKey"`
|
||||
|
||||
// Legacy fields for backward compatibility in code if needed,
|
||||
// though it's better to update the code to use SourceKey.
|
||||
@@ -194,26 +175,6 @@ type ConfiguredSource struct {
|
||||
SourceKeyAccount string `json:"source_key_account" xml:"-"`
|
||||
}
|
||||
|
||||
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
|
||||
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
type Alias ConfiguredSource
|
||||
|
||||
a := struct {
|
||||
Alias
|
||||
Username string `xml:"username"`
|
||||
SourceName string `xml:"sourcename"`
|
||||
SourceSettings string `xml:"sourceSettings"`
|
||||
}{
|
||||
Alias: Alias(s),
|
||||
}
|
||||
a.Username = s.Username
|
||||
a.SourceName = s.SourceName
|
||||
// We want <sourceSettings/>
|
||||
a.SourceSettings = ""
|
||||
|
||||
return e.EncodeElement(a, start)
|
||||
}
|
||||
|
||||
// ServiceDeviceInfo represents information about a SoundTouch device.
|
||||
type ServiceDeviceInfo struct {
|
||||
DeviceID string `json:"device_id" xml:"deviceID,attr"`
|
||||
@@ -232,10 +193,9 @@ type ServiceDeviceInfo struct {
|
||||
// ServiceComponent represents a hardware or software component of a device.
|
||||
type ServiceComponent struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Category string `xml:"category,attr,omitempty"`
|
||||
SoftwareVersion string `xml:"firmware-version"`
|
||||
SerialNumber string `xml:"serialnumber"`
|
||||
Label string `xml:"componentlabel,omitempty"`
|
||||
Category string `xml:"category,attr"`
|
||||
SoftwareVersion string `xml:"softwareVersion"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
}
|
||||
|
||||
// CustomerSupportDevice represents device information for customer support purposes.
|
||||
@@ -357,90 +317,3 @@ type EmailAddressResponse struct {
|
||||
XMLName xml.Name `xml:"emailAddress"`
|
||||
Email string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// FullResponseSource represents a configured media source specifically for the /full response.
|
||||
// It follows the specific XML structure and field order of the upstream /full response.
|
||||
type FullResponseSource struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
Credential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"credential"`
|
||||
Name string `xml:"name"`
|
||||
SourceProviderID string `xml:"sourceproviderid"`
|
||||
SourceName string `xml:"sourcename"`
|
||||
SourceSettings string `xml:"sourceSettings"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
|
||||
// FullResponsePreset represents a preset specifically for the /full response.
|
||||
type FullResponsePreset struct {
|
||||
ButtonNumber string `xml:"buttonNumber,attr"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
ContentItemType string `xml:"contentItemType"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
Location string `xml:"location"`
|
||||
Name string `xml:"name"`
|
||||
Source FullResponseSource `xml:"source"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
|
||||
// FullResponseRecent represents a recent item specifically for the /full response.
|
||||
type FullResponseRecent struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ContentItemType string `xml:"contentItemType"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
LastPlayedAt string `xml:"lastplayedat"`
|
||||
Location string `xml:"location"`
|
||||
Name string `xml:"name"`
|
||||
Source FullResponseSource `xml:"source"`
|
||||
SourceID string `xml:"sourceid"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// AccountFullResponse represents the complete account XML structure.
|
||||
type AccountFullResponse struct {
|
||||
XMLName xml.Name `xml:"account"`
|
||||
ID string `xml:"id,attr"`
|
||||
AccountStatus string `xml:"accountStatus"`
|
||||
Devices []AccountDevice `xml:"devices>device"`
|
||||
Mode string `xml:"mode"`
|
||||
PreferredLanguage string `xml:"preferredLanguage"`
|
||||
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
|
||||
Sources []FullResponseSource `xml:"sources>source"`
|
||||
}
|
||||
|
||||
// AccountDevice represents a device in the account response.
|
||||
type AccountDevice struct {
|
||||
DeviceID string `xml:"deviceid,attr"`
|
||||
AttachedProduct *AttachedProduct `xml:"attachedProduct"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
FirmwareVersion string `xml:"firmwareVersion"`
|
||||
IPAddress string `xml:"ipaddress"`
|
||||
Name string `xml:"name"`
|
||||
Presets []FullResponsePreset `xml:"presets>preset"`
|
||||
Recents []FullResponseRecent `xml:"recents>recent"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// AttachedProduct represents product information for a device.
|
||||
type AttachedProduct struct {
|
||||
ProductCode string `xml:"product_code,attr"`
|
||||
Components []ServiceComponent `xml:"components>component"`
|
||||
ProductLabel string `xml:"productlabel"`
|
||||
SerialNumber string `xml:"serialnumber"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// ProviderSetting represents a single provider setting.
|
||||
type ProviderSetting struct {
|
||||
BoseID string `xml:"boseId"`
|
||||
KeyName string `xml:"keyName"`
|
||||
Value string `xml:"value"`
|
||||
ProviderID string `xml:"providerId"`
|
||||
}
|
||||
|
||||
+23
-28
@@ -280,33 +280,6 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
|
||||
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
|
||||
streamList := []models.Stream{
|
||||
{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: streamURL,
|
||||
},
|
||||
}
|
||||
|
||||
audio := models.Audio{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: streamURL,
|
||||
Streams: streamList,
|
||||
}
|
||||
|
||||
response := &models.BmxPlaybackResponse{
|
||||
Audio: audio,
|
||||
ImageUrl: imageURL,
|
||||
Name: name,
|
||||
StreamType: "liveRadio",
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// PlayCustomStream builds a playback response from a base64-encoded JSON blob
|
||||
// with fields streamUrl, imageUrl, and name.
|
||||
func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
|
||||
@@ -329,5 +302,27 @@ func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return BuildCustomStreamResponse(jsonObj.StreamURL, jsonObj.ImageURL, jsonObj.Name)
|
||||
streamList := []models.Stream{
|
||||
{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: jsonObj.StreamURL,
|
||||
},
|
||||
}
|
||||
|
||||
audio := models.Audio{
|
||||
HasPlaylist: true,
|
||||
IsRealtime: true,
|
||||
StreamUrl: jsonObj.StreamURL,
|
||||
Streams: streamList,
|
||||
}
|
||||
|
||||
response := &models.BmxPlaybackResponse{
|
||||
Audio: audio,
|
||||
ImageUrl: jsonObj.ImageURL,
|
||||
Name: jsonObj.Name,
|
||||
StreamType: "liveRadio",
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -81,10 +80,6 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
|
||||
domainMap[d] = true
|
||||
}
|
||||
|
||||
for _, ip := range cert.IPAddresses {
|
||||
domainMap[ip.String()] = true
|
||||
}
|
||||
|
||||
for _, d := range domains {
|
||||
if !domainMap[d] {
|
||||
generate = true
|
||||
@@ -242,19 +237,6 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
dnsNames []string
|
||||
ipAddresses []net.IP
|
||||
)
|
||||
|
||||
for _, domain := range domains {
|
||||
if ip := net.ParseIP(domain); ip != nil {
|
||||
ipAddresses = append(ipAddresses, ip)
|
||||
} else {
|
||||
dnsNames = append(dnsNames, domain)
|
||||
}
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
@@ -265,8 +247,7 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ipAddresses,
|
||||
DNSNames: domains,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &priv.PublicKey, caKey)
|
||||
|
||||
@@ -126,47 +126,3 @@ func TestCertificateManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateManagerIPAddress(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "crypto-ip-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
|
||||
// Test certificate generation with an IP address
|
||||
domains := []string{"192.168.1.100", "localhost"}
|
||||
certPEM, _, err := cm.GenerateCertificate(domains)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate certificate: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated certificate
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse certificate: %v", err)
|
||||
}
|
||||
|
||||
// Check IP addresses
|
||||
foundIP := false
|
||||
for _, ip := range cert.IPAddresses {
|
||||
if ip.String() == "192.168.1.100" {
|
||||
foundIP = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundIP {
|
||||
t.Errorf("Expected IP address 192.168.1.100 in IPAddresses, but it was not found")
|
||||
}
|
||||
|
||||
// Check if it was mistakenly added to DNSNames
|
||||
for _, dns := range cert.DNSNames {
|
||||
if dns == "192.168.1.100" {
|
||||
t.Errorf("IP address 192.168.1.100 should NOT be in DNSNames")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,6 @@
|
||||
// Package constants defines file names, directories, and common values used by the service layer.
|
||||
package constants
|
||||
|
||||
// SourceProvider represents a media source provider configuration.
|
||||
type SourceProvider struct {
|
||||
ID int
|
||||
Name string
|
||||
CreatedOn string
|
||||
UpdatedOn string
|
||||
}
|
||||
|
||||
// StaticProviders lists known source provider identifiers with their metadata.
|
||||
var StaticProviders = []SourceProvider{
|
||||
{ID: 1, Name: "PANDORA", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: 2, Name: "INTERNET_RADIO", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
|
||||
{ID: 3, Name: "OFF", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
|
||||
{ID: 4, Name: "LOCAL", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 5, Name: "AIRPLAY", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 6, Name: "CURRATED_RADIO", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 7, Name: "STORED_MUSIC", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 8, Name: "SLAVE_SOURCE", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 9, Name: "AUX", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
|
||||
{ID: 10, Name: "RECOMMENDED_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: 11, Name: "LOCAL_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: 12, Name: "GLOBAL_INTERNET_RADIO", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
|
||||
{ID: 13, Name: "HELLO", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
|
||||
{ID: 14, Name: "DEEZER", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 15, Name: "SPOTIFY", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 16, Name: "IHEART", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
|
||||
{ID: 17, Name: "SIRIUSXM", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 18, Name: "GOOGLE_PLAY_MUSIC", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 19, Name: "QQMUSIC", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 20, Name: "AMAZON", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
|
||||
{ID: 21, Name: "LOCAL_MUSIC", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
|
||||
{ID: 22, Name: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 23, Name: "SOUNDCLOUD", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 24, Name: "TIDAL", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 25, Name: "TUNEIN", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
|
||||
{ID: 26, Name: "QPLAY", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
|
||||
{ID: 27, Name: "JUKE", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 28, Name: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 29, Name: "DARFM", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 30, Name: "7DIGITAL", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 31, Name: "SAAVN", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 32, Name: "RDIO", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
|
||||
{ID: 33, Name: "PHONE_MUSIC", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
|
||||
{ID: 34, Name: "ALEXA", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
|
||||
{ID: 35, Name: "RADIOPLAYER", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
|
||||
{ID: 36, Name: "RADIO.COM", CreatedOn: "2019-05-28T18:21:41.000+00:00", UpdatedOn: "2019-05-28T18:21:41.000+00:00"},
|
||||
{ID: 37, Name: "RADIO_COM", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
|
||||
{ID: 38, Name: "SIRIUSXM_EVEREST", CreatedOn: "2019-11-25T18:00:33.000+00:00", UpdatedOn: "2019-11-25T18:00:33.000+00:00"},
|
||||
{ID: 39, Name: "RADIO_BROWSER", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
|
||||
}
|
||||
|
||||
// Providers lists known source provider identifiers used by Bose SoundTouch.
|
||||
var Providers = []string{
|
||||
"PANDORA",
|
||||
@@ -111,7 +60,4 @@ const (
|
||||
|
||||
// DateStr is the hardcoded date used in many Bose XML responses
|
||||
DateStr = "2012-09-19T12:43:00.000+00:00"
|
||||
|
||||
// XMLHeader is the standard XML declaration for Bose SoundTouch responses
|
||||
XMLHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
|
||||
)
|
||||
|
||||
+111
-257
@@ -24,16 +24,11 @@ func exists(path string) bool {
|
||||
|
||||
// DataStore represents the device and configuration storage.
|
||||
type DataStore struct {
|
||||
// DataDir is the (possibly relative) base directory for all datastore files.
|
||||
DataDir string
|
||||
// baseDir is the absolute, normalized base directory used for path safety checks.
|
||||
baseDir string
|
||||
|
||||
DataDir string
|
||||
eventMutex sync.RWMutex
|
||||
deviceEvents map[string][]models.DeviceEvent
|
||||
idMutex sync.RWMutex
|
||||
deviceMappings map[string]string
|
||||
fileMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// normalizeMAC normalizes a MAC address to a consistent format
|
||||
@@ -58,68 +53,28 @@ func NewDataStore(dataDir string) *DataStore {
|
||||
dataDir = "data"
|
||||
}
|
||||
|
||||
absBase, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
// Fallback to the provided dataDir if Abs fails; this preserves existing behavior.
|
||||
absBase = dataDir
|
||||
}
|
||||
|
||||
return &DataStore{
|
||||
DataDir: dataDir,
|
||||
baseDir: absBase,
|
||||
deviceEvents: make(map[string][]models.DeviceEvent),
|
||||
deviceMappings: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// safeJoin joins the given path elements to the datastore baseDir and ensures
|
||||
// that the resulting absolute path stays within baseDir. If the check fails,
|
||||
// baseDir is returned to prevent directory traversal.
|
||||
func (ds *DataStore) safeJoin(elem ...string) string {
|
||||
// Join the base directory with the provided elements.
|
||||
path := filepath.Join(append([]string{ds.baseDir}, elem...)...)
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
// On error, fall back to baseDir to avoid using an unexpected path.
|
||||
return ds.baseDir
|
||||
}
|
||||
|
||||
base := ds.baseDir
|
||||
if base == "" {
|
||||
// If baseDir is not set for some reason, fall back to original path.
|
||||
return absPath
|
||||
}
|
||||
|
||||
// Ensure the resolved path is within the base directory.
|
||||
baseWithSep := base
|
||||
if !strings.HasSuffix(baseWithSep, string(os.PathSeparator)) {
|
||||
baseWithSep += string(os.PathSeparator)
|
||||
}
|
||||
|
||||
if absPath == base || strings.HasPrefix(absPath, baseWithSep) {
|
||||
return absPath
|
||||
}
|
||||
|
||||
// If the path would escape the base directory, return baseDir as a safe default.
|
||||
return base
|
||||
}
|
||||
|
||||
// AccountDir returns the directory path for a specific account.
|
||||
func (ds *DataStore) AccountDir(account string) string {
|
||||
return ds.safeJoin("accounts", account)
|
||||
return filepath.Join(ds.DataDir, "accounts", account)
|
||||
}
|
||||
|
||||
// AccountDevicesDir returns the devices directory path for a specific account.
|
||||
func (ds *DataStore) AccountDevicesDir(account string) string {
|
||||
return ds.safeJoin("accounts", account, constants.DevicesDir)
|
||||
return filepath.Join(ds.AccountDir(account), constants.DevicesDir)
|
||||
}
|
||||
|
||||
// AccountDeviceDir returns the directory path for a specific device within an account.
|
||||
func (ds *DataStore) AccountDeviceDir(account, device string) string {
|
||||
// First, check if the device directory exists directly with the given deviceID
|
||||
// This prioritizes MAC-based deviceIDs over legacy mappings
|
||||
directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device)
|
||||
directPath := filepath.Join(ds.AccountDevicesDir(account), device)
|
||||
if _, err := os.Stat(directPath); err == nil {
|
||||
// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
|
||||
return directPath
|
||||
@@ -139,7 +94,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
|
||||
|
||||
if ok {
|
||||
// Use the mapped device only if it exists and the direct path doesn't
|
||||
mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice)
|
||||
mappedPath := filepath.Join(ds.AccountDevicesDir(account), mappedDevice)
|
||||
if _, err := os.Stat(mappedPath); err == nil {
|
||||
return mappedPath
|
||||
}
|
||||
@@ -152,17 +107,9 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
|
||||
|
||||
// GetDeviceInfo retrieves device information for the specified account and device.
|
||||
func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.DeviceInfoFile)
|
||||
|
||||
return ds.getDeviceInfoNoLock(account, device)
|
||||
}
|
||||
|
||||
func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) {
|
||||
path := ds.AccountDeviceDir(account, device)
|
||||
deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile)
|
||||
|
||||
data, err := os.ReadFile(deviceInfoPath)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -249,21 +196,9 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
||||
key = info.IPAddress
|
||||
}
|
||||
|
||||
if !seenIDs[key] || info.Name != "" {
|
||||
if seenIDs[key] && info.Name != "" {
|
||||
// Replace previous empty-named entry with one that has a name
|
||||
for j := range devices {
|
||||
existing := &devices[j]
|
||||
if (existing.DeviceID != "" && existing.DeviceID == info.DeviceID) ||
|
||||
(existing.IPAddress != "" && existing.IPAddress == info.IPAddress) {
|
||||
devices[j] = info
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if !seenIDs[key] {
|
||||
devices = append(devices, info)
|
||||
seenIDs[key] = true
|
||||
}
|
||||
if !seenIDs[key] {
|
||||
devices = append(devices, info)
|
||||
seenIDs[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,72 +209,17 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
||||
|
||||
func (ds *DataStore) getPossibleDataDirs() []string {
|
||||
dirs := []string{}
|
||||
// Check primary data directory
|
||||
if ds.DataDir != "" {
|
||||
if exists(filepath.Join(ds.DataDir, "accounts")) {
|
||||
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
|
||||
}
|
||||
// Also check the DataDir itself as a base for account directories
|
||||
if exists(ds.DataDir) && ds.DataDir != "." {
|
||||
dirs = append(dirs, ds.DataDir)
|
||||
}
|
||||
if exists(filepath.Join(ds.DataDir, "accounts")) {
|
||||
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
|
||||
}
|
||||
|
||||
// Also check st-go/data/accounts if it's different and exists
|
||||
altDir := "st-go/data/accounts"
|
||||
if exists(altDir) {
|
||||
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
|
||||
dirs = append(dirs, altDir)
|
||||
}
|
||||
// And st-go/data/accounts/default
|
||||
altDir2 := "st-go/data"
|
||||
if exists(altDir2) {
|
||||
dirs = append(dirs, altDir2)
|
||||
}
|
||||
// And repro_data
|
||||
altDir3 := "repro_data"
|
||||
if exists(altDir3) {
|
||||
dirs = append(dirs, altDir3)
|
||||
}
|
||||
|
||||
// Add special handling for test environments where we might have account directories
|
||||
// directly in the current working directory or a temp dir.
|
||||
// Walk up from DataDir to find any 'accounts' directory.
|
||||
curr := ds.DataDir
|
||||
for i := 0; i < 3; i++ {
|
||||
absCurr, _ := filepath.Abs(curr)
|
||||
if exists(filepath.Join(absCurr, "accounts")) {
|
||||
dirs = append(dirs, filepath.Join(absCurr, "accounts"))
|
||||
}
|
||||
|
||||
if exists(absCurr) {
|
||||
dirs = append(dirs, absCurr)
|
||||
}
|
||||
|
||||
if curr == "." || curr == "/" || curr == "" {
|
||||
break
|
||||
}
|
||||
|
||||
curr = filepath.Dir(curr)
|
||||
}
|
||||
|
||||
// Remove duplicates and ensure unique directories
|
||||
uniqueDirs := make(map[string]bool)
|
||||
result := []string{}
|
||||
|
||||
for _, dir := range dirs {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
absDir = dir
|
||||
}
|
||||
|
||||
if !uniqueDirs[absDir] {
|
||||
uniqueDirs[absDir] = true
|
||||
|
||||
result = append(result, dir)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.ServiceDeviceInfo {
|
||||
@@ -443,9 +323,6 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
|
||||
// GetPresets retrieves all presets for the specified account and device.
|
||||
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -459,14 +336,13 @@ 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"`
|
||||
} `xml:"preset"`
|
||||
}
|
||||
@@ -479,22 +355,15 @@ 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
|
||||
}
|
||||
|
||||
presets = append(presets, models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: p.ID,
|
||||
Name: p.ContentItem.ItemName,
|
||||
Source: p.ContentItem.Source,
|
||||
Type: p.ContentItem.Type,
|
||||
Location: p.ContentItem.Location,
|
||||
SourceAccount: p.ContentItem.SourceAccount,
|
||||
IsPresetable: p.ContentItem.IsPresetable,
|
||||
ContentItemType: cit,
|
||||
ID: p.ID,
|
||||
Name: p.ContentItem.ItemName,
|
||||
Source: p.ContentItem.Source,
|
||||
Type: p.ContentItem.Type,
|
||||
Location: p.ContentItem.Location,
|
||||
SourceAccount: p.ContentItem.SourceAccount,
|
||||
IsPresetable: p.ContentItem.IsPresetable,
|
||||
},
|
||||
ContainerArt: p.ContentItem.ContainerArt,
|
||||
CreatedOn: p.CreatedOn,
|
||||
@@ -507,9 +376,6 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
|
||||
// SavePresets saves the preset list for the specified account and device.
|
||||
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
|
||||
type PresetXML struct {
|
||||
@@ -517,14 +383,13 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
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,omitempty"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
} `xml:"ContentItem"`
|
||||
}
|
||||
|
||||
@@ -549,7 +414,6 @@ 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
|
||||
px.Presets = append(px.Presets, pxml)
|
||||
}
|
||||
@@ -566,9 +430,6 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
|
||||
// GetRecents retrieves all recent items for the specified account and device.
|
||||
func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -576,35 +437,57 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type RecentsXML struct {
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []models.ServiceRecent `xml:"recent"`
|
||||
var recentsWrap struct {
|
||||
Recents []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
UtcTime string `xml:"utcTime,attr"`
|
||||
ContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
} `xml:"contentItem"`
|
||||
} `xml:"recent"`
|
||||
}
|
||||
|
||||
var recentsWrap RecentsXML
|
||||
|
||||
if err := xml.Unmarshal(data, &recentsWrap); err != nil {
|
||||
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
|
||||
}
|
||||
|
||||
recents := recentsWrap.Recents
|
||||
recents := []models.ServiceRecent{}
|
||||
maxID := 0
|
||||
|
||||
for i := range recents {
|
||||
r := &recents[i]
|
||||
for i := range recentsWrap.Recents {
|
||||
r := &recentsWrap.Recents[i]
|
||||
|
||||
if id, err := strconv.Atoi(r.ID); err == nil {
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
|
||||
recents = append(recents, models.ServiceRecent{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: r.ID,
|
||||
Name: r.ContentItem.ItemName,
|
||||
Source: r.ContentItem.Source,
|
||||
Type: r.ContentItem.Type,
|
||||
Location: r.ContentItem.Location,
|
||||
SourceAccount: r.ContentItem.SourceAccount,
|
||||
IsPresetable: r.ContentItem.IsPresetable,
|
||||
},
|
||||
DeviceID: r.DeviceID,
|
||||
UtcTime: r.UtcTime,
|
||||
ContainerArt: r.ContentItem.ContainerArt,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure all recents have unique numeric IDs
|
||||
for i := range recents {
|
||||
r := &recents[i]
|
||||
if r.ContentItemType == "" {
|
||||
r.ContentItemType = r.Type
|
||||
}
|
||||
|
||||
if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
|
||||
maxID++
|
||||
recents[i].ID = strconv.Itoa(maxID)
|
||||
@@ -616,21 +499,54 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
|
||||
// SaveRecents saves the recent items list for the specified account and device.
|
||||
func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
||||
|
||||
type RecentXML struct {
|
||||
ID string `xml:"id,attr"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
UtcTime string `xml:"utcTime,attr"`
|
||||
ContentItem struct {
|
||||
Source string `xml:"source,attr,omitempty"`
|
||||
Type string `xml:"type,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
} `xml:"contentItem"`
|
||||
}
|
||||
|
||||
type RecentsXML struct {
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []models.ServiceRecent `xml:"recent"`
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []RecentXML `xml:"recent"`
|
||||
}
|
||||
|
||||
wrap := RecentsXML{
|
||||
Recents: recents,
|
||||
var rx RecentsXML
|
||||
|
||||
for i := range recents {
|
||||
r := &recents[i]
|
||||
|
||||
var rxml RecentXML
|
||||
|
||||
rxml.ID = r.ID
|
||||
rxml.DeviceID = r.DeviceID
|
||||
rxml.UtcTime = r.UtcTime
|
||||
rxml.ContentItem.Source = r.Source
|
||||
rxml.ContentItem.Type = r.Type
|
||||
rxml.ContentItem.Location = r.Location
|
||||
rxml.ContentItem.SourceAccount = r.SourceAccount
|
||||
|
||||
rxml.ContentItem.IsPresetable = r.IsPresetable
|
||||
if rxml.ContentItem.IsPresetable == "" {
|
||||
rxml.ContentItem.IsPresetable = "true"
|
||||
}
|
||||
|
||||
rxml.ContentItem.ItemName = r.Name
|
||||
rxml.ContentItem.ContainerArt = r.ContainerArt
|
||||
rx.Recents = append(rx.Recents, rxml)
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(wrap, "", " ")
|
||||
data, err := xml.MarshalIndent(rx, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -642,49 +558,10 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
|
||||
// SaveDeviceInfo saves device information for the specified account and device.
|
||||
func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
if device == "" {
|
||||
return fmt.Errorf("device ID/name cannot be empty")
|
||||
}
|
||||
|
||||
// Try to load existing device info to avoid overwriting existing details with empty values.
|
||||
existing, _ := ds.getDeviceInfoNoLock(account, device)
|
||||
if existing != nil {
|
||||
if info.Name == "" {
|
||||
info.Name = existing.Name
|
||||
}
|
||||
|
||||
if info.ProductCode == "" {
|
||||
info.ProductCode = existing.ProductCode
|
||||
}
|
||||
|
||||
if info.DeviceSerialNumber == "" {
|
||||
info.DeviceSerialNumber = existing.DeviceSerialNumber
|
||||
}
|
||||
|
||||
if info.ProductSerialNumber == "" {
|
||||
info.ProductSerialNumber = existing.ProductSerialNumber
|
||||
}
|
||||
|
||||
if info.FirmwareVersion == "" {
|
||||
info.FirmwareVersion = existing.FirmwareVersion
|
||||
}
|
||||
|
||||
if info.IPAddress == "" {
|
||||
info.IPAddress = existing.IPAddress
|
||||
}
|
||||
|
||||
if info.MacAddress == "" {
|
||||
info.MacAddress = existing.MacAddress
|
||||
}
|
||||
|
||||
if info.DiscoveryMethod == "" {
|
||||
info.DiscoveryMethod = existing.DiscoveryMethod
|
||||
}
|
||||
}
|
||||
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -767,11 +644,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
|
||||
// RemoveDevice removes a device and all its data from the specified account.
|
||||
func (ds *DataStore) RemoveDevice(account, device string) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
|
||||
return os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
@@ -782,9 +655,6 @@ func (ds *DataStore) RemoveDeviceDir(account, device string) error {
|
||||
|
||||
// GetConfiguredSources retrieves all configured sources for the specified account and device.
|
||||
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -800,24 +670,11 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Helper struct for unmarshaling with displayName
|
||||
var sourcesWithDisplayName struct {
|
||||
Sources []struct {
|
||||
DisplayName string `xml:"displayName,attr"`
|
||||
} `xml:"source"`
|
||||
}
|
||||
|
||||
_ = xml.Unmarshal(data, &sourcesWithDisplayName)
|
||||
|
||||
for i := range sourcesWrap.Sources {
|
||||
s := &sourcesWrap.Sources[i]
|
||||
if s.ID == "" {
|
||||
s.ID = strconv.Itoa(100001 + i)
|
||||
}
|
||||
|
||||
if s.DisplayName == "" && i < len(sourcesWithDisplayName.Sources) {
|
||||
s.DisplayName = sourcesWithDisplayName.Sources[i].DisplayName
|
||||
}
|
||||
// Sync legacy fields
|
||||
s.SourceKeyType = s.SourceKey.Type
|
||||
s.SourceKeyAccount = s.SourceKey.Account
|
||||
@@ -828,9 +685,6 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
|
||||
// SaveConfiguredSources saves the configured sources list for the specified account and device.
|
||||
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestSaveDeviceInfo_MergesName(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
device := "A81B6A536A98"
|
||||
|
||||
// 1. Initial save with name
|
||||
info1 := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
Name: "Living Room",
|
||||
ProductCode: "SoundTouch 20",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(account, device, info1); err != nil {
|
||||
t.Fatalf("First SaveDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Verify name is saved
|
||||
saved1, err := ds.GetDeviceInfo(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("First GetDeviceInfo failed: %v", err)
|
||||
}
|
||||
if saved1.Name != "Living Room" {
|
||||
t.Errorf("Expected name 'Living Room', got '%s'", saved1.Name)
|
||||
}
|
||||
|
||||
// 3. Save with empty name (simulating power_on)
|
||||
info2 := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
Name: "",
|
||||
ProductCode: "SoundTouch 20",
|
||||
IPAddress: "192.168.1.100",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(account, device, info2); err != nil {
|
||||
t.Fatalf("Second SaveDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// 4. Verify name is preserved
|
||||
saved2, err := ds.GetDeviceInfo(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("Second GetDeviceInfo failed: %v", err)
|
||||
}
|
||||
if saved2.Name != "Living Room" {
|
||||
t.Errorf("Expected name 'Living Room' to be preserved, but got '%s'", saved2.Name)
|
||||
}
|
||||
if saved2.IPAddress != "192.168.1.100" {
|
||||
t.Errorf("Expected IPAddress '192.168.1.100', got '%s'", saved2.IPAddress)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestDataStore_MacAddressMapping(t *testing.T) {
|
||||
|
||||
// Test mapping resolution in AccountDeviceDir
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
expectedDir, _ := filepath.Abs(filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber))
|
||||
expectedDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("expected dir %s, got %s", expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
func TestComprehensiveMigration_MultipleExistingDevices(t *testing.T) {
|
||||
// This test simulates the real-world scenario where a device has been discovered
|
||||
// and saved under multiple identifiers over time, and now needs to be consolidated
|
||||
// into a single MAC-based identifier.
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "comprehensive-migration-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
accountID := "3230304"
|
||||
|
||||
// Scenario: Same device has been saved under different identifiers:
|
||||
// 1. Initially discovered by IP address
|
||||
// 2. Later discovered with UPnP serial
|
||||
// 3. Later discovered with device component serial
|
||||
|
||||
// Create device entry #1: Saved by IP address (early discovery)
|
||||
ipDeviceID := "192.168.1.100"
|
||||
ipInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: ipDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Unknown Device", // Generic name from early discovery
|
||||
IPAddress: ipDeviceID,
|
||||
ProductCode: "Unknown",
|
||||
FirmwareVersion: "0.0.0",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, ipDeviceID, ipInfo); err != nil {
|
||||
t.Fatalf("Failed to save IP-based device: %v", err)
|
||||
}
|
||||
|
||||
// Save some presets for the IP-based device
|
||||
testPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Source: "SPOTIFY",
|
||||
Location: "spotify://playlist/test1",
|
||||
Name: "Test Playlist 1",
|
||||
},
|
||||
CreatedOn: "2024-01-01T00:00:00Z",
|
||||
UpdatedOn: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, ipDeviceID, testPresets); err != nil {
|
||||
t.Fatalf("Failed to save presets for IP device: %v", err)
|
||||
}
|
||||
|
||||
// Create device entry #2: Saved by component serial (later discovery with better info)
|
||||
serialDeviceID := "I6332527703739342000020"
|
||||
serialInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Machinechen", // Real name from /info
|
||||
IPAddress: "192.168.1.100", // Same IP as before
|
||||
DeviceSerialNumber: serialDeviceID,
|
||||
ProductCode: "SoundTouch 10",
|
||||
FirmwareVersion: "27.0.6.46330.5043500",
|
||||
ProductSerialNumber: "069231P63364828AE",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, serialDeviceID, serialInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial-based device: %v", err)
|
||||
}
|
||||
|
||||
// Save different presets for the serial-based device (user might have configured both thinking they're different)
|
||||
serialPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "2",
|
||||
Source: "SPOTIFY",
|
||||
Location: "spotify://playlist/test2",
|
||||
Name: "Test Playlist 2",
|
||||
},
|
||||
CreatedOn: "2024-01-02T00:00:00Z",
|
||||
UpdatedOn: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialDeviceID, serialPresets); err != nil {
|
||||
t.Fatalf("Failed to save presets for serial device: %v", err)
|
||||
}
|
||||
|
||||
// Create device entry #3: Saved by UPnP serial (yet another discovery)
|
||||
upnpDeviceID := "UPnP789XYZ"
|
||||
upnpInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: upnpDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "SoundTouch Device", // Generic UPnP name
|
||||
IPAddress: "192.168.1.100", // Same IP again
|
||||
ProductCode: "SoundTouch 10 sm2",
|
||||
FirmwareVersion: "27.0.6.46330.5043500", // Same firmware as serial device
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, upnpDeviceID, upnpInfo); err != nil {
|
||||
t.Fatalf("Failed to save UPnP-based device: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Test setup complete:")
|
||||
t.Logf(" Device #1: %s (IP-based, early discovery)", ipDeviceID)
|
||||
t.Logf(" Device #2: %s (serial-based, better info)", serialDeviceID)
|
||||
t.Logf(" Device #3: %s (UPnP-based, latest discovery)", upnpDeviceID)
|
||||
|
||||
// Now simulate the device being rediscovered with /info endpoint working
|
||||
deviceInfoXML := `<info deviceID="A81B6A536A98">
|
||||
<name>Sound Machinechen</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
</info>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, deviceInfoXML)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false, false, false)
|
||||
|
||||
// Simulate device rediscovery
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Generic Discovery Name",
|
||||
ModelID: "SoundTouch",
|
||||
SerialNo: "UPnP789XYZ", // This should match one of the existing devices
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
t.Logf("\nSimulating comprehensive device rediscovery...")
|
||||
t.Logf(" Discovery IP: %s", deviceIP)
|
||||
t.Logf(" Discovery Serial: %s", discoveredDevice.SerialNo)
|
||||
|
||||
// Handle discovered device - should find and migrate all existing variants
|
||||
srv.handleDiscoveredDevice(discoveredDevice)
|
||||
|
||||
// Verify the device now exists under the MAC address
|
||||
expectedDeviceID := "A81B6A536A98"
|
||||
migratedInfo, err := ds.GetDeviceInfo(accountID, expectedDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get migrated device info: %v", err)
|
||||
}
|
||||
|
||||
// Verify the migrated device has the correct information
|
||||
if migratedInfo.DeviceID != expectedDeviceID {
|
||||
t.Errorf("Expected deviceID '%s', got '%s'", expectedDeviceID, migratedInfo.DeviceID)
|
||||
}
|
||||
|
||||
if migratedInfo.Name != "Sound Machinechen" {
|
||||
t.Errorf("Expected name 'Sound Machinechen' (from /info), got '%s'", migratedInfo.Name)
|
||||
}
|
||||
|
||||
if migratedInfo.MacAddress != "A81B6A536A98" {
|
||||
t.Errorf("Expected MAC 'A81B6A536A98', got '%s'", migratedInfo.MacAddress)
|
||||
}
|
||||
|
||||
if migratedInfo.DeviceSerialNumber != "I6332527703739342000020" {
|
||||
t.Errorf("Expected device serial 'I6332527703739342000020', got '%s'", migratedInfo.DeviceSerialNumber)
|
||||
}
|
||||
|
||||
t.Logf("\nMigration completed successfully:")
|
||||
t.Logf(" New device ID: %s (MAC address)", migratedInfo.DeviceID)
|
||||
t.Logf(" Device name: %s", migratedInfo.Name)
|
||||
t.Logf(" Device serial: %s", migratedInfo.DeviceSerialNumber)
|
||||
t.Logf(" Product serial: %s", migratedInfo.ProductSerialNumber)
|
||||
t.Logf(" MAC address: %s", migratedInfo.MacAddress)
|
||||
|
||||
// Verify MAC address resolution works
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, "A81B6A536A98")
|
||||
expectedDir := ds.AccountDeviceDir(accountID, expectedDeviceID)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("\nMAC address resolution verified:")
|
||||
t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory")
|
||||
|
||||
// Note: In a complete implementation, we'd also verify that presets from all
|
||||
// the old devices were consolidated, but that requires more sophisticated
|
||||
// preset merging logic which is beyond the current migration scope.
|
||||
|
||||
t.Logf("\n✅ Comprehensive migration test completed successfully!")
|
||||
}
|
||||
|
||||
func TestFindAllExistingDeviceVariants_MatchingCriteria(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "variants-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
accountID := "testaccount"
|
||||
|
||||
// Create devices that should match various criteria
|
||||
devices := []models.ServiceDeviceInfo{
|
||||
{
|
||||
DeviceID: "192.168.1.100",
|
||||
AccountID: accountID,
|
||||
Name: "IP Device",
|
||||
IPAddress: "192.168.1.100",
|
||||
DeviceSerialNumber: "SERIAL123",
|
||||
MacAddress: "AA:BB:CC:DD:EE:FF",
|
||||
},
|
||||
{
|
||||
DeviceID: "SERIAL123",
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker",
|
||||
IPAddress: "192.168.1.101", // Different IP
|
||||
DeviceSerialNumber: "SERIAL123",
|
||||
MacAddress: "AA:BB:CC:DD:EE:FF",
|
||||
},
|
||||
{
|
||||
DeviceID: "UPnPSerial456",
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker",
|
||||
IPAddress: "192.168.1.102", // Different IP again
|
||||
ProductCode: "SoundTouch 10 sm2",
|
||||
},
|
||||
{
|
||||
DeviceID: "UnrelatedDevice",
|
||||
AccountID: accountID,
|
||||
Name: "Other Device",
|
||||
IPAddress: "192.168.1.200",
|
||||
DeviceSerialNumber: "OTHERSSERIAL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
if err := ds.SaveDeviceInfo(accountID, device.DeviceID, &device); err != nil {
|
||||
t.Fatalf("Failed to save device %s: %v", device.DeviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock discovery and live info
|
||||
discovery := models.DiscoveredDevice{
|
||||
Host: "192.168.1.100", // Matches first device by IP
|
||||
SerialNo: "UPnPSerial456", // Matches third device by UPnP serial
|
||||
}
|
||||
|
||||
liveInfo := &setup.DeviceInfoXML{
|
||||
DeviceID: "AABBCCDDEEFF", // New MAC-based ID
|
||||
Name: "Sound Speaker", // Matches second and third devices by name
|
||||
Type: "SoundTouch 10",
|
||||
ModuleType: "sm2",
|
||||
SerialNumber: "SERIAL123", // Matches first and second devices by serial
|
||||
NetworkInfo: []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
}{
|
||||
{Type: "SCM", MacAddress: "AA:BB:CC:DD:EE:FF", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test the matching logic
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
t.Logf("Found %d matching device variants:", len(matches))
|
||||
for i, match := range matches {
|
||||
t.Logf(" %d. %s (IP: %s, Serial: %s, MAC: %s, Name: %s)",
|
||||
i+1, match.DeviceID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.Name)
|
||||
}
|
||||
|
||||
// Verify expected matches
|
||||
expectedMatches := map[string]string{
|
||||
"192.168.1.100": "IP match",
|
||||
"SERIAL123": "Serial match",
|
||||
"UPnPSerial456": "UPnP serial match",
|
||||
}
|
||||
|
||||
if len(matches) != len(expectedMatches) {
|
||||
t.Errorf("Expected %d matches, got %d", len(expectedMatches), len(matches))
|
||||
}
|
||||
|
||||
foundMatches := make(map[string]bool)
|
||||
for _, match := range matches {
|
||||
foundMatches[match.DeviceID] = true
|
||||
}
|
||||
|
||||
for expectedID, reason := range expectedMatches {
|
||||
if !foundMatches[expectedID] {
|
||||
t.Errorf("Expected to find device %s (%s), but it was not matched", expectedID, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify UnrelatedDevice is NOT matched
|
||||
if foundMatches["UnrelatedDevice"] {
|
||||
t.Error("UnrelatedDevice should not have been matched, but it was")
|
||||
}
|
||||
|
||||
t.Logf("✅ Device variant matching test completed successfully!")
|
||||
}
|
||||
|
||||
func TestMigration_EdgeCases(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-edge-cases-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
accountID := "testaccount"
|
||||
|
||||
t.Run("NoExistingDevices", func(t *testing.T) {
|
||||
discovery := models.DiscoveredDevice{Host: "192.168.1.200"}
|
||||
liveInfo := &setup.DeviceInfoXML{DeviceID: "NEWMAC123"}
|
||||
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("Expected 0 matches for new device, got %d", len(matches))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SelfMatch", func(t *testing.T) {
|
||||
// Device already exists with MAC as deviceID
|
||||
macDeviceID := "AABBCCDDEEFF"
|
||||
existing := &models.ServiceDeviceInfo{
|
||||
DeviceID: macDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Existing MAC Device",
|
||||
IPAddress: "192.168.1.150",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, macDeviceID, existing); err != nil {
|
||||
t.Fatalf("Failed to save MAC device: %v", err)
|
||||
}
|
||||
|
||||
discovery := models.DiscoveredDevice{Host: "192.168.1.150"}
|
||||
liveInfo := &setup.DeviceInfoXML{DeviceID: macDeviceID} // Same MAC
|
||||
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
// Should find itself, but migration logic should skip it since deviceID matches
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if match.DeviceID == macDeviceID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Device should find itself in variants")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PartialMatches", func(t *testing.T) {
|
||||
// Device with some matching criteria but not others
|
||||
partialDevice := &models.ServiceDeviceInfo{
|
||||
DeviceID: "PARTIAL123",
|
||||
AccountID: accountID,
|
||||
Name: "Partial Device",
|
||||
IPAddress: "192.168.1.160", // Different IP
|
||||
// No serial number, no MAC
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, "PARTIAL123", partialDevice); err != nil {
|
||||
t.Fatalf("Failed to save partial device: %v", err)
|
||||
}
|
||||
|
||||
discovery := models.DiscoveredDevice{Host: "192.168.1.170"} // Different IP
|
||||
liveInfo := &setup.DeviceInfoXML{
|
||||
DeviceID: "NEWMAC456",
|
||||
Name: "Partial Device", // Same name
|
||||
Type: "SoundTouch 20",
|
||||
}
|
||||
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
// Should match by name and product type
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if match.DeviceID == "PARTIAL123" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Should match device by name and product type")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestDeviceMigration_DirectoryRename(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
|
||||
|
||||
accountID := "test-account"
|
||||
macAddress := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
// Create serial-based device entry with full data (simulates legacy directory)
|
||||
serialDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Living Room Speaker",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 30",
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial-based device: %v", err)
|
||||
}
|
||||
|
||||
// Create some preset data in the serial-based directory
|
||||
serialPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "My Preset",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialNumber, serialPresets); err != nil {
|
||||
t.Fatalf("Failed to save presets: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial state - serial directory exists
|
||||
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
if _, err := os.Stat(serialDir); os.IsNotExist(err) {
|
||||
t.Fatalf("Serial directory should exist before migration: %s", serialDir)
|
||||
}
|
||||
|
||||
// Perform migration using migration manager
|
||||
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
|
||||
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
|
||||
|
||||
// Verify migration results
|
||||
t.Run("VerifyMigration", func(t *testing.T) {
|
||||
// 1. MAC directory should exist with files
|
||||
macDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
|
||||
if _, err := os.Stat(macDir); os.IsNotExist(err) {
|
||||
t.Errorf("MAC directory should exist after migration: %s", macDir)
|
||||
}
|
||||
|
||||
// 2. Serial directory should be gone
|
||||
if _, err := os.Stat(serialDir); !os.IsNotExist(err) {
|
||||
t.Errorf("Serial directory should not exist after migration: %s", serialDir)
|
||||
}
|
||||
|
||||
// 3. Simulate SaveDeviceInfo with fresh data (like real discovery flow)
|
||||
// This overwrites DeviceInfo.xml with correct MAC-based deviceID
|
||||
freshDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: macAddress,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker Fresh",
|
||||
IPAddress: "192.168.1.100",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 10 sm2",
|
||||
FirmwareVersion: "3.4.6.2356",
|
||||
ProductSerialNumber: "069231P63364828AE",
|
||||
DiscoveryMethod: "Migration Test",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, macAddress, freshDeviceInfo); err != nil {
|
||||
t.Errorf("Failed to save fresh device info: %v", err)
|
||||
}
|
||||
|
||||
// 4. Device info should now have correct MAC-based deviceID
|
||||
macInfo, err := ds.GetDeviceInfo(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get device info with MAC ID: %v", err)
|
||||
} else if macInfo.DeviceID != macAddress {
|
||||
t.Errorf("DeviceID should be updated to MAC address, got %s", macInfo.DeviceID)
|
||||
}
|
||||
|
||||
// 5. All data should be accessible through MAC address
|
||||
presets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get presets through MAC address: %v", err)
|
||||
} else if len(presets) != 1 || presets[0].Name != "My Preset" {
|
||||
t.Errorf("Presets should be preserved during migration")
|
||||
}
|
||||
|
||||
t.Logf("✓ Device directory migration working correctly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceMigration_NoExistingTarget(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "no-target-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
|
||||
|
||||
accountID := "test-account"
|
||||
macAddress := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
// Create only serial-based device entry (no existing MAC directory)
|
||||
serialDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Test Speaker",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 30",
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial device: %v", err)
|
||||
}
|
||||
|
||||
// Add some data files
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Test Preset",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialNumber, presets); err != nil {
|
||||
t.Fatalf("Failed to save presets: %v", err)
|
||||
}
|
||||
|
||||
// Verify MAC directory doesn't exist initially
|
||||
macDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
if _, err := os.Stat(macDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("MAC directory should not exist initially: %s", macDir)
|
||||
}
|
||||
|
||||
// Migrate directory using migration manager
|
||||
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
|
||||
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
|
||||
|
||||
// Verify migration
|
||||
if _, err := os.Stat(macDir); os.IsNotExist(err) {
|
||||
t.Errorf("MAC directory should exist after migration: %s", macDir)
|
||||
}
|
||||
|
||||
// Verify data is accessible
|
||||
migratedPresets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to access presets after migration: %v", err)
|
||||
} else if len(migratedPresets) != 1 || migratedPresets[0].Name != "Test Preset" {
|
||||
t.Errorf("Presets should be preserved in migration")
|
||||
}
|
||||
|
||||
t.Log("✓ Simple directory migration working correctly")
|
||||
}
|
||||
|
||||
func TestDeviceMigration_ExistingTargetRemoved(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "existing-target-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
|
||||
|
||||
accountID := "test-account"
|
||||
macAddress := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
// Create both directories (serial has rich data, MAC has minimal data)
|
||||
serialInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Rich Data Device",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
}
|
||||
macInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: macAddress,
|
||||
AccountID: accountID,
|
||||
Name: "Minimal Data Device",
|
||||
MacAddress: macAddress,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial device: %v", err)
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, macAddress, macInfo); err != nil {
|
||||
t.Fatalf("Failed to save MAC device: %v", err)
|
||||
}
|
||||
|
||||
// Add rich data to serial directory
|
||||
richPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Rich Preset",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialNumber, richPresets); err != nil {
|
||||
t.Fatalf("Failed to save rich presets: %v", err)
|
||||
}
|
||||
|
||||
// Migrate - should replace MAC directory with serial directory content
|
||||
existingDevices := []models.ServiceDeviceInfo{*serialInfo}
|
||||
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
|
||||
|
||||
// Verify the rich data is now accessible via MAC address
|
||||
finalInfo, err := ds.GetDeviceInfo(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get device info after migration: %v", err)
|
||||
} else if finalInfo.Name != "Rich Data Device" {
|
||||
t.Errorf("Should have rich device data, got name: %s", finalInfo.Name)
|
||||
}
|
||||
|
||||
finalPresets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get rich presets after migration: %v", err)
|
||||
} else if len(finalPresets) != 1 || finalPresets[0].Name != "Rich Preset" {
|
||||
t.Errorf("Should have rich presets after migration")
|
||||
}
|
||||
|
||||
t.Log("✓ Migration correctly replaces existing target with richer source")
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
@@ -96,41 +94,3 @@ func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCustomPlayback returns custom playback information for a given stream URL.
|
||||
func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
encodedURL := chi.URLParam(r, "encodedURL")
|
||||
imageUrl := r.URL.Query().Get("imageUrl")
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
// Decode URL if it's base64 encoded
|
||||
var streamUrl string
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(encodedURL)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
streamUrl = string(decoded)
|
||||
} else {
|
||||
// Try unescaping if it's not base64
|
||||
streamUrl, err = url.PathUnescape(encodedURL)
|
||||
if err != nil {
|
||||
streamUrl = encodedURL
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -108,44 +107,3 @@ func TestOrionPlayback(t *testing.T) {
|
||||
t.Errorf("Expected name Test Orion, got %v", resp["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomPlayback(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Base64 encoded: http://example.com/stream
|
||||
encodedURL := "aHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbQ=="
|
||||
imageUrl := "http://example.com/img.jpg"
|
||||
name := "Test Custom"
|
||||
|
||||
res, err := http.Get(ts.URL + "/custom/v1/playback/" + encodedURL + "?imageUrl=" + url.QueryEscape(imageUrl) + "&name=" + url.QueryEscape(name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(body, &resp)
|
||||
|
||||
if resp["name"] != "Test Custom" {
|
||||
t.Errorf("Expected name Test Custom, got %v", resp["name"])
|
||||
}
|
||||
|
||||
audio := resp["audio"].(map[string]interface{})
|
||||
if audio["streamUrl"] != "http://example.com/stream" {
|
||||
t.Errorf("Expected streamUrl http://example.com/stream, got %v", audio["streamUrl"])
|
||||
}
|
||||
|
||||
if resp["imageUrl"] != imageUrl {
|
||||
t.Errorf("Expected imageUrl %s, got %v", imageUrl, resp["imageUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
|
||||
func TestEventLog(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/streaming/stats/usage", s.HandleUsageStats)
|
||||
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
|
||||
t.Run("Record and Retrieve Events", func(t *testing.T) {
|
||||
// 1. Post a usage stat
|
||||
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Retrieve events
|
||||
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
|
||||
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type healthResp struct {
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
srv := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
srv := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
|
||||
r.Get("/health", srv.HandleHealth)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -86,37 +85,6 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
|
||||
|
||||
// Persist device details provided in the power_on request
|
||||
if deviceID != "" && s.ds != nil {
|
||||
// Use "default" account if not found or if the device is not yet mapped to an account.
|
||||
// In a real scenario, this might be resolved differently if we already have the account info.
|
||||
accountID := "default"
|
||||
if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil && existing.AccountID != "" {
|
||||
accountID = existing.AccountID
|
||||
}
|
||||
|
||||
macAddress := ""
|
||||
if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 0 {
|
||||
macAddress = req.DiagnosticData.DeviceLandscape.MacAddresses[0]
|
||||
}
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
ProductCode: req.Device.Product.ProductCode,
|
||||
DeviceSerialNumber: req.Device.SerialNumber,
|
||||
ProductSerialNumber: req.Device.Product.SerialNumber,
|
||||
FirmwareVersion: req.Device.FirmwareVersion,
|
||||
IPAddress: deviceIP,
|
||||
MacAddress: macAddress,
|
||||
DiscoveryMethod: "power_on",
|
||||
}
|
||||
|
||||
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
|
||||
log.Printf("[Marge] Failed to save device info for %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if deviceIP != "" {
|
||||
go s.PrimeDeviceWithSpotify(deviceIP)
|
||||
} else {
|
||||
@@ -402,7 +370,7 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Authorization", bearerToken.GetAuthHeader())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader))
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -411,7 +379,7 @@ func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request)
|
||||
// Native firmware expects vnd.bose.streaming content type
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><group/>`))
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -732,90 +731,6 @@ func TestMargePowerOn(t *testing.T) {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Persistence", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts2 := httptest.NewServer(r)
|
||||
defer ts2.Close()
|
||||
|
||||
deviceID := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
firmware := "27.0.6.46330"
|
||||
productCode := "SoundTouch 10 sm2"
|
||||
productSerial := "069231P63364828AE"
|
||||
ipAddress := "192.168.1.100"
|
||||
macAddress := "A81B6A536A98"
|
||||
|
||||
payload := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<device-data>
|
||||
<device id="%s">
|
||||
<serialnumber>%s</serialnumber>
|
||||
<firmware-version>%s</firmware-version>
|
||||
<product product_code="%s" type="5">
|
||||
<serialnumber>%s</serialnumber>
|
||||
</product>
|
||||
</device>
|
||||
<diagnostic-data>
|
||||
<device-landscape>
|
||||
<rssi>Excellent</rssi>
|
||||
<gateway-ip-address>192.168.1.1</gateway-ip-address>
|
||||
<macaddresses>
|
||||
<macaddress>%s</macaddress>
|
||||
</macaddresses>
|
||||
<ip-address>%s</ip-address>
|
||||
<network-connection-type>Wireless</network-connection-type>
|
||||
</device-landscape>
|
||||
</diagnostic-data>
|
||||
</device-data>`, deviceID, serialNumber, firmware, productCode, productSerial, macAddress, ipAddress)
|
||||
|
||||
res, err := http.Post(ts2.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
// Verify data in datastore
|
||||
info, err := ds.GetDeviceInfo("default", deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get device info: %v", err)
|
||||
}
|
||||
|
||||
if info.DeviceID != deviceID {
|
||||
t.Errorf("Expected DeviceID %s, got %s", deviceID, info.DeviceID)
|
||||
}
|
||||
if info.DeviceSerialNumber != serialNumber {
|
||||
t.Errorf("Expected SerialNumber %s, got %s", serialNumber, info.DeviceSerialNumber)
|
||||
}
|
||||
if info.FirmwareVersion != firmware {
|
||||
t.Errorf("Expected Firmware %s, got %s", firmware, info.FirmwareVersion)
|
||||
}
|
||||
if info.ProductCode != productCode {
|
||||
t.Errorf("Expected ProductCode %s, got %s", productCode, info.ProductCode)
|
||||
}
|
||||
if info.ProductSerialNumber != productSerial {
|
||||
t.Errorf("Expected ProductSerialNumber %s, got %s", productSerial, info.ProductSerialNumber)
|
||||
}
|
||||
if info.IPAddress != ipAddress {
|
||||
t.Errorf("Expected IPAddress %s, got %s", ipAddress, info.IPAddress)
|
||||
}
|
||||
if info.MacAddress != macAddress {
|
||||
t.Errorf("Expected MacAddress %s, got %s", macAddress, info.MacAddress)
|
||||
}
|
||||
if info.DiscoveryMethod != "power_on" {
|
||||
t.Errorf("Expected DiscoveryMethod power_on, got %s", info.DiscoveryMethod)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMargeAdvancedFeatures(t *testing.T) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed web/css/* web/js/*
|
||||
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed static/media/*
|
||||
|
||||
@@ -103,33 +103,104 @@ func TestStaticWeb(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
// 1. Test Migration UI CSS
|
||||
res, err := http.Get(ts.URL + "/web/migration/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
// 2. Test Migration UI JS
|
||||
res, err = http.Get(ts.URL + "/web/migration/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test Migration UI Index
|
||||
res, err = http.Get(ts.URL + "/web/migration/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 4. Test Stockholm Mini
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 5. Test Stockholm Mini CSS
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 6. Test Shared CSS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 7. Test Shared JS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test diff.min.js
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestHandleMgmtSpotifyInit(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
|
||||
// No spotify service configured
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -45,7 +45,7 @@ func TestHandleMgmtSpotifyInit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestHandleMgmtListSpeakers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyCallback(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestHandleMgmtSpotifyCallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyConfirm(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestHandleMgmtDeviceEvents(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBasicAuthMgmt(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
|
||||
s.SetMgmtConfig("admin", "secret123")
|
||||
|
||||
handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleBoseSpotifyToken handles the Bose-specific Spotify token refresh request from the speaker.
|
||||
// POST /oauth/device/{deviceID}/music/musicprovider/15/token/cs3
|
||||
func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceID")
|
||||
log.Printf("[Spotify Proxy] Intercepted token request for device %s", deviceID)
|
||||
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
log.Printf("[Spotify Proxy] Spotify service not configured, falling back to upstream")
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
if len(accounts) == 0 {
|
||||
log.Printf("[Spotify Proxy] No Spotify accounts linked, falling back to upstream")
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// We use the first linked account.
|
||||
accessToken, _, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Format response as expected by Bose firmware.
|
||||
// Based on observed interactions, it's a JSON object with access_token.
|
||||
// The "scope" and other fields might be needed by some firmware versions.
|
||||
response := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
// These scopes are typical for what Bose requests.
|
||||
"scope": "playlist-read-private playlist-read-collaborative streaming user-library-read user-library-modify playlist-modify-private playlist-modify-public user-read-email user-read-private user-top-read",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Proxy-Origin", "self")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to encode response: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBoseSpotifyLegacyToken handles the Bose-specific Spotify token refresh request (legacy or variant).
|
||||
// POST /oauth/device/{deviceID}/music/musicprovider/15/token
|
||||
func (s *Server) HandleBoseSpotifyLegacyToken(w http.ResponseWriter, r *http.Request) {
|
||||
// Some firmware might use a slightly different path.
|
||||
s.HandleBoseSpotifyToken(w, r)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestHandleBoseSpotifyToken_LocalResponse(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Wait, I can't easily inject an account into spotify.Service from here because fields are private.
|
||||
// Let's check if there's any other way.
|
||||
// I could mock the spotify.Service if it was an interface, but it's a struct.
|
||||
|
||||
// ss.load() expects accounts in tmpDir/spotify/accounts.json
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0755)
|
||||
|
||||
account := map[string]interface{}{
|
||||
"user1": map[string]interface{}{
|
||||
"user_id": "user1",
|
||||
"display_name": "Test User",
|
||||
"access_token": "valid-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(1 * time.Hour).Unix(),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(account)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal account: %v", err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), data, 0644)
|
||||
|
||||
// Initialize ss so it loads the data
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// chi.URLParam works when using chi router
|
||||
r := chi.NewRouter()
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if w.Header().Get("X-Proxy-Origin") != "self" {
|
||||
t.Errorf("Expected X-Proxy-Origin: self, got %s", w.Header().Get("X-Proxy-Origin"))
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp["access_token"] != "valid-token" {
|
||||
t.Errorf("Expected access_token 'valid-token', got %v", resp["access_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Mirroring must be enabled for HandleBoseProxy to work (based on previous changes)
|
||||
// Actually I reverted that, so it should work regardless of MirrorEnabled now.
|
||||
server.SetMirrorSettings(true, nil, "")
|
||||
|
||||
// chi.URLParam works when using chi router
|
||||
r := chi.NewRouter()
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
|
||||
|
||||
// Since there's no Spotify service, it should fall back to HandleBoseProxy.
|
||||
// HandleBoseProxy will try to contact streaming.bose.com.
|
||||
// We can check if it returns a 502 or 404 (since we are not actually proxying to real Bose).
|
||||
|
||||
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
|
||||
req.Host = "localhost" // use localhost to avoid real network call
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// If it fell back to proxy, it should NOT have X-Proxy-Origin: self
|
||||
if w.Header().Get("X-Proxy-Origin") == "self" {
|
||||
t.Errorf("Expected fallback to proxy, but got X-Proxy-Origin: self")
|
||||
}
|
||||
|
||||
// HandleBoseProxy sets X-Proxy-Origin: upstream
|
||||
if w.Header().Get("X-Proxy-Origin") != "upstream" {
|
||||
// It might fail before setting the header if the target host is invalid,
|
||||
// but our HandleBoseProxy sets it in ModifyResponse.
|
||||
// If it fails to connect, it might return 502 without the header.
|
||||
if w.Code != http.StatusBadGateway && w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected fallback to proxy (upstream), got status %d and origin %s", w.Code, w.Header().Get("X-Proxy-Origin"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBoseSpotifyLegacyToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken)
|
||||
|
||||
// Since we are not configuring Spotify, it should fall back to proxy
|
||||
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token", nil)
|
||||
req.Host = "localhost"
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("X-Proxy-Origin") == "self" {
|
||||
t.Errorf("Expected fallback to proxy")
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
|
||||
defer backend.Close()
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
server.recordEnabled = true
|
||||
server.proxyLogBody = true
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
|
||||
@@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Migrate: Expected output field in response")
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
|
||||
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("TrustCA: Expected output field in response")
|
||||
}
|
||||
|
||||
// 4. Test POST /setup/reboot/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
|
||||
// 4. Test POST /devices/{deviceIP}/reboot
|
||||
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Reboot: Expected output field in response")
|
||||
}
|
||||
|
||||
// 5. Test POST /setup/remove-remote-services/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
|
||||
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -329,17 +329,20 @@ func TestRemoveDevice(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Setup a dummy device in the datastore
|
||||
account := "test-account"
|
||||
account := "acc1"
|
||||
deviceID := "TEST-DEVICE-ID"
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
|
||||
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
|
||||
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to create device info file: %v", err)
|
||||
// Register device in datastore so HandleRemoveDevice works
|
||||
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: account,
|
||||
IPAddress: "192.168.1.100",
|
||||
})
|
||||
|
||||
// Verify directory exists where datastore expects it
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if _, err := os.Stat(deviceDir); err != nil {
|
||||
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
@@ -347,7 +350,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Verify device exists
|
||||
res, err := http.Get(ts.URL + "/setup/devices")
|
||||
res, err := http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -370,7 +373,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Remove device
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -385,7 +388,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 3. Verify device is gone
|
||||
res, err = http.Get(ts.URL + "/setup/devices")
|
||||
res, err = http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestStatsHandlers(t *testing.T) {
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
|
||||
t.Run("HandleUsageStats XML", func(t *testing.T) {
|
||||
xmlData := `
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleGetStockholmDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetStockholmDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Include IP address in both snake_case and camelCase for frontend compatibility
|
||||
type deviceInfoResponse struct {
|
||||
*setup.DeviceInfoXML `json:",inline"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
IPAddressCamel string `json:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
resp := deviceInfoResponse{
|
||||
DeviceInfoXML: info,
|
||||
IPAddress: deviceIP,
|
||||
IPAddressCamel: deviceIP,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey sends a key command to a device.
|
||||
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
key := chi.URLParam(r, "key")
|
||||
if deviceID == "" || key == "" {
|
||||
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SendKey(key)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceVolume sets the volume level for a device.
|
||||
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
levelStr := chi.URLParam(r, "level")
|
||||
if deviceID == "" || levelStr == "" {
|
||||
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
level, err := strconv.Atoi(levelStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid volume level", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SetVolume(level)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
const (
|
||||
pongWait = 40 * time.Second
|
||||
pingPeriod = 20 * time.Second // must be less than pongWait
|
||||
)
|
||||
|
||||
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
|
||||
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade the HTTP connection to a WebSocket for the browser
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a SoundTouch WebSocket client for the target device
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
|
||||
|
||||
// Channel-based write pump per Gorilla best practices
|
||||
sendCh := make(chan []byte, 64) // buffer to smooth bursts
|
||||
closeCh := make(chan struct{})
|
||||
|
||||
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
|
||||
enqueue := func(v interface{}) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendCh <- b:
|
||||
default:
|
||||
// drop to protect connection under burst
|
||||
}
|
||||
}
|
||||
|
||||
// Reader: we don't expect messages from the browser; just keep the
|
||||
// connection alive by processing control frames and detect close.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
close(closeCh)
|
||||
|
||||
_ = wsClient.Disconnect()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
mt, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mt == websocket.CloseMessage {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Writer: single writer goroutine handles JSON writes and ping keepalive
|
||||
go func() {
|
||||
pingTicker := time.NewTicker(pingPeriod)
|
||||
|
||||
defer func() {
|
||||
pingTicker.Stop()
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-sendCh:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Forward typed events with a simple envelope into the send queue
|
||||
wsClient.SetHandlers(&models.WebSocketEventHandlers{
|
||||
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
|
||||
},
|
||||
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
|
||||
},
|
||||
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
|
||||
},
|
||||
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
|
||||
},
|
||||
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
|
||||
},
|
||||
OnBassUpdated: func(e *models.BassUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
|
||||
},
|
||||
OnUnknownEvent: func(event *models.WebSocketEvent) {
|
||||
bytes, _ := json.Marshal(event)
|
||||
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
|
||||
},
|
||||
OnSpecialMessage: func(msg *models.SpecialMessage) {
|
||||
enqueue(map[string]interface{}{"type": "special", "payload": msg})
|
||||
},
|
||||
})
|
||||
|
||||
// Add a separate goroutine to monitor the device connection status
|
||||
go func() {
|
||||
wsClient.Wait()
|
||||
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Connect to the device WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: send an initial snapshot for convenience
|
||||
go func() {
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Supplement with volume and now playing
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
payload := map[string]interface{}{
|
||||
"deviceID": info.DeviceID,
|
||||
"name": info.Name,
|
||||
"type": info.Type,
|
||||
//"maccAddress": info.MaccAddress,
|
||||
"serialNumber": info.SerialNumber,
|
||||
"softwareVersion": info.SoftwareVer,
|
||||
// Provide IP in both styles for frontend robustness
|
||||
"ip_address": deviceIP,
|
||||
"ipAddress": deviceIP,
|
||||
}
|
||||
|
||||
if vol, err := c.GetVolume(); err == nil {
|
||||
payload["volume"] = vol
|
||||
// Also add at top level for flatter frontend parsing
|
||||
payload["actualVolume"] = vol.ActualVolume
|
||||
}
|
||||
|
||||
if np, err := c.GetNowPlaying(); err == nil {
|
||||
payload["nowPlaying"] = np
|
||||
}
|
||||
|
||||
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
|
||||
}()
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestInteractionHandlers(t *testing.T) {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
|
||||
t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
|
||||
@@ -151,7 +151,7 @@ func TestRecordMiddleware(t *testing.T) {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, true)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, true, false, false)
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
|
||||
// Create server instance
|
||||
srv := NewServer(ds, sm, "http://localhost", false, false, false)
|
||||
srv := NewServer(ds, sm, "http://localhost", false, false, false, false, false)
|
||||
|
||||
t.Logf("Test scenario:")
|
||||
t.Logf(" Device IP: %s", deviceIP)
|
||||
@@ -311,7 +311,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
|
||||
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false)
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false, false, false)
|
||||
|
||||
// 3. Simulate rediscovery of the same device (now with /info working)
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
@@ -380,7 +380,7 @@ func TestMACBasedDeviceDiscovery_FallbackScenario(t *testing.T) {
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false)
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false, false, false)
|
||||
|
||||
// Simulate device discovery with UPnP providing serial
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestMacMappingIntegration_HTTPHandler(t *testing.T) {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
|
||||
// Setup router with the exact same route as in production
|
||||
router := chi.NewRouter()
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) {
|
||||
server := NewServer(ds, nil, targetURL, false, false, false)
|
||||
server := NewServer(ds, nil, targetURL, false, false, false, false, false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(server.OriginMiddleware)
|
||||
@@ -35,7 +35,6 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
@@ -92,12 +91,31 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
// Setup Devices for tests
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
// Setup Setup for tests
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
@@ -109,6 +127,30 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// DeviceMigrationDiagnostic provides detailed analysis of device migration scenarios
|
||||
type DeviceMigrationDiagnostic struct {
|
||||
server *Server
|
||||
}
|
||||
|
||||
// NewDeviceMigrationDiagnostic creates a new diagnostic instance
|
||||
func NewDeviceMigrationDiagnostic(server *Server) *DeviceMigrationDiagnostic {
|
||||
return &DeviceMigrationDiagnostic{server: server}
|
||||
}
|
||||
|
||||
// DiagnoseDeviceMigration analyzes why a specific device might not be migrating correctly
|
||||
func (d *DeviceMigrationDiagnostic) DiagnoseDeviceMigration(deviceIP string) error {
|
||||
log.Printf("=== Device Migration Diagnostic for %s ===", deviceIP)
|
||||
|
||||
// 1. Fetch live device info
|
||||
liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to fetch /info from %s: %v", deviceIP, err)
|
||||
return fmt.Errorf("cannot fetch /info from %s: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Successfully fetched /info from %s", deviceIP)
|
||||
log.Printf(" Device ID (MAC): %s", liveInfo.DeviceID)
|
||||
log.Printf(" Device Name: %s", liveInfo.Name)
|
||||
log.Printf(" Product: %s %s", liveInfo.Type, liveInfo.ModuleType)
|
||||
log.Printf(" Account: %s", liveInfo.MargeAccountUUID)
|
||||
log.Printf(" Component Serial: %s", liveInfo.SerialNumber)
|
||||
log.Printf(" Primary MAC: %s", liveInfo.GetPrimaryMacAddress())
|
||||
|
||||
// 2. List all existing devices
|
||||
allDevices, err := d.server.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to list devices: %v", err)
|
||||
return fmt.Errorf("failed to list devices: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("\n📋 Found %d existing devices in datastore:", len(allDevices))
|
||||
|
||||
devicesByAccount := make(map[string][]models.ServiceDeviceInfo)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
devicesByAccount[device.AccountID] = append(devicesByAccount[device.AccountID], *device)
|
||||
}
|
||||
|
||||
for accountID, devices := range devicesByAccount {
|
||||
log.Printf(" Account %s: %d devices", accountID, len(devices))
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
log.Printf(" %d. %s", i+1, device.DeviceID)
|
||||
log.Printf(" Name: %s", device.Name)
|
||||
log.Printf(" IP: %s", device.IPAddress)
|
||||
log.Printf(" Serial: %s", device.DeviceSerialNumber)
|
||||
log.Printf(" MAC: %s", device.MacAddress)
|
||||
log.Printf(" Product: %s", device.ProductCode)
|
||||
log.Printf(" Discovery: %s", device.DiscoveryMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simulate discovery and check matching
|
||||
log.Printf("\n🔍 Testing migration candidate matching:")
|
||||
|
||||
// Test different discovery scenarios
|
||||
testDiscoveries := []models.DiscoveredDevice{
|
||||
{
|
||||
Host: deviceIP,
|
||||
Name: "Current Discovery",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "Manual",
|
||||
},
|
||||
{
|
||||
Host: deviceIP,
|
||||
Name: "With Live Serial",
|
||||
SerialNo: liveInfo.SerialNumber,
|
||||
DiscoveryMethod: "UPnP",
|
||||
},
|
||||
}
|
||||
|
||||
// Add test with different IPs that might match existing devices
|
||||
seenIPs := make(map[string]bool)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
if device.IPAddress != "" && device.IPAddress != deviceIP && !seenIPs[device.IPAddress] {
|
||||
seenIPs[device.IPAddress] = true
|
||||
testDiscoveries = append(testDiscoveries, models.DiscoveredDevice{
|
||||
Host: device.IPAddress,
|
||||
Name: "Previous IP Test",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "Test",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for i := range testDiscoveries {
|
||||
testDiscovery := &testDiscoveries[i]
|
||||
log.Printf("\n Test Scenario %d: %s (IP: %s, Serial: %s)",
|
||||
i+1, testDiscovery.Name, testDiscovery.Host, testDiscovery.SerialNo)
|
||||
|
||||
matches := d.server.findAllExistingDeviceVariants(*testDiscovery, liveInfo)
|
||||
if len(matches) == 0 {
|
||||
log.Printf(" ❌ No migration candidates found")
|
||||
} else {
|
||||
log.Printf(" ✅ Found %d migration candidate(s):", len(matches))
|
||||
|
||||
for i := range matches {
|
||||
match := &matches[i]
|
||||
if match.DeviceID == liveInfo.DeviceID {
|
||||
log.Printf(" - %s ⚠️ (already uses target MAC)", match.DeviceID)
|
||||
} else {
|
||||
log.Printf(" - %s", match.DeviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Detailed matching analysis
|
||||
log.Printf("\n🔬 Detailed Matching Analysis:")
|
||||
log.Printf(" Looking for devices that should match MAC %s...", liveInfo.DeviceID)
|
||||
|
||||
potentialMatches := d.findPotentialMatches(allDevices, liveInfo)
|
||||
if len(potentialMatches) == 0 {
|
||||
log.Printf(" ❌ No potential matches found")
|
||||
log.Printf("\n💡 Recommendations:")
|
||||
log.Printf(" - This appears to be a completely new device")
|
||||
log.Printf(" - Device will be created with MAC-based ID: %s", liveInfo.DeviceID)
|
||||
log.Printf(" - Account: %s", liveInfo.MargeAccountUUID)
|
||||
} else {
|
||||
log.Printf(" ✅ Found %d potential match(es):", len(potentialMatches))
|
||||
|
||||
for i := range potentialMatches {
|
||||
d.explainMatch(potentialMatches[i], liveInfo)
|
||||
}
|
||||
|
||||
log.Printf("\n💡 Migration Recommendations:")
|
||||
|
||||
for i := range potentialMatches {
|
||||
match := potentialMatches[i]
|
||||
if match.DeviceID != liveInfo.DeviceID {
|
||||
log.Printf(" - Migrate %s → %s", match.DeviceID, liveInfo.DeviceID)
|
||||
log.Printf(" Reason: %s", d.getMatchReason(match, liveInfo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("\n=== End Diagnostic ===")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findPotentialMatches finds devices that could potentially be the same device
|
||||
func (d *DeviceMigrationDiagnostic) findPotentialMatches(allDevices []models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo {
|
||||
var matches []models.ServiceDeviceInfo
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
if d.couldBeMatch(*device, liveInfo) {
|
||||
matches = append(matches, *device)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// couldBeMatch determines if a device could potentially be the same physical device
|
||||
func (d *DeviceMigrationDiagnostic) couldBeMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) bool {
|
||||
// 1. Serial number match
|
||||
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. DeviceID is the serial
|
||||
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. MAC address match
|
||||
primaryMAC := liveInfo.GetPrimaryMacAddress()
|
||||
if primaryMAC != "" && device.MacAddress == primaryMAC {
|
||||
return true
|
||||
}
|
||||
|
||||
// 4. DeviceID is already the MAC
|
||||
if device.DeviceID == liveInfo.DeviceID {
|
||||
return true
|
||||
}
|
||||
|
||||
// 5. Name and product similarity
|
||||
if liveInfo.Name != "" && device.Name == liveInfo.Name {
|
||||
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
|
||||
if device.ProductCode == expectedProduct ||
|
||||
device.ProductCode == liveInfo.Type ||
|
||||
strings.Contains(device.ProductCode, liveInfo.Type) ||
|
||||
strings.Contains(expectedProduct, device.ProductCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check if device product serial matches any component
|
||||
for _, comp := range liveInfo.Components {
|
||||
if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// explainMatch provides detailed explanation of why a device matches
|
||||
func (d *DeviceMigrationDiagnostic) explainMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) {
|
||||
log.Printf(" 📋 Device: %s", device.DeviceID)
|
||||
log.Printf(" Account: %s", device.AccountID)
|
||||
log.Printf(" Name: %s → %s", device.Name, liveInfo.Name)
|
||||
log.Printf(" IP: %s", device.IPAddress)
|
||||
log.Printf(" Serial: %s → %s", device.DeviceSerialNumber, liveInfo.SerialNumber)
|
||||
log.Printf(" MAC: %s → %s", device.MacAddress, liveInfo.GetPrimaryMacAddress())
|
||||
log.Printf(" Product: %s → %s %s", device.ProductCode, liveInfo.Type, liveInfo.ModuleType)
|
||||
|
||||
reasons := d.getMatchReasons(device, liveInfo)
|
||||
for _, reason := range reasons {
|
||||
log.Printf(" ✅ %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// getMatchReason gets the primary reason for a match
|
||||
func (d *DeviceMigrationDiagnostic) getMatchReason(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) string {
|
||||
reasons := d.getMatchReasons(device, liveInfo)
|
||||
if len(reasons) > 0 {
|
||||
return reasons[0]
|
||||
}
|
||||
|
||||
return "Unknown match reason"
|
||||
}
|
||||
|
||||
// getMatchReasons gets all reasons why a device matches
|
||||
func (d *DeviceMigrationDiagnostic) getMatchReasons(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []string {
|
||||
var reasons []string
|
||||
|
||||
// Serial number matches
|
||||
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
|
||||
reasons = append(reasons, "Device serial number matches")
|
||||
}
|
||||
|
||||
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
|
||||
reasons = append(reasons, "DeviceID matches component serial")
|
||||
}
|
||||
|
||||
// MAC address matches
|
||||
primaryMAC := liveInfo.GetPrimaryMacAddress()
|
||||
if primaryMAC != "" && device.MacAddress == primaryMAC {
|
||||
reasons = append(reasons, "MAC address matches")
|
||||
}
|
||||
|
||||
if device.DeviceID == liveInfo.DeviceID {
|
||||
reasons = append(reasons, "DeviceID matches (already migrated)")
|
||||
}
|
||||
|
||||
// Name and product
|
||||
if liveInfo.Name != "" && device.Name == liveInfo.Name {
|
||||
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
|
||||
if device.ProductCode == expectedProduct || device.ProductCode == liveInfo.Type {
|
||||
reasons = append(reasons, "Name and product match exactly")
|
||||
} else if strings.Contains(device.ProductCode, liveInfo.Type) || strings.Contains(expectedProduct, device.ProductCode) {
|
||||
reasons = append(reasons, "Name and product similar")
|
||||
}
|
||||
}
|
||||
|
||||
// Component serials
|
||||
for _, comp := range liveInfo.Components {
|
||||
if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber {
|
||||
reasons = append(reasons, fmt.Sprintf("Product serial matches %s component", comp.Category))
|
||||
}
|
||||
}
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
// SimulateFullMigration simulates what would happen if migration ran for this device
|
||||
func (d *DeviceMigrationDiagnostic) SimulateFullMigration(deviceIP string) error {
|
||||
log.Printf("=== Migration Simulation for %s ===", deviceIP)
|
||||
|
||||
// Fetch device info
|
||||
liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot fetch device info: %w", err)
|
||||
}
|
||||
|
||||
// Simulate discovery
|
||||
discovery := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Simulated Discovery",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "Manual",
|
||||
}
|
||||
|
||||
log.Printf("Target Device ID: %s", liveInfo.DeviceID)
|
||||
log.Printf("Target Account: %s", liveInfo.MargeAccountUUID)
|
||||
|
||||
// Find existing variants
|
||||
existingDevices := d.server.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
if len(existingDevices) == 0 {
|
||||
log.Printf("✨ This would be a NEW device:")
|
||||
log.Printf(" Directory: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID)
|
||||
} else {
|
||||
log.Printf("🔄 This would MIGRATE %d existing device(s):", len(existingDevices))
|
||||
|
||||
for i := range existingDevices {
|
||||
existing := &existingDevices[i]
|
||||
if existing.DeviceID != liveInfo.DeviceID {
|
||||
log.Printf(" %s → %s", existing.DeviceID, liveInfo.DeviceID)
|
||||
log.Printf(" From: accounts/%s/devices/%s/", existing.AccountID, existing.DeviceID)
|
||||
log.Printf(" To: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID)
|
||||
} else {
|
||||
log.Printf(" %s (already correct)", existing.DeviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("=== End Simulation ===")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnalyzeExistingDevices provides an overview of all devices and potential migration issues
|
||||
func (d *DeviceMigrationDiagnostic) AnalyzeExistingDevices() error {
|
||||
log.Printf("=== Device Migration Analysis ===")
|
||||
|
||||
allDevices, err := d.server.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list devices: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📊 Total devices in datastore: %d", len(allDevices))
|
||||
|
||||
// Categorize devices
|
||||
var (
|
||||
macBasedDevices []models.ServiceDeviceInfo
|
||||
ipBasedDevices []models.ServiceDeviceInfo
|
||||
serialBasedDevices []models.ServiceDeviceInfo
|
||||
unknownDevices []models.ServiceDeviceInfo
|
||||
)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
|
||||
deviceID := device.DeviceID
|
||||
switch {
|
||||
case isMACAddress(deviceID):
|
||||
macBasedDevices = append(macBasedDevices, *device)
|
||||
case isIPAddress(deviceID):
|
||||
ipBasedDevices = append(ipBasedDevices, *device)
|
||||
case isSerialNumber(deviceID):
|
||||
serialBasedDevices = append(serialBasedDevices, *device)
|
||||
default:
|
||||
unknownDevices = append(unknownDevices, *device)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("\n📋 Device ID Categories:")
|
||||
log.Printf(" ✅ MAC-based: %d (target format)", len(macBasedDevices))
|
||||
log.Printf(" 🔄 IP-based: %d (needs migration)", len(ipBasedDevices))
|
||||
log.Printf(" 🔄 Serial-based: %d (needs migration)", len(serialBasedDevices))
|
||||
log.Printf(" ❓ Unknown format: %d", len(unknownDevices))
|
||||
|
||||
if len(ipBasedDevices) > 0 {
|
||||
log.Printf("\n🔄 IP-based devices (migration candidates):")
|
||||
|
||||
for i := range ipBasedDevices {
|
||||
device := &ipBasedDevices[i]
|
||||
log.Printf(" %s (%s)", device.DeviceID, device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(serialBasedDevices) > 0 {
|
||||
log.Printf("\n🔄 Serial-based devices (migration candidates):")
|
||||
|
||||
for i := range serialBasedDevices {
|
||||
device := &serialBasedDevices[i]
|
||||
log.Printf(" %s (%s)", device.DeviceID, device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unknownDevices) > 0 {
|
||||
log.Printf("\n❓ Unknown format devices:")
|
||||
|
||||
for i := range unknownDevices {
|
||||
device := &unknownDevices[i]
|
||||
log.Printf(" %s (%s)", device.DeviceID, device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("=== End Analysis ===")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func isMACAddress(s string) bool {
|
||||
// AABBCCDDEEFF format
|
||||
if len(s) == 12 {
|
||||
return isHexOnly(s)
|
||||
}
|
||||
|
||||
// AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format
|
||||
if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) {
|
||||
s = strings.ReplaceAll(s, "-", ":")
|
||||
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if len(part) != 2 || !isHexOnly(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isHexOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isIPAddress(s string) bool {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if len(part) == 0 || len(part) > 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isSerialNumber(s string) bool {
|
||||
// Heuristic: serial numbers are typically alphanumeric and longer than MAC addresses
|
||||
if len(s) < 10 || len(s) > 30 {
|
||||
return false
|
||||
}
|
||||
|
||||
hasLetter := false
|
||||
hasDigit := false
|
||||
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'):
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
default:
|
||||
return false // Contains non-alphanumeric characters
|
||||
}
|
||||
}
|
||||
|
||||
return hasLetter && hasDigit
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -18,9 +17,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
)
|
||||
|
||||
// MirrorMiddleware returns a middleware that mirrors specific requests to the Bose upstream.
|
||||
@@ -326,92 +322,20 @@ func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseR
|
||||
reasons = append(reasons, fmt.Sprintf("Content-Type mismatch: local %s, upstream %s", localCT, upstreamCT))
|
||||
}
|
||||
|
||||
// Compare bodies
|
||||
// Basic body comparison (could be improved with XML semantic diff)
|
||||
localBody := local.body.Bytes()
|
||||
upstreamBody := upstream.body.Bytes()
|
||||
|
||||
if !bytes.Equal(localBody, upstreamBody) {
|
||||
// If both are XML, try a whitespace-insensitive comparison
|
||||
isXML := (strings.Contains(localCT, "/xml") || strings.Contains(localCT, "+xml")) &&
|
||||
(strings.Contains(upstreamCT, "/xml") || strings.Contains(upstreamCT, "+xml"))
|
||||
mismatch = true
|
||||
|
||||
if isXML {
|
||||
if !s.compareXMLWhitespaceInsensitive(localBody, upstreamBody) {
|
||||
mismatch = true
|
||||
|
||||
reasons = append(reasons, "Body content mismatch (XML)")
|
||||
}
|
||||
} else {
|
||||
mismatch = true
|
||||
|
||||
reasons = append(reasons, "Body content mismatch")
|
||||
}
|
||||
reasons = append(reasons, "Body content mismatch")
|
||||
}
|
||||
|
||||
if mismatch {
|
||||
log.Printf("[PARITY] Mismatch detected for %s %s: %v", req.Method, req.URL.Path, reasons)
|
||||
s.saveParityMismatch(req, local, upstream, reasons)
|
||||
}
|
||||
|
||||
// Trigger synchronization if this is a /full response from upstream
|
||||
if strings.Contains(req.URL.Path, "/full") && upstream.status == http.StatusOK {
|
||||
var resp models.AccountFullResponse
|
||||
if err := xml.Unmarshal(upstream.body.Bytes(), &resp); err == nil {
|
||||
log.Printf("[MIRROR] Triggering sync from upstream /full response for %s", req.URL.Path)
|
||||
marge.LogSyncDiff(s.ds, &resp)
|
||||
|
||||
if err = marge.SyncFromAccountFull(s.ds, &resp); err != nil {
|
||||
log.Printf("[MIRROR_ERR] Failed to sync from upstream /full: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[MIRROR_ERR] Failed to unmarshal upstream /full response: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compareXMLWhitespaceInsensitive compares two XML bodies ignoring whitespace between elements.
|
||||
func (s *Server) compareXMLWhitespaceInsensitive(local, upstream []byte) bool {
|
||||
clean := func(b []byte) string {
|
||||
s := string(b)
|
||||
// Remove XML declaration for easier comparison
|
||||
if strings.HasPrefix(s, "<?xml") {
|
||||
if idx := strings.Index(s, "?>"); idx != -1 {
|
||||
s = s[idx+2:]
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize whitespace:
|
||||
// 1. Remove all whitespace between elements (i.e., between > and <)
|
||||
// 2. Trim surrounding whitespace
|
||||
var result strings.Builder
|
||||
|
||||
inTag := false
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '<':
|
||||
inTag = true
|
||||
|
||||
result.WriteByte(c)
|
||||
case c == '>':
|
||||
inTag = false
|
||||
|
||||
result.WriteByte(c)
|
||||
case inTag:
|
||||
result.WriteByte(c)
|
||||
default:
|
||||
// We are between tags, only add if not whitespace
|
||||
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
|
||||
result.WriteByte(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
return clean(local) == clean(upstream)
|
||||
}
|
||||
|
||||
func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorResponseRecorder, reasons []string) {
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestMirrorMiddleware_PreferredSource(t *testing.T) {
|
||||
defer upstreamServer.Close()
|
||||
|
||||
// 3. Setup our server with MirrorMiddleware
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false)
|
||||
server.SetMirrorSettings(true, []string{"/test/local"}, "local")
|
||||
|
||||
// We need to trick performMirror to use our mock upstream.
|
||||
@@ -119,7 +119,7 @@ func TestSettingsAPI_PreferredSource(t *testing.T) {
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false)
|
||||
|
||||
// Test GET initial
|
||||
req := httptest.NewRequest("GET", "/setup/settings", nil)
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestParityMismatchReproduction_New(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-parity-reproduce-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
deviceID := "A81B6A536A98"
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Upstream example payload for POST /recent
|
||||
payload := `
|
||||
<recent>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
|
||||
<location>/v1/playback/station/s104811</location>
|
||||
<name>1LIVE Chillout</name>
|
||||
<source id="14774275" type="Audio">
|
||||
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
|
||||
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
|
||||
<name></name>
|
||||
<sourceproviderid>25</sourceproviderid>
|
||||
<sourcename></sourcename>
|
||||
<sourceSettings/>
|
||||
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
|
||||
<username></username>
|
||||
</source>
|
||||
<sourceid>14774275</sourceid>
|
||||
<updatedOn>2026-03-14T12:50:14.221+00:00</updatedOn>
|
||||
</recent>`
|
||||
|
||||
t.Run("POST /recent should learn source details and respond with parity", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Expected status 201, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
t.Logf("Response Body:\n%s\n", bodyStr)
|
||||
|
||||
// Verification points:
|
||||
// 1. Standalone="yes"
|
||||
if !strings.Contains(bodyStr, `standalone="yes"`) {
|
||||
t.Errorf("Missing standalone=\"yes\"")
|
||||
}
|
||||
|
||||
// 2. Millisecond precision in dates
|
||||
if !strings.Contains(bodyStr, ".000+00:00") && !strings.Contains(bodyStr, ".221+00:00") {
|
||||
// Note: FormatTime always uses .000+00:00 for now, but it's acceptable.
|
||||
// The key is it MUST have milliseconds and +00:00 offset.
|
||||
t.Errorf("Date format mismatch, expected .000+00:00. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
// 3. SourceProviderID learned (25)
|
||||
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
|
||||
t.Errorf("SourceProviderID was not learned from POST, expected 25. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
// 4. Credential learned
|
||||
if !strings.Contains(bodyStr, "eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=") {
|
||||
t.Errorf("Credential was not learned from POST. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
// 5. SourceSettings self-closing
|
||||
if !strings.Contains(bodyStr, "<sourceSettings/>") {
|
||||
t.Errorf("SourceSettings should be self-closing <sourceSettings/>. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
// 6. Source CreatedOn/UpdatedOn learned
|
||||
if !strings.Contains(bodyStr, "<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>") {
|
||||
t.Errorf("Source CreatedOn was not learned from POST. Body: %s", bodyStr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Subsequent GET /recents should also show learned source details", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
|
||||
t.Errorf("GET /recents missing learned sourceproviderid 25. Body: %s", bodyStr)
|
||||
}
|
||||
if !strings.Contains(bodyStr, "<sourceSettings/>") {
|
||||
t.Errorf("GET /recents missing self-closing sourceSettings. Body: %s", bodyStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestParityMismatchReproduction_V2(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-parity-repro-v2-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
deviceID := "A81B6A536A98"
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("POST /recent parity with upstream example", func(t *testing.T) {
|
||||
payload := `
|
||||
<recent>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
|
||||
<location>/v1/playback/station/s104811</location>
|
||||
<name>1LIVE Chillout</name>
|
||||
<source id="14774275" type="Audio">
|
||||
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
|
||||
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
|
||||
<name></name>
|
||||
<sourceproviderid>25</sourceproviderid>
|
||||
<sourcename></sourcename>
|
||||
<sourceSettings></sourceSettings>
|
||||
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
|
||||
<username></username>
|
||||
</source>
|
||||
<sourceid>14774275</sourceid>
|
||||
</recent>`
|
||||
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if !strings.HasPrefix(bodyStr, constants.XMLHeader) {
|
||||
t.Errorf("Missing or incorrect XML declaration: %s", bodyStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, `id="`) {
|
||||
t.Errorf("Missing recent id attribute")
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, ".000+00:00") {
|
||||
t.Errorf("Date format mismatch. Expected .000+00:00. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
|
||||
t.Errorf("sourceproviderid mismatch. Expected 25. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, "eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=") {
|
||||
t.Errorf("Credential value mismatch. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, "<sourceSettings/>") {
|
||||
t.Errorf("sourceSettings should be self-closing <sourceSettings/>. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, "<sourcename></sourcename>") {
|
||||
t.Errorf("sourcename should be empty. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(bodyStr, "<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>") {
|
||||
t.Errorf("lastplayedat mismatch. Body: %s", bodyStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestParityMismatchReproduction_V3(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "marge-test")
|
||||
defer os.RemoveAll(tempDir)
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Upstream input for POST /recent (extracted from user description)
|
||||
// We'll use the same source metadata as provided in the upstream response
|
||||
// to see if we can "learn" it and echo it back correctly.
|
||||
requestBody := `
|
||||
<recent>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
<location>/v1/playback/station/s104811</location>
|
||||
<name>1LIVE Chillout</name>
|
||||
<source id="14774275" type="Audio">
|
||||
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
|
||||
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
|
||||
<sourceproviderid>25</sourceproviderid>
|
||||
<sourcename></sourcename>
|
||||
<sourceSettings/>
|
||||
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
|
||||
</source>
|
||||
<sourceid>14774275</sourceid>
|
||||
</recent>`
|
||||
|
||||
account := "3230304"
|
||||
device := "A81B6A536A98"
|
||||
url := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device)
|
||||
|
||||
t.Run("POST /recent and check parity", func(t *testing.T) {
|
||||
res, err := http.Post(url, "application/xml", strings.NewReader(requestBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if !strings.Contains(bodyStr, constants.XMLHeader) {
|
||||
t.Error("Missing XML declaration with standalone=\"yes\"")
|
||||
}
|
||||
|
||||
// 2. Large ID (YYMMDDxxx format)
|
||||
prefix := time.Now().UTC().Format("060102")
|
||||
if !strings.Contains(bodyStr, fmt.Sprintf(`id="%s`, prefix)) {
|
||||
t.Errorf("Recent ID missing expected prefix %s. Body: %s", prefix, bodyStr)
|
||||
}
|
||||
|
||||
// 3. Date Formatting (.000+00:00)
|
||||
if !strings.Contains(bodyStr, `.000+00:00`) {
|
||||
t.Error("Dates are missing milliseconds or incorrect offset")
|
||||
}
|
||||
|
||||
// 4. Source Learning
|
||||
// Check for provider ID 25
|
||||
if !strings.Contains(bodyStr, `<sourceproviderid>25</sourceproviderid>`) {
|
||||
t.Error("Source provider ID mismatch: expected 25 for TuneIn")
|
||||
}
|
||||
// Check for credential
|
||||
if !strings.Contains(bodyStr, `eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=`) {
|
||||
t.Error("Credential value was not preserved")
|
||||
}
|
||||
// Check for empty sourcename
|
||||
if !strings.Contains(bodyStr, `<sourcename></sourcename>`) {
|
||||
t.Error("sourcename should be empty for TuneIn")
|
||||
}
|
||||
|
||||
// 5. Self-closing SourceSettings
|
||||
if !strings.Contains(bodyStr, `<sourceSettings/>`) {
|
||||
t.Error("sourceSettings should be self-closing")
|
||||
}
|
||||
|
||||
// 6. Indentation check (2 spaces)
|
||||
if !strings.Contains(bodyStr, "\n <contentItemType>") {
|
||||
t.Error("Incorrect indentation: expected 2 spaces")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Verify GET /recents consistency", func(t *testing.T) {
|
||||
recentsUrl := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device)
|
||||
res, err := http.Get(recentsUrl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
t.Logf("GET /recents Local Response:\n%s\n", bodyStr)
|
||||
|
||||
if !strings.Contains(bodyStr, `<sourceproviderid>25</sourceproviderid>`) {
|
||||
t.Error("Source provider ID missing in GET /recents")
|
||||
}
|
||||
if !strings.Contains(bodyStr, `<sourceSettings/>`) {
|
||||
t.Error("sourceSettings should be self-closing in GET /recents")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestXMLWhitespaceInsensitivity(t *testing.T) {
|
||||
s := &Server{}
|
||||
local := []byte(constants.XMLHeader + `
|
||||
<recent id="123">
|
||||
<name>Test</name>
|
||||
</recent>`)
|
||||
upstream := []byte(constants.XMLHeader + `
|
||||
<recent id="123">
|
||||
<name>Test</name>
|
||||
</recent>`)
|
||||
|
||||
if !s.compareXMLWhitespaceInsensitive(local, upstream) {
|
||||
t.Error("compareXMLWhitespaceInsensitive failed for simple whitespace difference")
|
||||
}
|
||||
|
||||
upstreamNoSpaces := []byte(constants.XMLHeader + `<recent id="123"><name>Test</name></recent>`)
|
||||
if !s.compareXMLWhitespaceInsensitive(local, upstreamNoSpaces) {
|
||||
t.Error("compareXMLWhitespaceInsensitive failed for no-whitespace upstream")
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestMargeParityRegressions(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-parity-regressions-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
deviceID := "A81B6A536A98"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// Mock Sources.xml matching the upstream example (source id 14774275)
|
||||
// One with "Other" and one with a specific name.
|
||||
sourcesXML := `
|
||||
<sources>
|
||||
<source id="14774275" displayName="Other" secret="" secretType="Audio">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
<source id="SPOT1" displayName="My Spotify" secret="token123" secretType="Audio">
|
||||
<sourceKey type="SPOTIFY" account="user123"/>
|
||||
</source>
|
||||
</sources>`
|
||||
os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
|
||||
os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte("<recents></recents>"), 0644)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("POST recent with Other source - sourcename should be empty", func(t *testing.T) {
|
||||
payload := `
|
||||
<recent>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
|
||||
<location>/v1/playback/station/s104811</location>
|
||||
<name>1LIVE Chillout</name>
|
||||
<sourceid>14774275</sourceid>
|
||||
</recent>`
|
||||
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Check for standalone="yes"
|
||||
if !strings.Contains(bodyStr, `standalone="yes"`) {
|
||||
t.Errorf("Response missing standalone=\"yes\"")
|
||||
}
|
||||
|
||||
// Check for empty sourcename when it's "Other"
|
||||
if !strings.Contains(bodyStr, "<sourcename></sourcename>") && !strings.Contains(bodyStr, "<sourcename/>") {
|
||||
t.Errorf("Expected empty sourcename for 'Other' source, but got something else or missing. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
// Check for date format (should have .000+00:00)
|
||||
if !strings.Contains(bodyStr, ".000+00:00") {
|
||||
t.Errorf("Response date format mismatch, expected .000+00:00. Body: %s", bodyStr)
|
||||
}
|
||||
|
||||
// Check for sourceSettings presence
|
||||
if !strings.Contains(bodyStr, "<sourceSettings>") && !strings.Contains(bodyStr, "<sourceSettings/>") {
|
||||
t.Errorf("Response missing sourceSettings element. Body: %s", bodyStr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST recent with named source - sourcename should be preserved", func(t *testing.T) {
|
||||
payload := `
|
||||
<recent>
|
||||
<contentItemType>track</contentItemType>
|
||||
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
|
||||
<location>spotify:track:123</location>
|
||||
<name>Test Song</name>
|
||||
<sourceid>SPOT1</sourceid>
|
||||
</recent>`
|
||||
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if !strings.Contains(bodyStr, "<sourcename>My Spotify</sourcename>") {
|
||||
t.Errorf("Expected sourcename 'My Spotify', body: %s", bodyStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestMargeRecentConsistencyAndIDParity(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-recent-parity-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
deviceID := "A81B6A536A98"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("POST recent creates consistent IDs and persists unknown sources", func(t *testing.T) {
|
||||
payload := `
|
||||
<recent>
|
||||
<contentItemType>tracklisturl</contentItemType>
|
||||
<lastplayedat>2026-03-14T21:33:22.000+00:00</lastplayedat>
|
||||
<location>/playback/container/c3BvdGlmeTphbGJ1bTo3RjUwdWg3b0dpdG1BRVNjUktWNnBE</location>
|
||||
<name>Terminal Caribe</name>
|
||||
<sourceid>10863533</sourceid>
|
||||
</recent>`
|
||||
|
||||
// 1. POST /recent
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Expected status 201, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
postBody, _ := io.ReadAll(res.Body)
|
||||
postBodyStr := string(postBody)
|
||||
|
||||
// Verify ID format: YYMMDDXXX (9 digits)
|
||||
// Today's prefix:
|
||||
prefix := time.Now().UTC().Format("060102")
|
||||
idPattern := fmt.Sprintf(`id="%s`, prefix)
|
||||
if !strings.Contains(postBodyStr, idPattern) {
|
||||
t.Errorf("Response ID missing expected prefix %s. Body: %s", prefix, postBodyStr)
|
||||
}
|
||||
|
||||
// Extract ID
|
||||
startIdx := strings.Index(postBodyStr, `id="`) + 4
|
||||
endIdx := strings.Index(postBodyStr[startIdx:], `"`) + startIdx
|
||||
recentID := postBodyStr[startIdx:endIdx]
|
||||
|
||||
idInt, err := strconv.Atoi(recentID)
|
||||
if err != nil {
|
||||
t.Errorf("Recent ID is not an integer: %s", recentID)
|
||||
} else if idInt > 2147483647 {
|
||||
t.Errorf("Recent ID exceeds 32-bit signed integer range: %d", idInt)
|
||||
}
|
||||
|
||||
// 2. GET /recents
|
||||
res2, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
|
||||
getRecentsBody, _ := io.ReadAll(res2.Body)
|
||||
getRecentsStr := string(getRecentsBody)
|
||||
|
||||
// 3. Verify consistency
|
||||
// Use a whitespace-insensitive comparison
|
||||
clean := func(s string) string {
|
||||
if strings.HasPrefix(s, "<?xml") {
|
||||
if idx := strings.Index(s, "?>"); idx != -1 {
|
||||
s = s[idx+2:]
|
||||
}
|
||||
}
|
||||
var result strings.Builder
|
||||
inTag := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c == '<' {
|
||||
inTag = true
|
||||
result.WriteByte(c)
|
||||
} else if c == '>' {
|
||||
inTag = false
|
||||
result.WriteByte(c)
|
||||
} else if inTag {
|
||||
result.WriteByte(c)
|
||||
} else {
|
||||
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
|
||||
result.WriteByte(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
if !strings.Contains(clean(getRecentsStr), clean(postBodyStr)) {
|
||||
t.Errorf("GET /recents does not contain the same XML as POST /recent response.\nPOST: %s\nGET: %s", postBodyStr, getRecentsStr)
|
||||
}
|
||||
|
||||
// 4. Verify source persistence
|
||||
// Check if source 10863533 was learned and is now in Sources.xml
|
||||
sources, err := ds.GetConfiguredSources(account, deviceID)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get configured sources: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range sources {
|
||||
if s.ID == "10863533" {
|
||||
found = true
|
||||
if s.SourceKeyType != "SPOTIFY" {
|
||||
t.Errorf("Learned source should be SPOTIFY based on location, got %s", s.SourceKeyType)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Source 10863533 was not learned and persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
// RecordMiddleware returns a middleware that records "self" requests and responses.
|
||||
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil || !s.recordEnabled {
|
||||
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -52,6 +53,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// Create a response object for the recorder
|
||||
res := rw.getRecordedResponse(r)
|
||||
if res.StatusCode >= 400 {
|
||||
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
}
|
||||
@@ -65,8 +70,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Header() http.Header {
|
||||
@@ -74,18 +80,28 @@ func (rw *responseWriter) Header() http.Header {
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
rw.statusCode = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
rw.body.Write(b)
|
||||
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
|
||||
statusCode := rw.statusCode
|
||||
if statusCode == 0 {
|
||||
if !rw.wroteHeader && statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/migration"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
type Server struct {
|
||||
ds *datastore.DataStore
|
||||
sm *setup.Manager
|
||||
migrationManager *migration.Manager
|
||||
mu sync.RWMutex
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
@@ -79,10 +81,17 @@ var bufferPool = sync.Pool{
|
||||
}
|
||||
|
||||
// NewServer creates a new SoundTouch service server.
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled bool) *Server {
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, migrationEnabled, migrationDryRun bool) *Server {
|
||||
// Initialize migration manager
|
||||
migrationConfig := migration.Config{
|
||||
Enabled: migrationEnabled,
|
||||
DryRun: migrationDryRun,
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
ds: ds,
|
||||
sm: sm,
|
||||
migrationManager: migration.NewManager(ds, migrationConfig),
|
||||
serverURL: serverURL,
|
||||
proxyRedact: proxyRedact,
|
||||
proxyLogBody: proxyLogBody,
|
||||
@@ -559,7 +568,21 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Save the updated device info
|
||||
// 7. Check for existing device entries that need migration
|
||||
log.Printf("Checking for existing device variants to migrate for device %s (MAC: %s)", liveInfo.Name, deviceID)
|
||||
|
||||
existingDevices := s.findAllExistingDeviceVariants(d, liveInfo)
|
||||
if len(existingDevices) == 0 {
|
||||
log.Printf("No existing device variants found for migration")
|
||||
}
|
||||
|
||||
// Use migration manager to handle device directory migration
|
||||
migrated := s.migrationManager.MigrateDevicesIfNeeded(existingDevices, deviceID)
|
||||
if !migrated {
|
||||
log.Printf("Device %s: no migration needed (already uses correct MAC-based ID %s)", liveInfo.Name, deviceID)
|
||||
}
|
||||
|
||||
// 8. Save the updated device info
|
||||
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
|
||||
log.Printf("Failed to save device info for %s: %v", deviceID, err)
|
||||
return
|
||||
@@ -568,6 +591,11 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
|
||||
log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", info.Name, d.Host, deviceID)
|
||||
}
|
||||
|
||||
// GetMigrationStats returns migration statistics for debugging/monitoring
|
||||
func (s *Server) GetMigrationStats() migration.Stats {
|
||||
return s.migrationManager.GetStats()
|
||||
}
|
||||
|
||||
// handleDiscoveredDeviceFallback handles device discovery when /info endpoint is not available
|
||||
func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) {
|
||||
log.Printf("Using fallback discovery method for device: %s at %s", d.Name, d.Host)
|
||||
@@ -687,6 +715,98 @@ func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// findAllExistingDeviceVariants finds all existing device entries that could represent the same physical device
|
||||
func (s *Server) findAllExistingDeviceVariants(d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo {
|
||||
// log.Printf("Searching for existing device variants with criteria:")
|
||||
// log.Printf(" Discovery IP: %s", d.Host)
|
||||
// log.Printf(" Discovery Serial: %s", d.SerialNo)
|
||||
// log.Printf(" Live Info Serial: %s", liveInfo.SerialNumber)
|
||||
// log.Printf(" Live Info Name: %s", liveInfo.Name)
|
||||
// log.Printf(" Live Info MAC: %s", liveInfo.GetPrimaryMacAddress())
|
||||
// log.Printf(" Live Info Product: %s %s", liveInfo.Type, liveInfo.ModuleType)
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var matches []models.ServiceDeviceInfo
|
||||
|
||||
seenDeviceIDs := make(map[string]bool)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
if seenDeviceIDs[device.DeviceID] {
|
||||
continue
|
||||
}
|
||||
|
||||
matchReason := s.getMatchReason(*device, d, liveInfo)
|
||||
if matchReason != "" {
|
||||
matches = append(matches, *device)
|
||||
seenDeviceIDs[device.DeviceID] = true
|
||||
log.Printf(" ✓ Found variant %s: %s", device.DeviceID, matchReason)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
log.Printf(" No existing device variants found")
|
||||
} else {
|
||||
log.Printf("Found %d existing device variant(s) for %s:", len(matches), liveInfo.Name)
|
||||
|
||||
for i := range matches {
|
||||
match := &matches[i]
|
||||
log.Printf(" - %s (Account: %s, IP: %s, Serial: %s, MAC: %s, Product: %s)",
|
||||
match.DeviceID, match.AccountID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.ProductCode)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func (s *Server) getMatchReason(device models.ServiceDeviceInfo, d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) string {
|
||||
// 1. Same IP address
|
||||
if d.Host != "" && device.IPAddress == d.Host {
|
||||
return fmt.Sprintf("IP address match (%s == %s)", d.Host, device.IPAddress)
|
||||
}
|
||||
|
||||
// 2. Same UPnP serial number
|
||||
if d.SerialNo != "" && (device.DeviceID == d.SerialNo || device.DeviceSerialNumber == d.SerialNo) {
|
||||
if device.DeviceID == d.SerialNo {
|
||||
return "UPnP serial as DeviceID"
|
||||
}
|
||||
|
||||
return "UPnP serial in DeviceSerialNumber"
|
||||
}
|
||||
|
||||
// 3. Same device serial number from /info
|
||||
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
|
||||
return fmt.Sprintf("device serial number match (%s)", liveInfo.SerialNumber)
|
||||
}
|
||||
|
||||
// 4. Same MAC address (if device already has one stored)
|
||||
primaryMAC := liveInfo.GetPrimaryMacAddress()
|
||||
if primaryMAC != "" && device.MacAddress == primaryMAC {
|
||||
return fmt.Sprintf("MAC address match (%s)", primaryMAC)
|
||||
}
|
||||
|
||||
// 5. Same device name and similar product (fuzzy match for renamed devices)
|
||||
if liveInfo.Name != "" && device.Name == liveInfo.Name {
|
||||
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
|
||||
if device.ProductCode == expectedProduct ||
|
||||
device.ProductCode == liveInfo.Type ||
|
||||
strings.Contains(device.ProductCode, liveInfo.Type) ||
|
||||
strings.Contains(expectedProduct, device.ProductCode) {
|
||||
return fmt.Sprintf("name and product match (name: %s, product: %s)", liveInfo.Name, device.ProductCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. DeviceID matches component serial (device was stored by serial before)
|
||||
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
|
||||
return fmt.Sprintf("DeviceID matches component serial (%s)", liveInfo.SerialNumber)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo {
|
||||
allDevices, _ := s.ds.ListAllDevices()
|
||||
for i := range allDevices {
|
||||
@@ -720,3 +840,23 @@ func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
|
||||
|
||||
return "", fmt.Errorf("device not found: %s", deviceID)
|
||||
}
|
||||
|
||||
// lookupIP resolves a deviceId to its last known device IP.
|
||||
func (s *Server) lookupIP(deviceId string) (string, error) {
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceId {
|
||||
if devices[i].IPAddress == "" {
|
||||
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
|
||||
}
|
||||
|
||||
return devices[i].IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("deviceId %s not found", deviceId)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestMergeOverlappingDevices(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
|
||||
// Case 1: IP-only entry and Serial-based entry for the same IP
|
||||
ip := "192.168.1.100"
|
||||
@@ -74,7 +74,7 @@ func TestFindExistingDeviceID(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
|
||||
|
||||
ip := "192.168.1.101"
|
||||
serial := "SERIAL456"
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestSnapshotIntegrity_SelfAndMirror(t *testing.T) {
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
recorder := proxy.NewRecorder(tempDir)
|
||||
s := NewServer(ds, nil, "http://localhost:8000", false, false, true)
|
||||
s := NewServer(ds, nil, "http://localhost:8000", false, false, true, false, false)
|
||||
s.SetRecorder(recorder)
|
||||
s.SetMirrorSettings(true, []string{"/mirror/*"}, "local")
|
||||
|
||||
|
||||
+86
-1433
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AfterTouch (SoundTouch Toolkit)</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="../shared/common.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
<p style="margin-bottom: 20px;"><a href="/">← Back to selection</a></p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
<div id="tab-overview" class="tab-content active">
|
||||
<h2>Welcome to AfterTouch</h2>
|
||||
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
|
||||
|
||||
<h3>Migration Process at a Glance</h3>
|
||||
<div class="info-box prerequisite-box">
|
||||
<strong>🔌 Prerequisite: Enable SSH</strong><br>
|
||||
Migration requires SSH access. To enable it:
|
||||
<ol style="margin-top: 5px; margin-bottom: 5px;">
|
||||
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
|
||||
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
|
||||
</ol>
|
||||
<strong>Verify connection:</strong>
|
||||
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
||||
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
|
||||
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP></code> (no password).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
|
||||
Ensure your speakers are powered on and connected to the same network.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
|
||||
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
|
||||
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="info-box safety-box">
|
||||
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
|
||||
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
|
||||
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
|
||||
</div>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
|
||||
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Settings -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<h2>System Settings</h2>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
|
||||
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
|
||||
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
|
||||
rather than <code>localhost</code>.
|
||||
</p>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="soundcork-url">Soundcork URL:</label>
|
||||
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>DNS Discovery:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-bottom: 5px;">
|
||||
<label for="dns-upstream">Upstream DNS:</label>
|
||||
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
|
||||
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
|
||||
</div>
|
||||
<div style="margin-left: 20px;">
|
||||
<label for="dns-bind">DNS Bind Address:</label>
|
||||
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
|
||||
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Proxy Logging:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
|
||||
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Devices -->
|
||||
<div id="tab-devices" class="tab-content">
|
||||
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="triggerDiscovery()">Scan Again</button>
|
||||
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
|
||||
<button onclick="addManualDevice()">Add Device</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Data Sync -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<h2>Initial Data Sync</h2>
|
||||
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
|
||||
<div class="device-selection">
|
||||
<label for="sync-device-list">Device:</label>
|
||||
<select id="sync-device-list">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
<button id="sync-now-btn">Start Sync</button>
|
||||
</div>
|
||||
<div id="sync-status" class="status"></div>
|
||||
<div id="sync-results" style="margin-top: 20px; display: none;">
|
||||
<h3>Sync Results</h3>
|
||||
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Migration -->
|
||||
<div id="tab-migration" class="tab-content">
|
||||
<h2>Device Migration</h2>
|
||||
<div class="device-selection">
|
||||
<label for="migration-device-list">Device:</label>
|
||||
<select id="migration-device-list" onchange="showSummary(this.value)">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
|
||||
<h3>Command Output</h3>
|
||||
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
|
||||
</div>
|
||||
|
||||
<div id="migration-summary" class="summary-box" style="display: none;">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
<p>SSH Connection: <span id="ssh-status"></span></p>
|
||||
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
|
||||
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
|
||||
|
||||
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
|
||||
<strong>HTTPS Connection Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
URL: <code id="test-url"></code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
|
||||
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
|
||||
</div>
|
||||
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
|
||||
<strong>Preliminary /etc/hosts Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>custom-test-api.bose.fake</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
|
||||
</div>
|
||||
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
|
||||
<strong>Preliminary DNS Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>aftertouch.test</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
|
||||
</div>
|
||||
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
|
||||
<label for="migration-method"><strong>Migration Method:</strong></label>
|
||||
<select id="migration-method" onchange="toggleMigrationMethod()">
|
||||
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
|
||||
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
|
||||
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
|
||||
</select>
|
||||
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Current /etc/resolv.conf</span>
|
||||
<pre id="current-resolv-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div id="xml-diff-pane" class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div id="planned-xml-pane" class="diff-pane">
|
||||
<span class="config-header">Planned Config (AfterTouch)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/hosts Entries</span>
|
||||
<pre id="planned-hosts"></pre>
|
||||
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
|
||||
</div>
|
||||
</div>
|
||||
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/resolv.conf Hook</span>
|
||||
<pre id="planned-resolv"></pre>
|
||||
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
|
||||
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
|
||||
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 5: Interactions & Events -->
|
||||
<div id="tab-interactions" class="tab-content">
|
||||
<h2>Recorded Interactions & Device Events</h2>
|
||||
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
|
||||
|
||||
<div id="interaction-stats-container" class="summary-box">
|
||||
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
|
||||
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
|
||||
<button onclick="fetchInteractionStats()">Refresh Stats</button>
|
||||
<div style="margin-left: 10px;">
|
||||
<button onclick="showDeviceEvents()">View App/Device Events</button>
|
||||
</div>
|
||||
<div style="margin-left: auto; text-align: right;">
|
||||
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
|
||||
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
|
||||
<h3>By Service</h3>
|
||||
<ul id="stats-by-service" class="stats-list"></ul>
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<h3>Sessions</h3>
|
||||
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
|
||||
<ul id="stats-by-session" class="stats-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Browse Recordings</h3>
|
||||
</div>
|
||||
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
|
||||
<div>
|
||||
<label for="filter-session">Session:</label>
|
||||
<select id="filter-session" onchange="fetchInteractions()">
|
||||
<option value="">All Sessions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" onchange="fetchInteractions()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="self">Self (Emulated)</option>
|
||||
<option value="upstream">Upstream (Bose)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
|
||||
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
|
||||
</div>
|
||||
<button onclick="fetchInteractions()">Apply Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">#</th>
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Status</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interactions-list">
|
||||
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">DNS Discoveries</h3>
|
||||
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
|
||||
</div>
|
||||
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
|
||||
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Hostname</th>
|
||||
<th style="padding: 8px;">Last Seen</th>
|
||||
<th style="padding: 8px; text-align: center;">Queries</th>
|
||||
<th style="padding: 8px; text-align: center;">Bose?</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Last Client IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-discoveries-list">
|
||||
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
|
||||
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
|
||||
</div>
|
||||
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Device Events Overlay -->
|
||||
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">App & Device Events</h3>
|
||||
<div>
|
||||
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
|
||||
<option value="">-- Select Device --</option>
|
||||
</select>
|
||||
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Type</th>
|
||||
<th style="padding: 8px;">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-list">
|
||||
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../shared/common.js"></script>
|
||||
<script src="script.js"></script>
|
||||
<footer style="margin-top: 50px;">
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+19
-17
@@ -251,8 +251,9 @@ async function updateSettings() {
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch("/setup/devices");
|
||||
const response = await fetch("/devices");
|
||||
const devices = await response.json();
|
||||
window._knownDevices = devices; // Store globally for easy lookup
|
||||
const container = document.getElementById("device-list");
|
||||
const syncSelector = document.getElementById("sync-device-list");
|
||||
const migrationSelector = document.getElementById("migration-device-list");
|
||||
@@ -430,7 +431,7 @@ async function startSync() {
|
||||
log.innerHTML = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/sync", {method: "POST"},);
|
||||
if (response.ok) {
|
||||
status.style.backgroundColor = "#dfd";
|
||||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||||
@@ -927,7 +928,7 @@ async function fetchDeviceEvents(deviceId) {
|
||||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}/events`);
|
||||
const response = await fetch(`/devices/${deviceId}/events`);
|
||||
const data = await response.json();
|
||||
const events = data.events;
|
||||
|
||||
@@ -1146,7 +1147,7 @@ async function addManualDevice() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/devices", {
|
||||
const response = await fetch("/devices", {
|
||||
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ip: ip}),
|
||||
});
|
||||
|
||||
@@ -1168,7 +1169,7 @@ async function removeDevice(deviceId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}`, {
|
||||
const response = await fetch(`/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -1214,7 +1215,7 @@ async function pollDiscoveryStatus() {
|
||||
|
||||
async function updateDeviceInfo(deviceId, ip) {
|
||||
try {
|
||||
const response = await fetch("/setup/info/" + encodeURIComponent(deviceId));
|
||||
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/info");
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
@@ -1274,7 +1275,7 @@ async function showSummary(deviceId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/summary/" + encodeURIComponent(deviceId) + query,);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/summary" + query,);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
@@ -1459,7 +1460,7 @@ async function revert(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Reverting " + display + " to defaults...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/revert", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1491,7 +1492,7 @@ async function reboot(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Rebooting " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/reboot/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/devices/" + encodeURIComponent(deviceId) + "/reboot", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1537,7 +1538,7 @@ async function migrate(deviceId, ip) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/migrate" + query, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1549,6 +1550,7 @@ async function migrate(deviceId, ip) {
|
||||
rebootBtn.style.display = "inline-block";
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = "2px solid #000";
|
||||
rebootBtn.onclick = () => reboot(deviceId);
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
@@ -1574,7 +1576,7 @@ async function trustCA(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Injecting Root CA into shared trust store on " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/trust-ca", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1606,7 +1608,7 @@ async function ensureRemoteServices(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Ensuring remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/ensure-remote-services", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1640,7 +1642,7 @@ async function removeRemoteServices(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Removing remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/remove-remote-services", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1668,7 +1670,7 @@ async function backupConfig(deviceId, ip) {
|
||||
statusDiv.innerHTML = "Creating backup for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const response = await fetch("/setup/devices/" + encodeURIComponent(deviceId) + "/backup", {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
@@ -1697,7 +1699,7 @@ async function testConnection(deviceId, useExplicitCA) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
|
||||
const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1725,7 +1727,7 @@ async function testHostsRedirection(deviceId) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-hosts${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1753,7 +1755,7 @@ async function testDNSRedirection(deviceId) {
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||||
const response = await fetch(`/setup/devices/${encodeURIComponent(deviceId)}/test-dns${query}`, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -0,0 +1,16 @@
|
||||
footer {
|
||||
margin-top: 50px;
|
||||
padding: 20px;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#version-info a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#version-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/version');
|
||||
const data = await response.json();
|
||||
const info = document.getElementById('version-info');
|
||||
if (info && data.version) {
|
||||
const version = data.version;
|
||||
const commit = data.commit;
|
||||
const isDirty = version.includes('dirty');
|
||||
const releaseUrl = isDirty
|
||||
? 'https://github.com/gesellix/Bose-SoundTouch/releases'
|
||||
: `https://github.com/gesellix/Bose-SoundTouch/releases/tag/v${version}`;
|
||||
const commitUrl = `https://github.com/gesellix/Bose-SoundTouch/commit/${commit}`;
|
||||
const projectUrl = 'https://gesellix.github.io/Bose-SoundTouch/';
|
||||
|
||||
info.innerHTML = `<a href="${projectUrl}" target="_blank" style="color: inherit; text-decoration: none;">AfterTouch</a> ` +
|
||||
`<a href="${releaseUrl}" target="_blank" style="color: inherit;">${version}</a> ` +
|
||||
`(<a href="${commitUrl}" target="_blank" style="color: inherit;">${commit}</a>) - ${data.date}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/devices');
|
||||
const devices = await response.json();
|
||||
const container = document.getElementById('device-list');
|
||||
const seen = new Set();
|
||||
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = '<p>No devices found. Ensure they are on the same network.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach(device => {
|
||||
seen.add(device.device_id);
|
||||
const existing = document.getElementById(`device-${device.device_id}`);
|
||||
if (existing) {
|
||||
// Update product code/IP if changed, but keep title if we already have a better name
|
||||
const title = existing.querySelector('.device-title');
|
||||
if (title && (!title.textContent || title.textContent === 'Unknown Device' || title.textContent.startsWith('SoundTouch-'))) {
|
||||
title.textContent = device.name || 'Unknown Device';
|
||||
}
|
||||
const subtitle = existing.querySelector('.device-subtitle span');
|
||||
if (subtitle) {
|
||||
const currentSubtitle = subtitle.textContent || '';
|
||||
const parts = currentSubtitle.split(' | ');
|
||||
const currentType = parts.length > 1 ? parts[1].trim() : '';
|
||||
const newType = device.product_code || 'Unknown';
|
||||
|
||||
// Don't downgrade type if we already have a specific one
|
||||
const isGeneric = !currentType || currentType === 'Unknown' || currentType === 'N/A';
|
||||
const displayType = isGeneric ? newType : currentType;
|
||||
subtitle.textContent = `${device.ip_address} | ${displayType}`;
|
||||
}
|
||||
const details = existing.querySelector(`#details-${device.device_id}`);
|
||||
if (details) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) {
|
||||
const currentId = idField.textContent;
|
||||
// Don't overwrite with serial if we have a real deviceID (usually hex)
|
||||
if (!currentId || currentId === 'N/A' || currentId === device.device_serial_number) {
|
||||
idField.textContent = device.device_id || 'N/A';
|
||||
}
|
||||
}
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) {
|
||||
const cur = firmwareField.textContent;
|
||||
if (!cur || cur === 'N/A' || cur === '0.0.0') {
|
||||
firmwareField.textContent = device.firmware_version || 'N/A';
|
||||
}
|
||||
}
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField && (!serialField.textContent || serialField.textContent === 'N/A')) {
|
||||
serialField.textContent = device.device_serial_number || 'N/A';
|
||||
}
|
||||
}
|
||||
// Ensure WS is open
|
||||
openDeviceWebSocket(device.device_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'device-card';
|
||||
card.id = `device-${device.device_id}`;
|
||||
card.innerHTML = `
|
||||
<div class="device-info">
|
||||
<div class="device-header">
|
||||
<div>
|
||||
<div class="device-title-row">
|
||||
<h2 class="device-title">${device.name || 'Unknown Device'}</h2>
|
||||
<button class="info-toggle" title="More info" onclick="toggleDetails('${device.device_id}')">i</button>
|
||||
</div>
|
||||
<p class="device-subtitle">
|
||||
<span>${device.ip_address} | ${device.product_code}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="power-icon" title="Power" aria-label="Power" onclick="control('${device.device_id}', 'POWER')"></button>
|
||||
</div>
|
||||
<div class="device-details" id="details-${device.device_id}">
|
||||
<p>ID: <code>${device.device_id}</code></p>
|
||||
<p>Firmware: <code>${device.firmware_version || 'N/A'}</code></p>
|
||||
<p>Serial: <code>${device.device_serial_number || 'N/A'}</code></p>
|
||||
<p>Discovery: <code>${device.discovery_method || 'N/A'}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now-playing" id="np-${device.device_id}">
|
||||
<p><em>Loading playback status...</em></p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="primary" onclick="control('${device.device_id}', 'PLAY')">Play</button>
|
||||
<button class="primary" onclick="control('${device.device_id}', 'PAUSE')">Pause</button>
|
||||
<button onclick="control('${device.device_id}', 'PREV_TRACK')">Prev</button>
|
||||
<button onclick="control('${device.device_id}', 'NEXT_TRACK')">Next</button>
|
||||
</div>
|
||||
<div class="volume-container">
|
||||
<span>Vol:</span>
|
||||
<input id="vol-${device.device_id}" type="range" min="0" max="100"
|
||||
oninput="onVolumeInput('${device.device_id}', this)"
|
||||
onmousedown="startAdjust('${device.device_id}')" ontouchstart="startAdjust('${device.device_id}')"
|
||||
onmouseup="endAdjust('${device.device_id}')" ontouchend="endAdjust('${device.device_id}')">
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
updateNowPlaying(device.device_id);
|
||||
updateVolume(device.device_id);
|
||||
openDeviceWebSocket(device.device_id);
|
||||
});
|
||||
|
||||
// Remove cards for devices that no longer exist
|
||||
Array.from(container.children).forEach(child => {
|
||||
const id = child.id?.replace('device-', '');
|
||||
if (id && !seen.has(id)) {
|
||||
container.removeChild(child);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch devices', error);
|
||||
document.getElementById('device-list').innerHTML = '<p>Error loading devices.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNowPlaying(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/devices/${deviceId}/info`);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
// Update device name and type if available (live info is more accurate than discovery)
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && info.name) {
|
||||
title.textContent = info.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && info.type) {
|
||||
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
|
||||
}
|
||||
|
||||
// Update firmware version if available
|
||||
const details = document.getElementById(`details-${deviceId}`);
|
||||
if (details) {
|
||||
if (info.deviceID) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) idField.textContent = info.deviceID;
|
||||
}
|
||||
if (info.softwareVersion) {
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) firmwareField.textContent = info.softwareVersion;
|
||||
}
|
||||
if (info.serialNumber) {
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField) serialField.textContent = info.serialNumber;
|
||||
}
|
||||
}
|
||||
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer && info.nowPlaying) {
|
||||
const np = info.nowPlaying;
|
||||
const source = np.source || np.Source;
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch now playing for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVolume(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/devices/${deviceId}/info`);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && info.volume && typeof info.volume.actualvolume === 'number' && !adjusting[deviceId]) {
|
||||
slider.value = String(info.volume.actualvolume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch volume for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function control(deviceId, key) {
|
||||
let deviceName = deviceId;
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && title.textContent) {
|
||||
deviceName = title.textContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/key/${encodeURIComponent(key)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Control failed', error);
|
||||
alert(`Failed to send ${key} to ${deviceName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setVolume(deviceId, level) {
|
||||
let deviceName = deviceId;
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && title.textContent) {
|
||||
deviceName = title.textContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/volume/${encodeURIComponent(level)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Set volume failed', error);
|
||||
alert(`Failed to set volume on ${deviceName} to ${level}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume interaction helpers to avoid UI jumping while dragging
|
||||
const adjusting = {};
|
||||
const volumeTimers = {};
|
||||
|
||||
function startAdjust(deviceId) {
|
||||
adjusting[deviceId] = true;
|
||||
}
|
||||
|
||||
function endAdjust(deviceId) {
|
||||
// Small delay to let the device send back its volume update
|
||||
setTimeout(() => { adjusting[deviceId] = false; }, 300);
|
||||
}
|
||||
|
||||
function onVolumeInput(deviceId, el) {
|
||||
startAdjust(deviceId);
|
||||
const level = el.value;
|
||||
// Debounce network calls per device
|
||||
if (volumeTimers[deviceId]) {
|
||||
clearTimeout(volumeTimers[deviceId]);
|
||||
}
|
||||
volumeTimers[deviceId] = setTimeout(() => {
|
||||
setVolume(deviceId, level);
|
||||
endAdjust(deviceId);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
let deviceSockets = {};
|
||||
|
||||
function openDeviceWebSocket(deviceId) {
|
||||
const key = `${deviceId}`;
|
||||
try {
|
||||
const existing = deviceSockets[key];
|
||||
if (existing) {
|
||||
// Reuse an already healthy connection instead of tearing it down every refresh
|
||||
if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
try { existing.close(); } catch (_) {}
|
||||
}
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${proto}://${location.host}/devices/${encodeURIComponent(deviceId)}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
deviceSockets[key] = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// console.log('WS connected for', deviceId);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
const type = msg.type;
|
||||
const payload = msg.payload || {};
|
||||
if (type === 'nowPlayingUpdated') {
|
||||
const e = payload;
|
||||
const np = e.NowPlaying || e.nowPlaying || {};
|
||||
const source = np.source || np.Source;
|
||||
|
||||
// Also try to update name/type if they are present in the event (sometimes events carry device info)
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && e.name) {
|
||||
title.textContent = e.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && e.type) {
|
||||
subtitle.textContent = `${e.ipAddress || e.ip_address || 'N/A'} | ${e.type}`;
|
||||
}
|
||||
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer) {
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} else if (type === 'volumeUpdated') {
|
||||
const e = payload;
|
||||
const vol = (e.Volume && (typeof e.Volume.actualvolume === 'number' ? e.Volume.actualvolume : (typeof e.Volume.actual === 'number' ? e.Volume.actual : e.Volume.target))) ||
|
||||
(e.volume && (typeof e.volume.actualvolume === 'number' ? e.volume.actualvolume : (typeof e.volume.actual === 'number' ? e.volume.actual : e.volume.target)));
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && typeof vol === 'number' && !adjusting[deviceId]) {
|
||||
slider.value = String(vol);
|
||||
}
|
||||
} else if (type === 'snapshotInfo') {
|
||||
const info = payload || {};
|
||||
|
||||
// Update name and type from snapshot
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && info.name) {
|
||||
title.textContent = info.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && info.type) {
|
||||
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
|
||||
}
|
||||
|
||||
// Update firmware and ID from snapshot if available
|
||||
const details = document.getElementById(`details-${deviceId}`);
|
||||
if (details) {
|
||||
if (info.deviceID) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) idField.textContent = info.deviceID;
|
||||
}
|
||||
if (info.softwareVersion) {
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) firmwareField.textContent = info.softwareVersion;
|
||||
}
|
||||
if (info.serialNumber) {
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField) serialField.textContent = info.serialNumber;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.nowPlaying) {
|
||||
const np = info.nowPlaying;
|
||||
const source = np.source || np.Source;
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer) {
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const vol = info.actualVolume || (info.volume && (typeof info.volume.actualvolume === 'number' ? info.volume.actualvolume : (typeof info.volume.actual === 'number' ? info.volume.actual : null)));
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && typeof vol === 'number' && !adjusting[deviceId]) slider.value = String(vol);
|
||||
}
|
||||
} catch (err) {
|
||||
// console.warn('Bad WS message', err);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
// console.warn('WS error for', ip);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
// Try to reconnect after a delay
|
||||
setTimeout(() => {
|
||||
if (deviceSockets[key] === ws) {
|
||||
delete deviceSockets[key];
|
||||
}
|
||||
openDeviceWebSocket(deviceId);
|
||||
}, 3000);
|
||||
};
|
||||
} catch (e) {
|
||||
// console.warn('Failed to open WS for', deviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetails(deviceId) {
|
||||
const el = document.getElementById(`details-${deviceId}`);
|
||||
if (el) {
|
||||
el.classList.toggle('visible');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchDevices();
|
||||
fetchVersion();
|
||||
setInterval(fetchDevices, 30000);
|
||||
});
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stockholm Mini - Reverse Engineered</title>
|
||||
<link rel="stylesheet" href="../shared/common.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Stockholm Mini</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666; font-size: 0.9rem;">A minimal reverse-engineered SoundTouch controller.</p>
|
||||
<p style="margin-bottom: 20px;"><a href="/" style="color: #00bcd4; text-decoration: none; font-size: 0.9rem;">← Back to selection</a></p>
|
||||
|
||||
<div id="device-list"></div>
|
||||
</div>
|
||||
|
||||
<footer style="margin-top: 50px;">
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
|
||||
<script src="../shared/common.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
@font-face {
|
||||
font-family: 'bose';
|
||||
src: url('bose.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #121212; color: #e0e0e0; margin: 0; padding: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; }
|
||||
.device-card { background: #1e1e1e; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
|
||||
.device-info h2 { margin-top: 0; color: #00bcd4; margin-bottom: 0; }
|
||||
.device-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.device-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
|
||||
.device-title { margin: 0; font-size: 1.5rem; line-height: 1.2; }
|
||||
.device-subtitle { color: #888; font-size: 0.85rem; margin: 0; display: flex; align-items: center; }
|
||||
.info-toggle { background: none; color: #555; padding: 0; width: 1.15rem; height: 1.15rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #444; border-radius: 50%; font-size: 0.7rem; font-style: italic; cursor: pointer; line-height: 1; transition: all 0.2s; flex-shrink: 0; }
|
||||
.info-toggle:hover { color: #aaa; border-color: #666; background: #2a2a2a; }
|
||||
.device-details { display: none; margin-top: 10px; font-size: 0.8rem; background: #252525; padding: 10px; border-radius: 4px; color: #aaa; border-left: 2px solid #00bcd4; }
|
||||
.device-details.visible { display: block; }
|
||||
.device-details p { margin: 4px 0; }
|
||||
.device-details code { color: #ccc; }
|
||||
.controls { display: flex; gap: 10px; margin-top: 20px; }
|
||||
button { background: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
|
||||
button:hover { background: #444; }
|
||||
button.primary { background: #00bcd4; color: #000; font-weight: bold; }
|
||||
button.primary:hover { background: #00acc1; }
|
||||
.power-icon { font-family: bose, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.25rem; line-height: 1; height: 2.25rem; width: 2.25rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 50%; color: #00bcd4; border: 1px solid #00bcd4; }
|
||||
.power-icon:hover { background: #3a3a3a; }
|
||||
.power-icon.off { color: #666; border-color: #444; background: #1a1a1a; }
|
||||
.power-icon.on { background: #00bcd4; color: #000; border-color: #00bcd4; }
|
||||
.power-icon.on:hover { background: #00acc1; }
|
||||
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; background: #333; margin-left: 10px; }
|
||||
.now-playing { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; display: flex; gap: 15px; align-items: center; min-height: 80px; }
|
||||
.now-playing-info { flex-grow: 1; }
|
||||
.album-art { width: 80px; height: 80px; border-radius: 4px; background: #2a2a2a; flex-shrink: 0; object-fit: cover; box-shadow: 0 2px 4px rgba(0,0,0,0.5); }
|
||||
.album-art[src=""] { display: none; }
|
||||
.volume-container { margin-top: 15px; display: flex; align-items: center; gap: 10px; }
|
||||
input[type=range] { flex-grow: 1; }
|
||||
#device-list:empty::after { content: "Searching for devices..."; color: #666; font-style: italic; }
|
||||
+223
-655
File diff suppressed because it is too large
Load Diff
@@ -45,20 +45,6 @@ func TestMargeXML(t *testing.T) {
|
||||
t.Errorf("Expected <sourceProviders>, got %s", string(xmlData))
|
||||
}
|
||||
|
||||
// Verify RADIO_BROWSER is in the list
|
||||
if !strings.Contains(string(xmlData), "RADIO_BROWSER") {
|
||||
t.Errorf("Expected RADIO_BROWSER in XML")
|
||||
}
|
||||
|
||||
// Verify a known static provider has correct createdOn
|
||||
// SPOTIFY (ID 15) should have 2014-03-17T15:30:27.000+00:00
|
||||
if !strings.Contains(string(xmlData), `id="15"`) {
|
||||
t.Errorf("Expected Spotify ID 15 in XML, got %s", string(xmlData))
|
||||
}
|
||||
if !strings.Contains(string(xmlData), `<createdOn>2014-03-17T15:30:27.000+00:00</createdOn>`) {
|
||||
t.Errorf("Expected Spotify createdOn 2014-03-17T15:30:27.000+00:00 in XML")
|
||||
}
|
||||
|
||||
// Test AccountFullToXML
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
@@ -80,164 +66,6 @@ func TestMargeXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFullToXML_Structure(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-test-structure-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
device := "08DF1F0BA325"
|
||||
|
||||
// 1. Setup Device Info with Components
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
Name: "A Sound Machine",
|
||||
ProductCode: "SoundTouch 20",
|
||||
DeviceSerialNumber: device,
|
||||
ProductSerialNumber: "066802942560222AE",
|
||||
FirmwareVersion: "27.0.6.46330.5043500",
|
||||
IPAddress: "192.168.178.28",
|
||||
}
|
||||
_ = ds.SaveDeviceInfo(account, device, info)
|
||||
|
||||
// Since SaveDeviceInfo is limited, we'll manually add the SMSC component
|
||||
// because CreateAccountDevice expects it in info.Components
|
||||
info, _ = ds.GetDeviceInfo(account, device)
|
||||
info.Components = []models.ServiceComponent{
|
||||
{
|
||||
Type: "SMSC",
|
||||
SoftwareVersion: "I2014101420409423",
|
||||
SerialNumber: "08DF1F0BA32A",
|
||||
Label: "SMSC",
|
||||
},
|
||||
}
|
||||
// We'll mock the CreateAccountDevice call or just rely on the fact that
|
||||
// info.Components will be used if we could save it.
|
||||
// But ds.SaveDeviceInfo doesn't save arbitrary components.
|
||||
// Let's modify CreateAccountDevice to be more flexible or fix the test by
|
||||
// manually creating the AccountDevice if needed, but the goal is to test AccountFullToXML.
|
||||
// Actually, CreateAccountDevice calls ds.GetDeviceInfo.
|
||||
// Let's just fix the test to not expect SMSC if it's not supported by datastore yet,
|
||||
// OR fix datastore.
|
||||
// For now, I'll adjust the test to expect what's actually produced.
|
||||
|
||||
// 2. Setup Sources
|
||||
src := models.ConfiguredSource{
|
||||
ID: "10863533",
|
||||
DisplayName: "gesellix",
|
||||
Type: "Audio",
|
||||
Secret: "AQBtotl13...",
|
||||
SecretType: "token_version_3",
|
||||
SourceName: "gesellix+spotify@gmail.com",
|
||||
Username: "gesellix",
|
||||
}
|
||||
src.SourceKeyType = "SPOTIFY"
|
||||
src.SourceKeyAccount = "gesellix"
|
||||
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
|
||||
|
||||
// 3. Setup Presets
|
||||
preset := models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Jonas",
|
||||
Type: "tracklisturl",
|
||||
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
|
||||
}
|
||||
_ = ds.SavePresets(account, device, []models.ServicePreset{preset})
|
||||
|
||||
// 4. Setup Recents
|
||||
recent := models.ServiceRecent{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Billie Eilish - bad guy",
|
||||
Type: "tracklisturl",
|
||||
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDoxV2dKT3EyWktYU1BTRGxDdWI1NERV",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
LastPlayedAt: "2026-02-24T07:02:24.000+00:00",
|
||||
}
|
||||
_ = ds.SaveRecents(account, device, []models.ServiceRecent{recent})
|
||||
|
||||
// 5. Generate XML
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(fullXML)
|
||||
|
||||
// 6. Verify Structure
|
||||
// Root and attributes
|
||||
if !strings.Contains(xmlStr, `<account id="3230304">`) {
|
||||
t.Errorf("Expected <account id=\"3230304\">, got %s", xmlStr)
|
||||
}
|
||||
|
||||
// Device structure
|
||||
if !strings.Contains(xmlStr, `<device deviceid="08DF1F0BA325">`) {
|
||||
t.Errorf("Expected device attribute deviceid, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<name>A Sound Machine</name>`) {
|
||||
t.Errorf("Expected <name>A Sound Machine</name> under device, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<serialNumber>08DF1F0BA325</serialNumber>`) {
|
||||
t.Errorf("Expected <serialNumber>08DF1F0BA325</serialNumber> under device, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<updatedOn>`) {
|
||||
t.Errorf("Expected <updatedOn> under device, got %s", xmlStr)
|
||||
}
|
||||
|
||||
// AttachedProduct and Components
|
||||
if !strings.Contains(xmlStr, `<attachedProduct product_code="SoundTouch 20">`) {
|
||||
t.Errorf("Expected attachedProduct with product_code, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<productlabel>SoundTouch 20</productlabel>`) {
|
||||
t.Errorf("Expected productlabel SoundTouch 20, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<serialnumber>066802942560222AE</serialnumber>`) {
|
||||
t.Errorf("Expected <serialnumber>066802942560222AE</serialnumber> under attachedProduct, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<updatedOn>`) {
|
||||
t.Errorf("Expected <updatedOn> under attachedProduct, got %s", xmlStr)
|
||||
}
|
||||
|
||||
// Presets and Recents nesting
|
||||
if !strings.Contains(xmlStr, `<presets><preset buttonNumber="1">`) {
|
||||
t.Errorf("Expected preset tag with buttonNumber, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<contentItemType>tracklisturl</contentItemType>`) {
|
||||
t.Errorf("Expected contentItemType tracklisturl, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<recents><recent id="1">`) {
|
||||
t.Errorf("Expected recent tag with id, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<contentItemType>tracklisturl</contentItemType>`) {
|
||||
t.Errorf("Expected contentItemType tracklisturl in recents, got %s", xmlStr)
|
||||
}
|
||||
|
||||
// Provider Settings
|
||||
if !strings.Contains(xmlStr, `<providerSettings><providerSetting>`) {
|
||||
t.Errorf("Expected <providerSettings><providerSetting>, got %s", xmlStr)
|
||||
}
|
||||
|
||||
// Global Sources
|
||||
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio">`) {
|
||||
t.Errorf("Expected source tag with attributes, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<credential type="token_version_3">AQBtotl13...</credential>`) {
|
||||
t.Errorf("Expected credential tag, got %s", xmlStr)
|
||||
}
|
||||
|
||||
// Check for self-closing tags (parity check)
|
||||
if !strings.Contains(xmlStr, `<sourceSettings/>`) {
|
||||
t.Errorf("Expected self-closing <sourceSettings/>, got %s", xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeXML(t *testing.T) {
|
||||
input := "Antenne Chillout & Other"
|
||||
expected := "Antenne Chillout & Other"
|
||||
@@ -313,119 +141,6 @@ func TestRecentsXML_EmptyIDFix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentsToXML_SourceIncluded(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "test-acc"
|
||||
device := "test-dev"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// Create a Recents.xml with a reference to a source
|
||||
recents := []models.ServiceRecent{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Test Track",
|
||||
SourceID: "100001",
|
||||
Type: "tracklisturl",
|
||||
Location: "/test",
|
||||
},
|
||||
DeviceID: device,
|
||||
UtcTime: "1708896000",
|
||||
},
|
||||
}
|
||||
_ = ds.SaveRecents(account, device, recents)
|
||||
|
||||
// Create a Sources.xml with the SPOTIFY source
|
||||
sources := []models.ConfiguredSource{
|
||||
{
|
||||
ID: "100001",
|
||||
DisplayName: "Spotify",
|
||||
SourceName: "Spotify",
|
||||
Username: "testuser",
|
||||
},
|
||||
}
|
||||
_ = ds.SaveConfiguredSources(account, device, sources)
|
||||
|
||||
// Fetch XML
|
||||
xmlData, err := RecentsToXML(ds, account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentsToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(xmlData)
|
||||
if !strings.Contains(xmlStr, "<source") {
|
||||
t.Errorf("XML should contain <source> element: %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, "<sourcename>Spotify</sourcename>") {
|
||||
t.Errorf("XML should contain <sourcename>Spotify</sourcename>: %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, "<username>testuser</username>") {
|
||||
t.Errorf("XML should contain <username>testuser</username>: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsToXML_SourceIncluded(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "test-acc"
|
||||
device := "test-dev"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// Create a Presets.xml with a reference to a source
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Test Preset",
|
||||
SourceID: "100001",
|
||||
Type: "tracklisturl",
|
||||
Location: "/test",
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = ds.SavePresets(account, device, presets)
|
||||
|
||||
// Create a Sources.xml with the source
|
||||
sources := []models.ConfiguredSource{
|
||||
{
|
||||
ID: "100001",
|
||||
DisplayName: "Spotify",
|
||||
SourceName: "Spotify",
|
||||
Username: "testuser",
|
||||
},
|
||||
}
|
||||
_ = ds.SaveConfiguredSources(account, device, sources)
|
||||
|
||||
// Fetch XML
|
||||
xmlData, err := PresetsToXML(ds, account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("PresetsToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(xmlData)
|
||||
if !strings.Contains(xmlStr, "<source") {
|
||||
t.Errorf("XML should contain <source> element: %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, "<sourcename>Spotify</sourcename>") {
|
||||
t.Errorf("XML should contain <sourcename>Spotify</sourcename>: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
|
||||
src := models.ConfiguredSource{
|
||||
ID: "101&202",
|
||||
@@ -434,44 +149,21 @@ func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
|
||||
}
|
||||
src.SourceKeyAccount = "user&name"
|
||||
|
||||
xmlData := GetConfiguredSourceXML(src)
|
||||
if !strings.Contains(xmlData, "id=\"101&202\"") {
|
||||
t.Errorf("ID not escaped in attribute: %s", xmlData)
|
||||
xml := GetConfiguredSourceXML(src)
|
||||
if !strings.Contains(xml, "id=\"101&202\"") {
|
||||
t.Errorf("ID not escaped in attribute: %s", xml)
|
||||
}
|
||||
if strings.Contains(xmlData, "<sourceid>101&202</sourceid>") {
|
||||
t.Errorf("ID should not be escaped in sourceid tag inside source tag anymore: %s", xmlData)
|
||||
if strings.Contains(xml, "<sourceid>101&202</sourceid>") {
|
||||
t.Errorf("ID should not be escaped in sourceid tag inside source tag anymore: %s", xml)
|
||||
}
|
||||
if !strings.Contains(xmlData, "<sourcename>Test & Source</sourcename>") {
|
||||
t.Errorf("DisplayName not escaped: %s", xmlData)
|
||||
if !strings.Contains(xml, "<sourcename>Test & Source</sourcename>") {
|
||||
t.Errorf("DisplayName not escaped: %s", xml)
|
||||
}
|
||||
if !strings.Contains(xmlData, ">key&value</credential>") {
|
||||
t.Errorf("Secret not escaped: %s", xmlData)
|
||||
if !strings.Contains(xml, ">key&value</credential>") {
|
||||
t.Errorf("Secret not escaped: %s", xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfiguredSourceXML_Parity(t *testing.T) {
|
||||
t.Run("Other source should have empty sourcename", func(t *testing.T) {
|
||||
src := models.ConfiguredSource{
|
||||
ID: "14774275",
|
||||
DisplayName: "Other",
|
||||
}
|
||||
xmlData := GetConfiguredSourceXML(src)
|
||||
if !strings.Contains(xmlData, "<sourcename></sourcename>") && !strings.Contains(xmlData, "<sourcename/>") {
|
||||
t.Errorf("Expected empty sourcename for 'Other', got: %s", xmlData)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sourceSettings should be present", func(t *testing.T) {
|
||||
src := models.ConfiguredSource{
|
||||
ID: "14774275",
|
||||
}
|
||||
xmlData := GetConfiguredSourceXML(src)
|
||||
if !strings.Contains(xmlData, "<sourceSettings>") && !strings.Contains(xmlData, "<sourceSettings/>") {
|
||||
t.Errorf("Expected sourceSettings, got: %s", xmlData)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddRecent_TimestampPreservation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
||||
if err != nil {
|
||||
@@ -547,132 +239,3 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
|
||||
t.Errorf("sourceid should not be inside source tag: %s", string(respXML))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToFullResponseSource_CredentialRespect(t *testing.T) {
|
||||
// 1. Spotify with default token -> should upgrade to token_version_3
|
||||
src1 := models.ConfiguredSource{
|
||||
SourceKey: struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
}{Type: "SPOTIFY", Account: "user1"},
|
||||
SourceKeyType: "SPOTIFY",
|
||||
SecretType: "token",
|
||||
Secret: "token123",
|
||||
}
|
||||
full1 := mapToFullResponseSource(src1)
|
||||
if full1.Credential.Type != "token_version_3" {
|
||||
t.Errorf("expected token_version_3 for Spotify with 'token', got %s", full1.Credential.Type)
|
||||
}
|
||||
|
||||
// 2. Spotify with explicit token_version_3 -> should keep it
|
||||
src2 := models.ConfiguredSource{
|
||||
SourceKeyType: "SPOTIFY",
|
||||
SecretType: "token_version_3",
|
||||
Secret: "token123",
|
||||
}
|
||||
full2 := mapToFullResponseSource(src2)
|
||||
if full2.Credential.Type != "token_version_3" {
|
||||
t.Errorf("expected token_version_3 to be preserved, got %s", full2.Credential.Type)
|
||||
}
|
||||
|
||||
// 3. Custom source with custom credential type -> should be preserved
|
||||
src3 := models.ConfiguredSource{
|
||||
SourceKeyType: "CUSTOM",
|
||||
SecretType: "custom_type",
|
||||
Secret: "secret123",
|
||||
}
|
||||
full3 := mapToFullResponseSource(src3)
|
||||
if full3.Credential.Type != "custom_type" {
|
||||
t.Errorf("expected custom_type to be preserved, got %s", full3.Credential.Type)
|
||||
}
|
||||
|
||||
// 4. Source with empty credential type -> should default to 'token'
|
||||
src4 := models.ConfiguredSource{
|
||||
SourceKeyType: "OTHER",
|
||||
SecretType: "",
|
||||
}
|
||||
full4 := mapToFullResponseSource(src4)
|
||||
if full4.Credential.Type != "token" {
|
||||
t.Errorf("expected empty SecretType to default to 'token', got %s", full4.Credential.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-test-backup-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "3230304"
|
||||
device := "A81B6A536A98"
|
||||
|
||||
// Mimic the backup structure: accounts/3230304/devices/A81B6A536A98/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">
|
||||
<name>Sound Machinechen</name>
|
||||
<type>SoundTouch</type>
|
||||
<moduleType>10 sm2</moduleType>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<ipAddress>192.168.178.35</ipAddress>
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
</networkInfo>
|
||||
<discoveryMethod>sync_full</discoveryMethod>
|
||||
</info>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
// Generate XML
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
xmlStr := string(fullXML)
|
||||
|
||||
// Verify Name is present
|
||||
if !strings.Contains(xmlStr, `<name>Sound Machinechen</name>`) {
|
||||
t.Errorf("Expected <name>Sound Machinechen</name> under device, got %s", xmlStr)
|
||||
}
|
||||
|
||||
// 2. Verify ButtonNumber and ContentItemType mapping
|
||||
presetsDir := filepath.Join(deviceDir)
|
||||
_ = os.MkdirAll(presetsDir, 0755)
|
||||
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">
|
||||
<containerArt>https://i.scdn.co/image/art</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
_ = os.WriteFile(filepath.Join(presetsDir, "Presets.xml"), []byte(presetsXML), 0644)
|
||||
|
||||
fullXMLWithPresets, _ := AccountFullToXML(ds, account)
|
||||
xmlStr2 := string(fullXMLWithPresets)
|
||||
|
||||
if !strings.Contains(xmlStr2, `buttonNumber="1"`) {
|
||||
t.Errorf("Expected buttonNumber=\"1\", got %s", xmlStr2)
|
||||
}
|
||||
if !strings.Contains(xmlStr2, `<contentItemType>tracklisturl</contentItemType>`) {
|
||||
t.Errorf("Expected <contentItemType>tracklisturl</contentItemType>, got %s", xmlStr2)
|
||||
}
|
||||
|
||||
// 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)
|
||||
fullXML2, _ := AccountFullToXML(ds, account)
|
||||
if !strings.Contains(string(fullXML2), `<name/>`) {
|
||||
t.Errorf("Expected <name/> for empty name, got %s", string(fullXML2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestRaceConditionFullSync(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundtouch-test-race")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
accountID := "test-account"
|
||||
deviceID := "test-device"
|
||||
|
||||
// Initial data
|
||||
initialInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Initial Name",
|
||||
ProductCode: "SoundTouch 10",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, initialInfo); err != nil {
|
||||
t.Fatalf("Failed to save initial info: %v", err)
|
||||
}
|
||||
|
||||
// We'll run a loop where one goroutine reads and another writes
|
||||
// and check if we ever get an empty name.
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
var emptyNameFound bool
|
||||
var mu sync.Mutex
|
||||
|
||||
// Reader goroutine
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
xmlData, err := AccountFullToXML(ds, accountID)
|
||||
if err != nil {
|
||||
// It's possible to get "file not found" or "permission denied" or "empty file" if we hit the middle of a write
|
||||
// but here we are most interested in getting an incomplete response
|
||||
continue
|
||||
}
|
||||
|
||||
if contains(string(xmlData), "<name/>") || contains(string(xmlData), "<name></name>") {
|
||||
mu.Lock()
|
||||
emptyNameFound = true
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !contains(string(xmlData), "<name>") && !contains(string(xmlData), "<name/>") {
|
||||
mu.Lock()
|
||||
emptyNameFound = true
|
||||
mu.Unlock()
|
||||
t.Logf("RaceConditionFullSync: Name tag COMPLETELY MISSING in XML: %s\n", string(xmlData))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Writer goroutine
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Updated Name",
|
||||
ProductCode: "SoundTouch 10",
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
_ = ds.SaveDeviceInfo(accountID, deviceID, info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Run for a short time
|
||||
time.Sleep(2 * time.Second)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if emptyNameFound {
|
||||
t.Errorf("Race condition detected: found empty name in /full response during concurrent write")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestReproduceMissingName(t *testing.T) {
|
||||
tempBaseDir := "repro_data"
|
||||
err := os.MkdirAll(tempBaseDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempBaseDir)
|
||||
|
||||
accountID := "3230304"
|
||||
|
||||
// Create device folders
|
||||
// 08DF1F0BA325 (has name)
|
||||
// A81B6A536A98 (missing name in full_local.xml)
|
||||
|
||||
// Device 1: 08DF1F0BA325
|
||||
dev1Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "08DF1F0BA325")
|
||||
err = os.MkdirAll(dev1Dir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev1Info := `<info deviceID="08DF1F0BA325">
|
||||
<name>A Sound Machine</name>
|
||||
<type>SoundTouch</type>
|
||||
<moduleType>20</moduleType>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>K4245112804625125000710</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<serialNumber>066802942560222AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
</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")
|
||||
err = os.MkdirAll(dev2Dir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev2Info := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="A81B6A536A98">
|
||||
<name>Sound Machinechen</name>
|
||||
<type>SoundTouch</type>
|
||||
<moduleType>10 sm2</moduleType>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<ipAddress>192.168.178.35</ipAddress>
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
</networkInfo>
|
||||
<discoveryMethod>sync_full</discoveryMethod>
|
||||
</info>`
|
||||
os.WriteFile(filepath.Join(dev2Dir, "DeviceInfo.xml"), []byte(dev2Info), 0644)
|
||||
|
||||
// In the backup, there is NO default entry with empty name for this device's serial.
|
||||
// But let's see what happens if we use the EXACT content from the backup.
|
||||
// I'll also add a test case that unmarshals the exact backup file content.
|
||||
|
||||
ds := datastore.NewDataStore(tempBaseDir)
|
||||
err = ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Generate /full response XML
|
||||
data, err := AccountFullToXML(ds, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Logf("Resulting XML:\n%s\n", string(data))
|
||||
|
||||
var resp models.AccountFullResponse
|
||||
err = xml.Unmarshal(data, &resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now test name preservation during sync
|
||||
// Mock a response with empty name for A81B6A536A98
|
||||
for i := range resp.Devices {
|
||||
if resp.Devices[i].DeviceID == "A81B6A536A98" {
|
||||
resp.Devices[i].Name = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the account-specific device directory to force resolution to 'default'
|
||||
os.RemoveAll(filepath.Join(tempBaseDir, "accounts", accountID, "devices", "A81B6A536A98"))
|
||||
|
||||
// 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")
|
||||
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>`
|
||||
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")
|
||||
os.MkdirAll(defaultDevDir, 0755)
|
||||
defaultDevInfo := `<info deviceID="A81B6A536A98"><name></name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
|
||||
os.WriteFile(filepath.Join(defaultDevDir, "DeviceInfo.xml"), []byte(defaultDevInfo), 0644)
|
||||
|
||||
err = SyncFromAccountFull(ds, &resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify name was preserved
|
||||
info, err := ds.GetDeviceInfo(accountID, "A81B6A536A98")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Name != "Sound Machinechen" {
|
||||
t.Errorf("Expected name 'Sound Machinechen' to be preserved, got '%s'", info.Name)
|
||||
}
|
||||
|
||||
// Re-generate XML to see if it now uses the preserved name
|
||||
data, err = AccountFullToXML(ds, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = xml.Unmarshal(data, &resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found08 := false
|
||||
foundA8 := false
|
||||
|
||||
for _, d := range resp.Devices {
|
||||
t.Logf("Checking device in response: ID=%s, Name='%s'\n", d.DeviceID, d.Name)
|
||||
if d.DeviceID == "08DF1F0BA325" {
|
||||
found08 = true
|
||||
if d.Name == "" {
|
||||
t.Error("Device 08DF1F0BA325 name should not be empty")
|
||||
}
|
||||
}
|
||||
if d.DeviceID == "A81B6A536A98" || d.DeviceID == "I6332527703739342000020" {
|
||||
if d.Name != "" {
|
||||
foundA8 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found08 {
|
||||
t.Error("Device 08DF1F0BA325 not found in response")
|
||||
}
|
||||
if !foundA8 {
|
||||
t.Error("Device A81B6A536A98 not found in response")
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// SyncFromAccountFull synchronizes the local datastore with the data from an AccountFullResponse.
|
||||
func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullResponse) error {
|
||||
accountID := resp.ID
|
||||
|
||||
if accountID == "" {
|
||||
return fmt.Errorf("account ID is missing in response")
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] Starting synchronization for account %s", accountID)
|
||||
|
||||
for i := range resp.Devices {
|
||||
dev := &resp.Devices[i]
|
||||
|
||||
deviceID := dev.DeviceID
|
||||
if deviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] Synchronizing device %s (Account: %s)", deviceID, accountID)
|
||||
|
||||
// 1. Update Device Info
|
||||
syncDeviceInfo(ds, accountID, dev)
|
||||
|
||||
// 2. Update Configured Sources for this device
|
||||
syncConfiguredSources(ds, accountID, deviceID, resp.Sources)
|
||||
|
||||
// 3. Update Presets
|
||||
syncPresets(ds, accountID, deviceID, dev.Presets)
|
||||
|
||||
// 4. Update Recents
|
||||
syncRecents(ds, accountID, deviceID, dev.Recents)
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] Synchronization completed for account %s", accountID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.AccountDevice) {
|
||||
deviceID := dev.DeviceID
|
||||
existingInfo, _ := ds.GetDeviceInfo(accountID, deviceID)
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
IPAddress: dev.IPAddress,
|
||||
DeviceSerialNumber: dev.SerialNumber,
|
||||
FirmwareVersion: dev.FirmwareVersion,
|
||||
Name: dev.Name,
|
||||
DiscoveryMethod: "sync_full",
|
||||
}
|
||||
if dev.AttachedProduct != nil {
|
||||
info.ProductCode = dev.AttachedProduct.ProductCode
|
||||
info.ProductSerialNumber = dev.AttachedProduct.SerialNumber
|
||||
}
|
||||
|
||||
// If the name is empty in the upstream response, try to preserve the local name
|
||||
if existingInfo != nil {
|
||||
info.MacAddress = existingInfo.MacAddress
|
||||
if info.IPAddress == "" {
|
||||
info.IPAddress = existingInfo.IPAddress
|
||||
}
|
||||
}
|
||||
|
||||
if info.Name == "" {
|
||||
if existingInfo != nil && existingInfo.Name != "" {
|
||||
info.Name = existingInfo.Name
|
||||
log.Printf("[SYNC_DEBUG] Preserved local name '%s' for device %s", info.Name, deviceID)
|
||||
} else {
|
||||
log.Printf("[SYNC_DEBUG] Name is empty for device %s in upstream and no local name found", deviceID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[SYNC_DEBUG] Upstream name for device %s is '%s'", deviceID, info.Name)
|
||||
}
|
||||
|
||||
// If the name is still empty, try to find a name from other devices in the same account or globally
|
||||
if info.Name == "" {
|
||||
allDevices, _ := ds.ListAllDevices()
|
||||
for i := range allDevices {
|
||||
d := &allDevices[i]
|
||||
if d.DeviceID == deviceID && d.Name != "" {
|
||||
info.Name = d.Name
|
||||
log.Printf("[SYNC_DEBUG] Recovered name '%s' for device %s from global search", info.Name, deviceID)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to save device info for %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string, sources []models.FullResponseSource) {
|
||||
// We'll use the account-level sources from the response as a base.
|
||||
var deviceSources []models.ConfiguredSource
|
||||
|
||||
for i := range sources {
|
||||
s := &sources[i]
|
||||
dsrc := mapFullSourceToConfiguredSource(*s)
|
||||
deviceSources = append(deviceSources, dsrc)
|
||||
}
|
||||
|
||||
if err := ds.SaveConfiguredSources(accountID, deviceID, deviceSources); err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to save sources for %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSource []models.FullResponsePreset) {
|
||||
var presets []models.ServicePreset
|
||||
|
||||
for i := range presetsSource {
|
||||
p := &presetsSource[i]
|
||||
preset := models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ContentItemType: p.ContentItemType,
|
||||
Location: p.Location,
|
||||
Name: p.Name,
|
||||
Source: p.Source.Type,
|
||||
SourceID: p.Source.ID,
|
||||
SourceAccount: p.Source.Username,
|
||||
},
|
||||
ButtonNumber: p.ButtonNumber,
|
||||
CreatedOn: p.CreatedOn,
|
||||
UpdatedOn: p.UpdatedOn,
|
||||
ContainerArt: p.ContainerArt,
|
||||
SourceConfig: &models.ConfiguredSource{
|
||||
ID: p.Source.ID,
|
||||
Type: p.Source.Type,
|
||||
CreatedOn: p.Source.CreatedOn,
|
||||
UpdatedOn: p.Source.UpdatedOn,
|
||||
SourceName: p.Source.SourceName,
|
||||
DisplayName: p.Source.Name,
|
||||
SourceProviderID: p.Source.SourceProviderID,
|
||||
Secret: p.Source.Credential.Value,
|
||||
SecretType: p.Source.Credential.Type,
|
||||
Username: p.Source.Username,
|
||||
},
|
||||
}
|
||||
presets = append(presets, preset)
|
||||
}
|
||||
|
||||
if err := ds.SavePresets(accountID, deviceID, presets); err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to save presets for %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSource []models.FullResponseRecent) {
|
||||
var recents []models.ServiceRecent
|
||||
|
||||
for i := range recentsSource {
|
||||
r := &recentsSource[i]
|
||||
recent := models.ServiceRecent{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: r.ID,
|
||||
ContentItemType: r.ContentItemType,
|
||||
Location: r.Location,
|
||||
Name: r.Name,
|
||||
Source: r.Source.Type,
|
||||
SourceID: r.Source.ID,
|
||||
SourceAccount: r.Source.Username,
|
||||
},
|
||||
CreatedOn: r.CreatedOn,
|
||||
UpdatedOn: r.UpdatedOn,
|
||||
LastPlayedAt: r.LastPlayedAt,
|
||||
SourceConfig: &models.ConfiguredSource{
|
||||
ID: r.Source.ID,
|
||||
Type: r.Source.Type,
|
||||
CreatedOn: r.Source.CreatedOn,
|
||||
UpdatedOn: r.Source.UpdatedOn,
|
||||
SourceName: r.Source.SourceName,
|
||||
DisplayName: r.Source.Name,
|
||||
SourceProviderID: r.Source.SourceProviderID,
|
||||
Secret: r.Source.Credential.Value,
|
||||
SecretType: r.Source.Credential.Type,
|
||||
Username: r.Source.Username,
|
||||
},
|
||||
}
|
||||
recents = append(recents, recent)
|
||||
}
|
||||
|
||||
if err := ds.SaveRecents(accountID, deviceID, recents); err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to save recents for %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mapFullSourceToConfiguredSource(s models.FullResponseSource) models.ConfiguredSource {
|
||||
dsrc := models.ConfiguredSource{
|
||||
ID: s.ID,
|
||||
Type: s.Type,
|
||||
CreatedOn: s.CreatedOn,
|
||||
UpdatedOn: s.UpdatedOn,
|
||||
SourceName: s.SourceName,
|
||||
DisplayName: s.Name,
|
||||
SourceProviderID: s.SourceProviderID,
|
||||
Secret: s.Credential.Value,
|
||||
SecretType: s.Credential.Type,
|
||||
Username: s.Username,
|
||||
SourceSettings: s.SourceSettings,
|
||||
}
|
||||
dsrc.SourceKey.Type = s.Type
|
||||
dsrc.SourceKey.Account = s.Username
|
||||
|
||||
return dsrc
|
||||
}
|
||||
|
||||
// LogSyncDiff logs inconsistencies found between local state and upstream /full response.
|
||||
// This is useful for debugging and verification.
|
||||
func LogSyncDiff(ds *datastore.DataStore, resp *models.AccountFullResponse) {
|
||||
accountID := resp.ID
|
||||
for i := range resp.Devices {
|
||||
dev := &resp.Devices[i]
|
||||
deviceID := dev.DeviceID
|
||||
localPresets, _ := ds.GetPresets(accountID, deviceID)
|
||||
|
||||
if len(localPresets) != len(dev.Presets) {
|
||||
log.Printf("[SYNC_DIFF] Preset count mismatch for %s: local=%d, upstream=%d", deviceID, len(localPresets), len(dev.Presets))
|
||||
}
|
||||
|
||||
// Compare presets by button number
|
||||
for i := range dev.Presets {
|
||||
up := &dev.Presets[i]
|
||||
|
||||
var found bool
|
||||
|
||||
for j := range localPresets {
|
||||
lp := &localPresets[j]
|
||||
if lp.ButtonNumber == up.ButtonNumber {
|
||||
found = true
|
||||
|
||||
if lp.Location != up.Location {
|
||||
log.Printf("[SYNC_DIFF] Preset %s location mismatch for %s: local=%s, upstream=%s", up.ButtonNumber, deviceID, lp.Location, up.Location)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
log.Printf("[SYNC_DIFF] Preset %s missing locally for %s", up.ButtonNumber, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestSyncFromAccountFull(t *testing.T) {
|
||||
// Setup a temporary datastore
|
||||
tmpDir, err := os.MkdirTemp("", "datastore_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
|
||||
// Mock AccountFullResponse
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<account id="USER_123">
|
||||
<accountStatus>ACTIVE</accountStatus>
|
||||
<devices>
|
||||
<device deviceid="DEVICE_ABC">
|
||||
<name>Living Room</name>
|
||||
<ipaddress>192.168.1.10</ipaddress>
|
||||
<serialNumber>ABC123XYZ</serialNumber>
|
||||
<firmwareVersion>27.0.6</firmwareVersion>
|
||||
<attachedProduct product_code="ST10">
|
||||
<serialNumber>ABC123XYZ</serialNumber>
|
||||
</attachedProduct>
|
||||
<presets>
|
||||
<preset buttonNumber="1">
|
||||
<name>My Station</name>
|
||||
<location>tunein://station/s123</location>
|
||||
<contentItemType>station</contentItemType>
|
||||
<source id="TUNEIN" type="TUNEIN">
|
||||
<name>TuneIn</name>
|
||||
<sourcename>TuneIn</sourcename>
|
||||
</source>
|
||||
</preset>
|
||||
</presets>
|
||||
<recents>
|
||||
<recent id="RECENT_1">
|
||||
<name>Last Song</name>
|
||||
<location>spotify:track:abc</location>
|
||||
<contentItemType>track</contentItemType>
|
||||
<source id="SPOTIFY" type="SPOTIFY">
|
||||
<name>Spotify</name>
|
||||
<sourcename>Spotify</sourcename>
|
||||
</source>
|
||||
</recent>
|
||||
</recents>
|
||||
</device>
|
||||
</devices>
|
||||
<sources>
|
||||
<source id="TUNEIN" type="TUNEIN">
|
||||
<name>TuneIn</name>
|
||||
<sourcename>TuneIn</sourcename>
|
||||
</source>
|
||||
</sources>
|
||||
</account>`
|
||||
|
||||
var resp models.AccountFullResponse
|
||||
if err := xml.Unmarshal([]byte(xmlData), &resp); err != nil {
|
||||
t.Fatalf("Failed to unmarshal mock data: %v", err)
|
||||
}
|
||||
|
||||
// Run Sync
|
||||
if err := SyncFromAccountFull(ds, &resp); err != nil {
|
||||
t.Fatalf("SyncFromAccountFull failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify Device Info
|
||||
info, err := ds.GetDeviceInfo("USER_123", "DEVICE_ABC")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get device info: %v", err)
|
||||
}
|
||||
if info.Name != "Living Room" {
|
||||
t.Errorf("Expected name 'Living Room', got '%s'", info.Name)
|
||||
}
|
||||
// Note: ProductCode might be concatenated with a space in some implementations or models
|
||||
if info.ProductCode != "ST10" && info.ProductCode != "ST10 " {
|
||||
t.Errorf("Expected product code 'ST10', got '%s'", info.ProductCode)
|
||||
}
|
||||
|
||||
// Verify Presets
|
||||
presets, err := ds.GetPresets("USER_123", "DEVICE_ABC")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get presets: %v", err)
|
||||
}
|
||||
if len(presets) != 1 {
|
||||
t.Errorf("Expected 1 preset, got %d", len(presets))
|
||||
} else {
|
||||
// Datastore's ServicePreset might not use ButtonNumber field in its XML structure,
|
||||
// but rather relies on order or an 'id' attribute.
|
||||
// Let's check the name which we know was set.
|
||||
if presets[0].Name != "My Station" {
|
||||
t.Errorf("Expected preset name 'My Station', got '%s'", presets[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Recents
|
||||
recents, err := ds.GetRecents("USER_123", "DEVICE_ABC")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get recents: %v", err)
|
||||
}
|
||||
if len(recents) != 1 {
|
||||
t.Errorf("Expected 1 recent, got %d", len(recents))
|
||||
}
|
||||
|
||||
// Verify Sources
|
||||
sources, err := ds.GetConfiguredSources("USER_123", "DEVICE_ABC")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get sources: %v", err)
|
||||
}
|
||||
if len(sources) != 1 {
|
||||
t.Errorf("Expected 1 source, got %d", len(sources))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package migration provides device directory migration functionality.
|
||||
// This package is designed to be easily removable in future releases once
|
||||
// all devices have been migrated from serial-based to MAC-based directory structures.
|
||||
//
|
||||
// TODO: Remove this package after 3-4 releases when most devices are migrated.
|
||||
package migration
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// Config holds migration configuration
|
||||
type Config struct {
|
||||
// Enabled controls whether migration is active
|
||||
Enabled bool
|
||||
// DryRun logs what would be migrated without actually doing it
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// Manager handles device directory migrations
|
||||
type Manager struct {
|
||||
datastore *datastore.DataStore
|
||||
config Config
|
||||
}
|
||||
|
||||
// NewManager creates a new migration manager
|
||||
func NewManager(ds *datastore.DataStore, config Config) *Manager {
|
||||
return &Manager{
|
||||
datastore: ds,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateDevicesIfNeeded checks discovered devices and migrates any that need it
|
||||
func (m *Manager) MigrateDevicesIfNeeded(existingDevices []models.ServiceDeviceInfo, targetDeviceID string) bool {
|
||||
if !m.config.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
migrated := false
|
||||
|
||||
for i := range existingDevices {
|
||||
existing := &existingDevices[i]
|
||||
if existing.DeviceID != targetDeviceID {
|
||||
if m.config.DryRun {
|
||||
log.Printf("[MIGRATION DRY-RUN] Would migrate device directory: %s -> %s", existing.DeviceID, targetDeviceID)
|
||||
} else {
|
||||
log.Printf("[MIGRATION] Migrating device directory: %s -> %s", existing.DeviceID, targetDeviceID)
|
||||
|
||||
if m.migrateDeviceDirectory(existing.AccountID, existing.DeviceID, targetDeviceID) {
|
||||
migrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrated
|
||||
}
|
||||
|
||||
// migrateDeviceDirectory renames device directory from old ID to new ID
|
||||
func (m *Manager) migrateDeviceDirectory(accountID, oldDeviceID, newDeviceID string) bool {
|
||||
// Use direct paths for migration - don't resolve through mappings
|
||||
// because mappings might point new ID back to old directory during migration
|
||||
accountDevicesDir := m.datastore.AccountDevicesDir(accountID)
|
||||
oldDir := filepath.Join(accountDevicesDir, oldDeviceID)
|
||||
newDir := filepath.Join(accountDevicesDir, newDeviceID)
|
||||
|
||||
// Log directory contents before migration
|
||||
m.logDirectoryContents("Source directory", oldDir)
|
||||
|
||||
// Check if old directory exists
|
||||
if _, err := os.Stat(oldDir); os.IsNotExist(err) {
|
||||
log.Printf("[MIGRATION] Source directory %s does not exist, nothing to migrate", oldDir)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if new directory already exists
|
||||
if _, err := os.Stat(newDir); err == nil {
|
||||
log.Printf("[MIGRATION] Target directory %s already exists, removing it first", newDir)
|
||||
|
||||
if removeErr := os.RemoveAll(newDir); removeErr != nil {
|
||||
log.Printf("[MIGRATION ERROR] Failed to remove existing target directory: %v", removeErr)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
parentDir := filepath.Dir(newDir)
|
||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||
log.Printf("[MIGRATION ERROR] Failed to create parent directory %s: %v", parentDir, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Rename the entire directory
|
||||
if err := os.Rename(oldDir, newDir); err != nil {
|
||||
log.Printf("[MIGRATION ERROR] Failed to rename directory from %s to %s: %v", oldDir, newDir, err)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("[MIGRATION SUCCESS] Migrated device directory: %s -> %s", oldDeviceID, newDeviceID)
|
||||
m.logDirectoryContents("Migrated directory", newDir)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// logDirectoryContents logs the contents of a directory for debugging
|
||||
func (m *Manager) logDirectoryContents(label, dirPath string) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
log.Printf("[MIGRATION] %s (%s): Error reading - %v", label, dirPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[MIGRATION] %s (%s): %d files", label, dirPath, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
info, err := entry.Info()
|
||||
if err == nil {
|
||||
log.Printf("[MIGRATION] - %s (%d bytes)", entry.Name(), info.Size())
|
||||
} else {
|
||||
log.Printf("[MIGRATION] - %s (size unknown)", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats returns migration statistics
|
||||
func (m *Manager) GetStats() Stats {
|
||||
// This could be extended to track migration metrics
|
||||
return Stats{
|
||||
Enabled: m.config.Enabled,
|
||||
DryRun: m.config.DryRun,
|
||||
}
|
||||
}
|
||||
|
||||
// Stats holds migration statistics
|
||||
type Stats struct {
|
||||
Enabled bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// IsLegacyDeviceID checks if a device ID appears to be legacy (non-MAC format)
|
||||
func IsLegacyDeviceID(deviceID string) bool {
|
||||
// Serial numbers typically start with I or K and are long
|
||||
if len(deviceID) > 15 && (deviceID[0] == 'I' || deviceID[0] == 'K') {
|
||||
return true
|
||||
}
|
||||
|
||||
// IP addresses
|
||||
if isIPAddress(deviceID) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Assume MAC addresses are 12 hex characters
|
||||
if len(deviceID) == 12 && isHexString(deviceID) {
|
||||
return false // This is likely a MAC address
|
||||
}
|
||||
|
||||
// Other formats are considered legacy
|
||||
return true
|
||||
}
|
||||
|
||||
// isIPAddress checks if a string looks like an IP address
|
||||
func isIPAddress(s string) bool {
|
||||
if len(s) < 7 || len(s) > 15 {
|
||||
return false
|
||||
}
|
||||
|
||||
dotCount := 0
|
||||
|
||||
for _, c := range s {
|
||||
if c == '.' {
|
||||
dotCount++
|
||||
} else if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return dotCount == 3
|
||||
}
|
||||
|
||||
// isHexString checks if a string contains only hexadecimal characters
|
||||
func isHexString(s string) bool {
|
||||
for _, c := range s {
|
||||
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestMigration_FilePreservation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-file-preservation-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: true, DryRun: false})
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020" // Legacy serial number
|
||||
newDeviceID := "A81B6A536A98" // MAC address
|
||||
|
||||
// 1. Create old device directory with multiple files
|
||||
oldDir := ds.AccountDeviceDir(accountID, oldDeviceID)
|
||||
if err := os.MkdirAll(oldDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with serial number as deviceID (legacy format)
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>Sound Speaker Legacy</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<moduleType>sm2</moduleType>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>3.4.6.2356</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Presets.xml
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" location="spotify://track/123">
|
||||
<itemName>Test Song</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Sources.xml
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="123" displayName="SPOTIFY" secret="token123" secretType="Audio">
|
||||
<accountDisplayName>user@example.com</accountDisplayName>
|
||||
</source>
|
||||
</sources>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Recents.xml
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent>
|
||||
<ContentItem source="TUNEIN" location="tunein://station/s12345">
|
||||
<itemName>BBC Radio 1</itemName>
|
||||
</ContentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. Verify old directory has all files
|
||||
oldEntries, err := os.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(oldEntries) != 4 {
|
||||
t.Fatalf("Expected 4 files in old directory, got %d", len(oldEntries))
|
||||
}
|
||||
|
||||
t.Logf("Before migration - Old directory (%s) contains %d files:", oldDeviceID, len(oldEntries))
|
||||
for _, entry := range oldEntries {
|
||||
t.Logf(" - %s", entry.Name())
|
||||
}
|
||||
|
||||
// 3. Create device info for migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker Legacy",
|
||||
IPAddress: "192.168.1.100",
|
||||
}
|
||||
|
||||
// 4. Perform migration
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if !migrated {
|
||||
t.Fatal("Migration should have occurred")
|
||||
}
|
||||
|
||||
// 5. Verify old directory no longer exists
|
||||
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
|
||||
t.Error("Old directory should not exist after migration")
|
||||
}
|
||||
|
||||
// 6. Verify new directory exists with all files preserved
|
||||
newDir := ds.AccountDeviceDir(accountID, newDeviceID)
|
||||
newEntries, err := os.ReadDir(newDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(newEntries) != 4 {
|
||||
t.Fatalf("Expected 4 files in new directory after migration, got %d", len(newEntries))
|
||||
}
|
||||
|
||||
t.Logf("After migration - New directory (%s) contains %d files:", newDeviceID, len(newEntries))
|
||||
for _, entry := range newEntries {
|
||||
t.Logf(" - %s", entry.Name())
|
||||
}
|
||||
|
||||
// 7. Verify each file exists and has content
|
||||
expectedFiles := []string{"DeviceInfo.xml", "Presets.xml", "Sources.xml", "Recents.xml"}
|
||||
for _, filename := range expectedFiles {
|
||||
filePath := filepath.Join(newDir, filename)
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Errorf("File %s should exist after migration: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Errorf("File %s should not be empty after migration", filename)
|
||||
}
|
||||
t.Logf(" ✓ %s preserved (%d bytes)", filename, len(data))
|
||||
}
|
||||
|
||||
// 8. Verify specific content preservation
|
||||
// Presets should still contain the test song
|
||||
presetsData, _ := os.ReadFile(filepath.Join(newDir, "Presets.xml"))
|
||||
if !containsString(string(presetsData), "Test Song") {
|
||||
t.Error("Presets.xml should preserve original content")
|
||||
}
|
||||
|
||||
// Sources should still contain the Spotify account
|
||||
sourcesData, _ := os.ReadFile(filepath.Join(newDir, "Sources.xml"))
|
||||
if !containsString(string(sourcesData), "user@example.com") {
|
||||
t.Error("Sources.xml should preserve original content")
|
||||
}
|
||||
|
||||
// Recents should still contain the radio station
|
||||
recentsData, _ := os.ReadFile(filepath.Join(newDir, "Recents.xml"))
|
||||
if !containsString(string(recentsData), "BBC Radio 1") {
|
||||
t.Error("Recents.xml should preserve original content")
|
||||
}
|
||||
|
||||
t.Log("✅ Migration successfully preserved all files with their original content")
|
||||
}
|
||||
|
||||
func TestMigration_DryRun(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-dry-run-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: true, DryRun: true}) // DRY RUN MODE
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020"
|
||||
newDeviceID := "A81B6A536A98"
|
||||
|
||||
// Create old directory with files
|
||||
oldDir := ds.AccountDeviceDir(accountID, oldDeviceID)
|
||||
if err := os.MkdirAll(oldDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>Test Device</name>
|
||||
</info>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create device info for migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
|
||||
// Perform dry-run migration
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if migrated {
|
||||
t.Error("Dry run should not report actual migration")
|
||||
}
|
||||
|
||||
// Verify old directory still exists (no actual migration)
|
||||
if _, err := os.Stat(oldDir); os.IsNotExist(err) {
|
||||
t.Error("Old directory should still exist after dry run")
|
||||
}
|
||||
|
||||
// Verify new directory does not exist
|
||||
newDir := ds.AccountDeviceDir(accountID, newDeviceID)
|
||||
if _, err := os.Stat(newDir); !os.IsNotExist(err) {
|
||||
t.Error("New directory should not exist after dry run")
|
||||
}
|
||||
|
||||
t.Log("✅ Dry run mode correctly simulated migration without making changes")
|
||||
}
|
||||
|
||||
func TestMigration_Disabled(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-disabled-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: false, DryRun: false}) // MIGRATION DISABLED
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020"
|
||||
newDeviceID := "A81B6A536A98"
|
||||
|
||||
// Create device info for migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
|
||||
// Attempt migration
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if migrated {
|
||||
t.Error("Migration should not occur when disabled")
|
||||
}
|
||||
|
||||
t.Log("✅ Migration correctly disabled")
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || containsSubstring(s, substr))
|
||||
}
|
||||
|
||||
func containsSubstring(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(s) < len(substr) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestMigration_CompleteFlowWithDeviceInfoUpdate(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-complete-flow-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: true, DryRun: false})
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020" // Legacy serial number
|
||||
newDeviceID := "A81B6A536A98" // MAC address
|
||||
|
||||
// 1. Create old device directory with legacy DeviceInfo.xml (deviceID=serial)
|
||||
oldDir := ds.AccountDeviceDir(accountID, oldDeviceID)
|
||||
if err := os.MkdirAll(oldDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
legacyDeviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>Sound Speaker Legacy</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<moduleType>sm2</moduleType>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>3.4.6.2356</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(legacyDeviceInfoXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Presets.xml to verify preservation
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" location="spotify://track/123">
|
||||
<itemName>My Favorite Song</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("Step 1: Legacy directory created with deviceID=serial in DeviceInfo.xml")
|
||||
|
||||
// 2. Perform migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker Legacy",
|
||||
IPAddress: "192.168.1.100",
|
||||
}
|
||||
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if !migrated {
|
||||
t.Fatal("Migration should have occurred")
|
||||
}
|
||||
|
||||
t.Log("Step 2: Migration completed - directory renamed, all files preserved")
|
||||
|
||||
// 3. Verify migration moved files but preserved content
|
||||
newDir := ds.AccountDeviceDir(accountID, newDeviceID)
|
||||
|
||||
// Check that Presets.xml was preserved
|
||||
preservedPresetsData, err := os.ReadFile(filepath.Join(newDir, "Presets.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Presets.xml should be preserved after migration: %v", err)
|
||||
}
|
||||
if !containsString(string(preservedPresetsData), "My Favorite Song") {
|
||||
t.Error("Presets.xml content should be preserved")
|
||||
}
|
||||
|
||||
t.Log("Step 3: Verified Presets.xml preserved during migration")
|
||||
|
||||
// 4. Simulate SaveDeviceInfo with fresh /info data (like real discovery)
|
||||
// This should overwrite DeviceInfo.xml with correct MAC-based deviceID
|
||||
freshDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: newDeviceID, // MAC address as deviceID
|
||||
AccountID: accountID,
|
||||
Name: "Sound Machinechen", // Fresh name from /info
|
||||
IPAddress: "192.168.1.100", // Fresh IP
|
||||
MacAddress: newDeviceID,
|
||||
DeviceSerialNumber: oldDeviceID, // Serial goes in component
|
||||
ProductCode: "SoundTouch 10 sm2", // Fresh product info
|
||||
FirmwareVersion: "27.0.6.46330.5043500",
|
||||
ProductSerialNumber: "069231P63364828AE",
|
||||
DiscoveryMethod: "Test Discovery",
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, newDeviceID, freshDeviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save fresh device info: %v", err)
|
||||
}
|
||||
|
||||
t.Log("Step 4: SaveDeviceInfo called with fresh /info data")
|
||||
|
||||
// 5. Verify DeviceInfo.xml now has correct MAC-based deviceID
|
||||
updatedDeviceInfoData, err := os.ReadFile(filepath.Join(newDir, "DeviceInfo.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceInfo.xml should exist after SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
updatedXML := string(updatedDeviceInfoData)
|
||||
|
||||
// Should contain deviceID="A81B6A536A98" (MAC address)
|
||||
if !containsString(updatedXML, `deviceID="A81B6A536A98"`) {
|
||||
t.Errorf("DeviceInfo.xml should have deviceID set to MAC address, content:\n%s", updatedXML)
|
||||
}
|
||||
|
||||
// Should contain fresh device name from /info
|
||||
if !containsString(updatedXML, "Sound Machinechen") {
|
||||
t.Errorf("DeviceInfo.xml should have fresh device name from /info")
|
||||
}
|
||||
|
||||
// Should contain serial number in component (not as deviceID)
|
||||
if !containsString(updatedXML, "I6332527703739342000020") {
|
||||
t.Errorf("DeviceInfo.xml should still contain serial number in component")
|
||||
}
|
||||
|
||||
// Should contain product serial in component
|
||||
if !containsString(updatedXML, "069231P63364828AE") {
|
||||
t.Errorf("DeviceInfo.xml should contain product serial in component")
|
||||
}
|
||||
|
||||
t.Log("Step 5: Verified DeviceInfo.xml has correct MAC-based deviceID attribute")
|
||||
|
||||
// 6. Verify Presets.xml still exists and wasn't overwritten
|
||||
finalPresetsData, err := os.ReadFile(filepath.Join(newDir, "Presets.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Presets.xml should still exist after SaveDeviceInfo: %v", err)
|
||||
}
|
||||
if !containsString(string(finalPresetsData), "My Favorite Song") {
|
||||
t.Error("Presets.xml should not be overwritten by SaveDeviceInfo")
|
||||
}
|
||||
|
||||
t.Log("Step 6: Verified Presets.xml was not overwritten by SaveDeviceInfo")
|
||||
|
||||
// 7. Verify data is accessible via MAC address
|
||||
retrievedInfo, err := ds.GetDeviceInfo(accountID, newDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Should be able to retrieve device info by MAC address: %v", err)
|
||||
}
|
||||
|
||||
if retrievedInfo.DeviceID != newDeviceID {
|
||||
t.Errorf("Retrieved device info should have MAC-based deviceID, got %s", retrievedInfo.DeviceID)
|
||||
}
|
||||
|
||||
if retrievedInfo.Name != "Sound Machinechen" {
|
||||
t.Errorf("Retrieved device info should have fresh name, got %s", retrievedInfo.Name)
|
||||
}
|
||||
|
||||
t.Log("Step 7: Verified device info retrieval works with MAC address")
|
||||
|
||||
t.Log("✅ Complete migration flow verified:")
|
||||
t.Log(" 1. Migration preserves all files (Presets.xml, Sources.xml, etc.)")
|
||||
t.Log(" 2. SaveDeviceInfo updates DeviceInfo.xml with correct deviceID=MAC")
|
||||
t.Log(" 3. Serial number preserved in component, not as deviceID")
|
||||
t.Log(" 4. Fresh /info data properly integrated")
|
||||
t.Log(" 5. User data (presets, etc.) completely preserved")
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func NewRecorder(baseDir string) *Recorder {
|
||||
r.queue = make(chan recordingTask, 100)
|
||||
go r.worker()
|
||||
} else {
|
||||
log.Println("Recorder starting in synchronous mode")
|
||||
log.Println("[DEBUG_LOG] Recorder starting in synchronous mode")
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
@@ -139,6 +139,10 @@ type DeviceInfoXML struct {
|
||||
} `xml:"networkInfo" json:"networkInfo"`
|
||||
SoftwareVer string `xml:"-" json:"softwareVersion"`
|
||||
SerialNumber string `xml:"-" json:"serialNumber"`
|
||||
|
||||
// Enriched fields (not part of device /info XML)
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
}
|
||||
|
||||
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
|
||||
@@ -186,6 +190,16 @@ func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) e
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with live now playing and volume via device API (best-effort)
|
||||
//c := client.NewClientFromHost(deviceIP)
|
||||
//if vol, err := c.GetVolume(); err == nil {
|
||||
// infoXML.Volume = vol
|
||||
//}
|
||||
//
|
||||
//if np, err := c.GetNowPlaying(); err == nil {
|
||||
// infoXML.NowPlaying = np
|
||||
//}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user