diff --git a/pkg/service/datastore/content_item_case_test.go b/pkg/service/datastore/content_item_case_test.go new file mode 100644 index 0000000..00d5cea --- /dev/null +++ b/pkg/service/datastore/content_item_case_test.go @@ -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 +// (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 + // child element, only the first preset has createdOn/updatedOn. + presetsXML := ` + + + + Internet Radio + + + + + Internet Radio + + + + + Internet Radio + + +` + 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 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 := ` + + + + Internet Radio + + +` + 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), " after auto-rewrite:\n%s", string(rewritten)) + } + + if !strings.Contains(string(rewritten), " after auto-rewrite:\n%s", string(rewritten)) + } +} diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 3b501cb..21d603e 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -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 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 (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 (capital C); normalise to lowercase + // before unmarshaling so legacy files are parsed correctly. + normalized := bytes.ReplaceAll(data, []byte(""), []byte("")) + + 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