mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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>
287 lines
6.8 KiB
Go
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
|
|
}
|