Files
Bose-SoundTouch/pkg/models/presets.go
T
Tobias GesellchenandClaude Opus 4.7 c668c732df fix(#308): handle placeholder presets without panicking
The ST10's /presets response after a factory reset emits self-closing
<preset/> entries with no ContentItem child. cmd/soundtouch-cli's
getPresets() handled the missing ContentItem in GetDisplayName() but
then dereferenced preset.ContentItem.Source on the next line, panicking
with "invalid memory address or nil pointer dereference" the moment the
loop reached the first empty entry.

A second placeholder shape was observed on healthy devices that were
never reset: <preset id="0"><ContentItem source="INVALID_SOURCE"
isPresetable="true"/></preset>. ContentItem is non-nil here, so the
previous "ContentItem != nil" guard at other call sites still let
these placeholders through into listings and into the AfterTouch
datastore.

Fix shape:

  pkg/models/presets.go - extend Preset.IsEmpty() to recognise both
  shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE").
  HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest
  about which slots actually carry playable content.

  cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice
  via IsEmpty before the print loop, and switch the still-printed
  fields to the existing nil-safe Get* helpers.

  pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem ==
  nil" continue-guard to IsEmpty so Shape B placeholders don't get
  persisted in the AfterTouch datastore and then surface as junk
  rows in the admin web UI.

  cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same
  nil-guard upgrade. These already nil-checked so were crash-safe;
  the change is for consistency and to stop printing
  "Preset 0:  (INVALID_SOURCE)" demo lines.

  examples/preset-management/main.go - had the same latent crash as
  cmd_info.go; same fix shape.

Regression tests in pkg/models/presets_test.go cover both shapes using
the exact XML observed in the wild: the reporter's three <preset/>
placeholders plus the three INVALID_SOURCE entries from a live device.
The reporter XML test walks every preset through the same accessor
path the CLI used and asserts no panic.

The soundtouch-web Go code does not deref preset.ContentItem.X
anywhere - presets flow through as JSON - so no separate crash trap
exists there. The web frontend will pick up the cleaner data once
syncPresets stops persisting placeholders.

Closes #308

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:36:55 +02:00

287 lines
6.8 KiB
Go

package models
import (
"encoding/xml"
"strconv"
"time"
)
// Presets represents the response from /presets endpoint
// Note: POST /presets is officially not supported by the SoundTouch API.
// Presets can only be read, not created or modified via the API.
type Presets struct {
XMLName xml.Name `xml:"presets"`
Preset []Preset `xml:"preset"`
}
// Preset represents an individual preset
// Presets are read-only via the API and can only be created/modified
// through the SoundTouch app or physical device controls.
type Preset struct {
XMLName xml.Name `xml:"preset"`
ID int `xml:"id,attr"`
CreatedOn *int64 `xml:"createdOn,attr,omitempty"`
UpdatedOn *int64 `xml:"updatedOn,attr,omitempty"`
ContentItem *ContentItem `xml:"ContentItem,omitempty"`
}
// GetCreatedTime returns the creation time as a time.Time
func (p *Preset) GetCreatedTime() time.Time {
if p.CreatedOn != nil {
return time.Unix(*p.CreatedOn, 0)
}
return time.Time{}
}
// GetUpdatedTime returns the last updated time as a time.Time
func (p *Preset) GetUpdatedTime() time.Time {
if p.UpdatedOn != nil {
return time.Unix(*p.UpdatedOn, 0)
}
return time.Time{}
}
// HasTimestamps returns true if the preset has creation/update timestamps
func (p *Preset) HasTimestamps() bool {
return p.CreatedOn != nil || p.UpdatedOn != nil
}
// GetDisplayName returns the best available display name for the preset
func (p *Preset) GetDisplayName() string {
if p.ContentItem != nil && p.ContentItem.ItemName != "" {
return p.ContentItem.ItemName
}
return "Preset " + strconv.Itoa(p.ID)
}
// GetArtworkURL returns the artwork URL if available
func (p *Preset) GetArtworkURL() string {
if p.ContentItem != nil && p.ContentItem.ContainerArt != "" {
return p.ContentItem.ContainerArt
}
return ""
}
// IsSpotifyPreset returns true if this is a Spotify preset
func (p *Preset) IsSpotifyPreset() bool {
return p.ContentItem != nil && p.ContentItem.Source == "SPOTIFY"
}
// IsEmpty returns true if the preset has no playable content. Two
// placeholder shapes are observed in the wild and both count as empty:
//
// - <preset/> (or <preset id="0"/>) — no ContentItem child at all.
// Emitted by some firmware after a factory reset (issue #308).
// - <preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true"/></preset>
// — a placeholder ContentItem the firmware uses for unconfigured
// slots, observed on FW 27.0.6 even on devices that were never
// reset.
//
// Treating both as empty keeps GetEmptyPresetSlots, GetUsedPresetSlots
// and HasPresets honest, and lets callers safely skip placeholders
// before formatting a preset for display.
func (p *Preset) IsEmpty() bool {
if p.ContentItem == nil {
return true
}
return p.ContentItem.Source == "" || p.ContentItem.Source == "INVALID_SOURCE"
}
// GetSource returns the source of the preset content
func (p *Preset) GetSource() string {
if p.ContentItem != nil {
return p.ContentItem.Source
}
return ""
}
// GetSourceAccount returns the source account of the preset content
func (p *Preset) GetSourceAccount() string {
if p.ContentItem != nil {
return p.ContentItem.SourceAccount
}
return ""
}
// GetContentType returns the content type of the preset
func (p *Preset) GetContentType() string {
if p.ContentItem != nil {
return p.ContentItem.Type
}
return ""
}
// GetLocation returns the content location/URL
func (p *Preset) GetLocation() string {
if p.ContentItem != nil {
return p.ContentItem.Location
}
return ""
}
// IsPresetable returns true if the content can be saved as a preset
func (p *Preset) IsPresetable() bool {
return p.ContentItem != nil && p.ContentItem.IsPresetable
}
// GetPresetCount returns the total number of presets
func (ps *Presets) GetPresetCount() int {
return len(ps.Preset)
}
// GetPresetByID returns a preset by its ID
func (ps *Presets) GetPresetByID(id int) *Preset {
for _, preset := range ps.Preset {
if preset.ID == id {
return &preset
}
}
return nil
}
// GetSpotifyPresets returns all Spotify presets
func (ps *Presets) GetSpotifyPresets() []Preset {
var spotify []Preset
for _, preset := range ps.Preset {
if preset.IsSpotifyPreset() {
spotify = append(spotify, preset)
}
}
return spotify
}
// GetPresetsBySource returns presets filtered by source
func (ps *Presets) GetPresetsBySource(source string) []Preset {
var filtered []Preset
for _, preset := range ps.Preset {
if preset.GetSource() == source {
filtered = append(filtered, preset)
}
}
return filtered
}
// GetEmptyPresetSlots returns preset IDs that are empty (1-6)
func (ps *Presets) GetEmptyPresetSlots() []int {
var empty []int
used := make(map[int]bool)
// Mark used slots
for _, preset := range ps.Preset {
if !preset.IsEmpty() {
used[preset.ID] = true
}
}
// Find empty slots (1-6 are typical preset slots)
for i := 1; i <= 6; i++ {
if !used[i] {
empty = append(empty, i)
}
}
return empty
}
// HasPresets returns true if there are any presets configured
func (ps *Presets) HasPresets() bool {
for _, preset := range ps.Preset {
if !preset.IsEmpty() {
return true
}
}
return false
}
// GetUsedPresetSlots returns preset IDs that have content
func (ps *Presets) GetUsedPresetSlots() []int {
var used []int
for _, preset := range ps.Preset {
if !preset.IsEmpty() {
used = append(used, preset.ID)
}
}
return used
}
// GetMostRecentPreset returns the most recently updated preset
func (ps *Presets) GetMostRecentPreset() *Preset {
var (
mostRecent *Preset
latestTime int64
)
for _, preset := range ps.Preset {
if preset.UpdatedOn != nil && *preset.UpdatedOn > latestTime {
latestTime = *preset.UpdatedOn
mostRecent = &preset
} else if preset.CreatedOn != nil && preset.UpdatedOn == nil && *preset.CreatedOn > latestTime {
latestTime = *preset.CreatedOn
mostRecent = &preset
}
}
return mostRecent
}
// GetOldestPreset returns the oldest preset
func (ps *Presets) GetOldestPreset() *Preset {
var oldest *Preset
var earliestTime int64 = 9223372036854775807 // max int64
for _, preset := range ps.Preset {
if preset.CreatedOn != nil && *preset.CreatedOn < earliestTime {
earliestTime = *preset.CreatedOn
oldest = &preset
}
}
return oldest
}
// GetPresetsSummary returns a summary of preset usage
func (ps *Presets) GetPresetsSummary() map[string]int {
summary := map[string]int{
"total": ps.GetPresetCount(),
"used": len(ps.GetUsedPresetSlots()),
"empty": len(ps.GetEmptyPresetSlots()),
"spotify": len(ps.GetSpotifyPresets()),
}
// Count by source
sources := make(map[string]int)
for _, preset := range ps.Preset {
if !preset.IsEmpty() {
source := preset.GetSource()
sources[source]++
}
}
// Add source counts to summary
for source, count := range sources {
summary[source] = count
}
return summary
}