Files
Tobias GesellchenandClaude Opus 4.8 28d7675fc4 refactor(handlers): resolve client IP via a clientHost helper
Route every HTTP read of the client IP through a single clientHost(r)
helper backed by chi's new middleware.GetClientIP, falling back to the
socket peer from r.RemoteAddr. AddDeviceToAccount now takes a bare client
host instead of a "host:port" RemoteAddr. Behavior is unchanged in this
commit (no ClientIP middleware is wired yet, so the fallback is always
taken); a follow-up wires middleware.ClientIP and removes the deprecated
middleware.RealIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:15:49 +02:00

2475 lines
80 KiB
Go

// Package marge provides XML generation and data management for the Marge service,
// which handles SoundTouch device configuration, presets, recents, and account management.
package marge
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/xml"
"fmt"
"log"
"net"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// FormatTime formats a time according to the Bose SoundTouch standard.
func FormatTime(t time.Time) string {
return t.UTC().Format("2006-01-02T15:04:05.000+00:00")
}
// SourceProviders returns a list of available media source providers.
func SourceProviders() []models.SourceProvider {
providers := make([]models.SourceProvider, 0, len(constants.StaticProviders))
for _, p := range constants.StaticProviders {
providers = append(providers, models.SourceProvider{
ID: p.ID,
CreatedOn: p.CreatedOn,
Name: p.Name,
UpdatedOn: p.UpdatedOn,
})
}
return providers
}
// SourceProvidersXML represents the XML structure for source providers.
type SourceProvidersXML struct {
XMLName xml.Name `xml:"sourceProviders"`
Providers []models.SourceProvider `xml:"sourceprovider"`
}
// SourceProvidersToXML converts source providers to XML format.
func SourceProvidersToXML() ([]byte, error) {
sp := SourceProvidersXML{
Providers: SourceProviders(),
}
data, err := xml.MarshalIndent(sp, "", " ")
if err != nil {
return nil, err
}
return append([]byte(constants.XMLHeader+"\n"), data...), nil
}
// ConfiguredSourceToXML converts a configured source to XML format.
func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) {
// Use the model's own MarshalXML for consistent output
data, err := xml.Marshal(cs)
if err != nil {
return nil, err
}
return data, nil
}
// EscapeXML escapes special characters for XML.
func EscapeXML(s string) string {
var b bytes.Buffer
if err := xml.EscapeText(&b, []byte(s)); err != nil {
return s
}
return b.String()
}
// GetConfiguredSourceXML returns the XML representation of a configured source as a string.
func GetConfiguredSourceXML(cs models.ConfiguredSource) string {
data, _ := ConfiguredSourceToXML(cs)
return string(data)
}
// PrepareConfiguredSource sets up the source for XML marshaling.
func PrepareConfiguredSource(s *models.ConfiguredSource) {
ensureTimestamps(s)
ensureSourceType(s)
ensureSourceProviderID(s)
syncCredentials(s)
syncLegacySourceKey(s)
}
func ensureTimestamps(s *models.ConfiguredSource) {
if s.CreatedOn == "" {
s.CreatedOn = constants.DateStr
}
if s.UpdatedOn == "" {
s.UpdatedOn = constants.DateStr
}
}
func ensureSourceType(s *models.ConfiguredSource) {
// AUX must be normalized to Type="Audio" — the speaker rejects type="AUX"
// (which the datastore previously synthesized from SourceKey.Type).
// Bluetooth is left alone since its canonical Type isn't "Audio".
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderBluetooth) {
if s.SourceKey.Type == constants.ProviderAmazon {
s.Type = constants.ProviderAmazon
} else {
s.Type = "Audio"
}
}
}
// canonicalProviderIDByName returns the canonical sourceproviderid for a
// symbolic source-key type ("TUNEIN", "SPOTIFY", ...) via constants.StaticProviders.
// Returns the provider ID as a decimal string and ok=true on match, ("", false) otherwise.
func canonicalProviderIDByName(name string) (string, bool) {
for _, p := range constants.StaticProviders {
if p.Name == name {
return strconv.Itoa(p.ID), true
}
}
return "", false
}
// HasResolvableProviderID reports whether s can be given a canonical
// sourceproviderid — it already carries one, or its source-key type maps to a
// constants.StaticProviders entry. Sources without one are device-local /
// transient slots (STORED_MUSIC_MEDIA_RENDERER, UPNP, the INVALID_SOURCE
// sentinel, ...) that a speaker rejects as INVALID_SOURCE (issue #334); they
// must not be persisted for, or served to, a speaker.
func HasResolvableProviderID(s models.ConfiguredSource) bool {
if s.SourceProviderID != "" {
return true
}
if _, ok := canonicalProviderIDByName(s.SourceKey.Type); ok {
return true
}
if _, ok := canonicalProviderIDByName(s.SourceKeyType); ok {
return true
}
return false
}
func ensureSourceProviderID(s *models.ConfiguredSource) {
if s.SourceProviderID == "" && s.SourceKey.Type != "" {
if id, ok := canonicalProviderIDByName(s.SourceKey.Type); ok {
s.SourceProviderID = id
}
}
}
func syncCredentials(s *models.ConfiguredSource) {
if s.SecretType == "" {
if s.SourceKey.Type == constants.ProviderSpotify {
s.SecretType = constants.CredentialTypeTokenV3
} else {
s.SecretType = constants.CredentialTypeToken
}
}
if s.Credential.Type == "" {
s.Credential.Type = s.SecretType
}
if s.Credential.Value == "" {
s.Credential.Value = s.Secret
}
if s.Secret == "" && s.Credential.Value != "" {
s.Secret = s.Credential.Value
}
if s.SecretType == "" && s.Credential.Type != "" {
s.SecretType = s.Credential.Type
}
}
func syncLegacySourceKey(s *models.ConfiguredSource) {
if s.SourceKey.Type == "" && s.SourceKeyType != "" {
s.SourceKey.Type = s.SourceKeyType
}
if s.SourceKey.Account == "" && s.SourceKeyAccount != "" {
s.SourceKey.Account = s.SourceKeyAccount
}
}
// PresetsXML is the XML wrapper for a list of presets.
type PresetsXML struct {
XMLName xml.Name `xml:"presets"`
Presets []presetParityXML `xml:"preset"`
}
type presetParityXML struct {
ButtonNumber string `xml:"buttonNumber,attr,omitempty"`
ContainerArt string `xml:"containerArt"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
Location string `xml:"location"`
Name string `xml:"name"`
Source *models.ConfiguredSource `xml:"source,omitempty"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
func (p presetParityXML) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type Alias presetParityXML
start.Name.Local = "preset"
return e.EncodeElement(Alias(p), start)
}
// recentSourceUsername returns the account/username to emit in a recent's
// <source> block. The account (e.g. STORED_MUSIC's "<UDN>/0") is persisted in
// SourceKey.Account, not Username, which does not round-trip through the
// datastore — so fall back to SourceKeyAccount. Without this, STORED_MUSIC
// media-server recents get an empty <username>, the speaker falls back to the
// provider id (e.g. "7") as the account, and replay fails with INVALID_SOURCE.
// Mirrors the fallback in mapToFullResponseSource; TuneIn / Internet Radio /
// Local Internet Radio intentionally keep an empty username (parity).
func recentSourceUsername(src *models.ConfiguredSource) string {
if src.Username != "" {
return src.Username
}
switch src.SourceKeyType {
case constants.ProviderTunein, constants.ProviderInternetRadio, constants.ProviderLocalInternetRadio:
return ""
}
return src.SourceKeyAccount
}
func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentItemParitySource {
sxml := &models.RecentItemParitySource{
ID: src.ID,
Type: src.Type,
CreatedOn: src.CreatedOn,
UpdatedOn: src.UpdatedOn,
Name: src.DisplayName,
SourceProviderID: src.SourceProviderID,
SourceName: src.SourceName,
Username: recentSourceUsername(src),
Credential: &models.RecentItemParityCredential{
Type: src.Credential.Type,
Value: src.Credential.Value,
},
}
if sxml.Name == "TuneIn" || sxml.Name == constants.ProviderLocalInternetRadio {
sxml.Name = ""
}
secret := src.Secret
if secret == "" {
secret = src.Credential.Value
}
secretType := src.SecretType
if secretType == "" {
secretType = src.Credential.Type
}
if secretType == "" {
secretType = constants.CredentialTypeToken
}
if sxml.Credential.Value == "" {
sxml.Credential.Value = secret
}
if sxml.Credential.Type == "" {
sxml.Credential.Type = secretType
}
if sxml.SourceName == "" {
sxml.SourceName = src.SourceKeyType
}
if sxml.SourceName == "" {
sxml.SourceName = sxml.Username
}
if sxml.Username == "" {
sxml.Username = sxml.SourceName
}
if sxml.Name == "" {
sxml.Name = sxml.SourceName
}
return sxml
}
func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSource) *presetParityXML {
// Find and prepare source
matchedSource := findMatchingSourceForPreset(sources, p)
if matchedSource != nil {
PrepareConfiguredSource(matchedSource)
}
if p.ContentItemType == "" && p.Name == "" && p.Location == "" && (matchedSource == nil || matchedSource.ID == "") {
return nil
}
p.ButtonNumber = p.ID
if p.CreatedOn == "" {
p.CreatedOn = constants.DateStr
} else if t, e := strconv.ParseInt(p.CreatedOn, 10, 64); e == nil {
p.CreatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
if p.UpdatedOn == "" {
p.UpdatedOn = constants.DateStr
} else if t, e := strconv.ParseInt(p.UpdatedOn, 10, 64); e == nil {
p.UpdatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
username := p.Username
if username == "" {
username = p.Name
}
return &presetParityXML{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: p.CreatedOn,
Location: p.Location,
Name: p.Name,
Source: matchedSource,
UpdatedOn: p.UpdatedOn,
Username: username,
}
}
// AccountPresetsToXML aggregates presets from all account devices and converts to XML format.
func AccountPresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
accountDir := ds.AccountDevicesDir(account)
entries, err := ds.ReadDirUnderBase(accountDir)
if err != nil {
if os.IsNotExist(err) {
return []byte(constants.XMLHeader + "\n<presets/>"), nil
}
return nil, err
}
type presetsParityWrapper struct {
XMLName xml.Name `xml:"presets"`
Presets []presetParityXML `xml:"preset"`
}
pxml := presetsParityWrapper{
Presets: make([]presetParityXML, 0),
}
seenPresets := make(map[string]bool)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
deviceID := entry.Name()
presets, gerr := ds.GetPresets(account, deviceID)
if gerr != nil {
continue
}
sources, serr := ds.GetConfiguredSources(account, deviceID)
if serr != nil {
continue
}
for i := range presets {
p := presets[i]
// Deduplicate by ID (which is the buttonNumber in our store)
if seenPresets[p.ID] {
continue
}
if pXML := mapPresetToParityXML(p, sources); pXML != nil {
seenPresets[p.ID] = true
pxml.Presets = append(pxml.Presets, *pXML)
}
}
}
// Sort by buttonNumber (optional but nice)
sort.Slice(pxml.Presets, func(i, j int) bool {
ni, _ := strconv.Atoi(pxml.Presets[i].ButtonNumber)
nj, _ := strconv.Atoi(pxml.Presets[j].ButtonNumber)
return ni < nj
})
data, err := xml.MarshalIndent(pxml, "", " ")
if err != nil {
return nil, err
}
return append([]byte(constants.XMLHeader+"\n"), data...), nil
}
// PresetsToXML converts account presets to XML format for Marge responses.
func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, error) {
presets, err := ds.GetPresets(account, deviceID)
if err != nil {
return nil, err
}
sources, err := ds.GetConfiguredSources(account, deviceID)
if err != nil {
return nil, err
}
type presetsParityWrapper struct {
XMLName xml.Name `xml:"presets"`
Presets []presetParityXML `xml:"preset"`
}
pxml := presetsParityWrapper{
Presets: make([]presetParityXML, 0, len(presets)),
}
for i := range presets {
if pXML := mapPresetToParityXML(presets[i], sources); pXML != nil {
pxml.Presets = append(pxml.Presets, *pXML)
}
}
data, err := xml.MarshalIndent(pxml, "", " ")
if err != nil {
return nil, err
}
return append([]byte(constants.XMLHeader+"\n"), data...), nil
}
func findMatchingSourceForPreset(sources []models.ConfiguredSource, p models.ServicePreset) *models.ConfiguredSource {
// First try exact ID match
if p.SourceID != "" {
for j := range sources {
if sources[j].ID == p.SourceID {
return &sources[j]
}
}
}
// Then try type and account match
for j := range sources {
s := &sources[j]
if (s.SourceKey.Type == p.Source && (p.SourceAccount == "" || s.SourceKey.Account == p.SourceAccount)) ||
(s.SourceKeyType == p.Source && (p.SourceAccount == "" || s.SourceKeyAccount == p.SourceAccount)) {
return s
}
}
return nil
}
// RecentsToXML converts account recent items to XML format for Marge responses.
func RecentsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, error) {
recents, err := ds.GetRecents(account, deviceID)
if err != nil {
if os.IsNotExist(err) {
return []byte(constants.XMLHeader + `<recents/>`), nil
}
return nil, err
}
type recentsParityXML struct {
XMLName xml.Name `xml:"recents"`
Recents []recent `xml:"recent"`
}
rxml := recentsParityXML{
Recents: make([]recent, len(recents)),
}
sources, _ := ds.GetConfiguredSources(account, deviceID)
for i := range recents {
r := &recents[i]
var matchingSrc *models.ConfiguredSource
if r.SourceID != "" {
matchingSrc = findMatchingSource(sources, r.SourceID)
}
if matchingSrc != nil {
PrepareConfiguredSource(matchingSrc)
} else if r.Source != "" {
// Try to find by Source and SourceAccount if SourceID didn't match
for j := range sources {
if sources[j].SourceKeyType == r.Source && sources[j].SourceKeyAccount == r.SourceAccount {
matchingSrc = &sources[j]
PrepareConfiguredSource(matchingSrc)
break
}
}
}
rxml.Recents[i] = recentToXML(r, matchingSrc)
}
data, err := xml.MarshalIndent(rxml, "", " ")
if err != nil {
return nil, err
}
// Parity: use self-closing tags for empty SourceSettings
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
header := constants.XMLHeader
return append([]byte(header+"\n"), data...), nil
}
type recent struct {
ID string `xml:"id,attr"`
ContentItem *contentItem `xml:"contentItem"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
LastPlayedAt string `xml:"lastplayedat"`
Location string `xml:"location"`
Name string `xml:"name"`
Source *models.RecentItemParitySource `xml:"source,omitempty"`
SourceID string `xml:"sourceid"`
UpdatedOn string `xml:"updatedOn"`
}
type contentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}
func recentToXML(r *models.ServiceRecent, matchingSrc *models.ConfiguredSource) recent {
utcTime := int64(0)
if r.UtcTime != "" {
if t, parseErr := strconv.ParseInt(r.UtcTime, 10, 64); parseErr == nil {
utcTime = t
}
}
createdOn := r.CreatedOn
if createdOn == "" {
createdOn = FormatTime(time.Now())
}
updatedOn := r.UpdatedOn
if updatedOn == "" {
updatedOn = createdOn
}
lastPlayedAt := r.LastPlayedAt
if lastPlayedAt == "" && utcTime > 0 {
lastPlayedAt = time.Unix(utcTime, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
res := recent{
ID: r.ID,
ContentItemType: r.ContentItemType,
CreatedOn: createdOn,
UpdatedOn: updatedOn,
LastPlayedAt: lastPlayedAt,
Location: r.Location,
Name: r.Name,
SourceID: r.SourceID,
ContentItem: &contentItem{
Source: r.Source,
Type: r.Type,
Location: r.Location,
SourceAccount: r.SourceAccount,
IsPresetable: r.IsPresetable,
ItemName: r.Name,
ContainerArt: r.ContainerArt,
},
}
if matchingSrc != nil {
res.Source = prepareRecentItemParitySource(matchingSrc)
}
return res
}
// ProviderSettingsToXML generates provider settings XML for the specified account.
func ProviderSettingsToXML(account string) string {
type providerSetting struct {
BoseID string `xml:"boseId"`
KeyName string `xml:"keyName"`
Value string `xml:"value"`
ProviderID string `xml:"providerId"`
}
type providerSettings struct {
XMLName xml.Name `xml:"providerSettings"`
Settings []providerSetting `xml:"providerSetting"`
}
payload := providerSettings{
Settings: []providerSetting{
{
BoseID: account,
KeyName: "ELIGIBLE_FOR_TRIAL",
Value: "false",
ProviderID: "14",
},
{
BoseID: account,
KeyName: "STREAMING_QUALITY",
Value: "2",
ProviderID: "15",
},
},
}
out, err := xml.Marshal(payload)
if err != nil {
return constants.XMLHeader + `<providerSettings></providerSettings>`
}
return constants.XMLHeader + string(out)
}
// SoftwareUpdateToXML generates software update configuration XML.
func SoftwareUpdateToXML() string {
return constants.XMLHeader + `
<software_update>
<softwareUpdateLocation></softwareUpdateLocation>
</software_update>`
}
// APIVersionsToXML returns the XML response for Marge API versions.
func APIVersionsToXML() ([]byte, error) {
resp := models.MargeAPIVersionsResponse{
Version: "221",
Project: "origin/master",
Apis: []models.MargeAPI{
{
Type: "streaming",
XML: "application/vnd.bose.streaming-v1.0+xml",
JSON: "application/vnd.bose.streaming-v1.0+json",
},
{
Type: "customer",
XML: "application/vnd.bose.customer-v1.0+xml",
JSON: "application/vnd.bose.customer-v1.0+json",
},
{
Type: "support",
XML: "application/vnd.bose.support-v1.0+xml",
JSON: "application/vnd.bose.support-v1.0+json",
},
},
}
output, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
return nil, err
}
return append([]byte(constants.XMLHeader+"\n"), output...), nil
}
// CreateAccountDevice creates an AccountDevice model for the given account and device.
func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (models.AccountDevice, error) {
info, err := ds.GetDeviceInfo(account, deviceID)
if err != nil {
return models.AccountDevice{}, err
}
if info == nil {
return models.AccountDevice{}, fmt.Errorf("device info not found")
}
device := models.AccountDevice{
DeviceID: deviceID,
AttachedProduct: &models.AttachedProduct{
ProductCode: info.ProductCode,
ProductLabel: info.ProductCode,
SerialNumber: info.ProductSerialNumber,
UpdatedOn: constants.DateStr,
},
CreatedOn: constants.DateStr,
FirmwareVersion: info.FirmwareVersion,
IPAddress: info.IPAddress,
Name: info.Name,
SerialNumber: info.DeviceSerialNumber,
UpdatedOn: constants.DateStr,
}
if device.SerialNumber == "" && info.DeviceID != "" {
device.SerialNumber = info.DeviceID
}
if device.AttachedProduct.SerialNumber == "" && info.ProductSerialNumber != "" {
device.AttachedProduct.SerialNumber = info.ProductSerialNumber
} else if device.AttachedProduct.SerialNumber == "" && device.SerialNumber != "" {
device.AttachedProduct.SerialNumber = device.SerialNumber
}
if len(info.Components) > 0 {
for _, comp := range info.Components {
device.AttachedProduct.Components = append(device.AttachedProduct.Components, models.ServiceComponent{
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
Label: comp.Label,
})
}
}
sources, err := ds.GetConfiguredSources(account, deviceID)
if err != nil {
return models.AccountDevice{}, err
}
presets, _ := ds.GetPresets(account, deviceID)
recents, _ := ds.GetRecents(account, deviceID)
device.Presets = mapPresetsToFullResponse(presets, sources)
device.Recents = mapRecentsToFullResponse(recents, sources)
return device, nil
}
func resolveSourceName(s models.ConfiguredSource) string {
// Prefer human-readable names; fall back to the account ID only when nothing better is set.
var name string
switch {
case s.SourceName != "":
name = s.SourceName
case s.DisplayName != "":
name = s.DisplayName
default:
name = s.SourceKeyAccount
}
// FALLBACKS for common sources
if name == "" {
switch s.SourceKeyType {
case constants.ProviderInternetRadio:
name = constants.ProviderInternetRadio
case constants.ProviderLocalInternetRadio:
name = constants.ProviderLocalInternetRadio
case constants.ProviderTunein:
name = constants.ProviderTunein
case constants.ProviderRadioBrowser:
name = constants.ProviderRadioBrowser
case constants.ProviderAux:
name = constants.ProviderAux
}
}
// FINAL fallback: name should not be empty if possible
if name == "" {
if s.ID != "" {
name = s.ID
} else if s.SourceProviderID != "" {
name = s.SourceProviderID
}
}
return name
}
func mapToFullResponseCredential(s models.ConfiguredSource, fullSource *models.FullResponseSource) {
if s.Credential.Value != "" {
fullSource.Credential.Value = s.Credential.Value
fullSource.Credential.Type = s.Credential.Type
}
if fullSource.Credential.Value == "" && s.Secret != "" {
fullSource.Credential.Value = s.Secret
fullSource.Credential.Type = s.SecretType
}
if fullSource.Credential.Type == "" || fullSource.Credential.Type == constants.CredentialTypeToken {
if s.Type == constants.ProviderSpotify || s.SourceProviderID == constants.ProviderSpotify || s.SourceKeyType == constants.ProviderSpotify {
fullSource.Credential.Type = constants.CredentialTypeTokenV3
} else if fullSource.Credential.Type == "" {
fullSource.Credential.Type = constants.CredentialTypeToken
}
}
}
func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSource {
fullSource := models.FullResponseSource{
ID: s.ID,
Type: s.Type,
DisplayName: s.DisplayName,
CreatedOn: s.CreatedOn,
Name: resolveSourceName(s),
SourceProviderID: s.SourceProviderID,
SourceName: s.SourceName,
SourceSettings: "",
UpdatedOn: s.UpdatedOn,
Username: s.Username,
}
mapToFullResponseCredential(s, &fullSource)
if s.SourceKeyType == constants.ProviderTunein {
fullSource.SourceName = ""
}
if fullSource.Username == "" && s.SourceKeyType != constants.ProviderTunein && s.SourceKeyType != constants.ProviderInternetRadio && s.SourceKeyType != constants.ProviderLocalInternetRadio {
fullSource.Username = s.SourceKeyAccount
}
// SourceProviderID is a required protobuf field inside recents/preset
// source blocks. A persisted source that lost its SourceKey.Type (e.g.
// poisoned by an older "INVALID" classification) lands here with an
// empty value, so fall back to the canonical default whose ID matches.
if fullSource.SourceProviderID == "" && s.ID != "" {
if def := canonicalProviderIDByID(s.ID); def != "" {
fullSource.SourceProviderID = def
}
}
return fullSource
}
// canonicalProviderIDByID returns the canonical SourceProviderID for one of
// the well-known built-in source IDs (10001..10005), or "" if the ID isn't
// recognised.
func canonicalProviderIDByID(id string) string {
switch id {
case "10002":
return strconv.Itoa(constants.InternetRadioProviderID)
case "10003":
return strconv.Itoa(constants.LocalInternetRadioProviderID)
case "10004":
return strconv.Itoa(constants.TuneinProviderID)
case "10005":
return strconv.Itoa(constants.RadioBrowserProviderID)
}
return ""
}
// inferSourceKeyTypeFromLocation makes a best-effort guess at the source
// type from a preset/recent's location URL. Used *only* for diagnostic
// logs — never to decide which source to bind to. URL-pattern matching
// is fragile (a location format can be reused across providers; tests
// can't enumerate every shape Bose's firmware ever emits), so the
// inference is one-way visibility: when it disagrees with the actually-
// bound source, surface a log line so an operator can investigate.
// When it can't make a confident inference, returns "" and the caller
// silently accepts the bound source as-is.
func inferSourceKeyTypeFromLocation(location string) string {
switch {
case location == "":
return ""
case strings.HasPrefix(location, "/v1/playback/station/s"),
strings.HasPrefix(location, "/v1/playback/episodes/t"):
return constants.ProviderTunein
case strings.HasPrefix(location, "/playback/container/"),
strings.HasPrefix(location, "/playback/track/"):
return constants.ProviderSpotify
case strings.Contains(location, "/custom/v1/playback/"):
return constants.ProviderLocalInternetRadio
}
return ""
}
// canonicalSourceKeyTypeByProviderID is the inverse of the canonical
// sourceproviderid mapping used in /full responses (25 = TuneIn, 11 =
// LocalInternetRadio, …). Used by syncPresets/syncRecents to project the
// upstream cloud /full perspective ("Audio" + providerid=25) back onto
// the speaker's perspective ("TUNEIN") at persist time, so the on-disk
// ServicePreset/ServiceRecent.Source matches what the speaker itself
// would write via setup.syncPresets (which is the source of truth).
//
// Returns "" for unknown / non-canonical provider IDs; the caller is
// expected to fall back to whatever upstream gave us.
func canonicalSourceKeyTypeByProviderID(providerID string) string {
for i := range constants.StaticProviders {
if strconv.Itoa(constants.StaticProviders[i].ID) == providerID {
return constants.StaticProviders[i].Name
}
}
return ""
}
// canonicalDefaultsByType returns the canonical (built-in) source ID and
// SourceProviderID for a well-known provider key type. Used to synthesise a
// minimum-viable <source> block when a preset references a source we no
// longer have in the configured-sources list (e.g. after a factory reset
// that hasn't repopulated the device's sources yet).
//
// Returns ("", "") for provider types whose IDs are account-specific
// (Spotify, Amazon) — those cannot be synthesised without losing
// per-account state, so callers must skip the preset instead.
func canonicalDefaultsByType(sourceKeyType string) (id, providerID string) {
switch sourceKeyType {
case constants.ProviderInternetRadio:
return "10002", strconv.Itoa(constants.InternetRadioProviderID)
case constants.ProviderLocalInternetRadio:
return "10003", strconv.Itoa(constants.LocalInternetRadioProviderID)
case constants.ProviderTunein:
return "10004", strconv.Itoa(constants.TuneinProviderID)
case constants.ProviderRadioBrowser:
return "10005", strconv.Itoa(constants.RadioBrowserProviderID)
}
return "", ""
}
func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.ConfiguredSource) []models.FullResponsePreset {
var fullPresets []models.FullResponsePreset
for i := range presets {
p := &presets[i]
if p.CreatedOn == "" {
p.CreatedOn = constants.DateStr
}
if p.UpdatedOn == "" {
p.UpdatedOn = constants.DateStr
}
var matchedSource *models.ConfiguredSource
// 1. Exact ID match — but only if the source's type is
// compatible with what the preset claims. GH-343: a numeric
// SourceID collision between e.g. a TuneIn preset and a
// RADIOPLAYER source in the configured-sources list used to
// silently rewrite the preset's source attribute to
// RADIOPLAYER on the next /full sync. Strict-match refuses
// the bind when the types disagree; the fallback below then
// finds the right source (or synthesise/skip kicks in).
if p.SourceID != "" {
for j := range sources {
if sources[j].ID == p.SourceID && sourceTypeCompatible(p.Source, sources[j].SourceKeyType) {
copySource := sources[j]
PrepareConfiguredSource(&copySource)
matchedSource = &copySource
break
}
if sources[j].ID == p.SourceID && !sourceTypeCompatible(p.Source, sources[j].SourceKeyType) {
log.Printf("[Marge] /full: refusing cross-type bind for preset %s — preset.Source=%q but configured source id=%s has type %q; falling through to type-based match",
sanitizeLog(p.ButtonNumber), sanitizeLog(p.Source), sanitizeLog(sources[j].ID), sanitizeLog(sources[j].SourceKeyType))
}
}
}
// 2. Fallback to type/account match if ID didn't match or was empty
if matchedSource == nil {
for j := range sources {
s := sources[j]
if s.SourceKeyType == p.Source && (p.SourceAccount == "" || s.SourceKeyAccount == p.SourceAccount) {
copySource := s
PrepareConfiguredSource(&copySource)
matchedSource = &copySource
break
}
}
}
fullPreset := models.FullResponsePreset{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: p.CreatedOn,
Location: p.Location,
Name: p.Name,
UpdatedOn: p.UpdatedOn,
Username: p.Username,
}
if fullPreset.Username == "" {
fullPreset.Username = p.Name
}
if fullPreset.ContentItemType == "" && p.Type != "" {
fullPreset.ContentItemType = p.Type
}
if matchedSource != nil {
fullPreset.Source = mapToFullResponseSource(*matchedSource)
fullPresets = append(fullPresets, fullPreset)
continue
}
// No configured source matches this preset. Emitting the preset
// with an empty <source/> block produces a /full response whose
// inner protobuf is missing required fields (sourceproviderid,
// id, type, credential), which makes the speaker abort the whole
// account sync and wipe its local presets. See GH-269.
//
// For the well-known built-in providers we can synthesise a
// minimum-viable source block from canonical defaults; the
// credential will be empty so play-time will fail loudly, but
// the sync survives and other presets stay intact. For
// account-bound providers (Spotify, Amazon) we can't synthesise
// without losing state, so we skip the preset entirely — its
// slot reverts to "Select a preset" until the source is
// repopulated, which is recoverable; a wiped /presets is not.
if synth, ok := synthesiseDefaultSourceForPreset(p); ok {
log.Printf("[Marge] /full: synthesising canonical %s source (id=%s, providerid=%s) for preset %s — original source %q not in configured sources; preset will appear on the speaker but play-time may fail until the source is repopulated",
sanitizeLog(p.Source), sanitizeLog(synth.ID), sanitizeLog(synth.SourceProviderID), sanitizeLog(p.ButtonNumber), sanitizeLog(p.SourceID))
fullPreset.Source = synth
fullPresets = append(fullPresets, fullPreset)
continue
}
log.Printf("[Marge] /full: skipping preset %s — source %q (id=%q, account=%q) not in configured sources and not synthesisable; the speaker's preset slot will revert to empty until the source is repopulated",
sanitizeLog(p.ButtonNumber), sanitizeLog(p.Source), sanitizeLog(p.SourceID), sanitizeLog(p.SourceAccount))
}
return fullPresets
}
// synthesiseDefaultSourceForPreset builds a minimum-viable FullResponseSource
// for a preset whose source is no longer in the configured-sources list,
// using canonical built-in defaults. Returns (zero, false) when the preset's
// provider type isn't one we can safely synthesise (e.g. Spotify, Amazon,
// where the ID and credential are account-bound).
func synthesiseDefaultSourceForPreset(p *models.ServicePreset) (models.FullResponseSource, bool) {
return synthesiseDefaultSourceByType(p.Source)
}
// synthesiseDefaultSourceForRecent is the recent-side twin of
// synthesiseDefaultSourceForPreset. Same protobuf-required-field invariants
// apply to recents>recent>source.
func synthesiseDefaultSourceForRecent(r *models.ServiceRecent) (models.FullResponseSource, bool) {
return synthesiseDefaultSourceByType(r.Source)
}
// synthesiseDefaultSourceByType is the shared helper behind the preset/recent
// synthesisers. Keep them as separate wrappers so callers stay readable and
// the call site signals which container we're patching up.
func synthesiseDefaultSourceByType(sourceKeyType string) (models.FullResponseSource, bool) {
id, providerID := canonicalDefaultsByType(sourceKeyType)
if id == "" || providerID == "" {
return models.FullResponseSource{}, false
}
synth := models.FullResponseSource{
ID: id,
Type: "Audio",
CreatedOn: constants.DateStr,
UpdatedOn: constants.DateStr,
SourceProviderID: providerID,
Name: sourceKeyType,
}
synth.Credential.Type = constants.CredentialTypeToken
return synth, true
}
// findMatchingSourceForRecent resolves the configured source a recent
// references. Tries exact SourceID match (but only if the matched source's
// type is compatible with the recent's claimed Source — see GH-343), then
// SourceKeyType+account. Returns nil when nothing matches; caller falls
// back to synthesise/skip.
func findMatchingSourceForRecent(r *models.ServiceRecent, sources []models.ConfiguredSource) *models.ConfiguredSource {
if r.SourceID != "" {
for j := range sources {
if sources[j].ID == r.SourceID && sourceTypeCompatible(r.Source, sources[j].SourceKeyType) {
copySource := sources[j]
PrepareConfiguredSource(&copySource)
return &copySource
}
if sources[j].ID == r.SourceID && !sourceTypeCompatible(r.Source, sources[j].SourceKeyType) {
log.Printf("[Marge] /full: refusing cross-type bind for recent id=%s — recent.Source=%q but configured source id=%s has type %q; falling through to type-based match",
sanitizeLog(r.ID), sanitizeLog(r.Source), sanitizeLog(sources[j].ID), sanitizeLog(sources[j].SourceKeyType))
}
}
}
for j := range sources {
s := sources[j]
if s.SourceKeyType == r.Source && (r.SourceAccount == "" || s.SourceKeyAccount == r.SourceAccount) {
copySource := s
PrepareConfiguredSource(&copySource)
return &copySource
}
}
return nil
}
// sourceTypeCompatible decides whether a preset/recent whose original
// source is `claimed` can be bound to a configured source of type
// `configured`. Strict-match policy: identical types compatible; either
// side empty is treated as "no info, allow" (matches pre-fix behaviour for
// records that lost their Source attribute). Refuse when both are set and
// disagree — that's the GH-343 cross-type rewrite the speaker reflects.
func sourceTypeCompatible(claimed, configured string) bool {
if claimed == "" || configured == "" {
return true
}
return claimed == configured
}
func mapRecentsToFullResponse(recents []models.ServiceRecent, sources []models.ConfiguredSource) []models.FullResponseRecent {
var fullRecents []models.FullResponseRecent
for i := range recents {
r := &recents[i]
if r.CreatedOn == "" {
r.CreatedOn = constants.DateStr
}
if r.UpdatedOn == "" {
r.UpdatedOn = constants.DateStr
}
matchedSource := findMatchingSourceForRecent(r, sources)
fullRecent := models.FullResponseRecent{
ID: r.ID,
ContentItemType: r.ContentItemType,
CreatedOn: r.CreatedOn,
LastPlayedAt: r.LastPlayedAt,
Location: r.Location,
Name: r.Name,
SourceID: r.SourceID,
UpdatedOn: r.UpdatedOn,
Username: r.Username,
}
if fullRecent.LastPlayedAt == "" && r.UtcTime != "" {
if ut, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil {
fullRecent.LastPlayedAt = time.Unix(ut, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
}
}
if fullRecent.Username == "" {
fullRecent.Username = r.Name
}
if fullRecent.ContentItemType == "" && r.Type != "" {
fullRecent.ContentItemType = r.Type
}
if matchedSource != nil {
fullRecent.Source = mapToFullResponseSource(*matchedSource)
if fullRecent.SourceID == "" {
fullRecent.SourceID = fullRecent.Source.ID
}
fullRecents = append(fullRecents, fullRecent)
continue
}
// Same protobuf-required-field hazard as presets: emitting a
// recent with an empty <source/> block makes the speaker abort
// the whole /full sync. Mirror the preset behaviour — synthesise
// for well-known radio providers, skip otherwise. Recents are
// less load-bearing than presets, but a single broken recent can
// still take the sync down.
if synth, ok := synthesiseDefaultSourceForRecent(r); ok {
log.Printf("[Marge] /full: synthesising canonical %s source (id=%s, providerid=%s) for recent id=%s — original source %q not in configured sources",
sanitizeLog(r.Source), sanitizeLog(synth.ID), sanitizeLog(synth.SourceProviderID), sanitizeLog(r.ID), sanitizeLog(r.SourceID))
fullRecent.Source = synth
if fullRecent.SourceID == "" {
fullRecent.SourceID = synth.ID
}
fullRecents = append(fullRecents, fullRecent)
continue
}
log.Printf("[Marge] /full: skipping recent id=%s — source %q (id=%q, account=%q) not in configured sources and not synthesisable",
sanitizeLog(r.ID), sanitizeLog(r.Source), sanitizeLog(r.SourceID), sanitizeLog(r.SourceAccount))
}
return fullRecents
}
func fillDefaultProviderSettings(account string, resp *models.AccountFullResponse) {
for _, p := range constants.StaticProviders {
switch p.Name {
case constants.ProviderDeezer:
resp.ProviderSettings = append(resp.ProviderSettings, models.ProviderSetting{
BoseID: account,
KeyName: "ELIGIBLE_FOR_TRIAL",
Value: "false",
ProviderID: strconv.Itoa(p.ID),
})
case constants.ProviderSpotify:
resp.ProviderSettings = append(resp.ProviderSettings, models.ProviderSetting{
BoseID: account,
KeyName: "STREAMING_QUALITY",
Value: "2",
ProviderID: strconv.Itoa(p.ID),
})
}
}
}
func fillAccountInfo(ds *datastore.DataStore, account string, resp *models.AccountFullResponse) {
if info, _ := ds.GetAccountInfo(account); info != nil {
if info.PreferredLanguage != "" {
resp.PreferredLanguage = info.PreferredLanguage
}
if len(info.ProviderSettings) > 0 {
resp.ProviderSettings = info.ProviderSettings
}
}
for i := range resp.ProviderSettings {
ps := &resp.ProviderSettings[i]
if ps.ProviderName == "" {
ps.ProviderName = constants.GetProviderName(ps.ProviderID)
}
}
}
func getAccountDevices(ds *datastore.DataStore, account string, entries []os.DirEntry) ([]models.AccountDevice, string) {
var (
devices []models.AccountDevice
lastDeviceID string
)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
deviceID := entry.Name()
lastDeviceID = deviceID
dev, err := CreateAccountDevice(ds, account, deviceID)
if err != nil {
continue
}
if dev.Name == "" || dev.Name == " " {
if deviceID != "" {
dev.Name = deviceID
} else {
continue
}
}
devices = append(devices, dev)
}
return devices, lastDeviceID
}
func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []models.FullResponseSource {
var (
sources []models.ConfiguredSource
err error
)
if lastDeviceID != "" {
sources, err = ds.GetConfiguredSources(account, lastDeviceID)
if err == nil {
sources = mergeDefaultSources(sources, ds.GetInitialSources())
}
} else {
sources = ds.GetInitialSources()
}
if err != nil {
return nil
}
var fullSources []models.FullResponseSource
for i := range sources {
s := sources[i]
// Real Bose's /streaming/account/{a}/full never emitted AUX as
// a cloud-side <source> (verified across 61 captured upstream
// /full responses in scripts/android/captures/.../
// parity_mismatches/). AUX is hardware-local — the speaker
// enumerates it via isLocal=true in its own /sources response,
// it doesn't need the cloud to list it. AfterTouch emitting a
// malformed AUX entry here (with displayName=, empty
// <credential>, non-empty <name>/<username>) is the suspected
// trigger for issue #195: the speaker's source-reconciliation
// code marks AUX as cloud-side inconsistent and refuses
// dispatch, even though the local availability check reports
// it READY. We still keep AUX in getDefaultSources() because
// other call sites (default-sources init at startup, the
// SoundTouch web UI source picker) rely on it; the filter
// just keeps it out of /full's wire shape.
if s.SourceKeyType == constants.ProviderAux {
continue
}
PrepareConfiguredSource(&s)
fs := mapToFullResponseSource(s)
if fs.SourceProviderID == "" {
log.Printf("[Marge] /full: omitting source name=%q (sourceKey type %q) — no resolvable sourceproviderid; would be rejected as INVALID_SOURCE",
sanitizeLog(fs.Name), sanitizeLog(s.SourceKeyType))
continue
}
fullSources = append(fullSources, fs)
}
return fullSources
}
// mergeDefaultSources returns sources in canonical order: defaults first (using
// the stored entry's credentials when a match is found), followed by any stored
// sources that have no matching default (e.g. Spotify, Amazon). This ensures
// that default sources always appear in a predictable order regardless of what
// is present in the device's on-disk Sources.xml.
// It does not persist — initializeDefaultSources handles persistence at startup.
func mergeDefaultSources(stored, defaults []models.ConfiguredSource) []models.ConfiguredSource {
result := make([]models.ConfiguredSource, 0, len(stored)+len(defaults))
for i := range defaults {
found := -1
for j := range stored {
if stored[j].SourceKeyType == defaults[i].SourceKeyType {
found = j
break
}
}
if found >= 0 {
result = append(result, stored[found])
} else {
result = append(result, defaults[i])
}
}
for i := range stored {
isDefault := false
for j := range defaults {
if stored[i].SourceKeyType == defaults[j].SourceKeyType {
isDefault = true
break
}
}
if !isDefault {
result = append(result, stored[i])
}
}
return result
}
// AccountSourcesToXML generates the account sources XML.
func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
entries, err := ds.ReadDirUnderBase(devicesDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
_, lastDeviceID := getAccountDevices(ds, account, entries)
resp := models.AccountSourcesResponse{
Sources: getAccountSources(ds, account, lastDeviceID),
}
data, err := xml.Marshal(resp)
if err != nil {
return nil, err
}
// Parity: use self-closing tags and handle empty sourceproviderid
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
data = bytes.ReplaceAll(data, []byte("<sourceproviderid></sourceproviderid>"), []byte(""))
return append([]byte(constants.XMLHeader), data...), nil
}
// AccountDevicesToXML generates the account devices XML.
func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
entries, err := ds.ReadDirUnderBase(devicesDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
devices, _ := getAccountDevices(ds, account, entries)
var margeDevices []models.MargeAccountDevice
for i := range devices {
d := &devices[i]
margeDevices = append(margeDevices, models.MargeAccountDevice{
DeviceID: d.DeviceID,
AttachedProduct: d.AttachedProduct,
CreatedOn: d.CreatedOn,
IPAddress: d.IPAddress,
Name: d.Name,
UpdatedOn: d.UpdatedOn,
})
}
resp := models.AccountDevicesResponse{
Devices: margeDevices,
}
// Fill provider settings
fullResp := &models.AccountFullResponse{}
fillDefaultProviderSettings(account, fullResp)
fillAccountInfo(ds, account, fullResp)
resp.ProviderSettings = fullResp.ProviderSettings
data, err := xml.Marshal(resp)
if err != nil {
return nil, err
}
// Parity: use self-closing tags for empty components
data = bytes.ReplaceAll(data, []byte("<components></components>"), []byte("<components/>"))
return append([]byte(constants.XMLHeader), data...), nil
}
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
resp := models.AccountFullResponse{
ID: account,
AccountStatus: "OK",
Mode: "global",
PreferredLanguage: "en",
}
fillDefaultProviderSettings(account, &resp)
fillAccountInfo(ds, account, &resp)
entries, err := ds.ReadDirUnderBase(devicesDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
devices, lastDeviceID := getAccountDevices(ds, account, entries)
resp.Devices = devices
resp.Sources = getAccountSources(ds, account, lastDeviceID)
data, err := xml.Marshal(resp)
if err != nil {
return nil, err
}
// Parity: use self-closing tags for empty components and sourceSettings.
// NOTE: do NOT strip empty <sourceproviderid> elements here — the speaker
// decodes /full into a protobuf message where recents>recent>source>
// sourceproviderid is a *required* field, so removing even an empty element
// trips "missing required field" and aborts the whole account sync.
data = bytes.ReplaceAll(data, []byte("<components></components>"), []byte("<components/>"))
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
return append([]byte(constants.XMLHeader), data...), nil
}
// RemovePreset clears a preset for the specified account and device.
func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber int) error {
presets, err := ds.GetPresets(account, device)
if err != nil {
return err
}
if presetNumber < 1 || presetNumber > len(presets) {
// Preset doesn't exist or index out of range, nothing to do
return nil
}
presets[presetNumber-1] = models.ServicePreset{}
return ds.SavePresets(account, device, presets)
}
// resolvePresetSource resolves the source a preset PUT is referencing,
// auto-adding a canonical built-in source when AfterTouch's per-device
// configured-sources list doesn't include it yet (GH-314 / GH-253). It
// returns the matched source plus the possibly-extended sources slice
// (since auto-add appends). Returns (nil, sources) when no match could be
// resolved — UpdatePreset turns that into a 500 with a diagnostic log line.
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
for i := range sources {
if sources[i].ID == sourceID {
return &sources[i], sources
}
}
// Fallback: SourceID is the symbolic provider name (the speaker
// sometimes sends e.g. <sourceid>TUNEIN</sourceid> instead of a
// numeric ID); match by SourceKeyType.
if sourceID == constants.ProviderInternetRadio || sourceID == constants.ProviderTunein || sourceID == constants.ProviderSpotify || sourceID == constants.ProviderAmazon {
for i := range sources {
if sources[i].SourceKeyType == sourceID {
return &sources[i], sources
}
}
}
// Auto-add a canonical built-in source the speaker referenced but
// AfterTouch hasn't been told about (post-factory-reset state). For
// account-bound sources (Spotify, Amazon) we can't synthesise
// credentials, so the caller will reject the PUT instead.
if canonical, ok := ds.CanonicalSourceByID(sourceID); ok {
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
sources = append(sources, canonical)
if saveErr := ds.SaveConfiguredSources(account, device, sources); saveErr != nil {
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
presetNumber, sanitizeErr(saveErr))
}
return &sources[len(sources)-1], sources
}
return nil, sources
}
// UpdatePreset updates or creates a preset for the specified account and device.
func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber int, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return nil, err
}
presets, err := ds.GetPresets(account, device)
if err != nil {
presets = []models.ServicePreset{}
}
var newPresetElem struct {
Name string `xml:"name"`
Username string `xml:"username"`
SourceID string `xml:"sourceid"`
Location string `xml:"location"`
ContentItemType string `xml:"contentItemType"`
ContainerArt string `xml:"containerArt"`
}
if err = xml.Unmarshal(sourceXML, &newPresetElem); err != nil {
return nil, err
}
// The PUT body field carrying the human-readable name varies by client:
// the speaker firmware sends it as <name>, the Stockholm mobile app sends
// it as <username>. soundcork documents this divergence too. Prefer
// <name>; fall back to <username> so app-initiated stores don't end up
// with an empty preset name.
if newPresetElem.Name == "" && newPresetElem.Username != "" {
log.Printf("[Marge] UpdatePreset(preset=%d): using Stockholm <username> as preset name; speaker firmware would have sent <name>", presetNumber)
newPresetElem.Name = newPresetElem.Username
}
matchingSrc, sources := resolvePresetSource(ds, account, device, sources, newPresetElem.SourceID, presetNumber)
if matchingSrc == nil {
log.Printf("[Marge] UpdatePreset(preset=%d): rejecting — source id=%q is neither in configured sources nor a canonical built-in we can auto-add. The speaker's long-press will appear to succeed locally but the preset will not survive a /full sync",
presetNumber, sanitizeLog(newPresetElem.SourceID))
return nil, fmt.Errorf("invalid account/source")
}
// Visibility-only: the speaker's PUT carries only <sourceid> — no
// symbolic source name — so we can't strict-match at write time.
// But if the preset's location URL has a recognisable pattern
// (TuneIn /v1/playback/station/, Spotify /playback/container/,
// LocalInternetRadio /custom/v1/playback/, …) and that pattern
// disagrees with the matched source's SourceKeyType, the operator
// is probably looking at the GH-343 footprint: a stale Sources.xml
// entry shadowing the speaker's actual intent. Log so the operator
// can re-trigger setup.syncSources without us guessing on their
// behalf — the binding still happens as the speaker requested.
if inferred := inferSourceKeyTypeFromLocation(newPresetElem.Location); inferred != "" && matchingSrc.SourceKeyType != "" && inferred != matchingSrc.SourceKeyType {
log.Printf("[Marge] UpdatePreset(preset=%d): location URL %q suggests %s but matched source id=%s has SourceKeyType=%s — proceeding as the speaker requested, but the Sources.xml entry may be stale; consider re-running setup.syncSources from the speaker to refresh",
presetNumber, sanitizeLog(newPresetElem.Location), sanitizeLog(inferred), sanitizeLog(matchingSrc.ID), sanitizeLog(matchingSrc.SourceKeyType))
}
nowStr := strconv.FormatInt(time.Now().Unix(), 10)
presetObj := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
Name: newPresetElem.Name,
Source: matchingSrc.SourceKeyType,
Type: newPresetElem.ContentItemType,
Location: newPresetElem.Location,
SourceAccount: matchingSrc.SourceKeyAccount,
SourceID: newPresetElem.SourceID,
ContentItemType: newPresetElem.ContentItemType,
},
ID: strconv.Itoa(presetNumber),
ContainerArt: newPresetElem.ContainerArt,
CreatedOn: nowStr,
UpdatedOn: nowStr,
ButtonNumber: strconv.Itoa(presetNumber),
Username: newPresetElem.Name,
}
// Ensure presets list is large enough
for len(presets) < presetNumber {
presets = append(presets, models.ServicePreset{})
}
presets[presetNumber-1] = presetObj
if err = ds.SavePresets(account, device, presets); err != nil {
return nil, err
}
// Return XML for the single preset
PrepareConfiguredSource(matchingSrc)
syncMatchingSource(matchingSrc, recentInput{
Source: struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
SourceName string `xml:"sourcename"`
SourceProviderID string `xml:"sourceproviderid"`
CreatedOn string `xml:"createdOn"`
UpdatedOn string `xml:"updatedOn"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
}{
ID: matchingSrc.ID,
SourceName: newPresetElem.Name,
},
})
presetObj.SourceID = matchingSrc.ID
presetObj.Username = newPresetElem.Name
// Parity: return the single preset
px := mapPresetToParityXML(presetObj, sources)
data, err := xml.Marshal(px)
if err != nil {
return nil, err
}
return append([]byte(constants.XMLHeader), data...), nil
}
type recentInput struct {
Name string `xml:"name"`
SourceID string `xml:"sourceid"`
Location string `xml:"location"`
ContentItemType string `xml:"contentItemType"`
LastPlayedAt string `xml:"lastplayedat"`
Source struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
SourceName string `xml:"sourcename"`
SourceProviderID string `xml:"sourceproviderid"`
CreatedOn string `xml:"createdOn"`
UpdatedOn string `xml:"updatedOn"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
} `xml:"source"`
}
func getSourceNameFromXML(sourceXML []byte, input recentInput) string {
sourceName := input.Source.SourceName
if sourceName == "" {
// Some clients might send sourcename as a direct child of recent
var altRecentElem struct {
SourceName string `xml:"sourcename"`
}
_ = xml.Unmarshal(sourceXML, &altRecentElem)
sourceName = altRecentElem.SourceName
}
return sourceName
}
func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput) {
if matchingSrc == nil {
return
}
if input.Source.ID != "" {
matchingSrc.ID = input.Source.ID
}
if input.Source.Type != "" {
matchingSrc.Type = input.Source.Type
}
// Ensure we use the latest secret from the input if it was just learned/updated
if input.Source.Credential.Value != "" {
matchingSrc.Secret = input.Source.Credential.Value
matchingSrc.SecretType = input.Source.Credential.Type
}
if input.Source.CreatedOn != "" {
matchingSrc.CreatedOn = input.Source.CreatedOn
}
if input.Source.UpdatedOn != "" {
matchingSrc.UpdatedOn = input.Source.UpdatedOn
}
if matchingSrc.ID == "" {
matchingSrc.ID = input.SourceID
}
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
// Parity: for some services like TuneIn, sourcename should be empty
if !strings.EqualFold(matchingSrc.DisplayName, constants.ProviderTunein) && matchingSrc.DisplayName != "Other" {
matchingSrc.SourceName = matchingSrc.DisplayName
}
}
if matchingSrc.DisplayName == "" && matchingSrc.SourceName != "" {
matchingSrc.DisplayName = matchingSrc.SourceName
}
if matchingSrc.Username == "" && matchingSrc.DisplayName != "" {
// Parity: for some services like TuneIn, username should be empty
if !strings.EqualFold(matchingSrc.DisplayName, constants.ProviderTunein) && matchingSrc.DisplayName != "Other" {
matchingSrc.Username = matchingSrc.DisplayName
}
}
}
// AddRecent adds or updates a recent item for the specified account and device.
func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return nil, err
}
recents, err := ds.GetRecents(account, device)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
var input recentInput
if err := xml.Unmarshal(sourceXML, &input); err != nil {
return nil, err
}
sourceName := getSourceNameFromXML(sourceXML, input)
// 1. learnSource handles persistence AND returns a matching source.
matchingSrc, learned := learnSource(ds, account, device, sources, input.SourceID, input.Location, sourceName, input.Source.Credential.Value, input.Source.SourceProviderID, input.Source.CreatedOn, input.Source.UpdatedOn)
// Parity: ensure generic tokens for TUNEIN and LOCAL_INTERNET_RADIO if missing.
// This covers both learned and already existing sources.
if (matchingSrc.SourceProviderID == strconv.Itoa(constants.TuneinProviderID) || matchingSrc.ID == constants.ProviderTunein || strings.Contains(input.Location, "/v1/playback/station/")) && matchingSrc.Secret == "" {
matchingSrc.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
matchingSrc.SecretType = constants.CredentialTypeToken
} else if matchingSrc.ID == constants.ProviderLocalInternetRadio && matchingSrc.Secret == "" {
matchingSrc.Secret = datastore.GenerateSerialSecret("local-internet-radio")
matchingSrc.SecretType = constants.CredentialTypeToken
}
if learned {
// Re-fetch sources to ensure we have the newly learned one in the slice
if updatedSources, err := ds.GetConfiguredSources(account, device); err == nil {
sources = updatedSources
_ = sources // avoid ineffectual assignment
}
}
if matchingSrc == nil {
// This should technically not happen as learnSource always returns something,
// but we keep it as a fallback for safety.
matchingSrc = &models.ConfiguredSource{
ID: input.SourceID,
SourceProviderID: input.Source.SourceProviderID,
Secret: input.Source.Credential.Value,
SecretType: input.Source.Credential.Type,
CreatedOn: input.Source.CreatedOn,
UpdatedOn: input.Source.UpdatedOn,
}
}
syncMatchingSource(matchingSrc, input)
utcTime := parseLastPlayedAt(input.LastPlayedAt)
recentObj, recents := updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
if err := ds.SaveRecents(account, device, recents); err != nil {
return nil, err
}
return formatRecentResponse(recentObj, matchingSrc, recentObj.CreatedOn, utcTime), nil
}
func learnSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID, location, sourceName, credentialValue, sourceProviderID, createdOn, updatedOn string) (*models.ConfiguredSource, bool) {
matchingSrc := findMatchingSource(sources, sourceID)
sourceLearned := false
if matchingSrc == nil {
matchingSrc = createLearnedSource(sourceID, location, sourceName, credentialValue, sourceProviderID, createdOn, updatedOn)
sourceLearned = true
} else {
sourceLearned = updateSourceFields(matchingSrc, credentialValue, sourceName, sourceProviderID, createdOn, updatedOn)
}
if sourceLearned {
// Ensure generic tokens for TUNEIN and LOCAL_INTERNET_RADIO if missing
if (matchingSrc.SourceProviderID == strconv.Itoa(constants.TuneinProviderID) || matchingSrc.ID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/")) && matchingSrc.Secret == "" {
matchingSrc.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
matchingSrc.SecretType = constants.CredentialTypeToken
} else if matchingSrc.ID == constants.ProviderLocalInternetRadio && matchingSrc.Secret == "" {
matchingSrc.Secret = datastore.GenerateSerialSecret("local-internet-radio")
matchingSrc.SecretType = constants.CredentialTypeToken
}
persistLearnedSource(ds, account, device, sources, matchingSrc)
}
return matchingSrc, sourceLearned
}
func createLearnedSource(sourceID, location, sourceName, credentialValue, sourceProviderID, createdOn, updatedOn string) *models.ConfiguredSource {
displayName := sourceName
if displayName == "" && sourceID != "" {
switch sourceID {
case constants.ProviderSpotify:
displayName = constants.ProviderSpotify
case constants.ProviderAmazon:
displayName = "Amazon Music"
}
}
src := &models.ConfiguredSource{
ID: sourceID,
DisplayName: displayName,
SourceName: sourceName,
Secret: credentialValue,
SourceProviderID: sourceProviderID,
CreatedOn: createdOn,
UpdatedOn: updatedOn,
}
classifyLearnedSource(src, sourceID, location, sourceProviderID)
return src
}
func classifyLearnedSource(src *models.ConfiguredSource, sourceID, location, sourceProviderID string) {
switch {
case sourceProviderID == strconv.Itoa(constants.TuneinProviderID) || sourceID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/"):
classifyAsTuneIn(src)
case sourceProviderID == strconv.Itoa(constants.LocalInternetRadioProviderID) || sourceID == constants.ProviderLocalInternetRadio || strings.Contains(location, "/custom/v1/playback/"):
classifyAsLocalInternetRadio(src)
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == constants.ProviderSpotify:
classifyAsSpotify(src)
case strings.Contains(location, "amazon") || sourceID == constants.ProviderAmazon || sourceProviderID == strconv.Itoa(constants.AmazonProviderID):
classifyAsAmazon(src)
case sourceProviderID == strconv.Itoa(constants.RadioBrowserProviderID) || strings.Contains(location, "/stations/byuuid/"):
classifyAsRadioBrowser(src)
}
// If we can't classify, leave SourceKey.Type empty so the canonical-by-ID
// fallback in mapToFullResponseSource and the read-side applyCanonicalDefaults
// still have a chance to repair it. Writing a literal "INVALID" used to lock
// the source out of every repair path, producing a <source> block with no
// <sourceproviderid> and breaking the speaker's protobuf required-field check.
}
func classifyAsTuneIn(src *models.ConfiguredSource) {
src.SourceKey.Type = constants.ProviderTunein
src.SourceKeyType = constants.ProviderTunein
src.Type = "Audio"
src.SecretType = constants.CredentialTypeToken
if src.Secret == "" {
src.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderTunein))
}
if src.DisplayName == "Other" || src.DisplayName == constants.ProviderTunein || src.DisplayName == "" {
src.DisplayName = constants.ProviderTunein
}
}
func classifyAsLocalInternetRadio(src *models.ConfiguredSource) {
src.SourceKey.Type = constants.ProviderLocalInternetRadio
src.SourceKeyType = constants.ProviderLocalInternetRadio
src.Type = "Audio"
src.SecretType = constants.CredentialTypeToken
if src.Secret == "" {
src.Secret = datastore.GenerateSerialSecret("local-internet-radio")
}
if src.DisplayName == "Other" || src.DisplayName == "Local Internet Radio" || src.DisplayName == "" {
src.DisplayName = "Local Internet Radio"
}
}
func classifyAsSpotify(src *models.ConfiguredSource) {
src.SourceKey.Type = constants.ProviderSpotify
src.SourceKeyType = constants.ProviderSpotify
src.Type = "Audio"
src.SecretType = constants.CredentialTypeTokenV3
if src.DisplayName == "Other" {
src.DisplayName = constants.ProviderSpotify
}
}
func classifyAsAmazon(src *models.ConfiguredSource) {
src.SourceKey.Type = constants.ProviderAmazon
src.SourceKeyType = constants.ProviderAmazon
src.Type = "Audio"
src.SecretType = constants.CredentialTypeToken
if src.DisplayName == "" || src.DisplayName == "Other" {
src.DisplayName = "Amazon Music"
}
}
func classifyAsRadioBrowser(src *models.ConfiguredSource) {
src.SourceKey.Type = constants.ProviderRadioBrowser
src.SourceKeyType = constants.ProviderRadioBrowser
src.Type = "Audio"
src.SecretType = constants.CredentialTypeToken
if src.Secret == "" {
src.Secret = datastore.GenerateSerialSecret(strings.ToLower(constants.ProviderRadioBrowser))
}
if src.DisplayName == "Other" || src.DisplayName == constants.ProviderRadioBrowser || src.DisplayName == "" {
src.DisplayName = constants.ProviderRadioBrowser
}
}
func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceName, sourceProviderID, createdOn, updatedOn string) bool {
learned := false
if credentialValue != "" && (src.Secret == "" || src.Secret != credentialValue) {
src.Secret = credentialValue
learned = true
}
if sourceName != "" && (src.SourceName == "" || src.SourceName != sourceName) {
src.SourceName = sourceName
learned = true
}
if sourceProviderID != "" && (src.SourceProviderID == "" || src.SourceProviderID != sourceProviderID) {
src.SourceProviderID = sourceProviderID
learned = true
}
if createdOn != "" && (src.CreatedOn == "" || src.CreatedOn != createdOn) {
src.CreatedOn = createdOn
learned = true
}
if updatedOn != "" && (src.UpdatedOn == "" || src.UpdatedOn != updatedOn) {
src.UpdatedOn = updatedOn
learned = true
}
return learned
}
func persistLearnedSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, matchingSrc *models.ConfiguredSource) {
updatedSources := make([]models.ConfiguredSource, len(sources))
copy(updatedSources, sources)
found := false
for i := range updatedSources {
if updatedSources[i].ID == matchingSrc.ID {
updatedSources[i] = *matchingSrc
found = true
break
}
}
if !found {
updatedSources = append(updatedSources, *matchingSrc)
}
if err := ds.SaveConfiguredSources(account, device, updatedSources); err != nil {
log.Printf("[MARGE_ERR] Failed to persist learned source for %s: %s", sanitizeLog(device), sanitizeErr(err))
}
}
func updateOrCreateRecent(recents []models.ServiceRecent, name string, matchingSrc *models.ConfiguredSource, contentItemType, location, device string, utcTime int64) (*models.ServiceRecent, []models.ServiceRecent) {
var recentObj *models.ServiceRecent
for i := range recents {
r := &recents[i]
sourceMatch := false
if matchingSrc != nil {
sourceMatch = r.Source == matchingSrc.SourceKeyType && r.SourceAccount == matchingSrc.SourceKeyAccount
}
if sourceMatch && r.Location == location {
recents[i].UtcTime = strconv.FormatInt(utcTime, 10)
recents[i].UpdatedOn = FormatTime(time.Now())
// Move the matched recent to the front. Copy the value out FIRST,
// then rebuild the slice into a fresh backing array. The previous
// in-place `append(recents[:i], recents[i+1:]...)` shuffle aliased
// the shared backing array and overwrote index i, which both
// corrupted the returned pointer (it pointed at the neighbour) and,
// because Go does not specify evaluation order between `*recentObj`
// and the inner append, could drop the matched recent and duplicate
// its neighbour in the saved list.
matched := recents[i]
reordered := make([]models.ServiceRecent, 0, len(recents))
reordered = append(reordered, matched)
reordered = append(reordered, recents[:i]...)
reordered = append(reordered, recents[i+1:]...)
return &reordered[0], reordered
}
}
recentObj = createNewRecent(recents, name, matchingSrc, contentItemType, location, device, utcTime)
recentObj.UpdatedOn = FormatTime(time.Now())
recents = append([]models.ServiceRecent{*recentObj}, recents...)
if len(recents) > 10 {
recents = recents[:10]
}
return recentObj, recents
}
func findMatchingSource(sources []models.ConfiguredSource, sourceID string) *models.ConfiguredSource {
for i := range sources {
if sources[i].ID == sourceID {
return &sources[i]
}
}
return nil
}
func parseLastPlayedAt(lastPlayedAt string) int64 {
utcTime := time.Now().Unix()
if lastPlayedAt != "" {
if t, err := time.Parse(time.RFC3339, lastPlayedAt); err == nil {
utcTime = t.Unix()
} else if t, err := time.Parse("2006-01-02T15:04:05.000-07:00", lastPlayedAt); err == nil {
utcTime = t.Unix()
} else if t, err := time.Parse("2006-01-02T15:04:05.000Z", lastPlayedAt); err == nil {
utcTime = t.Unix()
}
}
return utcTime
}
func createNewRecent(recents []models.ServiceRecent, name string, matchingSrc *models.ConfiguredSource, contentItemType, location, device string, utcTime int64) *models.ServiceRecent {
// Refined ID generation: YYMMDD (6 digits) + 3-digit counter (9 digits total, fits in 32-bit signed int).
// Max value for 32-bit signed int is 2,147,483,647.
// 260315999 is well within the limit.
prefixStr := time.Now().UTC().Format("060102")
// For testing parity with older logs, we might want to check if a specific date is requested.
// But usually, we just use today.
prefix, _ := strconv.Atoi(prefixStr)
baseID := int64(prefix) * 1000
maxCounter := 0
for j := range recents {
if id, err := strconv.Atoi(recents[j].ID); err == nil {
if int64(id) >= baseID && int64(id) < baseID+1000 {
counter := id % 1000
if counter > maxCounter {
maxCounter = counter
}
}
}
}
newID := int(baseID) + maxCounter + 1
r := &models.ServiceRecent{
ServiceContentItem: models.ServiceContentItem{
ID: strconv.Itoa(newID),
Name: name,
Type: contentItemType,
ContentItemType: contentItemType,
Location: location,
IsPresetable: "true",
},
DeviceID: device,
UtcTime: strconv.FormatInt(utcTime, 10),
CreatedOn: FormatTime(time.Now()),
}
if matchingSrc != nil {
r.Source = matchingSrc.SourceKeyType
r.SourceAccount = matchingSrc.SourceKeyAccount
r.SourceID = matchingSrc.ID
}
return r
}
func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.ConfiguredSource, createdOn string, utcTime int64) []byte {
// Create RecentItemParity for the flat web response
res := models.RecentItemParity{
ID: recentObj.ID,
ContentItemType: recentObj.ContentItemType,
CreatedOn: createdOn,
UpdatedOn: recentObj.UpdatedOn,
LastPlayedAt: time.Unix(utcTime, 0).UTC().Format("2006-01-02T15:04:05.000+00:00"),
Location: recentObj.Location,
Name: recentObj.Name,
SourceID: recentObj.SourceID,
}
if res.UpdatedOn == "" {
res.UpdatedOn = createdOn
}
if matchingSrc != nil {
PrepareConfiguredSource(matchingSrc)
res.Source = &models.RecentItemParitySource{
ID: matchingSrc.ID,
Type: matchingSrc.Type,
CreatedOn: matchingSrc.CreatedOn,
UpdatedOn: matchingSrc.UpdatedOn,
Name: matchingSrc.DisplayName,
SourceProviderID: matchingSrc.SourceProviderID,
SourceName: matchingSrc.SourceName,
Username: recentSourceUsername(matchingSrc),
}
if res.Source.Name == "TuneIn" || res.Source.Name == "LOCAL_INTERNET_RADIO" {
res.Source.Name = ""
}
switch {
case matchingSrc.Secret != "":
res.Source.Credential = &models.RecentItemParityCredential{
Type: matchingSrc.SecretType,
Value: matchingSrc.Secret,
}
case matchingSrc.Credential.Value != "":
res.Source.Credential = &models.RecentItemParityCredential{
Type: matchingSrc.Credential.Type,
Value: matchingSrc.Credential.Value,
}
default:
res.Source.Credential = &models.RecentItemParityCredential{
Type: "token",
Value: "",
}
}
if res.Source.Credential.Value == "" && matchingSrc.Secret != "" {
res.Source.Credential.Value = matchingSrc.Secret
}
if res.Source.Credential.Type == "" && matchingSrc.SecretType != "" {
res.Source.Credential.Type = matchingSrc.SecretType
}
if res.Source.SourceName == "" {
res.Source.SourceName = res.Source.Username
}
}
data, _ := xml.MarshalIndent(res, "", " ")
// Parity: use self-closing tags for empty SourceSettings
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
header := constants.XMLHeader
return append([]byte(header+"\n"), data...)
}
// AddDeviceToAccount upserts a device record for the given account.
// Called by both the device-create (POST) and device-rename (PUT)
// handlers — the persistence layer doesn't distinguish; only the
// response status differs.
//
// clientHost is the speaker's resolved client IP (bare IP, no port),
// as returned by handlers.clientHost(r). When the request body doesn't
// carry an `<ipaddress>` and the datastore has no IP for this device
// yet, we fall back to clientHost. An empty or non-IP clientHost is
// treated as "no fallback available" — never errors.
//
// Timestamps:
// - CreatedOn is preserved from any existing datastore record so a
// rename doesn't reset the "first paired in 2017" semantics real
// Bose emits. New devices get CreatedOn = now() at first save.
// - UpdatedOn is set to now() on every call.
//
// Returns the persisted deviceID and the marge XML response shape
// (`<device deviceid="…"><createdOn/><ipaddress/><name/><updatedOn/></device>`).
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte, clientHost string) (string, []byte, error) {
var newDeviceElem struct {
DeviceID string `xml:"deviceid,attr"`
Name string `xml:"name"`
MACAddress string `xml:"macaddress"`
}
if err := xml.Unmarshal(sourceXML, &newDeviceElem); err != nil {
return "", nil, err
}
now := FormatTime(time.Now())
// Build the info to save. Empty fields are filled in by the
// datastore's mergeWithExistingDeviceInfo (which preserves IP,
// MAC, CreatedOn, etc.) before the write — so the precedence
// here is "explicit > merged > remoteAddr fallback".
info := &models.ServiceDeviceInfo{
DeviceID: newDeviceElem.DeviceID,
Name: newDeviceElem.Name,
MacAddress: newDeviceElem.MACAddress,
UpdatedOn: now,
}
existing, _ := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
// CreatedOn: preserve from existing record for renames; set
// now() only on first registration (no prior record OR the
// record has no CreatedOn — older AfterTouch installs may
// have records without one).
if existing != nil && existing.CreatedOn != "" {
info.CreatedOn = existing.CreatedOn
} else {
info.CreatedOn = now
}
// IPAddress: prefer existing record's IP (the speaker may be
// hitting us through a different network path right now, e.g.
// SSH port-forward, and the persisted IP is the one other
// flows like DNS hints care about). Fall back to the inbound
// connection's client host only when there's no existing
// IP to preserve. An empty or non-IP clientHost leaves
// info.IPAddress empty, which the merge then handles.
if existing == nil || existing.IPAddress == "" {
if clientHost != "" && net.ParseIP(clientHost) != nil {
info.IPAddress = clientHost
}
}
if err := ds.SaveDeviceInfo(account, newDeviceElem.DeviceID, info); err != nil {
return "", nil, err
}
// Re-read the persisted record so the response XML reflects
// the merged state (preserved CreatedOn, preserved IP if the
// new info had none and the existing record did, etc.).
persisted, err := ds.GetDeviceInfo(account, newDeviceElem.DeviceID)
if err != nil {
return "", nil, fmt.Errorf("re-read persisted device info: %w", err)
}
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(persisted.DeviceID))
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(persisted.CreatedOn))
res += fmt.Sprintf(`<ipaddress>%s</ipaddress>`, EscapeXML(persisted.IPAddress))
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(persisted.Name))
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(persisted.UpdatedOn))
res += `</device>`
header := constants.XMLHeader
return persisted.DeviceID, append([]byte(header), []byte(res)...), nil
}
// RemoveDeviceFromAccount removes a device from the specified account.
func RemoveDeviceFromAccount(ds *datastore.DataStore, account, device string) error {
return ds.RemoveDevice(account, device)
}
// AddSourceToAccount adds a new music source to the account.
// POST /streaming/account/{account}/source
func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) {
var input struct {
XMLName xml.Name `xml:"source"`
Username string `xml:"username"`
SourceProviderID string `xml:"sourceproviderid"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
SourceName string `xml:"sourcename"`
}
if err := xml.Unmarshal(sourceXML, &input); err != nil {
return nil, fmt.Errorf("failed to unmarshal source XML: %w", err)
}
sourceID, err := AddSource(ds, account, input.Username, input.SourceProviderID, input.Credential.Value, input.Credential.Type, input.SourceName)
if err != nil {
return nil, err
}
resp := models.MargeAddSourceResponse{
SourceID: sourceID,
SourceProviderID: input.SourceProviderID,
CreatedOn: FormatTime(time.Now()),
UpdatedOn: FormatTime(time.Now()),
}
res, _ := xml.Marshal(resp)
header := constants.XMLHeader
return append([]byte(header), res...), nil
}
// RemoveSourceFromAccount deletes the source with the given id from every device
// of the account, mirroring AddSource (which fans a source out to all devices).
// Idempotent: a source absent from a device is silently skipped.
func RemoveSourceFromAccount(ds *datastore.DataStore, account, sourceID string) error {
devicesDir := ds.AccountDevicesDir(account)
entries, err := ds.ReadDirUnderBase(devicesDir)
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if delErr := ds.DeleteSourceByID(account, entry.Name(), sourceID); delErr != nil {
return delErr
}
}
return nil
}
// newSourceID returns a unique opaque source ID. SaveConfiguredSources dedups
// by ID, so it must be collision-free even for sources created in the same
// instant; it uses crypto/rand (64 bits) and falls back to a nanosecond
// timestamp only if the RNG ever fails.
func newSourceID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "SRC_" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
return "SRC_" + hex.EncodeToString(b[:])
}
// AddSource adds a new music source to the account and returns the generated source ID.
func AddSource(ds *datastore.DataStore, account, username, providerID, secret, secretType, sourceName string) (string, error) {
now := time.Now()
createdOn := FormatTime(now)
// SaveConfiguredSources dedups by ID, so IDs must be unique even when two
// sources are added in the same instant. Use a random ID rather than a
// timestamp (which can collide on coarse clocks or rapid calls).
sourceID := newSourceID()
// List accounts directly from the account directory to be sure we find them.
devicesDir := ds.AccountDevicesDir(account)
entries, _ := ds.ReadDirUnderBase(devicesDir)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
devID := entry.Name()
sources, _ := ds.GetConfiguredSources(account, devID)
newSrc := models.ConfiguredSource{
ID: sourceID,
SourceProviderID: providerID,
Username: username,
Secret: secret,
SecretType: secretType,
SourceName: sourceName,
Name: username,
CreatedOn: createdOn,
UpdatedOn: createdOn,
Status: "READY",
}
newSrc.SourceKey.Account = username
switch providerID {
case strconv.Itoa(constants.SpotifyProviderID):
newSrc.SourceKey.Type = constants.ProviderSpotify
case strconv.Itoa(constants.AmazonProviderID):
newSrc.SourceKey.Type = constants.ProviderAmazon
default:
newSrc.SourceKey.Type = providerID
}
log.Printf("[Marge] Adding source %s (%s) for device %s", sanitizeLog(newSrc.SourceKey.Type), sanitizeLog(username), sanitizeLog(devID))
PrepareConfiguredSource(&newSrc)
// Update or append. Most providers are singletons (one account each), so
// the same provider replaces the existing entry. STORED_MUSIC is the
// exception: each DLNA media server is a separate account (username =
// "<UDN>/0"), so it must only replace when the account also matches.
// Otherwise registering a second media server overwrites the first, which
// then vanishes from /full + /sources and the speaker drops it (only one
// media server could ever stay registered).
replaced := false
for i := range sources {
sameProvider := sources[i].SourceProviderID == providerID
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
// Match on the persisted account identity (SourceKey.Account),
// not Username, which does not round-trip through the datastore.
sameProvider = sameProvider && sources[i].SourceKey.Account == username
}
if sameProvider ||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
sources[i] = newSrc
replaced = true
break
}
}
if !replaced {
sources = append(sources, newSrc)
}
_ = ds.SaveConfiguredSources(account, devID, sources)
}
return sourceID, nil
}