mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
1025 lines
28 KiB
Go
1025 lines
28 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"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"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"
|
|
)
|
|
|
|
// DateStr is a fixed timestamp used in XML responses for consistency.
|
|
const DateStr = "2012-09-19T12:43:00.000+00:00"
|
|
|
|
// 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) {
|
|
type SourceXML struct {
|
|
XMLName xml.Name `xml:"source"`
|
|
ID string `xml:"id,attr"`
|
|
Type string `xml:"type,attr"`
|
|
CreatedOn string `xml:"createdOn"`
|
|
Credential struct {
|
|
Type string `xml:"type,attr"`
|
|
Value string `xml:",chardata"`
|
|
} `xml:"credential"`
|
|
Name string `xml:"name"`
|
|
SourceProviderID string `xml:"sourceproviderid"`
|
|
SourceName string `xml:"sourcename"`
|
|
SourceSettings string `xml:"sourceSettings"`
|
|
UpdatedOn string `xml:"updatedOn"`
|
|
Username string `xml:"username"`
|
|
}
|
|
|
|
providerID := cs.SourceProviderID
|
|
tokenType := "token"
|
|
|
|
if providerID == "" {
|
|
for _, p := range constants.StaticProviders {
|
|
if p.Name == cs.SourceKeyType {
|
|
providerID = strconv.Itoa(p.ID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if cs.SourceKeyType == "SPOTIFY" {
|
|
tokenType = "token_version_3"
|
|
}
|
|
|
|
if providerID == "" {
|
|
providerID = "0"
|
|
}
|
|
|
|
createdOn := cs.CreatedOn
|
|
if createdOn == "" {
|
|
createdOn = DateStr
|
|
}
|
|
|
|
updatedOn := cs.UpdatedOn
|
|
if updatedOn == "" {
|
|
updatedOn = DateStr
|
|
}
|
|
|
|
sxml := SourceXML{
|
|
ID: cs.ID,
|
|
Type: "Audio",
|
|
CreatedOn: createdOn,
|
|
Name: cs.SourceKeyAccount,
|
|
SourceProviderID: providerID,
|
|
SourceName: cs.DisplayName,
|
|
SourceSettings: "",
|
|
UpdatedOn: updatedOn,
|
|
Username: cs.SourceKeyAccount,
|
|
}
|
|
if sxml.SourceName == "Other" || cs.SourceKeyType == "TUNEIN" {
|
|
sxml.SourceName = ""
|
|
}
|
|
|
|
sxml.Credential.Type = tokenType
|
|
sxml.Credential.Value = cs.Secret
|
|
|
|
data, err := xml.Marshal(sxml)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parity: use self-closing tags for empty SourceSettings
|
|
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
|
|
|
|
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) {
|
|
providerID := s.SourceProviderID
|
|
tokenType := "token"
|
|
|
|
if providerID == "" {
|
|
for _, p := range constants.StaticProviders {
|
|
if p.Name == s.SourceKeyType {
|
|
providerID = strconv.Itoa(p.ID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.SecretType == "" {
|
|
if s.SourceKeyType == "SPOTIFY" {
|
|
tokenType = "token_version_3"
|
|
}
|
|
|
|
s.SecretType = tokenType
|
|
}
|
|
|
|
if providerID == "" {
|
|
providerID = "0"
|
|
}
|
|
|
|
if s.CreatedOn == "" {
|
|
s.CreatedOn = DateStr
|
|
}
|
|
|
|
if s.UpdatedOn == "" {
|
|
s.UpdatedOn = DateStr
|
|
}
|
|
|
|
s.Type = "Audio"
|
|
s.SourceProviderID = providerID
|
|
|
|
if s.SourceName == "" && s.DisplayName != "Other" {
|
|
s.SourceName = s.DisplayName
|
|
}
|
|
|
|
if s.SourceKeyType == "TUNEIN" {
|
|
s.SourceName = ""
|
|
}
|
|
|
|
if s.Username == "" {
|
|
s.Username = s.SourceKeyAccount
|
|
}
|
|
|
|
s.SourceSettings = ""
|
|
}
|
|
|
|
// 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 PresetsXML struct {
|
|
XMLName xml.Name `xml:"presets"`
|
|
Presets []models.ServicePreset `xml:"preset"`
|
|
}
|
|
|
|
pxml := PresetsXML{
|
|
Presets: make([]models.ServicePreset, 0, len(presets)),
|
|
}
|
|
|
|
for i := range presets {
|
|
p := presets[i]
|
|
|
|
p.ButtonNumber = p.ID
|
|
if p.CreatedOn == "" {
|
|
p.CreatedOn = DateStr
|
|
}
|
|
|
|
if p.UpdatedOn == "" {
|
|
p.UpdatedOn = DateStr
|
|
}
|
|
|
|
// Find and prepare source
|
|
for j := range sources {
|
|
s := sources[j]
|
|
if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
|
|
// Use a new variable to avoid pointer-to-iterator-variable bug
|
|
matchedSource := s
|
|
PrepareConfiguredSource(&matchedSource)
|
|
p.SourceConfig = &matchedSource
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
pxml.Presets = append(pxml.Presets, p)
|
|
}
|
|
|
|
data, err := xml.Marshal(pxml)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return append([]byte(constants.XMLHeader), data...), 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 RecentsXML struct {
|
|
XMLName xml.Name `xml:"recents"`
|
|
Recents []models.ServiceRecent `xml:"recent"`
|
|
}
|
|
|
|
rxml := RecentsXML{
|
|
Recents: recents,
|
|
}
|
|
|
|
for i := range rxml.Recents {
|
|
r := &rxml.Recents[i]
|
|
if r.SourceConfig == nil && r.SourceID != "" {
|
|
sources, _ := ds.GetConfiguredSources(account, deviceID)
|
|
for j := range sources {
|
|
s := sources[j]
|
|
if s.ID == r.SourceID {
|
|
// Use a new variable to avoid pointer-to-iterator-variable bug
|
|
matchedSource := s
|
|
r.SourceConfig = &matchedSource
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.SourceConfig != nil {
|
|
PrepareConfiguredSource(r.SourceConfig)
|
|
}
|
|
|
|
if r.UtcTime != "" {
|
|
if t, parseErr := strconv.ParseInt(r.UtcTime, 10, 64); parseErr == nil {
|
|
r.LastPlayedAt = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ProviderSettingsToXML generates provider settings XML for the specified account.
|
|
func ProviderSettingsToXML(account string) string {
|
|
return constants.XMLHeader + fmt.Sprintf(`<providerSettings>
|
|
<providerSetting>
|
|
<boseId>%s</boseId>
|
|
<keyName>ELIGIBLE_FOR_TRIAL</keyName>
|
|
<value>false</value>
|
|
<providerId>14</providerId>
|
|
</providerSetting>
|
|
<providerSetting>
|
|
<boseId>%s</boseId>
|
|
<keyName>STREAMING_QUALITY</keyName>
|
|
<value>2</value>
|
|
<providerId>15</providerId>
|
|
</providerSetting>
|
|
</providerSettings>`, EscapeXML(account), EscapeXML(account))
|
|
}
|
|
|
|
// SoftwareUpdateToXML generates software update configuration XML.
|
|
func SoftwareUpdateToXML() string {
|
|
return constants.XMLHeader + `
|
|
<software_update>
|
|
<softwareUpdateLocation></softwareUpdateLocation>
|
|
</software_update>`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
device := models.AccountDevice{
|
|
DeviceID: deviceID,
|
|
AttachedProduct: &models.AttachedProduct{
|
|
ProductCode: info.ProductCode,
|
|
ProductLabel: info.ProductCode,
|
|
SerialNumber: info.ProductSerialNumber,
|
|
UpdatedOn: DateStr,
|
|
},
|
|
CreatedOn: DateStr,
|
|
FirmwareVersion: info.FirmwareVersion,
|
|
IPAddress: info.IPAddress,
|
|
Name: info.Name,
|
|
SerialNumber: info.DeviceSerialNumber,
|
|
UpdatedOn: DateStr,
|
|
}
|
|
|
|
if len(info.Components) > 0 {
|
|
for _, comp := range info.Components {
|
|
device.AttachedProduct.Components = append(device.AttachedProduct.Components, models.ServiceComponent{
|
|
Type: comp.Type,
|
|
Label: comp.Label,
|
|
SoftwareVersion: comp.SoftwareVersion,
|
|
SerialNumber: comp.SerialNumber,
|
|
})
|
|
}
|
|
}
|
|
|
|
sources, _ := ds.GetConfiguredSources(account, deviceID)
|
|
presets, _ := ds.GetPresets(account, deviceID)
|
|
recents, _ := ds.GetRecents(account, deviceID)
|
|
|
|
device.Presets = mapPresetsToFullResponse(presets, sources)
|
|
device.Recents = mapRecentsToFullResponse(recents, sources)
|
|
|
|
return device, nil
|
|
}
|
|
|
|
func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSource {
|
|
fullSource := models.FullResponseSource{
|
|
ID: s.ID,
|
|
Type: s.Type,
|
|
CreatedOn: s.CreatedOn,
|
|
Name: s.SourceKeyAccount,
|
|
SourceProviderID: s.SourceProviderID,
|
|
SourceName: s.SourceName,
|
|
SourceSettings: "",
|
|
UpdatedOn: s.UpdatedOn,
|
|
Username: s.Username,
|
|
}
|
|
fullSource.Credential.Type = s.SecretType
|
|
fullSource.Credential.Value = s.Secret
|
|
|
|
if fullSource.Credential.Type == "" || fullSource.Credential.Type == "token" {
|
|
if s.Type == "SPOTIFY" || s.SourceProviderID == "SPOTIFY" || s.SourceKeyType == "SPOTIFY" {
|
|
fullSource.Credential.Type = "token_version_3"
|
|
} else if fullSource.Credential.Type == "" {
|
|
fullSource.Credential.Type = "token"
|
|
}
|
|
}
|
|
|
|
if s.SourceKeyType == "TUNEIN" {
|
|
fullSource.SourceName = ""
|
|
}
|
|
|
|
if fullSource.Username == "" {
|
|
fullSource.Username = s.SourceKeyAccount
|
|
}
|
|
|
|
return fullSource
|
|
}
|
|
|
|
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 = DateStr
|
|
}
|
|
|
|
if p.UpdatedOn == "" {
|
|
p.UpdatedOn = DateStr
|
|
}
|
|
|
|
var matchedSource *models.ConfiguredSource
|
|
|
|
for j := range sources {
|
|
s := sources[j]
|
|
if s.ID == p.SourceID || s.SourceKeyType == p.Source {
|
|
// Use a new variable to avoid pointer-to-iterator-variable bug
|
|
copySource := s
|
|
PrepareConfiguredSource(©Source)
|
|
matchedSource = ©Source
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
fullPreset := models.FullResponsePreset{
|
|
ButtonNumber: p.ID,
|
|
ContainerArt: p.ContainerArt,
|
|
ContentItemType: p.ContentItemType,
|
|
CreatedOn: p.CreatedOn,
|
|
Location: p.Location,
|
|
Name: p.Name,
|
|
UpdatedOn: p.UpdatedOn,
|
|
Username: p.Username,
|
|
}
|
|
if matchedSource != nil {
|
|
fullPreset.Source = mapToFullResponseSource(*matchedSource)
|
|
}
|
|
|
|
fullPresets = append(fullPresets, fullPreset)
|
|
}
|
|
|
|
return fullPresets
|
|
}
|
|
|
|
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 = DateStr
|
|
}
|
|
|
|
if r.UpdatedOn == "" {
|
|
r.UpdatedOn = DateStr
|
|
}
|
|
|
|
var matchedSource *models.ConfiguredSource
|
|
|
|
for j := range sources {
|
|
s := sources[j]
|
|
if s.ID == r.SourceID || s.SourceKeyType == r.Source {
|
|
// Use a new variable to avoid pointer-to-iterator-variable bug
|
|
copySource := s
|
|
PrepareConfiguredSource(©Source)
|
|
matchedSource = ©Source
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
if matchedSource != nil {
|
|
fullRecent.Source = mapToFullResponseSource(*matchedSource)
|
|
}
|
|
|
|
fullRecents = append(fullRecents, fullRecent)
|
|
}
|
|
|
|
return fullRecents
|
|
}
|
|
|
|
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
|
|
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
|
devicesDir := ds.AccountDevicesDir(account)
|
|
|
|
entries, err := os.ReadDir(devicesDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := models.AccountFullResponse{
|
|
ID: account,
|
|
AccountStatus: "OK",
|
|
Mode: "global",
|
|
PreferredLanguage: "en",
|
|
ProviderSettings: []models.ProviderSetting{
|
|
{
|
|
BoseID: account,
|
|
KeyName: "ELIGIBLE_FOR_TRIAL",
|
|
Value: "false",
|
|
ProviderID: "14",
|
|
},
|
|
{
|
|
BoseID: account,
|
|
KeyName: "STREAMING_QUALITY",
|
|
Value: "2",
|
|
ProviderID: "15",
|
|
},
|
|
},
|
|
}
|
|
|
|
var lastDeviceID string
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
deviceID := entry.Name()
|
|
lastDeviceID = deviceID
|
|
|
|
var dev models.AccountDevice
|
|
|
|
dev, err = CreateAccountDevice(ds, account, deviceID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
resp.Devices = append(resp.Devices, dev)
|
|
}
|
|
|
|
if lastDeviceID != "" {
|
|
sources, _ := ds.GetConfiguredSources(account, lastDeviceID)
|
|
for i := range sources {
|
|
s := sources[i]
|
|
PrepareConfiguredSource(&s)
|
|
|
|
resp.Sources = append(resp.Sources, mapToFullResponseSource(s))
|
|
}
|
|
}
|
|
|
|
data, err := xml.Marshal(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parity: use self-closing tags for empty components and sourceSettings
|
|
data = bytes.ReplaceAll(data, []byte("<components></components>"), []byte("<components/>"))
|
|
data = bytes.ReplaceAll(data, []byte("<sourceSettings> </sourceSettings>"), []byte("<sourceSettings/>"))
|
|
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
|
|
data = bytes.ReplaceAll(data, []byte("<name></name>"), []byte("<name/>"))
|
|
|
|
return append([]byte(constants.XMLHeader), data...), nil
|
|
}
|
|
|
|
// 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 {
|
|
return nil, err
|
|
}
|
|
|
|
var newPresetElem struct {
|
|
Name string `xml:"name"`
|
|
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
|
|
}
|
|
|
|
var matchingSrc *models.ConfiguredSource
|
|
|
|
for i := range sources {
|
|
if sources[i].ID == newPresetElem.SourceID {
|
|
matchingSrc = &sources[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if matchingSrc == nil {
|
|
return nil, fmt.Errorf("invalid account/source")
|
|
}
|
|
|
|
nowStr := strconv.FormatInt(time.Now().Unix(), 10)
|
|
presetObj := models.ServicePreset{
|
|
ServiceContentItem: models.ServiceContentItem{
|
|
ID: strconv.Itoa(presetNumber),
|
|
Name: newPresetElem.Name,
|
|
Source: matchingSrc.SourceKeyType,
|
|
Type: newPresetElem.ContentItemType,
|
|
Location: newPresetElem.Location,
|
|
SourceAccount: matchingSrc.SourceKeyAccount,
|
|
SourceID: newPresetElem.SourceID,
|
|
},
|
|
ContainerArt: newPresetElem.ContainerArt,
|
|
CreatedOn: nowStr,
|
|
UpdatedOn: nowStr,
|
|
ButtonNumber: strconv.Itoa(presetNumber),
|
|
}
|
|
|
|
// 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)
|
|
presetObj.SourceConfig = matchingSrc
|
|
|
|
data, err := xml.Marshal(presetObj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return append([]byte(constants.XMLHeader), data...), nil
|
|
}
|
|
|
|
// 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 && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
recents, err := ds.GetRecents(account, device)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
var newRecentElem 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"`
|
|
}
|
|
if err := xml.Unmarshal(sourceXML, &newRecentElem); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sourceName := newRecentElem.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
|
|
}
|
|
|
|
matchingSrc, learned := learnSource(ds, account, device, sources, newRecentElem.SourceID, newRecentElem.Location, sourceName, newRecentElem.Source.Credential.Value, newRecentElem.Source.SourceProviderID, newRecentElem.Source.CreatedOn, newRecentElem.Source.UpdatedOn)
|
|
if learned {
|
|
// Re-fetch sources to ensure we have the newly learned one
|
|
sources, _ = ds.GetConfiguredSources(account, device)
|
|
matchingSrc = findMatchingSource(sources, newRecentElem.SourceID)
|
|
}
|
|
|
|
if matchingSrc == nil {
|
|
matchingSrc = &models.ConfiguredSource{ID: newRecentElem.SourceID}
|
|
} else if matchingSrc.ID == "" {
|
|
matchingSrc.ID = newRecentElem.SourceID
|
|
}
|
|
|
|
// Ensure DisplayName and SourceName are consistent
|
|
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" && matchingSrc.DisplayName != "Other" {
|
|
matchingSrc.SourceName = matchingSrc.DisplayName
|
|
}
|
|
|
|
if matchingSrc.DisplayName == "" && matchingSrc.SourceName != "" {
|
|
matchingSrc.DisplayName = matchingSrc.SourceName
|
|
}
|
|
|
|
utcTime := parseLastPlayedAt(newRecentElem.LastPlayedAt)
|
|
recentObj, recents := updateOrCreateRecent(recents, newRecentElem.Name, matchingSrc, newRecentElem.ContentItemType, newRecentElem.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)
|
|
}
|
|
|
|
if sourceLearned {
|
|
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 == "" {
|
|
displayName = "Other"
|
|
}
|
|
|
|
src := &models.ConfiguredSource{
|
|
ID: sourceID,
|
|
DisplayName: displayName,
|
|
SourceName: sourceName,
|
|
Secret: credentialValue,
|
|
SourceProviderID: sourceProviderID,
|
|
CreatedOn: createdOn,
|
|
UpdatedOn: updatedOn,
|
|
}
|
|
|
|
switch {
|
|
case sourceProviderID == "25" || sourceID == "TUNEIN" || strings.Contains(location, "/v1/playback/station/"):
|
|
src.SourceKey.Type = "TUNEIN"
|
|
src.SourceKeyType = "TUNEIN"
|
|
src.Type = "Audio"
|
|
|
|
if src.DisplayName == "Other" || src.DisplayName == "TuneIn" {
|
|
src.DisplayName = "TuneIn"
|
|
}
|
|
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == "SPOTIFY":
|
|
src.SourceKey.Type = "SPOTIFY"
|
|
src.SourceKeyType = "SPOTIFY"
|
|
src.Type = "Audio"
|
|
src.SecretType = "token_version_3"
|
|
|
|
if src.DisplayName == "Other" {
|
|
src.DisplayName = "Spotify"
|
|
}
|
|
default:
|
|
src.SourceKey.Type = "INVALID"
|
|
src.SourceKeyType = "INVALID"
|
|
}
|
|
|
|
return src
|
|
}
|
|
|
|
func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceName, sourceProviderID string) bool {
|
|
learned := false
|
|
|
|
if credentialValue != "" && src.Secret == "" {
|
|
src.Secret = credentialValue
|
|
learned = true
|
|
}
|
|
|
|
if sourceName != "" && src.SourceName == "" {
|
|
src.SourceName = sourceName
|
|
learned = true
|
|
}
|
|
|
|
if sourceProviderID != "" && src.SourceProviderID == "" {
|
|
src.SourceProviderID = sourceProviderID
|
|
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: %v", device, 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())
|
|
recentObj = &recents[i]
|
|
// Move to front
|
|
recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if recentObj == nil {
|
|
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 {
|
|
if matchingSrc != nil {
|
|
PrepareConfiguredSource(matchingSrc)
|
|
recentObj.SourceConfig = matchingSrc
|
|
}
|
|
|
|
recentObj.CreatedOn = createdOn
|
|
recentObj.UpdatedOn = createdOn
|
|
recentObj.UtcTime = strconv.FormatInt(utcTime, 10)
|
|
recentObj.LastPlayedAt = time.Unix(utcTime, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
|
|
|
data, _ := xml.MarshalIndent(recentObj, "", " ")
|
|
|
|
// 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 adds a new device to the specified account.
|
|
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) {
|
|
var newDeviceElem struct {
|
|
DeviceID string `xml:"deviceid,attr"`
|
|
Name string `xml:"name"`
|
|
}
|
|
if err := xml.Unmarshal(sourceXML, &newDeviceElem); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info := &models.ServiceDeviceInfo{
|
|
DeviceID: newDeviceElem.DeviceID,
|
|
Name: newDeviceElem.Name,
|
|
// Other fields will be filled by discovery later or default
|
|
}
|
|
|
|
if err := ds.SaveDeviceInfo(account, newDeviceElem.DeviceID, info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
createdOn := FormatTime(time.Now())
|
|
res := fmt.Sprintf(`<device deviceid="%s">`, EscapeXML(newDeviceElem.DeviceID))
|
|
res += fmt.Sprintf(`<createdOn>%s</createdOn>`, EscapeXML(createdOn))
|
|
res += `<ipaddress></ipaddress>`
|
|
res += fmt.Sprintf(`<name>%s</name>`, EscapeXML(newDeviceElem.Name))
|
|
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, EscapeXML(createdOn))
|
|
res += `</device>`
|
|
|
|
header := constants.XMLHeader
|
|
|
|
return 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)
|
|
}
|