Improve parity with upstream Bose services

This commit is contained in:
Tobias Gesellchen
2026-02-26 21:08:14 +01:00
parent 2132674768
commit 6211e34050
23 changed files with 1632 additions and 275 deletions
+36 -9
View File
@@ -216,6 +216,12 @@ func main() {
Usage: "Log what would be migrated without actually doing it",
EnvVars: []string{"MIGRATION_DRY_RUN"},
},
&cli.StringFlag{
Name: "preferred-source",
Usage: "Preferred source of truth (local or upstream)",
Value: "local",
EnvVars: []string{"PREFERRED_SOURCE"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
@@ -247,7 +253,7 @@ func main() {
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
@@ -392,6 +398,7 @@ type serviceConfig struct {
mgmtPassword string
migrationEnabled bool
migrationDryRun bool
preferredSource string
}
func loadConfig(c *cli.Context) serviceConfig {
@@ -460,6 +467,7 @@ func loadConfig(c *cli.Context) serviceConfig {
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
preferredSource := c.String("preferred-source")
return serviceConfig{
port: port,
@@ -489,6 +497,7 @@ func loadConfig(c *cli.Context) serviceConfig {
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
preferredSource: preferredSource,
}
}
@@ -498,14 +507,17 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
"*.api.bose.io": true,
"*.api.bosecm.com": true,
// Core Bose domains (keep specific ones for clarity)
"streaming.bose.com": true,
"updates.bose.com": true,
"stats.bose.com": true,
"bmx.bose.com": true,
"worldwide.bose.com": true,
"music.api.bose.com": true,
"bose-prod.apigee.net": true,
"bose-test.apigee.net": true,
"streaming.bose.com": true,
"updates.bose.com": true,
"stats.bose.com": true,
"bmx.bose.com": true,
"worldwide.bose.com": true,
"music.api.bose.com": true,
"streamingoauth.bose.com": true,
"bosecm.com": true,
"bose.io": true,
"bose-prod.apigee.net": true,
"bose-test.apigee.net": true,
// Local service domains
setup.TestDomain: true,
hostname: true,
@@ -535,6 +547,13 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
return datastore.Settings{}
}
// Only override CLI values if settings file exists
// If no settings file exists, GetSettings returns empty Settings{} and we should preserve CLI values
settingsPath := filepath.Join(ds.DataDir, "settings.json")
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
return datastore.Settings{}
}
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
@@ -569,6 +588,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.mirrorEnabled = persisted.MirrorEnabled
config.mirrorEndpoints = persisted.MirrorEndpoints
config.preferredSource = persisted.PreferredSource
config.internalPaths = persisted.InternalPaths
return persisted
@@ -590,12 +610,14 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
},
}
_ = ds.SaveSettings(settings)
return settings
@@ -634,6 +656,7 @@ func startDeviceDiscovery(server *handlers.Server) {
func setupRouter(server *handlers.Server) *chi.Mux {
r := chi.NewRouter()
r.Use(server.SnapshotMiddleware)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.ShortcutMiddleware)
@@ -724,6 +747,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
r.Route("/oauth", func(r chi.Router) {
r.HandleFunc("/*", server.HandleBoseProxy)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
+337
View File
@@ -0,0 +1,337 @@
# Request Recording Concept
## Problem Statement
The current request recording system has fundamental issues when dealing with request cloning, body consumption, and multiple response scenarios. Specifically:
1. **Body Consumption**: HTTP request bodies can only be read once, leading to missing bodies in recordings
2. **Request Cloning**: A single original request may be cloned multiple times for different purposes (local handling, mirroring, recording)
3. **Multiple Responses**: The same logical request may generate different responses (local vs upstream mirror)
4. **Data Integrity**: No guarantee that recorded requests are identical across different execution paths
## Current Issues (Examples)
### Issue 1: Missing Request Bodies in Mirror Recordings
**Local Recording** (complete):
```http
### POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/A81B6A536A98
Host: events.api.bosecm.com
Content-Type: text/json; charset=utf-8
Content-Length: 587
Authorization: Bearer jGwEmFWr...
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
> {%
// Response: 200 OK
%}
```
**Mirror Recording** (missing body):
```http
### POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/A81B6A536A98
Host: events.api.bosecm.com
Content-Type: text/json; charset=utf-8
Content-Length: 587
Authorization: Bearer jGwEmFWr...
> {%
// Response: 200 OK
// Headers:
// X-Proxy-Origin: upstream-mirror
%}
```
### Issue 2: Request Flow Complexity
Current middleware execution order:
```
1. MirrorMiddleware - Buffers body, creates clones
2. RecordMiddleware - Also buffers body
3. Application Handler - Processes request
4. Mirror Execution - Async/sync mirror to upstream
5. Recording - Multiple recording points
```
Problems:
- Multiple body reads across middleware chain
- Inconsistent request state between clones
- Race conditions in async scenarios
- No guarantee of request equivalence
## Proposed Solution: Context-Bound Request Snapshots
### Core Concept
Create **immutable request snapshots** early in the request lifecycle and propagate them through the **Request Context**. This ensures all downstream consumers (Mirroring, Recording, Parity Check) use identical data without re-reading the request body.
### Architecture (Context-Only)
```
┌─────────────────┐
│ Original Request│
└─────────┬───────┘
┌─────────────────┐ ┌──────────────────┐
│ Snapshot Creator│───▶│ Request Context │
│ (Middleware) │ │ (Pointer-based) │
└─────────┬───────┘ └──────────────────┘
│ │
▼ │ (Safe for async)
┌─────────────────┐ │
│ Middleware │◀─────────────┘
│ Chain │
└─────────┬───────┘
┌───▼────┐ ┌─────────┐ ┌──────────────┐
│ Local │ │ Mirror │ │ Recording │
│Handler │ │Execution│ │ System │
└────────┘ └─────────┘ └──────────────┘
```
### Request Snapshot Structure
```go
type RequestSnapshot struct {
Method string
URL *url.URL
Headers http.Header
Body []byte
Host string
Timestamp time.Time
}
// Typed key for context safety
type contextKey struct{ name string }
var SnapshotKey = &contextKey{"request_snapshot"}
```
### Implementation Strategy
#### Phase 1: Snapshot Middleware
```go
func (s *Server) SnapshotMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Capture body once with size limit (e.g. 2MB)
body, _ := io.ReadAll(io.LimitReader(r.Body, 2*1024*1024))
r.Body.Close()
// 2. Create snapshot
snapshot := &RequestSnapshot{
Method: r.Method,
URL: cloneURL(r.URL),
Headers: r.Header.Clone(),
Body: body,
Host: r.Host,
Timestamp: time.Now(),
}
// 3. Inject pointer into context
ctx := context.WithValue(r.Context(), SnapshotKey, snapshot)
// 4. Restore r.Body for downstream compatibility
r = r.WithContext(ctx)
r.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
next.ServeHTTP(w, r)
})
}
```
#### Phase 2: Downstream Consumption
Consumers (Mirror/Record) retrieve the snapshot directly from context:
```go
snapshot, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot)
if ok {
// Use snapshot.Body directly instead of io.ReadAll(r.Body)
}
```
## Hardware Considerations (Raspberry Pi Zero 2W)
To protect MicroSD health and optimize for limited memory:
1. **No Intermediate Disk Storage**: Snapshots exist only in memory; they are never written to disk until the final `.http` recording is generated.
2. **Memory Management**: Use `sync.Pool` for temporary buffers to reduce GC churn on the single-core/low-memory SoC.
3. **Automatic Cleanup**: Snapshots are naturally garbage collected once the Request Context and all child goroutines (detached mirrors/recordings) finish.
4. **Body Capping**: Strict limits on snapshot size prevent OOM (Out-of-Memory) conditions.
#### Phase 2: Response Capture System
```go
type ResponseRecorder struct {
http.ResponseWriter
snapshot *ResponseSnapshot
snapshotID string
source string
startTime time.Time
}
func (r *ResponseRecorder) WriteHeader(statusCode int) {
r.snapshot.StatusCode = statusCode
r.snapshot.Headers = r.Header().Clone()
r.ResponseWriter.WriteHeader(statusCode)
}
func (r *ResponseRecorder) Write(data []byte) (int, error) {
r.snapshot.Body = append(r.snapshot.Body, data...)
return r.ResponseWriter.Write(data)
}
func (r *ResponseRecorder) finalize() {
r.snapshot.Duration = time.Since(r.startTime)
r.snapshot.Timestamp = time.Now()
}
```
#### Phase 3: Recording System Integration
```go
type RecordingManager struct {
storage SnapshotStorage
recorder *Recorder
patterns []string
}
func (rm *RecordingManager) RecordInteraction(snapshotID string, response *ResponseSnapshot) {
// Retrieve immutable request snapshot
request, exists := rm.storage.Get(snapshotID)
if !exists {
log.Printf("Request snapshot not found: %s", snapshotID)
return
}
// Record with guaranteed data integrity
rm.recorder.RecordInteraction(request, response)
}
func (r *Recorder) RecordInteraction(req *RequestSnapshot, res *ResponseSnapshot) error {
// Generate .http file with complete data
var buf bytes.Buffer
// Write request
fmt.Fprintf(&buf, "### %s %s\n", req.Method, req.URL.String())
fmt.Fprintf(&buf, "%s %s\n", req.Method, req.URL.String())
fmt.Fprintf(&buf, "Host: %s\n", req.Host)
for k, vv := range req.Headers {
for _, v := range vv {
fmt.Fprintf(&buf, "%s: %s\n", k, v)
}
}
buf.WriteString("\n")
buf.Write(req.Body)
buf.WriteString("\n\n")
// Write response
buf.WriteString("> {% \n")
fmt.Fprintf(&buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
buf.WriteString(" // Headers:\n")
for k, vv := range res.Headers {
for _, v := range vv {
fmt.Fprintf(&buf, " // %s: %s\n", k, v)
}
}
buf.WriteString("%}\n\n")
if len(res.Body) > 0 {
buf.WriteString("/*\n")
buf.Write(res.Body)
buf.WriteString("\n*/\n")
} else {
buf.WriteString("// [Binary response body: 0 bytes]\n")
}
// Write to file
return r.writeToFile(buf.Bytes(), req, res)
}
```
## Migration Strategy
### Phase 1: Introduce Snapshot System
- Add SnapshotMiddleware as first middleware
- Maintain existing recording system for compatibility
- Gradual migration of recording points
### Phase 2: Update Mirror System
- Modify MirrorMiddleware to use snapshots
- Ensure mirror requests use snapshot data
- Test parity between old and new systems
### Phase 3: Consolidate Recording
- Replace existing recording middleware
- Unified recording system using context-bound snapshots
- Remove duplicate body reading code
### Phase 4: Cleanup
- Remove legacy recording code
- Optimize memory usage with sync.Pool
- Performance validation on target hardware (Pi Zero)
## Benefits
1. **Zero Extra Disk IO**: Protecs MicroSD by avoiding snapshot disk persistence
2. **Memory Efficiency**: Natural lifecycle tied to Request Context
3. **Data Integrity**: Request data is captured once and remains immutable
4. **Consistency**: All consumers use identical request data
5. **Traceability**: Clear lineage from original request to all recordings
6. **Performance**: Reduces duplicate body reads and re-cloning
## Implementation Considerations
### Memory Management
- Use `sync.Pool` for byte buffers
- Strict size limits on captured bodies
- Rely on GC for snapshot cleanup
### Performance Impact
- Single body read vs multiple reads (net positive)
- Memory overhead for snapshot storage (manageable)
- Context propagation overhead (minimal)
### Backward Compatibility
- Maintain existing .http file format
- Preserve existing API contracts
- Gradual migration path
## Testing Strategy
### Unit Tests
- Snapshot creation and immutability
- Response recording accuracy
- Memory cleanup verification
### Integration Tests
- End-to-end request/response recording
- Mirror functionality with snapshots
- Parity validation between old/new systems
### Performance Tests
- Memory usage comparison
- Throughput impact analysis
- Large request body handling
## Future Enhancements
1. **Compression**: Compress stored snapshots for memory efficiency
2. **Streaming**: Support for streaming request/response bodies
3. **Filtering**: Selective snapshot creation based on patterns
4. **Analytics**: Request/response analysis and metrics
5. **Export**: Snapshot export for debugging and analysis
## Conclusion
This snapshot-based approach provides a robust foundation for reliable request recording while solving the current issues with body consumption and data inconsistency. The phased implementation ensures minimal disruption while delivering immediate benefits.
+1
View File
@@ -40,6 +40,7 @@
* [Feature Mapping](reference/FEATURE-MAPPING.md)
## Concepts
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
+2 -15
View File
@@ -39,21 +39,8 @@ func TestDNSDiscovery_Interception(t *testing.T) {
}
// Test intercepting streamingoauth.bose.com
m3 := new(dns.Msg)
m3.SetQuestion("streamingoauth.bose.com.", dns.TypeA)
rw3 := &mockResponseWriter{}
d.ServeDNS(rw3, m3)
if rw3.msg == nil || len(rw3.msg.Answer) == 0 {
t.Fatal("Expected response for streamingoauth.bose.com")
}
if a, ok := rw3.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s for streamingoauth.bose.com, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record for streamingoauth.bose.com, got %T", rw3.msg.Answer[0])
if !d.shouldIntercept("streamingoauth.bose.com") {
t.Error("Expected streamingoauth.bose.com to be intercepted")
}
// Test aftertouch.test
+19 -10
View File
@@ -177,16 +177,25 @@ type ConfiguredSource struct {
// ServiceDeviceInfo represents information about a SoundTouch device.
type ServiceDeviceInfo struct {
DeviceID string `json:"device_id" xml:"deviceID,attr"`
ProductCode string `json:"product_code" xml:"type"`
DeviceSerialNumber string `json:"device_serial_number" xml:"serialnumber"`
ProductSerialNumber string `json:"product_serial_number" xml:"product_serial_number"`
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
IPAddress string `json:"ip_address" xml:"ipAddress"`
Name string `json:"name" xml:"name"`
MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty"`
AccountID string `json:"account_id,omitempty"`
DeviceID string `json:"device_id" xml:"deviceID,attr"`
ProductCode string `json:"product_code" xml:"type"`
DeviceSerialNumber string `json:"device_serial_number" xml:"serialnumber"`
ProductSerialNumber string `json:"product_serial_number" xml:"product_serial_number"`
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
IPAddress string `json:"ip_address" xml:"ipAddress"`
Name string `json:"name" xml:"name"`
MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty"`
AccountID string `json:"account_id,omitempty"`
Components []ServiceComponent `json:"components,omitempty" xml:"-"`
}
// ServiceComponent represents a hardware or software component of a device.
type ServiceComponent struct {
Type string `xml:"type,attr"`
Category string `xml:"category,attr"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
}
// CustomerSupportDevice represents device information for customer support purposes.
+24 -1
View File
@@ -290,12 +290,18 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
deviceInfo := &models.ServiceDeviceInfo{
DeviceID: info.DeviceID,
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
ProductCode: info.Type,
Name: info.Name,
DiscoveryMethod: info.DiscoveryMethod,
}
for _, comp := range info.Components {
deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
switch comp.Category {
case "SCM":
deviceInfo.FirmwareVersion = comp.SoftwareVersion
@@ -453,9 +459,17 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
}
recents := []models.ServiceRecent{}
maxID := 0
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,
@@ -472,6 +486,14 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
})
}
// Ensure all recents have unique numeric IDs
for i := range recents {
if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
maxID++
recents[i].ID = strconv.Itoa(maxID)
}
}
return recents, nil
}
@@ -867,6 +889,7 @@ type Settings struct {
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
+9 -5
View File
@@ -61,7 +61,7 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[Marge] Failed to read power_on body: %v", err)
w.WriteHeader(http.StatusOK) // Silent failure is usually better for device requests
w.WriteHeader(http.StatusOK)
return
}
@@ -191,15 +191,21 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
// This route is specifically used by firmware like Bose_Lisa/27.0.6.
if chi.URLParam(r, "account") != "" {
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write([]byte(marge.SoftwareUpdateToXML()))
xmlData := marge.SoftwareUpdateToXML()
w.Header().Set("Content-Length", strconv.Itoa(len(xmlData)))
_, _ = w.Write([]byte(xmlData))
return
}
if len(swUpdateXML) > 0 {
w.Header().Set("Content-Length", strconv.Itoa(len(swUpdateXML)))
_, _ = w.Write(swUpdateXML)
} else {
_, _ = w.Write([]byte(marge.SoftwareUpdateToXML()))
xmlData := marge.SoftwareUpdateToXML()
w.Header().Set("Content-Length", strconv.Itoa(len(xmlData)))
_, _ = w.Write([]byte(xmlData))
}
}
@@ -415,7 +421,5 @@ func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Reque
},
}
s.ds.AddDeviceEvent(req.Device.ID, event)
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusOK)
}
+51 -2
View File
@@ -54,10 +54,13 @@ func TestMargeSoftwareUpdate(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
// Should contain software_update or INDEX (if swupdate.xml exists)
if !strings.Contains(string(body), "software_update") && !strings.Contains(string(body), "INDEX") {
// Should contain INDEX as we updated swupdate.xml
if !strings.Contains(string(body), "INDEX") {
t.Errorf("Unexpected response: %s", string(body))
}
if !strings.Contains(string(body), "0x0933") {
t.Errorf("Response missing VideoWave (0x0933) info: %s", string(body))
}
}
func TestMargeAccountFull(t *testing.T) {
@@ -761,6 +764,12 @@ func TestMargeAdvancedFeatures(t *testing.T) {
if !strings.Contains(string(body), "<boseId>123</boseId>") {
t.Errorf("Response body missing account ID: %s", body)
}
if !strings.Contains(string(body), "<keyName>ELIGIBLE_FOR_TRIAL</keyName>") {
t.Errorf("Response body missing ELIGIBLE_FOR_TRIAL: %s", body)
}
if !strings.Contains(string(body), "<keyName>STREAMING_QUALITY</keyName>") {
t.Errorf("Response body missing STREAMING_QUALITY: %s", body)
}
})
t.Run("StreamingToken", func(t *testing.T) {
@@ -823,6 +832,10 @@ func TestMargeAdvancedFeatures(t *testing.T) {
t.Errorf("Expected status OK, got %v", res.Status)
}
if ct := res.Header.Get("Content-Type"); ct != "" {
t.Errorf("Expected no Content-Type for customer support upload (empty body), got %v", ct)
}
// Verify event was recorded
events := ds.GetDeviceEvents("587A628A4042")
found := false
@@ -843,4 +856,40 @@ func TestMargeAdvancedFeatures(t *testing.T) {
t.Error("Customer support event not found in event log")
}
})
t.Run("AddRecent_Reproduction", func(t *testing.T) {
account := "3230304"
device := "A81B6A536A98"
// Setup sources for this device
deviceDir := ds.AccountDeviceDir(account, device)
_ = os.MkdirAll(deviceDir, 0755)
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte("<recents/>"), 0644)
// No Sources.xml
path := "/marge/streaming/account/" + account + "/device/" + device + "/recent"
payload := `<?xml version="1.0" encoding="UTF-8" ?><recent><lastplayedat>2026-02-25T23:03:14+00:00</lastplayedat><sourceid>10863533</sourceid><name>My top tracks playlist</name><location>/playback/container/c3BvdGlmeTpwbGF5bGlzdDo3YklIMERKRUdoVjFSZ2duandOYWxn</location><contentItemType>tracklisturl</contentItemType></recent>`
res, err := http.Post(ts.URL+path, "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(res.Body)
t.Errorf("Expected status Created (201), got %v: %s", res.Status, body)
}
// Verify it was saved
recents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("Failed to get recents: %v", err)
}
if len(recents) == 0 {
t.Error("Recents list is empty")
} else if recents[0].Name != "My top tracks playlist" {
t.Errorf("Expected name 'My top tracks playlist', got '%s'", recents[0].Name)
}
})
}
+5
View File
@@ -156,6 +156,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsBindAddr := s.dnsBindAddr
mirrorEnabled := s.mirrorEnabled
mirrorEndpoints := s.mirrorEndpoints
preferredSource := s.preferredSource
internalPaths := s.internalPaths
enableSoundcorkProxy := s.enableSoundcorkProxy
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
@@ -178,6 +179,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"dns_bind_addr": dnsBindAddr,
"mirror_enabled": mirrorEnabled,
"mirror_endpoints": mirrorEndpoints,
"preferred_source": preferredSource,
"internal_paths": internalPaths,
"enable_soundcork_proxy": enableSoundcorkProxy,
"redact_logs": redact,
@@ -203,6 +205,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
DNSBindAddr string `json:"dns_bind_addr"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints"`
PreferredSource string `json:"preferred_source"`
InternalPaths []string `json:"internal_paths"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
Shortcuts map[string]int `json:"shortcuts"`
@@ -252,6 +255,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.mirrorEnabled = settings.MirrorEnabled
s.mirrorEndpoints = settings.MirrorEndpoints
s.preferredSource = settings.PreferredSource
s.internalPaths = settings.InternalPaths
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
@@ -285,6 +289,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
DNSBindAddr: s.dnsBindAddr,
MirrorEnabled: s.mirrorEnabled,
MirrorEndpoints: s.mirrorEndpoints,
PreferredSource: s.preferredSource,
InternalPaths: s.internalPaths,
EnableSoundcorkProxy: s.enableSoundcorkProxy,
Shortcuts: s.shortcuts,
+163 -67
View File
@@ -22,92 +22,153 @@ import (
// MirrorMiddleware returns a middleware that mirrors specific requests to the Bose upstream.
func (s *Server) MirrorMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
enabled := s.mirrorEnabled
endpoints := s.mirrorEndpoints
s.mu.RUnlock()
enabled, endpoints, preferredSource := s.getMirrorSettings()
if !enabled || len(endpoints) == 0 {
if !enabled || len(endpoints) == 0 || !s.shouldMirror(r.URL.Path, endpoints) {
next.ServeHTTP(w, r)
return
}
shouldMirror := false
// Try to fetch snapshot from context
var snapshot *RequestSnapshot
if snap, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot); ok {
snapshot = snap
}
for _, pattern := range endpoints {
if matchPattern(pattern, r.URL.Path) {
shouldMirror = true
break
// Buffer request body if snapshot is missing (compatibility mode)
var bodyBytes []byte
if snapshot != nil {
bodyBytes = snapshot.Body
} else if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// Use request context but detach it for background operations to prevent cancellation when the primary request finishes
detachedCtx := context.WithoutCancel(r.Context())
if snapshot != nil {
detachedCtx = context.WithValue(detachedCtx, SnapshotKey, snapshot)
}
if preferredSource == "upstream" {
s.mirrorUpstreamPreferred(detachedCtx, w, r, next, bodyBytes)
return
}
s.mirrorLocalPreferred(detachedCtx, w, r, next, bodyBytes)
})
}
func (s *Server) getMirrorSettings() (bool, []string, string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.mirrorEnabled, s.mirrorEndpoints, s.preferredSource
}
func (s *Server) shouldMirror(path string, endpoints []string) bool {
for _, pattern := range endpoints {
if matchPattern(pattern, path) {
return true
}
}
return false
}
func (s *Server) mirrorUpstreamPreferred(detachedCtx context.Context, w http.ResponseWriter, r *http.Request, next http.Handler, bodyBytes []byte) {
log.Printf("[MIRROR] Upstream is preferred source for %s %s", r.Method, r.URL.Path)
// Clone request for local execution
rLocal := r.Clone(detachedCtx)
rLocal.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
localRecorder := &mirrorResponseRecorder{
headers: make(http.Header),
body: &bytes.Buffer{},
}
// Run local handler in background
localDone := make(chan struct{})
go func() {
next.ServeHTTP(localRecorder, rLocal)
close(localDone)
}()
// Clone request for mirror execution
rMirror := r.Clone(detachedCtx)
rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Execute mirror synchronously
mirrorRes := s.performMirror(rMirror)
// Send mirror response to client
if mirrorRes != nil && mirrorRes.status != 0 && mirrorRes.status < 500 {
for k, vv := range mirrorRes.headers {
for _, v := range vv {
w.Header().Add(k, v)
}
}
if !shouldMirror {
next.ServeHTTP(w, r)
return
w.WriteHeader(mirrorRes.status)
_, _ = w.Write(mirrorRes.body.Bytes())
} else {
// Fallback to local if mirror failed
log.Printf("[MIRROR_ERR] Mirror failed, falling back to local for %s", r.URL.Path)
<-localDone
for k, vv := range localRecorder.headers {
for _, v := range vv {
w.Header().Add(k, v)
}
}
// Buffer request body for both local and mirror
var bodyBytes []byte
if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
_ = r.Body.Close()
if localRecorder.status == 0 {
localRecorder.status = http.StatusOK
}
// Prepare local request
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
w.WriteHeader(localRecorder.status)
_, _ = w.Write(localRecorder.body.Bytes())
}
// Wrap response writer to capture local response for parity check
localRecorder := &mirrorResponseRecorder{
headers: make(http.Header),
body: &bytes.Buffer{},
// Perform parity check once local is done
go func() {
<-localDone
if mirrorRes != nil {
s.checkParity(r, localRecorder, mirrorRes)
}
}()
}
// Use a multi-writer if RecordMiddleware isn't already doing this,
// but let's just wrap it.
func (s *Server) mirrorLocalPreferred(detachedCtx context.Context, w http.ResponseWriter, r *http.Request, next http.Handler, bodyBytes []byte) {
// Default: local is preferred source of truth
// Prepare local request
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
wrappedWriter := &parityResponseWriter{
ResponseWriter: w,
recorder: localRecorder,
}
// Wrap response writer to capture local response for parity check
localRecorder := &mirrorResponseRecorder{
headers: make(http.Header),
body: &bytes.Buffer{},
}
if r.Method == http.MethodGet {
// GET: Local is primary, Mirror is asynchronous
log.Printf("[MIRROR] Mirroring GET %s asynchronously", r.URL.Path)
wrappedWriter := &parityResponseWriter{
ResponseWriter: w,
recorder: localRecorder,
}
// We need a clone for the async call, detached from original request context
// We use context.Background() because the original request's context
// will be canceled as soon as the local handler finishes and returns
// the response to the speaker.
//nolint:contextcheck
rMirror := r.Clone(context.Background())
rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
log.Printf("[MIRROR] Mirroring %s %s %s", r.Method, r.URL.Path, map[bool]string{true: "asynchronously", false: "synchronously"}[r.Method == http.MethodGet])
// For GET, we run mirror in background and don't wait for parity in real-time
// or we can wait for local to finish then trigger parity asynchronously.
rMirror := r.Clone(detachedCtx)
rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
next.ServeHTTP(wrappedWriter, r)
next.ServeHTTP(wrappedWriter, r)
go func() {
mirrorRes := s.performMirror(rMirror)
s.checkParity(r, localRecorder, mirrorRes)
}()
} else {
// POST/PUT/DELETE: Local is primary for speaker response, but we sync synchronously
log.Printf("[MIRROR] Mirroring %s %s synchronously", r.Method, r.URL.Path)
// We need a clone for the background sync call
//nolint:contextcheck
rMirror := r.Clone(context.Background())
rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
next.ServeHTTP(wrappedWriter, r)
go func() {
mirrorRes := s.performMirror(rMirror)
s.checkParity(r, localRecorder, mirrorRes)
}()
}
})
go func() {
mirrorRes := s.performMirror(rMirror)
s.checkParity(r, localRecorder, mirrorRes)
}()
}
type parityResponseWriter struct {
@@ -142,6 +203,41 @@ func (p *parityResponseWriter) WriteHeader(statusCode int) {
}
func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
// Try to fetch snapshot from context
var snapshot *RequestSnapshot
if snap, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot); ok {
snapshot = snap
}
// Preserve request body for recording before it gets consumed by the proxy
var requestForRecording *http.Request
if s.recorder != nil && s.recordEnabled {
requestForRecording = r.Clone(r.Context())
if snapshot != nil {
// Use snapshot for both proxy and recording
r.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
requestForRecording.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
} else if r.Body != nil {
// Compatibility fallback
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[MIRROR_ERR] Failed to read request body for recording: %v", err)
} else {
// Restore body for proxy
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Set body for recording
requestForRecording.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
// Ensure Content-Length is set for the recording clone
if requestForRecording.Body != nil {
if snapshot != nil {
requestForRecording.ContentLength = int64(len(snapshot.Body))
}
}
}
host := r.Host
if host == "" || host == "localhost" {
host = "streaming.bose.com"
@@ -183,9 +279,9 @@ func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
proxy.ModifyResponse = func(res *http.Response) error {
res.Header.Set("X-Proxy-Origin", "upstream-mirror")
// Record mirrored interaction
if s.recorder != nil && s.recordEnabled {
_ = s.recorder.Record("mirror", r, res)
// Record mirrored interaction with preserved request body
if s.recorder != nil && s.recordEnabled && requestForRecording != nil {
_ = s.recorder.Record("mirror", requestForRecording, res)
}
return nil
@@ -0,0 +1,160 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMirrorMiddleware_PreferredSource(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mirror-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// 1. Setup local handler
r := http.NewServeMux()
r.HandleFunc("/test/local", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Source", "local")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("local response"))
})
// 2. Setup "upstream" mock server
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Source", "upstream")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("upstream response"))
}))
defer upstreamServer.Close()
// 3. Setup our server with MirrorMiddleware
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false, false)
server.SetMirrorSettings(true, []string{"/test/local"}, "local")
// We need to trick performMirror to use our mock upstream.
// performMirror uses r.Host.
upstreamURL := upstreamServer.URL
upstreamHost := strings.TrimPrefix(upstreamURL, "http://")
middleware := server.MirrorMiddleware(r)
t.Run("PreferredLocal", func(t *testing.T) {
server.SetMirrorSettings(true, []string{"/test/local"}, "local")
req := httptest.NewRequest("GET", "/test/local", nil)
req.Host = upstreamHost // So performMirror targets the mock upstream
w := httptest.NewRecorder()
middleware.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Header().Get("X-Source") != "local" {
t.Errorf("Expected X-Source: local, got %s", w.Header().Get("X-Source"))
}
if w.Body.String() != "local response" {
t.Errorf("Expected 'local response', got '%s'", w.Body.String())
}
})
t.Run("PreferredUpstream", func(t *testing.T) {
server.SetMirrorSettings(true, []string{"/test/local"}, "upstream")
req := httptest.NewRequest("GET", "/test/local", nil)
req.Host = upstreamHost
w := httptest.NewRecorder()
middleware.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Expected status 201, got %d", w.Code)
}
if w.Header().Get("X-Source") != "upstream" {
t.Errorf("Expected X-Source: upstream, got %s", w.Header().Get("X-Source"))
}
if w.Body.String() != "upstream response" {
t.Errorf("Expected 'upstream response', got '%s'", w.Body.String())
}
})
t.Run("FallbackToLocal", func(t *testing.T) {
server.SetMirrorSettings(true, []string{"/test/local"}, "upstream")
// Use a non-existent host for mirror to trigger failure
req := httptest.NewRequest("GET", "/test/local", nil)
req.Host = "nonexistent.invalid"
w := httptest.NewRecorder()
middleware.ServeHTTP(w, req)
// Should fallback to local
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 (fallback), got %d", w.Code)
}
if w.Header().Get("X-Source") != "local" {
t.Errorf("Expected X-Source: local (fallback), got %s", w.Header().Get("X-Source"))
}
})
}
func TestSettingsAPI_PreferredSource(t *testing.T) {
tempDir, err := os.MkdirTemp("", "settings-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false, false)
// Test GET initial
req := httptest.NewRequest("GET", "/setup/settings", nil)
w := httptest.NewRecorder()
server.HandleGetSettings(w, req)
var settings map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &settings)
if settings["preferred_source"] != "" && settings["preferred_source"] != "local" {
t.Errorf("Initial preferred_source unexpected: %v", settings["preferred_source"])
}
// Test UPDATE
update := map[string]interface{}{
"preferred_source": "upstream",
}
body, err := json.Marshal(update)
if err != nil {
t.Fatalf("Failed to marshal update: %v", err)
}
req = httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(body))
w = httptest.NewRecorder()
server.HandleUpdateSettings(w, req)
if w.Code != http.StatusOK {
t.Errorf("POST /setup/settings failed: %d", w.Code)
}
if server.preferredSource != "upstream" {
t.Errorf("Server preferredSource did not update: %s", server.preferredSource)
}
// Verify persistence
persisted, _ := ds.GetSettings()
if persisted.PreferredSource != "upstream" {
t.Errorf("Datastore did not persist PreferredSource: %s", persisted.PreferredSource)
}
}
+79 -1
View File
@@ -2,6 +2,8 @@ package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -43,7 +45,7 @@ func TestMirroring(t *testing.T) {
recorder := proxy.NewRecorder(tempDir)
server.SetRecorder(recorder)
server.SetRecordEnabled(true)
server.SetMirrorSettings(true, []string{"/streaming/account/*/device/*/recent"})
server.SetMirrorSettings(true, []string{"/streaming/account/*/device/*/recent"}, "local")
ts := httptest.NewServer(r)
defer ts.Close()
@@ -137,6 +139,82 @@ func TestMirroring(t *testing.T) {
t.Errorf("Expected Upstream Content-Type application/vnd.bose.streaming-v1.2+xml, got %s", ct)
}
})
t.Run("POST Request Body Preservation", func(t *testing.T) {
// Set recorder to synchronous mode for testing
os.Setenv("RECORDER_ASYNC", "false")
defer os.Unsetenv("RECORDER_ASYNC")
// Create a mock upstream that echoes back the request body
postUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/scmudc/A81B6A536A98") {
// Read the request body
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
// Echo back the body in response for verification
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Request-Body-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer postUpstream.Close()
// Setup mirroring for the POST endpoint
server.SetMirrorSettings(true, []string{"/v1/scmudc/*"}, "local")
requestBody := `{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}`
path := "/v1/scmudc/A81B6A536A98"
req, _ := http.NewRequest("POST", ts.URL+path, strings.NewReader(requestBody))
req.Header.Set("Content-Type", "text/json; charset=utf-8")
req.Host = strings.TrimPrefix(postUpstream.URL, "http://")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
// Wait briefly for the synchronous recording to complete
time.Sleep(100 * time.Millisecond)
// Check if the mirrored interaction was recorded with the request body
matchesMirror, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "mirror", "v1", "scmudc", "*", "*-POST.http"))
if len(matchesMirror) == 0 {
// Try broader search pattern
allHttpFiles, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*", "*", "*.http"))
t.Errorf("Expected to find mirrored POST interaction. All .http files found: %v", allHttpFiles)
} else {
// Read the recorded mirrored interaction
recordedContent, err := os.ReadFile(matchesMirror[0])
if err != nil {
t.Fatalf("Failed to read recorded mirror interaction: %v", err)
}
recordedStr := string(recordedContent)
// Check if the request body was preserved in the recording
if !strings.Contains(recordedStr, requestBody) {
t.Errorf("Request body not found in mirrored recording. Content: %s", recordedStr)
}
// Check if the Content-Type header was preserved
if !strings.Contains(recordedStr, "Content-Type: text/json; charset=utf-8") {
t.Errorf("Content-Type header not found in mirrored recording. Content: %s", recordedStr)
}
}
})
}
// SetRecordEnabled is a helper for testing
+12 -10
View File
@@ -28,16 +28,18 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
}
}
// Buffer the request body if it exists
// Use snapshot if available, otherwise buffer body (compatibility mode)
var snapshot *RequestSnapshot
if s, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot); ok {
snapshot = s
}
var reqBody []byte
if r.Body != nil {
var err error
reqBody, err = io.ReadAll(r.Body)
if err == nil {
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
if snapshot != nil {
reqBody = snapshot.Body
} else if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
// wrap ResponseWriter to capture the response
@@ -54,7 +56,7 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
defer func() { _ = res.Body.Close() }()
}
// Put back the original request body for recording
// Restore body for recording
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
_ = s.recorder.Record("self", r, res)
+25 -1
View File
@@ -1,6 +1,7 @@
package handlers
import (
"bytes"
"context"
"fmt"
"io"
@@ -42,6 +43,7 @@ type Server struct {
dnsBindAddr string
mirrorEnabled bool
mirrorEndpoints []string
preferredSource string
internalPaths []string
enableSoundcorkProxy bool
shortcuts map[string]int
@@ -59,6 +61,27 @@ type Server struct {
spotifyService *spotify.Service
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
type RequestSnapshot struct {
Method string
URL *url.URL
Headers http.Header
Body []byte
Host string
Timestamp time.Time
}
type ctxKey struct{ name string }
// SnapshotKey is the context key for the RequestSnapshot.
var SnapshotKey = &ctxKey{"request_snapshot"}
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy, migrationEnabled, migrationDryRun bool) *Server {
// Initialize migration manager
@@ -317,12 +340,13 @@ func (s *Server) SetMgmtConfig(username, password string) {
}
// SetMirrorSettings sets the mirroring settings for the server.
func (s *Server) SetMirrorSettings(enabled bool, endpoints []string) {
func (s *Server) SetMirrorSettings(enabled bool, endpoints []string, preferredSource string) {
s.mu.Lock()
defer s.mu.Unlock()
s.mirrorEnabled = enabled
s.mirrorEndpoints = endpoints
s.preferredSource = preferredSource
}
// SetInternalPaths sets the internal paths for the server.
@@ -0,0 +1,126 @@
package handlers
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
)
func TestSnapshotIntegrity_SelfAndMirror(t *testing.T) {
tempDir, err := os.MkdirTemp("", "recording-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
recorder := proxy.NewRecorder(tempDir)
s := NewServer(ds, nil, "http://localhost:8000", false, false, true, false, false, false)
s.SetRecorder(recorder)
s.SetMirrorSettings(true, []string{"/mirror/*"}, "local")
// Upstream mock
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Header().Set("X-Request-Body-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(http.StatusOK)
w.Write([]byte("upstream response"))
}))
defer upstream.Close()
// Configure mirror to point to our mock upstream
s.SetMirrorSettings(true, []string{"/mirror/*"}, "local")
// We need to override the host in performMirror but for tests we can just mock it via env if needed or rely on the fact that performMirror uses r.Host
handler := s.SnapshotMiddleware(s.MirrorMiddleware(s.RecordMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
w.Write([]byte("local response: " + string(body)))
}))))
bodyText := `{"test":"integrity"}`
req := httptest.NewRequest("POST", "http://localhost:8000/mirror/test", strings.NewReader(bodyText))
req.Header.Set("Content-Type", "application/json")
// Override r.Host to point to our mock upstream (performMirror will use it)
req.Host = strings.TrimPrefix(upstream.URL, "http://")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Wait for async operations
time.Sleep(200 * time.Millisecond)
var selfFile, mirrorFile string
_ = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
if strings.Contains(path, "/self/") {
selfFile = path
} else if strings.Contains(path, "/mirror/") {
mirrorFile = path
}
}
return nil
})
// Retry a few times for async operations
for i := 0; i < 10 && (selfFile == "" || mirrorFile == ""); i++ {
time.Sleep(100 * time.Millisecond)
_ = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
if strings.Contains(path, "/self/") {
selfFile = path
} else if strings.Contains(path, "/mirror/") {
mirrorFile = path
}
}
return nil
})
}
if selfFile == "" {
// Try one more scan
filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() && strings.HasSuffix(path, ".http") {
if strings.Contains(path, "/self/") {
selfFile = path
} else if strings.Contains(path, "/mirror/") {
mirrorFile = path
}
}
return nil
})
}
if selfFile == "" {
t.Fatal("Self recording file not found")
}
if mirrorFile == "" {
t.Fatal("Mirror recording file not found")
}
selfContent, _ := os.ReadFile(selfFile)
mirrorContent, _ := os.ReadFile(mirrorFile)
if !bytes.Contains(selfContent, []byte(bodyText)) {
t.Errorf("Self recording missing body. Content:\n%s", string(selfContent))
}
if !bytes.Contains(mirrorContent, []byte(bodyText)) {
t.Errorf("Mirror recording missing body. Content:\n%s", string(mirrorContent))
}
}
@@ -0,0 +1,89 @@
package handlers
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"time"
)
// SnapshotMiddleware creates an immutable snapshot of the request body and metadata.
func (s *Server) SnapshotMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check if we already have a snapshot (shouldn't happen with correct middleware order)
if _, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot); ok {
next.ServeHTTP(w, r)
return
}
// 2. Capture body with size limit (e.g. 2MB)
const maxBodySize = 2 * 1024 * 1024
var body []byte
if r.Body != nil {
buf, ok := bufferPool.Get().(*bytes.Buffer)
if !ok {
buf = new(bytes.Buffer)
}
buf.Reset()
defer bufferPool.Put(buf)
// Read up to maxBodySize + 1 to detect truncation
_, err := io.CopyN(buf, r.Body, maxBodySize+1)
_ = r.Body.Close()
if err != nil && !errors.Is(err, io.EOF) {
// If reading fails, proceed with empty body but log it?
// For now, we follow the concept and proceed.
body = []byte{}
} else {
body = buf.Bytes()
if int64(len(body)) > maxBodySize {
body = body[:maxBodySize]
// Optional: mark as truncated if we add that field later
}
// Copy to a fresh byte slice because buf.Bytes() is a slice into the buffer
body = append([]byte(nil), body...)
}
}
// 3. Create snapshot
snapshot := &RequestSnapshot{
Method: r.Method,
URL: cloneURL(r.URL),
Headers: r.Header.Clone(),
Body: body,
Host: r.Host,
Timestamp: time.Now(),
}
// 4. Inject into context
ctx := context.WithValue(r.Context(), SnapshotKey, snapshot)
r = r.WithContext(ctx)
// 5. Restore r.Body for downstream compatibility
r.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
next.ServeHTTP(w, r)
})
}
// cloneURL provides a deep copy of a URL.
func cloneURL(u *url.URL) *url.URL {
if u == nil {
return nil
}
u2 := *u
if u.User != nil {
u2.User = new(url.Userinfo)
*u2.User = *u.User
}
return &u2
}
@@ -0,0 +1,101 @@
package handlers
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestSnapshotMiddleware(t *testing.T) {
s := &Server{}
t.Run("CapturesBodyAndMetadata", func(t *testing.T) {
bodyText := "hello world"
req := httptest.NewRequest("POST", "http://example.com/foo?bar=baz", bytes.NewBufferString(bodyText))
req.Header.Set("Content-Type", "text/plain")
req.Host = "example.com"
recorded := false
handler := s.SnapshotMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recorded = true
// Verify snapshot in context
snapshot, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot)
if !ok {
t.Fatal("Snapshot not found in context")
}
if snapshot.Method != "POST" {
t.Errorf("Expected method POST, got %s", snapshot.Method)
}
if snapshot.URL.Path != "/foo" {
t.Errorf("Expected path /foo, got %s", snapshot.URL.Path)
}
if snapshot.Headers.Get("Content-Type") != "text/plain" {
t.Errorf("Expected header text/plain, got %s", snapshot.Headers.Get("Content-Type"))
}
if string(snapshot.Body) != bodyText {
t.Errorf("Expected body %s, got %s", bodyText, string(snapshot.Body))
}
if snapshot.Host != "example.com" {
t.Errorf("Expected host example.com, got %s", snapshot.Host)
}
// Verify r.Body is still readable
body, _ := io.ReadAll(r.Body)
if string(body) != bodyText {
t.Errorf("Expected r.Body to be %s, got %s", bodyText, string(body))
}
}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !recorded {
t.Error("Handler was not called")
}
})
t.Run("HandlesEmptyBody", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
handler := s.SnapshotMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
snapshot, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot)
if !ok {
t.Fatal("Snapshot not found in context")
}
if len(snapshot.Body) != 0 {
t.Errorf("Expected empty body, got %d bytes", len(snapshot.Body))
}
}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
})
t.Run("RespectsSizeLimit", func(t *testing.T) {
largeBody := make([]byte, 3*1024*1024) // 3MB
for i := range largeBody {
largeBody[i] = 'A'
}
req := httptest.NewRequest("POST", "http://example.com/foo", bytes.NewReader(largeBody))
handler := s.SnapshotMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
snapshot, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot)
if !ok {
t.Fatal("Snapshot not found in context")
}
const maxBodySize = 2 * 1024 * 1024
if len(snapshot.Body) != maxBodySize {
t.Errorf("Expected body size %d, got %d", maxBodySize, len(snapshot.Body))
}
}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
})
}
+139 -101
View File
@@ -4,46 +4,50 @@
<!-- SoundTouch 20 -->
<DEVICE ID="0x0923" PRODUCTNAME="SoundTouch 20">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch 30 -->
<DEVICE ID="0x0924" PRODUCTNAME="SoundTouch 30">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch Portable -->
<DEVICE ID="0x0925" PRODUCTNAME="SoundTouch Portable">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/Update.stu">
<IMAGE SUBID="0" LENGTH="105879988" CRC="0x2d5a971e" FILENAME="Update_ti_27.0.6.46330.5043500.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch App HTML5 -->
<DEVICE ID="0x0931" PRODUCTNAME="SoundTouch App HTML5">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.13" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/" DOCVERSION="MjAxOC0wMi0xNQ==">
<IMAGE SUBID="0" LENGTH="18377810" CRC="0xaa8209b0" FILENAME="Stockholm_27.0.13-4277-8963611.zip" />
<NOTES URL="https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/relnotes/releasenotes_##LANG##.xml" />
<FEATURE NAME="TRIO" STATUS="OFF" />
<FEATURE NAME="ASTREAM" STATUS="OFF" />
<FEATURE NAME="RVT" STATUS="ON" />
<FEATURE NAME="AD" STATUS="OFF" />
<RELEASE REVISION="27.0.13" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/"
DOCVERSION="MjAxOC0wMi0xNQ==">
<IMAGE SUBID="0" LENGTH="18377810" CRC="0xaa8209b0" FILENAME="Stockholm_27.0.13-4277-8963611.zip"/>
<NOTES URL="https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/relnotes/releasenotes_##LANG##.xml"/>
<FEATURE NAME="TRIO" STATUS="OFF"/>
<FEATURE NAME="ASTREAM" STATUS="OFF"/>
<FEATURE NAME="RVT" STATUS="ON"/>
<FEATURE NAME="AD" STATUS="OFF"/>
</RELEASE>
<PROTOCOL REVISION="67">
<IMAGE PLATFORM="IOS" URL="https://itunes.apple.com/us/app/soundtouch-controller/id708379313" />
<IMAGE PLATFORM="ANDROID" URL="https://play.google.com/store/apps/details?id=com.bose.soundtouch" />
<IMAGE PLATFORM="IOS" URL="https://itunes.apple.com/us/app/soundtouch-controller/id708379313"/>
<IMAGE PLATFORM="ANDROID" URL="https://play.google.com/store/apps/details?id=com.bose.soundtouch"/>
<IMAGE PLATFORM="KINDLE" URL="http://www.amazon.com/gp/mas/dl/android?asin=B00R4VJMMU"/>
<IMAGE PLATFORM="PC" URL="http://www.bose.com/soundtouch_app_update" />
<IMAGE PLATFORM="PC" URL="http://www.bose.com/soundtouch_app_update"/>
</PROTOCOL>
</HARDWARE>
</DEVICE>
@@ -51,216 +55,248 @@
<!-- Wave SoundTouch -->
<DEVICE ID="0x0932" PRODUCTNAME="Wave SoundTouch">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/n/Update.stu">
<IMAGE SUBID="0" LENGTH="112367416" CRC="0x49d88de4" FILENAME="Update_ti_27.0.6.46330.5043500.nelson.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/n/Update.stu">
<IMAGE SUBID="0" LENGTH="112367416" CRC="0x49d88de4" FILENAME="Update_ti_27.0.6.46330.5043500.nelson.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- VideoWave (Developer Keys) -->
<DEVICE ID="0x0944" PRODUCTNAME="VideoWave">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xf39b7005" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xf39b7005"
FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Lifestyle (Developer Keys) -->
<DEVICE ID="0x0945" PRODUCTNAME="Lifestyle">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xf39b7005" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xf39b7005"
FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch Stereo JC -->
<DEVICE ID="0x0935" PRODUCTNAME="SoundTouch Stereo JC">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/Update.stu">
<IMAGE SUBID="0" LENGTH="110991796" CRC="0x01e35713" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/Update.stu">
<IMAGE SUBID="0" LENGTH="110991796" CRC="0x01e35713" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch SA-4 -->
<DEVICE ID="0x0936" PRODUCTNAME="SoundTouch SA-4">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/Update.stu">
<IMAGE SUBID="0" LENGTH="110991796" CRC="0x01e35713" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/Update.stu">
<IMAGE SUBID="0" LENGTH="110991796" CRC="0x01e35713" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Cinemate -->
<DEVICE ID="0x0938" PRODUCTNAME="Cinemate">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/t/Update.stu">
<IMAGE SUBID="0" LENGTH="114125624" CRC="0x562de6f1" FILENAME="Update_ti_27.0.6.46330.5043500.triode.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/t/Update.stu">
<IMAGE SUBID="0" LENGTH="114125624" CRC="0x562de6f1" FILENAME="Update_ti_27.0.6.46330.5043500.triode.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch 10 -->
<DEVICE ID="0x0939" PRODUCTNAME="SoundTouch 10">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/r/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="95906856" CRC="0xf18fe026" FILENAME="Update_ti_27.0.6.46330.5043500.rhino.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/r/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="95906856" CRC="0xf18fe026" FILENAME="Update_ti_27.0.6.46330.5043500.rhino.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch SA-5 -->
<DEVICE ID="0x093A" PRODUCTNAME="SoundTouch SA-5">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/b/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="100957944" CRC="0x0b99190b" FILENAME="Update_ti_27.0.6.46330.5043500.burns.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/b/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="100957944" CRC="0x0b99190b" FILENAME="Update_ti_27.0.6.46330.5043500.burns.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch 20 -->
<DEVICE ID="0x093B" PRODUCTNAME="SoundTouch 20">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch 30 -->
<DEVICE ID="0x093C" PRODUCTNAME="SoundTouch 30">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Wave SoundTouch -->
<DEVICE ID="0x093D" PRODUCTNAME="Wave SoundTouch">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/n/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="100297132" CRC="0x2e2fd417" FILENAME="Update_ti_27.0.6.46330.5043500.nelson.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/n/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="100297132" CRC="0x2e2fd417" FILENAME="Update_ti_27.0.6.46330.5043500.nelson.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- VideoWave (Developer Keys) -->
<DEVICE ID="0x0946" PRODUCTNAME="VideoWave">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x1858fa51" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x1858fa51"
FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Lifestyle (Developer Keys) -->
<DEVICE ID="0x0947" PRODUCTNAME="Lifestyle">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x1858fa51" FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x1858fa51"
FILENAME="Update_ti_27.0.6.46330.5043500.marconidev.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch Stereo JC -->
<DEVICE ID="0x0940" PRODUCTNAME="SoundTouch Stereo JC">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="98921512" CRC="0x07521bda" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="98921512" CRC="0x07521bda" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch SA-4 -->
<DEVICE ID="0x0941" PRODUCTNAME="SoundTouch SA-4">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="98921512" CRC="0x07521bda" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/l/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="98921512" CRC="0x07521bda" FILENAME="Update_ti_27.0.6.46330.5043500.lisa.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Cinemate -->
<DEVICE ID="0x0942" PRODUCTNAME="Cinemate">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/t/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="102700460" CRC="0xfbcab635" FILENAME="Update_ti_27.0.6.46330.5043500.triode.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/t/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="102700460" CRC="0xfbcab635" FILENAME="Update_ti_27.0.6.46330.5043500.triode.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- VideoWave (Production Keys) -->
<DEVICE ID="0x0933" PRODUCTNAME="VideoWave">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/Update.stu">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xd48b1181" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/Update.stu">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xd48b1181"
FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Lifestyle (Production Keys) -->
<DEVICE ID="0x0934" PRODUCTNAME="Lifestyle">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/Update.stu">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xd48b1181" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.scm.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/Update.stu">
<IMAGE SUBID="0" LENGTH="215402464" CRC="0xd48b1181"
FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.scm.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- VideoWave (Production Keys) -->
<DEVICE ID="0x093E" PRODUCTNAME="VideoWave">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x3f489bd5" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x3f489bd5"
FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Lifestyle (Production Keys) -->
<DEVICE ID="0x093F" PRODUCTNAME="Lifestyle">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x3f489bd5" FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/m/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="203977300" CRC="0x3f489bd5"
FILENAME="Update_ti_27.0.6.46330.5043500.marconiprod.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Lifestyle -->
<DEVICE ID="0x094B" PRODUCTNAME="Lifestyle">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.5043530" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/Update.avu">
<IMAGE SUBID="0" LENGTH="475186323" CRC="0x523be82b" FILENAME="Update_signed_27.0.6.5043530.avu" />
</RELEASE>
<RELEASE REVISION="27.0.6.5043530" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2"
USBPATH="/ced/soundtouch/mr4_22097fe2/stu/Update.avu">
<IMAGE SUBID="0" LENGTH="475186323" CRC="0x523be82b" FILENAME="Update_signed_27.0.6.5043530.avu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- Lifestyle -->
<DEVICE ID="0x0948" PRODUCTNAME="Lifestyle">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="105878364" CRC="0xc0b3d401" FILENAME="Update_ti_27.0.6.46330.5043500.bardeen.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2">
<IMAGE SUBID="0" LENGTH="105878364" CRC="0xc0b3d401" FILENAME="Update_ti_27.0.6.46330.5043500.bardeen.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch 300 -->
<DEVICE ID="0x0949" PRODUCTNAME="SoundTouch 300">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/g/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="102384736" CRC="0xed21efd3" FILENAME="Update_ti_27.0.6.46330.5043500.ginger.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/g/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="102384736" CRC="0xed21efd3" FILENAME="Update_ti_27.0.6.46330.5043500.ginger.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
<!-- SoundTouch Wireless Link adapter -->
<DEVICE ID="0x094A" PRODUCTNAME="SoundTouch Wireless Link adapter">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com" URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu" />
</RELEASE>
<RELEASE REVISION="27.0.6.46330.5043500" HTTPHOST="https://downloads.bose.com"
URLPATH="ced/soundtouch/mr4_22097fe2" USBPATH="/ced/soundtouch/mr4_22097fe2/stu/s/sm2/Update.stu">
<IMAGE SUBID="0" LENGTH="99445800" CRC="0x536e6d6f" FILENAME="Update_ti_27.0.6.46330.5043500.sm2.stu"/>
</RELEASE>
</HARDWARE>
</DEVICE>
@@ -268,8 +304,8 @@
<DEVICE ID="0x000A" PRODUCTNAME="SoundTouch App-A" SUPPORTEDOS="4.4.0">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.1" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
<IMAGE SUBID="0" LENGTH="23351636" CRC="0x425b2109" FILENAME="SoundTouch-release_27.0.1-3345-bafe54d.apk" />
</RELEASE>
<IMAGE SUBID="0" LENGTH="23351636" CRC="0x425b2109" FILENAME="SoundTouch-release_27.0.1-3345-bafe54d.apk"/>
</RELEASE>
</HARDWARE>
</DEVICE>
@@ -277,8 +313,8 @@
<DEVICE ID="0x000B" PRODUCTNAME="SoundTouch App-I" SUPPORTEDOS="8.0.0">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.1" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
<IMAGE SUBID="0" LENGTH="47413805" CRC="0xe4bf4961" FILENAME="SoundTouch-27.0.1-3498-699a15c.ipa" />
</RELEASE>
<IMAGE SUBID="0" LENGTH="47413805" CRC="0xe4bf4961" FILENAME="SoundTouch-27.0.1-3498-699a15c.ipa"/>
</RELEASE>
</HARDWARE>
</DEVICE>
@@ -286,8 +322,9 @@
<DEVICE ID="0x000C" PRODUCTNAME="SoundTouch App-M" SUPPORTEDOS="mac_10_8">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.0" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
<IMAGE SUBID="0" LENGTH="117483653" CRC="0xd6ca482b" FILENAME="SoundTouch-app-installer-27.0.0-3377-1037583.dmg" />
</RELEASE>
<IMAGE SUBID="0" LENGTH="117483653" CRC="0xd6ca482b"
FILENAME="SoundTouch-app-installer-27.0.0-3377-1037583.dmg"/>
</RELEASE>
</HARDWARE>
</DEVICE>
@@ -295,8 +332,9 @@
<DEVICE ID="0x000E" PRODUCTNAME="SoundTouch App-M" SUPPORTEDOS="mac_10_8">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.0" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
<IMAGE SUBID="0" LENGTH="117483653" CRC="0xd6ca482b" FILENAME="SoundTouch-app-installer-27.0.0-3377-1037583.dmg" />
</RELEASE>
<IMAGE SUBID="0" LENGTH="117483653" CRC="0xd6ca482b"
FILENAME="SoundTouch-app-installer-27.0.0-3377-1037583.dmg"/>
</RELEASE>
</HARDWARE>
</DEVICE>
@@ -304,8 +342,8 @@
<DEVICE ID="0x000D" PRODUCTNAME="SoundTouch App-W" SUPPORTEDOS="windows_6_0">
<HARDWARE REVISION="00.01.00">
<RELEASE REVISION="27.0.0.3377" HTTPHOST="downloads.bose.com" URLPATH="/ced/soundtouch/mr4_22097fe2/">
<IMAGE SUBID="0" LENGTH="120307712" CRC="0x3e97da1f" FILENAME="SoundTouch-app-installer-27.0.0.3377.msi" />
</RELEASE>
<IMAGE SUBID="0" LENGTH="120307712" CRC="0x3e97da1f" FILENAME="SoundTouch-app-installer-27.0.0.3377.msi"/>
</RELEASE>
</HARDWARE>
</DEVICE>
+3
View File
@@ -127,6 +127,9 @@
<input type="checkbox" id="mirror-enabled"> Enable Background Mirroring to Bose Cloud
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="preferred-source-upstream"> Prefer Upstream Response for Mirrored Endpoints
</label>
<label for="mirror-endpoints">Mirror Endpoints (one per line, supports * wildcards):</label><br>
<textarea id="mirror-endpoints" rows="4" style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;" placeholder="/streaming/account/*/device/*/recent&#10;/accounts/*/devices/*/presets/*"></textarea>
<div class="info-box" style="margin-top: 5px; font-size: 0.85em; padding: 10px;">
+4
View File
@@ -155,6 +155,9 @@ async function fetchSettings() {
if (settings.mirror_enabled !== undefined) {
document.getElementById('mirror-enabled').checked = settings.mirror_enabled;
}
if (settings.preferred_source !== undefined) {
document.getElementById('preferred-source-upstream').checked = settings.preferred_source === 'upstream';
}
if (settings.mirror_endpoints) {
document.getElementById('mirror-endpoints').value = settings.mirror_endpoints.join('\n');
}
@@ -226,6 +229,7 @@ async function updateSettings() {
dns_upstream: document.getElementById('dns-upstream').value,
dns_bind_addr: document.getElementById('dns-bind').value,
mirror_enabled: document.getElementById('mirror-enabled').checked,
preferred_source: document.getElementById('preferred-source-upstream').checked ? 'upstream' : 'local',
mirror_endpoints: document.getElementById('mirror-endpoints').value.split('\n').map(s => s.trim()).filter(s => s !== ''),
internal_paths: document.getElementById('internal-paths').value.split('\n').map(s => s.trim()).filter(s => s !== ''),
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
+118 -40
View File
@@ -3,10 +3,12 @@
package marge
import (
"bytes"
"encoding/xml"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -66,16 +68,22 @@ func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) {
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourcesettings"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
providerID := 0
tokenType := "token"
for i, p := range constants.Providers {
if p == cs.SourceKeyType {
providerID = i + 1
if p == "SPOTIFY" {
tokenType = "token_version_3"
}
break
}
}
@@ -90,25 +98,41 @@ func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) {
UpdatedOn: DateStr,
Username: cs.SourceKeyAccount,
}
sxml.Credential.Type = "token"
sxml.Credential.Type = tokenType
sxml.Credential.Value = cs.Secret
return xml.Marshal(sxml)
}
// EscapeXML escapes special characters for XML.
func EscapeXML(s string) string {
var b bytes.Buffer
if err := xml.EscapeText(&b, []byte(s)); err != nil {
return s
}
return b.String()
}
// GetConfiguredSourceXML returns the XML representation of a configured source as a string.
func GetConfiguredSourceXML(cs models.ConfiguredSource) string {
providerID := 0
tokenType := "token"
for i, p := range constants.Providers {
if p == cs.SourceKeyType {
providerID = i + 1
if p == "SPOTIFY" {
tokenType = "token_version_3"
}
break
}
}
return fmt.Sprintf(`<source id="%s" type="Audio"><createdOn>%s</createdOn><credential type="token">%s</credential><name>%s</name><sourceproviderid>%d</sourceproviderid><sourcename>%s</sourcename><sourcesettings></sourcesettings><updatedOn>%s</updatedOn><username>%s</username></source>`,
cs.ID, DateStr, cs.Secret, cs.SourceKeyAccount, providerID, cs.DisplayName, DateStr, cs.SourceKeyAccount)
return fmt.Sprintf(`<source id="%s" type="Audio"><createdOn>%s</createdOn><credential type="%s">%s</credential><name>%s</name><sourceproviderid>%d</sourceproviderid><sourcename>%s</sourcename><sourceSettings></sourceSettings><updatedOn>%s</updatedOn><username>%s</username></source>`,
EscapeXML(cs.ID), DateStr, EscapeXML(tokenType), EscapeXML(cs.Secret), EscapeXML(cs.SourceKeyAccount), providerID, EscapeXML(cs.DisplayName), DateStr, EscapeXML(cs.SourceKeyAccount))
}
// PresetsToXML converts account presets to XML format for Marge responses.
@@ -127,12 +151,12 @@ func PresetsToXML(ds *datastore.DataStore, account, device string) ([]byte, erro
for i := range presets {
p := &presets[i]
res += fmt.Sprintf(`<preset buttonNumber="%s">`, p.ID)
res += fmt.Sprintf(`<containerArt>%s</containerArt>`, p.ContainerArt)
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, p.Type)
res += fmt.Sprintf(`<preset buttonNumber="%s">`, EscapeXML(p.ID))
res += fmt.Sprintf(`<containerArt>%s</containerArt>`, EscapeXML(p.ContainerArt))
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, EscapeXML(p.Type))
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, DateStr)
res += fmt.Sprintf(`<location>%s</location>`, p.Location)
res += fmt.Sprintf(`<name>%s</name>`, p.Name)
res += fmt.Sprintf(`<location>%s</location>`, EscapeXML(p.Location))
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(p.Name))
// Content Item Source
for j := range sources {
@@ -174,22 +198,30 @@ func RecentsToXML(ds *datastore.DataStore, account, device string) ([]byte, erro
lastPlayed = time.Unix(sec, 0).Format(time.RFC3339)
}
res += fmt.Sprintf(`<recent id="%s">`, r.ID)
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, r.Type)
res += fmt.Sprintf(`<recent id="%s">`, EscapeXML(r.ID))
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, EscapeXML(r.Type))
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, DateStr)
res += fmt.Sprintf(`<lastplayedat>%s</lastplayedat>`, lastPlayed)
res += fmt.Sprintf(`<location>%s</location>`, r.Location)
res += fmt.Sprintf(`<name>%s</name>`, r.Name)
res += fmt.Sprintf(`<lastplayedat>%s</lastplayedat>`, EscapeXML(lastPlayed))
res += fmt.Sprintf(`<location>%s</location>`, EscapeXML(r.Location))
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(r.Name))
// Content Item Source
sourceID := ""
for j := range sources {
s := sources[j]
if s.ID == r.SourceID || (s.SourceKeyType == r.Source && s.SourceKeyAccount == r.SourceAccount) {
res += GetConfiguredSourceXML(s)
sourceID = s.ID
break
}
}
if sourceID != "" {
res += fmt.Sprintf(`<sourceid>%s</sourceid>`, EscapeXML(sourceID))
}
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, DateStr)
res += `</recent>`
}
@@ -201,7 +233,20 @@ func RecentsToXML(ds *datastore.DataStore, account, device string) ([]byte, erro
// ProviderSettingsToXML generates provider settings XML for the specified account.
func ProviderSettingsToXML(account string) string {
return fmt.Sprintf(`<providerSettings><providerSetting><boseId>%s</boseId><keyName>ELIGIBLE_FOR_TRIAL</keyName><value>true</value><providerId>14</providerId></providerSetting></providerSettings>`, account)
return xml.Header + fmt.Sprintf(`<providerSettings>
<providerSetting>
<boseId>%s</boseId>
<keyName>ELIGIBLE_FOR_TRIAL</keyName>
<value>false</value>
<providerId>14</providerId>
</providerSetting>
<providerSetting>
<boseId>%s</boseId>
<keyName>STREAMING_QUALITY</keyName>
<value>2</value>
<providerId>15</providerId>
</providerSetting>
</providerSettings>`, EscapeXML(account), EscapeXML(account))
}
// SoftwareUpdateToXML generates software update configuration XML.
@@ -218,7 +263,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
return nil, err
}
res := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><account id="%s"><accountStatus>OK</accountStatus><devices>`, account)
res := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><account id="%s"><accountStatus>OK</accountStatus><devices>`, EscapeXML(account))
lastDeviceID := ""
for _, entry := range entries {
@@ -234,13 +279,27 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
continue
}
res += fmt.Sprintf(`<device deviceid="%s">`, deviceID)
res += fmt.Sprintf(`<attachedProduct product_code="%s"><components/><productlabel>%s</productlabel><serialnumber>%s</serialnumber></attachedProduct>`,
info.ProductCode, info.ProductCode, info.ProductSerialNumber)
res += fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(deviceID))
res += fmt.Sprintf(`<attachedProduct product_code="%s">`, EscapeXML(info.ProductCode))
if len(info.Components) > 0 {
res += `<components>`
for _, comp := range info.Components {
res += fmt.Sprintf(`<component type="%s"><componentlabel>%s</componentlabel><firmware-version>%s</firmware-version><serialnumber>%s</serialnumber></component>`,
EscapeXML(comp.Category), EscapeXML(comp.Category), EscapeXML(comp.SoftwareVersion), EscapeXML(comp.SerialNumber))
}
res += `</components>`
} else {
res += `<components/>`
}
res += fmt.Sprintf(`<productlabel>%s</productlabel><serialnumber>%s</serialnumber></attachedProduct>`,
EscapeXML(info.ProductCode), EscapeXML(info.ProductSerialNumber))
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, DateStr)
res += fmt.Sprintf(`<firmwareVersion>%s</firmwareVersion>`, info.FirmwareVersion)
res += fmt.Sprintf(`<ipaddress>%s</ipaddress>`, info.IPAddress)
res += fmt.Sprintf(`<name>%s</name>`, info.Name)
res += fmt.Sprintf(`<firmwareVersion>%s</firmwareVersion>`, EscapeXML(info.FirmwareVersion))
res += fmt.Sprintf(`<ipaddress>%s</ipaddress>`, EscapeXML(info.IPAddress))
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(info.Name))
presets, _ := PresetsToXML(ds, account, deviceID)
if len(presets) > len(xml.Header) {
@@ -338,12 +397,12 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
}
// Return XML for the single preset
res := fmt.Sprintf(`<preset buttonNumber="%s">`, presetObj.ID)
res += fmt.Sprintf(`<containerArt>%s</containerArt>`, presetObj.ContainerArt)
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, presetObj.Type)
res := fmt.Sprintf(`<preset buttonNumber="%s">`, EscapeXML(presetObj.ID))
res += fmt.Sprintf(`<containerArt>%s</containerArt>`, EscapeXML(presetObj.ContainerArt))
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, EscapeXML(presetObj.Type))
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, DateStr)
res += fmt.Sprintf(`<location>%s</location>`, presetObj.Location)
res += fmt.Sprintf(`<name>%s</name>`, presetObj.Name)
res += fmt.Sprintf(`<location>%s</location>`, EscapeXML(presetObj.Location))
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(presetObj.Name))
res += GetConfiguredSourceXML(*matchingSrc)
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, DateStr)
res += `</preset>`
@@ -354,12 +413,12 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
// AddRecent adds or updates a recent item for the specified account and device.
func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
if err != nil && !os.IsNotExist(err) {
return nil, err
}
recents, err := ds.GetRecents(account, device)
if err != nil {
if err != nil && !os.IsNotExist(err) {
return nil, err
}
@@ -376,7 +435,25 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
matchingSrc := findMatchingSource(sources, newRecentElem.SourceID)
if matchingSrc == nil {
return nil, fmt.Errorf("invalid account/source")
// If we don't have a matching source, try to guess or create a virtual one.
// For Spotify, the location usually starts with /playback/container/c3...
// which is a base64 encoded spotify: URI.
if strings.Contains(newRecentElem.Location, "spotify") || newRecentElem.SourceID == "SPOTIFY" {
matchingSrc = &models.ConfiguredSource{
ID: newRecentElem.SourceID,
DisplayName: "Spotify",
}
matchingSrc.SourceKey.Type = "SPOTIFY"
matchingSrc.SourceKeyType = "SPOTIFY"
} else {
// fallback to a generic source if we can't guess
matchingSrc = &models.ConfiguredSource{
ID: newRecentElem.SourceID,
DisplayName: "Other",
}
matchingSrc.SourceKey.Type = "INVALID"
matchingSrc.SourceKeyType = "INVALID"
}
}
utcTime := parseLastPlayedAt(newRecentElem.LastPlayedAt)
@@ -464,13 +541,14 @@ func createNewRecent(recents []models.ServiceRecent, name string, matchingSrc *m
func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.ConfiguredSource, createdOn string, utcTime int64) []byte {
lastPlayed := time.Unix(utcTime, 0).Format(time.RFC3339)
res := fmt.Sprintf(`<recent id="%s">`, recentObj.ID)
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, recentObj.Type)
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, createdOn)
res += fmt.Sprintf(`<lastplayedat>%s</lastplayedat>`, lastPlayed)
res += fmt.Sprintf(`<location>%s</location>`, recentObj.Location)
res += fmt.Sprintf(`<name>%s</name>`, recentObj.Name)
res := fmt.Sprintf(`<recent id="%s">`, EscapeXML(recentObj.ID))
res += fmt.Sprintf(`<contentItemType>%s</contentItemType>`, EscapeXML(recentObj.Type))
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(createdOn))
res += fmt.Sprintf(`<lastplayedat>%s</lastplayedat>`, EscapeXML(lastPlayed))
res += fmt.Sprintf(`<location>%s</location>`, EscapeXML(recentObj.Location))
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(recentObj.Name))
res += GetConfiguredSourceXML(*matchingSrc)
res += fmt.Sprintf(`<sourceid>%s</sourceid>`, EscapeXML(matchingSrc.ID))
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, DateStr)
res += `</recent>`
@@ -498,11 +576,11 @@ func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
}
createdOn := time.Now().Format(time.RFC3339)
res := fmt.Sprintf(`<device deviceid="%s">`, newDeviceElem.DeviceID)
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, createdOn)
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(newDeviceElem.DeviceID))
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(createdOn))
res += `<ipaddress></ipaddress>`
res += fmt.Sprintf(`<name>%s</name>`, newDeviceElem.Name)
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, createdOn)
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(newDeviceElem.Name))
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(createdOn))
res += `</device>`
return append([]byte(xml.Header), []byte(res)...), nil
+106 -7
View File
@@ -2,6 +2,8 @@ package marge
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -64,6 +66,104 @@ func TestMargeXML(t *testing.T) {
}
}
func TestEscapeXML(t *testing.T) {
input := "Antenne Chillout & Other"
expected := "Antenne Chillout &amp; Other"
actual := EscapeXML(input)
if actual != expected {
t.Errorf("Expected %s, got %s", expected, actual)
}
inputWithAll := "< > & ' \""
expectedWithAll := "&lt; &gt; &amp; &#39; &#34;"
actualWithAll := EscapeXML(inputWithAll)
if actualWithAll != expectedWithAll {
t.Errorf("Expected %s, got %s", expectedWithAll, actualWithAll)
}
}
func TestRecentsXML_EmptyIDFix(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 empty ID
recentsXML := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent id="" deviceID="test-dev" utcTime="1708896000">
<contentItem source="SPOTIFY" type="tracklisturl" location="/test" sourceAccount="user" isPresetable="true">
<itemName>Test Item</itemName>
</contentItem>
</recent>
</recents>`)
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), recentsXML, 0644)
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte("<sources/>"), 0644)
// Fetching should fix the empty ID
recents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("Failed to get recents: %v", err)
}
if len(recents) != 1 {
t.Fatalf("Expected 1 recent, got %d", len(recents))
}
if recents[0].ID == "" {
t.Errorf("Expected non-empty ID for recent")
}
if _, err := strconv.Atoi(recents[0].ID); err != nil {
t.Errorf("Expected numeric ID, got %s", recents[0].ID)
}
// Verify the XML output also has the non-empty ID
xmlData, err := RecentsToXML(ds, account, device)
if err != nil {
t.Fatalf("RecentsToXML failed: %v", err)
}
if strings.Contains(string(xmlData), `recent id=""`) {
t.Errorf("XML should not contain empty recent ID: %s", string(xmlData))
}
if !strings.Contains(string(xmlData), `recent id="1"`) {
t.Errorf("XML should contain fixed numeric ID: %s", string(xmlData))
}
}
func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
src := models.ConfiguredSource{
ID: "101&202",
DisplayName: "Test & Source",
Secret: "key&value",
}
src.SourceKeyAccount = "user&name"
xml := GetConfiguredSourceXML(src)
if !strings.Contains(xml, "id=\"101&amp;202\"") {
t.Errorf("ID not escaped in attribute: %s", xml)
}
if strings.Contains(xml, "<sourceid>101&amp;202</sourceid>") {
t.Errorf("ID should not be escaped in sourceid tag inside source tag anymore: %s", xml)
}
if !strings.Contains(xml, "<sourcename>Test &amp; Source</sourcename>") {
t.Errorf("DisplayName not escaped: %s", xml)
}
if !strings.Contains(xml, ">key&amp;value</credential>") {
t.Errorf("Secret not escaped: %s", xml)
}
}
func TestAddRecent_TimestampPreservation(t *testing.T) {
tempDir, err := os.MkdirTemp("", "marge-test-*")
if err != nil {
@@ -112,9 +212,6 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
t.Fatalf("Expected 1 recent, got %d", len(recents))
}
originalCreatedOn := recents[0].UtcTime // It's stored in UtcTime field (unix string) in models.ServiceRecent but the AddRecent return XML uses <createdOn> tag which is DateStr or Now depending on logic.
// Actually let's check what AddRecent returns.
// 3. Add the same recent again (it should move to front and preserve createdOn)
// We'll wait a second to ensure time.Now() would be different if it were used for createdOn
time.Sleep(1 * time.Second)
@@ -134,9 +231,11 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
t.Errorf("Expected still 1 recent, got %d", len(recents))
}
// Check that UtcTime was updated (it should be, for lastplayedat)
if recents[0].UtcTime == originalCreatedOn {
// Wait, if they are the same it might be because we didn't specify LastPlayedAt in input XML so it used Now.
// Since we slept, it should be different.
// Verify that sourceid is present in recent response and is a sibling to source tag
if !strings.Contains(string(respXML), "<sourceid>101</sourceid>") {
t.Errorf("Expected sourceid in recent response: %s", string(respXML))
}
if strings.Contains(string(respXML), "<source id=\"101\" type=\"Audio\"><createdOn>2012-09-19T12:43:00.000+00:00</createdOn><credential type=\"token\">key&amp;value</credential><name>test-user</name><sourceid>101</sourceid>") {
t.Errorf("sourceid should not be inside source tag: %s", string(respXML))
}
}
+23 -6
View File
@@ -117,9 +117,18 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response
// Clone request
clonedReq = req.Clone(req.Context())
if req.Body != nil {
bodyBytes, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
log.Printf("failed to read request body for async recording: %v", err)
clonedReq.Body = http.NoBody
} else {
// Reset original body for subsequent consumers (though Record is usually called at the end)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Set body for async task
clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedReq.ContentLength = int64(len(bodyBytes))
}
}
// Clone response if present
@@ -130,9 +139,17 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response
Request: clonedReq,
}
if res.Body != nil {
bodyBytes, _ := io.ReadAll(res.Body)
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedRes.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
log.Printf("failed to read response body for async recording: %v", err)
res.Body = http.NoBody
clonedRes.Body = http.NoBody
} else {
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedRes.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedRes.ContentLength = int64(len(bodyBytes))
}
}
}
} else {