mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Improve parity with upstream Bose services
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 /accounts/*/devices/*/presets/*"></textarea>
|
||||
<div class="info-box" style="margin-top: 5px; font-size: 0.85em; padding: 10px;">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 & Other"
|
||||
actual := EscapeXML(input)
|
||||
if actual != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, actual)
|
||||
}
|
||||
|
||||
inputWithAll := "< > & ' \""
|
||||
expectedWithAll := "< > & ' ""
|
||||
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&202\"") {
|
||||
t.Errorf("ID not escaped in attribute: %s", xml)
|
||||
}
|
||||
if strings.Contains(xml, "<sourceid>101&202</sourceid>") {
|
||||
t.Errorf("ID should not be escaped in sourceid tag inside source tag anymore: %s", xml)
|
||||
}
|
||||
if !strings.Contains(xml, "<sourcename>Test & Source</sourcename>") {
|
||||
t.Errorf("DisplayName not escaped: %s", xml)
|
||||
}
|
||||
if !strings.Contains(xml, ">key&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&value</credential><name>test-user</name><sourceid>101</sourceid>") {
|
||||
t.Errorf("sourceid should not be inside source tag: %s", string(respXML))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user