mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
fix(datastore): parse legacy <ContentItem> (capital C) in Presets.xml
encoding/xml is case-sensitive, so Presets.xml files written by older AfterTouch versions using <ContentItem> (capital C) had all source, location, and type attributes silently dropped on read. Every preset for such a device had empty fields, causing mapPresetsToFullResponse to skip them all — the speaker received /full with zero presets and stored nothing. Fix: normalise <ContentItem> → <contentItem> before unmarshaling in the new readPresetsLocked helper. If normalisation was needed, GetPresets rewrites the file in canonical form after releasing the read lock, so the issue self-heals on first service start with no manual intervention. Diagnosed via the i218 encrypted diagnostic export (device 304511B46CBC, ST30 Master Bedroom): health check speaker_presets_count reported "Speaker shows 0 preset slot(s); service Presets.xml has 6", and the service log showed six [Marge] /full: skipping preset N — source "" messages per /full call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b3bab622cd
commit
1eb1fefc1d
@@ -0,0 +1,131 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetPresets_CapitalCContentItem checks that Presets.xml files written by
|
||||
// older AfterTouch versions (or verbatim speaker XML) using <ContentItem>
|
||||
// (capital C) are parsed correctly. encoding/xml is case-sensitive, so without
|
||||
// the normalisation in GetPresets the source and location fields would be empty
|
||||
// and /full would silently skip all presets for the device (i218 diagnostic).
|
||||
func TestGetPresets_CapitalCContentItem(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-capital-c-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "7961999"
|
||||
device := "304511B46CBC"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Verbatim format from the i218 diagnostic: capital-C ContentItem, no
|
||||
// <sourceid> child element, only the first preset has createdOn/updatedOn.
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1778969808" updatedOn="1778969808">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/OPB.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/AllClassicalPortland.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="3">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/AncientFM.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
if len(presets) != 3 {
|
||||
t.Fatalf("expected 3 presets, got %d (capital-C ContentItem not parsed)", len(presets))
|
||||
}
|
||||
|
||||
for i, p := range presets {
|
||||
if p.Source != "LOCAL_INTERNET_RADIO" {
|
||||
t.Errorf("preset %d: expected Source=LOCAL_INTERNET_RADIO, got %q", i+1, p.Source)
|
||||
}
|
||||
if p.Location == "" {
|
||||
t.Errorf("preset %d: Location is empty — ContentItem attributes not parsed", i+1)
|
||||
}
|
||||
if p.Name != "Internet Radio" {
|
||||
t.Errorf("preset %d: expected Name=Internet Radio, got %q", i+1, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if presets[0].CreatedOn != "1778969808" {
|
||||
t.Errorf("preset 1: expected CreatedOn=1778969808, got %q", presets[0].CreatedOn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPresets_CapitalCRewritesFile checks that after GetPresets detects the
|
||||
// legacy <ContentItem> format it rewrites Presets.xml in canonical lowercase
|
||||
// form, so subsequent reads are clean without needing the compat shim.
|
||||
func TestGetPresets_CapitalCRewritesFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-capital-c-rewrite-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "7961999"
|
||||
device := "304511B46CBC"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
presetsPath := filepath.Join(deviceDir, "Presets.xml")
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1778969808" updatedOn="1778969808">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/OPB.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(presetsPath, []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
if _, err := ds.GetPresets(account, device); err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
rewritten, err := os.ReadFile(presetsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read rewritten Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(string(rewritten), "<ContentItem") {
|
||||
t.Errorf("Presets.xml still contains <ContentItem> after auto-rewrite:\n%s", string(rewritten))
|
||||
}
|
||||
|
||||
if !strings.Contains(string(rewritten), "<contentItem") {
|
||||
t.Errorf("Presets.xml missing canonical <contentItem> after auto-rewrite:\n%s", string(rewritten))
|
||||
}
|
||||
}
|
||||
@@ -928,6 +928,26 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
|
||||
// GetPresets retrieves all presets for the specified account and device.
|
||||
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
|
||||
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if needsRewrite {
|
||||
log.Printf("[Datastore] Presets.xml for device %s used legacy <ContentItem> format; rewriting in canonical form", device)
|
||||
|
||||
if werr := ds.SavePresets(account, device, presets); werr != nil {
|
||||
log.Printf("[Datastore] failed to rewrite normalised Presets.xml for device %s: %v", device, werr)
|
||||
}
|
||||
}
|
||||
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// readPresetsLocked is the locked read half of GetPresets. It returns the
|
||||
// parsed presets and a flag indicating whether the on-disk file used the
|
||||
// legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
@@ -936,10 +956,10 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []models.ServicePreset{}, nil
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var presetsWrap struct {
|
||||
@@ -960,8 +980,16 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &presetsWrap); err != nil {
|
||||
return nil, fmt.Errorf("malformed presets XML at %s: %w", path, err)
|
||||
// encoding/xml is case-sensitive. Older AfterTouch versions (and raw
|
||||
// speaker XML) used <ContentItem> (capital C); normalise to lowercase
|
||||
// before unmarshaling so legacy files are parsed correctly.
|
||||
normalized := bytes.ReplaceAll(data, []byte("<ContentItem"), []byte("<contentItem"))
|
||||
normalized = bytes.ReplaceAll(normalized, []byte("</ContentItem>"), []byte("</contentItem>"))
|
||||
|
||||
needsRewrite := !bytes.Equal(normalized, data)
|
||||
|
||||
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
|
||||
return nil, false, fmt.Errorf("malformed presets XML at %s: %w", path, err)
|
||||
}
|
||||
|
||||
presets := []models.ServicePreset{}
|
||||
@@ -988,7 +1016,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
})
|
||||
}
|
||||
|
||||
return presets, nil
|
||||
return presets, needsRewrite, nil
|
||||
}
|
||||
|
||||
// repairLeakedSource quietly substitutes the speaker-perspective
|
||||
|
||||
Reference in New Issue
Block a user