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:
Tobias Gesellchen
2026-05-23 13:08:51 +02:00
co-authored by Claude Sonnet 4.6
parent b3bab622cd
commit 1eb1fefc1d
2 changed files with 164 additions and 5 deletions
@@ -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))
}
}
+33 -5
View File
@@ -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