// Package datastore provides a simple XML-based datastore for SoundTouch devices. package datastore import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "encoding/xml" "errors" "fmt" "io" "math/rand" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/constants" ) // ErrGroupNotFound is returned when no group is found for a given device. var ErrGroupNotFound = errors.New("group not found") func exists(path string) bool { _, err := os.Stat(path) return err == nil } // isSafeIdentifier returns true if the given identifier is safe to use // as a single path component (for account IDs, device IDs, etc.). // It rejects empty strings, path separators, and parent directory references. func isSafeIdentifier(id string) bool { if id == "" { return false } // Disallow obvious path traversal / multi-component paths. if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") { return false } // Allow a conservative set of characters commonly found in IDs: // letters, digits, underscore, dash, dot, and colon (for MAC-like IDs). for i := 0; i < len(id); i++ { c := id[i] if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ':' { continue } return false } return true } // DataStore represents the device and configuration storage. type DataStore struct { // DataDir is the (possibly relative) base directory for all datastore files. DataDir string // baseDir is the absolute, normalized base directory used for path safety checks. baseDir string eventMutex sync.RWMutex deviceEvents map[string][]models.DeviceEvent idMutex sync.RWMutex deviceMappings map[string]string fileMutex sync.RWMutex } // normalizeMAC normalizes a MAC address to a consistent format func normalizeMAC(mac string) string { if mac == "" { return "" } // Remove spaces and common separators, then convert to uppercase mac = strings.TrimSpace(mac) mac = strings.ReplaceAll(mac, " ", "") mac = strings.ReplaceAll(mac, ":", "") mac = strings.ReplaceAll(mac, "-", "") mac = strings.ToUpper(mac) return mac } // NewDataStore creates a new DataStore. // NewDataStore creates a new DataStore instance with the specified data directory. func NewDataStore(dataDir string) *DataStore { if dataDir == "" { dataDir = "data" } absBase, err := filepath.Abs(dataDir) if err != nil { // Fallback to the provided dataDir if Abs fails; this preserves existing behavior. absBase = dataDir } return &DataStore{ DataDir: dataDir, baseDir: absBase, deviceEvents: make(map[string][]models.DeviceEvent), deviceMappings: make(map[string]string), } } // safeJoin joins the given path elements to the datastore baseDir and ensures // that the resulting absolute path stays within baseDir. If the check fails, // baseDir is returned to prevent directory traversal. func (ds *DataStore) safeJoin(elem ...string) string { // Join the base directory with the provided elements. path := filepath.Join(append([]string{ds.baseDir}, elem...)...) absPath, err := filepath.Abs(path) if err != nil { // On error, fall back to baseDir to avoid using an unexpected path. return ds.baseDir } base := ds.baseDir if base == "" { // If baseDir is not set for some reason, fall back to original path. return absPath } // Ensure the resolved path is within the base directory. baseWithSep := base if !strings.HasSuffix(baseWithSep, string(os.PathSeparator)) { baseWithSep += string(os.PathSeparator) } if absPath == base || strings.HasPrefix(absPath, baseWithSep) { return absPath } // If the path would escape the base directory, return baseDir as a safe default. return base } // SafeJoin returns a safe joined path relative to the datastore base directory. func (ds *DataStore) SafeJoin(elem ...string) string { return ds.safeJoin(elem...) } // ListAccounts returns a list of all account IDs (directories in the data root). func (ds *DataStore) ListAccounts() ([]string, error) { ds.fileMutex.RLock() defer ds.fileMutex.RUnlock() // Account data is stored in 'accounts' subdirectory within the data root. accountsDir := filepath.Join(ds.baseDir, "accounts") if !exists(accountsDir) { return []string{"default"}, nil } entries, err := os.ReadDir(accountsDir) if err != nil { return nil, err } accounts := make([]string, 0) for _, entry := range entries { if entry.IsDir() { // Basic filter to ignore common hidden/system dirs if entry.Name() != ".git" && entry.Name() != "logs" { accounts = append(accounts, entry.Name()) } } } if len(accounts) == 0 { accounts = append(accounts, "default") } return accounts, nil } // AccountDir returns the directory path for a specific account. func (ds *DataStore) AccountDir(account string) string { return ds.safeJoin("accounts", account) } // AccountDevicesDir returns the devices directory path for a specific account. func (ds *DataStore) AccountDevicesDir(account string) string { return ds.safeJoin("accounts", account, constants.DevicesDir) } // AccountDeviceDir returns the directory path for a specific device within an account. func (ds *DataStore) AccountDeviceDir(account, device string) string { // First, check if the device directory exists directly with the given deviceID // This prioritizes MAC-based deviceIDs over legacy mappings directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device) if _, err := os.Stat(directPath); err == nil { // Directory exists, use the direct deviceID (preferred for MAC-based IDs) return directPath } // If direct path doesn't exist, check device mappings for backward compatibility ds.idMutex.RLock() mappedDevice, ok := ds.deviceMappings[device] if !ok { // Try with normalized MAC address normalizedDevice := normalizeMAC(device) mappedDevice, ok = ds.deviceMappings[normalizedDevice] } ds.idMutex.RUnlock() if ok { // Use the mapped device only if it exists and the direct path doesn't mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice) if _, err := os.Stat(mappedPath); err == nil { return mappedPath } } // If neither direct path nor mapping work, return the direct path // (this allows new devices to be created with MAC-based IDs) return directPath } // GetDeviceInfo retrieves device information for the specified account and device. func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) { ds.fileMutex.RLock() defer ds.fileMutex.RUnlock() return ds.getDeviceInfoNoLock(account, device) } func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) { path := ds.AccountDeviceDir(account, device) deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile) data, err := os.ReadFile(deviceInfoPath) if err != nil { return nil, err } var info struct { XMLName xml.Name `xml:"info"` DeviceID string `xml:"deviceID,attr"` Name string `xml:"name"` Type string `xml:"type"` ModuleType string `xml:"moduleType"` Components []struct { Category string `xml:"componentCategory"` SoftwareVersion string `xml:"softwareVersion"` SerialNumber string `xml:"serialNumber"` } `xml:"components>component"` NetworkInfo []struct { Type string `xml:"type,attr"` IPAddress string `xml:"ipAddress"` MacAddress string `xml:"macAddress"` } `xml:"networkInfo"` DiscoveryMethod string `xml:"discoveryMethod"` } if err := xml.Unmarshal(data, &info); err != nil { return nil, err } deviceInfo := &models.ServiceDeviceInfo{ DeviceID: info.DeviceID, AccountID: account, // Set AccountID from parameter ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType), Name: info.Name, DiscoveryMethod: info.DiscoveryMethod, } for _, comp := range info.Components { deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{ Category: comp.Category, SoftwareVersion: comp.SoftwareVersion, SerialNumber: comp.SerialNumber, }) switch comp.Category { case "SCM": deviceInfo.FirmwareVersion = comp.SoftwareVersion deviceInfo.DeviceSerialNumber = comp.SerialNumber case "PackagedProduct": deviceInfo.ProductSerialNumber = comp.SerialNumber } } for _, net := range info.NetworkInfo { if net.Type == "SCM" { deviceInfo.IPAddress = net.IPAddress deviceInfo.MacAddress = net.MacAddress } } return deviceInfo, nil } // ListAllDevices returns a list of all devices in all accounts. func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { dirs := ds.getPossibleDataDirs() if len(dirs) == 0 { return []models.ServiceDeviceInfo{}, nil } devices := []models.ServiceDeviceInfo{} seenIDs := make(map[string]bool) for _, dir := range dirs { accounts, err := os.ReadDir(dir) if err != nil { continue } for _, acc := range accounts { if !acc.IsDir() { continue } accDevices := ds.listDevicesInAccount(dir, acc.Name()) for i := range accDevices { info := accDevices[i] info.AccountID = acc.Name() key := info.DeviceID if key == "" { key = info.IPAddress } if !seenIDs[key] || info.Name != "" { if seenIDs[key] && info.Name != "" { // Replace previous empty-named entry with one that has a name for j := range devices { existing := &devices[j] if (existing.DeviceID != "" && existing.DeviceID == info.DeviceID) || (existing.IPAddress != "" && existing.IPAddress == info.IPAddress) { devices[j] = info break } } } else if !seenIDs[key] { devices = append(devices, info) seenIDs[key] = true } } } } } return devices, nil } func (ds *DataStore) getPossibleDataDirs() []string { dirs := []string{} // Check primary data directory if ds.DataDir != "" { if exists(filepath.Join(ds.DataDir, "accounts")) { dirs = append(dirs, filepath.Join(ds.DataDir, "accounts")) } // Also check the DataDir itself as a base for account directories if exists(ds.DataDir) && ds.DataDir != "." { dirs = append(dirs, ds.DataDir) } } // Also check st-go/data/accounts if it's different and exists altDir := "st-go/data/accounts" if exists(altDir) { dirs = append(dirs, altDir) } // And st-go/data/accounts/default altDir2 := "st-go/data" if exists(altDir2) { dirs = append(dirs, altDir2) } // And repro_data altDir3 := "repro_data" if exists(altDir3) { dirs = append(dirs, altDir3) } // Add special handling for test environments where we might have account directories // directly in the current working directory or a temp dir. // Walk up from DataDir to find any 'accounts' directory. curr := ds.DataDir for i := 0; i < 3; i++ { absCurr, _ := filepath.Abs(curr) if exists(filepath.Join(absCurr, "accounts")) { dirs = append(dirs, filepath.Join(absCurr, "accounts")) } if exists(absCurr) { dirs = append(dirs, absCurr) } if curr == "." || curr == "/" || curr == "" { break } curr = filepath.Dir(curr) } // Remove duplicates and ensure unique directories uniqueDirs := make(map[string]bool) result := []string{} for _, dir := range dirs { absDir, err := filepath.Abs(dir) if err != nil { absDir = dir } if !uniqueDirs[absDir] { uniqueDirs[absDir] = true result = append(result, dir) } } return result } func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.ServiceDeviceInfo { devices := []models.ServiceDeviceInfo{} devicesDir := filepath.Join(baseDir, accountName, constants.DevicesDir) deviceEntries, err := os.ReadDir(devicesDir) if err != nil { return devices } for _, dev := range deviceEntries { var ( info *models.ServiceDeviceInfo err error ) if !dev.IsDir() { if dev.Name() == constants.DeviceInfoFile { // Special case for DeviceInfo.xml directly in devicesDir path := filepath.Join(devicesDir, constants.DeviceInfoFile) info, err = ds.parseDeviceInfoFile(path) } } else { path := filepath.Join(devicesDir, dev.Name(), constants.DeviceInfoFile) info, err = ds.parseDeviceInfoFile(path) } if err == nil && info != nil { // Update bidirectional device mappings for resolution ds.updateDeviceMappings(*info) devices = append(devices, *info) } } return devices } func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var info struct { XMLName xml.Name `xml:"info"` DeviceID string `xml:"deviceID,attr"` Name string `xml:"name"` Type string `xml:"type"` ModuleType string `xml:"moduleType"` Components []struct { Category string `xml:"componentCategory"` SoftwareVersion string `xml:"softwareVersion"` SerialNumber string `xml:"serialNumber"` } `xml:"components>component"` NetworkInfo []struct { Type string `xml:"type,attr"` IPAddress string `xml:"ipAddress"` MacAddress string `xml:"macAddress"` } `xml:"networkInfo"` DiscoveryMethod string `xml:"discoveryMethod"` } if err := xml.Unmarshal(data, &info); err != nil { return nil, err } deviceInfo := &models.ServiceDeviceInfo{ DeviceID: info.DeviceID, ProductCode: info.Type, Name: info.Name, DiscoveryMethod: info.DiscoveryMethod, } for _, comp := range info.Components { deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{ Category: comp.Category, SoftwareVersion: comp.SoftwareVersion, SerialNumber: comp.SerialNumber, }) switch comp.Category { case "SCM": deviceInfo.FirmwareVersion = comp.SoftwareVersion deviceInfo.DeviceSerialNumber = comp.SerialNumber case "PackagedProduct": deviceInfo.ProductSerialNumber = comp.SerialNumber } } for _, net := range info.NetworkInfo { if net.Type == "SCM" { deviceInfo.IPAddress = net.IPAddress deviceInfo.MacAddress = net.MacAddress } } return deviceInfo, nil } // GetPresets retrieves all presets for the specified account and device. func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) { ds.fileMutex.RLock() defer ds.fileMutex.RUnlock() path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []models.ServicePreset{}, nil } return nil, err } var presetsWrap struct { Presets []struct { ID string `xml:"id,attr"` CreatedOn string `xml:"createdOn,attr"` UpdatedOn string `xml:"updatedOn,attr"` 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"` } `xml:"contentItem"` SourceID string `xml:"sourceid"` } `xml:"preset"` } if err := xml.Unmarshal(data, &presetsWrap); err != nil { return nil, fmt.Errorf("malformed presets XML at %s: %w", path, err) } presets := []models.ServicePreset{} for i := range presetsWrap.Presets { p := &presetsWrap.Presets[i] presets = append(presets, models.ServicePreset{ ServiceContentItem: models.ServiceContentItem{ Name: p.ContentItem.ItemName, Source: p.ContentItem.Source, Type: p.ContentItem.Type, ContentItemType: p.ContentItem.Type, Location: p.ContentItem.Location, SourceAccount: p.ContentItem.SourceAccount, IsPresetable: p.ContentItem.IsPresetable, SourceID: p.SourceID, }, ID: p.ID, ButtonNumber: p.ID, ContainerArt: p.ContentItem.ContainerArt, CreatedOn: p.CreatedOn, UpdatedOn: p.UpdatedOn, }) } return presets, nil } // SavePresets saves the preset list for the specified account and device. func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } type PresetXML struct { ID string `xml:"id,attr"` CreatedOn string `xml:"createdOn,attr"` UpdatedOn string `xml:"updatedOn,attr"` ContentItem struct { Source string `xml:"source,attr,omitempty"` 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"` } `xml:"contentItem"` SourceID string `xml:"sourceid,omitempty"` } type PresetsXML struct { XMLName xml.Name `xml:"presets"` Presets []PresetXML `xml:"preset"` } var px PresetsXML for i := range presets { p := &presets[i] var pxml PresetXML pxml.ID = p.ButtonNumber if pxml.ID == "" { pxml.ID = p.ID } pxml.CreatedOn = p.CreatedOn pxml.UpdatedOn = p.UpdatedOn pxml.ContentItem.Source = p.Source pxml.ContentItem.Type = p.Type pxml.ContentItem.Location = p.Location pxml.ContentItem.SourceAccount = p.SourceAccount pxml.ContentItem.IsPresetable = "true" pxml.ContentItem.ItemName = p.Name pxml.ContentItem.ContainerArt = p.ContainerArt pxml.SourceID = p.SourceID px.Presets = append(px.Presets, pxml) } data, err := xml.MarshalIndent(px, "", " ") if err != nil { return err } header := []byte(xml.Header) return ds.atomicWriteFile(path, append(header, data...)) } func (ds *DataStore) atomicWriteFile(filename string, data []byte) error { perm := os.FileMode(0644) tempFile := filename + ".tmp" if err := os.WriteFile(tempFile, data, perm); err != nil { return err } return os.Rename(tempFile, filename) } // GetRecents returns the list of recently played items for the specified account and device. func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) { ds.fileMutex.RLock() defer ds.fileMutex.RUnlock() path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []models.ServiceRecent{}, nil } return nil, err } type RecentXML struct { DeviceID string `xml:"deviceID,attr,omitempty"` UtcTime string `xml:"utcTime,attr,omitempty"` ID string `xml:"id,attr"` 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"` } `xml:"contentItem"` CreatedOn string `xml:"createdOn,omitempty"` UpdatedOn string `xml:"updatedOn,omitempty"` LastPlayedAt string `xml:"lastplayedat,omitempty"` SourceID string `xml:"sourceid,omitempty"` Username string `xml:"username,omitempty"` } type RecentsXML struct { XMLName xml.Name `xml:"recents"` Recents []RecentXML `xml:"recent"` } var wrap RecentsXML if err := xml.Unmarshal(data, &wrap); err != nil { return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err) } recents := make([]models.ServiceRecent, 0, len(wrap.Recents)) maxID := 0 for i := range wrap.Recents { rx := &wrap.Recents[i] r := models.ServiceRecent{ DeviceID: rx.DeviceID, UtcTime: rx.UtcTime, ServiceContentItem: models.ServiceContentItem{ ID: rx.ID, Name: rx.ContentItem.ItemName, Source: rx.ContentItem.Source, Type: rx.ContentItem.Type, Location: rx.ContentItem.Location, SourceAccount: rx.ContentItem.SourceAccount, IsPresetable: rx.ContentItem.IsPresetable, ContainerArt: rx.ContentItem.ContainerArt, }, CreatedOn: rx.CreatedOn, UpdatedOn: rx.UpdatedOn, LastPlayedAt: rx.LastPlayedAt, } r.SourceID = rx.SourceID if id, err := strconv.Atoi(r.ID); err == nil { if id > maxID { maxID = id } } recents = append(recents, r) } for i := range recents { r := &recents[i] if r.ContentItemType == "" { if r.Type != "" { r.ContentItemType = r.Type } } if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" { maxID++ recents[i].ID = strconv.Itoa(maxID) } } return recents, nil } // SaveRecents saves the recent items list for the specified account and device. func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() dir := ds.AccountDeviceDir(account, device) if err := os.MkdirAll(dir, 0755); err != nil { return err } path := filepath.Join(dir, constants.RecentsFile) type RecentXML struct { DeviceID string `xml:"deviceID,attr,omitempty"` UtcTime string `xml:"utcTime,attr,omitempty"` ID string `xml:"id,attr"` 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"` } `xml:"contentItem"` CreatedOn string `xml:"createdOn,omitempty"` UpdatedOn string `xml:"updatedOn,omitempty"` LastPlayedAt string `xml:"lastplayedat,omitempty"` SourceID string `xml:"sourceid,omitempty"` Username string `xml:"username,omitempty"` } type RecentsXML struct { XMLName xml.Name `xml:"recents"` Recents []RecentXML `xml:"recent"` } wrap := RecentsXML{ Recents: make([]RecentXML, 0, len(recents)), } for i := range recents { r := &recents[i] rx := RecentXML{ DeviceID: r.DeviceID, UtcTime: r.UtcTime, ID: r.ID, CreatedOn: r.CreatedOn, UpdatedOn: r.UpdatedOn, LastPlayedAt: r.LastPlayedAt, SourceID: r.SourceID, Username: r.Username, } rx.ContentItem.Source = r.Source rx.ContentItem.Type = r.Type rx.ContentItem.Location = r.Location rx.ContentItem.SourceAccount = r.SourceAccount rx.ContentItem.IsPresetable = r.IsPresetable if rx.ContentItem.IsPresetable == "" { rx.ContentItem.IsPresetable = "true" } rx.ContentItem.ItemName = r.Name rx.ContentItem.ContainerArt = r.ContainerArt rx.SourceID = r.SourceID wrap.Recents = append(wrap.Recents, rx) } data, err := xml.MarshalIndent(wrap, "", " ") if err != nil { return err } header := []byte(xml.Header) return ds.atomicWriteFile(path, append(header, data...)) } // SaveDeviceInfo saves device information for the specified account and device. func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() if device == "" { return fmt.Errorf("device ID/name cannot be empty") } if !isSafeIdentifier(device) { return fmt.Errorf("invalid device ID") } if account == "" { return fmt.Errorf("account ID cannot be empty") } if !isSafeIdentifier(account) { return fmt.Errorf("invalid account ID") } // Try to load existing device info to avoid overwriting existing details with empty values. ds.mergeWithExistingDeviceInfo(account, device, info) dir := ds.AccountDeviceDir(account, device) if err := os.MkdirAll(dir, 0755); err != nil { return err } path := filepath.Join(dir, constants.DeviceInfoFile) type NetworkInfoXML struct { Type string `xml:"type,attr"` IPAddress string `xml:"ipAddress"` MacAddress string `xml:"macAddress"` } type InfoXML struct { XMLName xml.Name `xml:"info"` DeviceID string `xml:"deviceID,attr"` Name string `xml:"name"` Type string `xml:"type"` ModuleType string `xml:"moduleType"` Components []componentXML `xml:"components>component"` NetworkInfo []NetworkInfoXML `xml:"networkInfo"` DiscoveryMethod string `xml:"discoveryMethod,omitempty"` } // Parsing product code back to type and moduleType (best effort) devType, moduleType := ds.parseProductCode(info.ProductCode) ix := InfoXML{ DeviceID: info.DeviceID, Name: info.Name, Type: devType, ModuleType: moduleType, DiscoveryMethod: info.DiscoveryMethod, } if ix.DiscoveryMethod == "" { ix.DiscoveryMethod = "sync_full" } ix.Components = ds.buildComponentsXML(info) ix.NetworkInfo = []NetworkInfoXML{ { Type: "SCM", IPAddress: info.IPAddress, MacAddress: info.MacAddress, }, } data, err := xml.MarshalIndent(ix, "", " ") if err != nil { return err } header := []byte(xml.Header) return ds.atomicWriteFile(path, append(header, data...)) } func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *models.ServiceDeviceInfo) { existing, _ := ds.getDeviceInfoNoLock(account, device) if existing == nil { return } if info.Name == "" { info.Name = existing.Name } if info.ProductCode == "" { info.ProductCode = existing.ProductCode } if info.DeviceSerialNumber == "" { info.DeviceSerialNumber = existing.DeviceSerialNumber } if info.ProductSerialNumber == "" { info.ProductSerialNumber = existing.ProductSerialNumber } if info.FirmwareVersion == "" { info.FirmwareVersion = existing.FirmwareVersion } if info.IPAddress == "" { info.IPAddress = existing.IPAddress } if info.MacAddress == "" { info.MacAddress = existing.MacAddress } if info.DiscoveryMethod == "" { info.DiscoveryMethod = existing.DiscoveryMethod } } func (ds *DataStore) parseProductCode(productCode string) (string, string) { devType := productCode moduleType := "" for i := 0; i < len(productCode); i++ { if productCode[i] == ' ' { devType = productCode[:i] moduleType = productCode[i+1:] break } } return devType, moduleType } type componentXML struct { ComponentCategory string `xml:"componentCategory"` SoftwareVersion string `xml:"softwareVersion,omitempty"` SerialNumber string `xml:"serialNumber,omitempty"` } func (ds *DataStore) buildComponentsXML(info *models.ServiceDeviceInfo) []componentXML { var components []componentXML for _, comp := range info.Components { components = append(components, componentXML{ ComponentCategory: comp.Category, SoftwareVersion: comp.SoftwareVersion, SerialNumber: comp.SerialNumber, }) } if len(components) == 0 && (info.FirmwareVersion != "" || info.DeviceSerialNumber != "" || info.ProductSerialNumber != "") { components = []componentXML{ { ComponentCategory: "SCM", SoftwareVersion: info.FirmwareVersion, SerialNumber: info.DeviceSerialNumber, }, { ComponentCategory: "PackagedProduct", SerialNumber: info.ProductSerialNumber, }, } } else if len(components) > 0 { if info.FirmwareVersion != "" { for i := range components { if components[i].ComponentCategory == "SCM" { components[i].SoftwareVersion = info.FirmwareVersion break } } } } return components } // SaveAccountInfo stores account-level metadata in the datastore. func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccountInfo) error { if ds == nil || ds.DataDir == "" || accountID == "" { return nil } dir := ds.AccountDir(accountID) if err := os.MkdirAll(dir, 0755); err != nil { return err } path := filepath.Join(dir, "account.json") data, err := json.MarshalIndent(info, "", " ") if err != nil { return err } return ds.atomicWriteFile(path, data) } // GetAccountInfo retrieves account-level metadata from the datastore. func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInfo, error) { if ds == nil || ds.DataDir == "" || accountID == "" { return &models.ServiceAccountInfo{AccountID: accountID}, nil } // Try account root (canonical location) path := filepath.Join(ds.AccountDir(accountID), "account.json") if !exists(path) { return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil } data, err := os.ReadFile(path) if err != nil { return nil, err } var info models.ServiceAccountInfo if err := json.Unmarshal(data, &info); err != nil { return nil, err } return &info, nil } // RemoveDevice removes a device and all its data from the specified account. func (ds *DataStore) RemoveDevice(account, device string) error { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() dir := ds.AccountDeviceDir(account, device) return os.RemoveAll(dir) } // RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility. func (ds *DataStore) RemoveDeviceDir(account, device string) error { return ds.RemoveDevice(account, device) } // DeduceSourceIDs updates the source IDs in the given slice by deducing them from recents and presets. func (ds *DataStore) DeduceSourceIDs(account, device string, sources []models.ConfiguredSource) { // Deduce source IDs from recents and presets deducedIDs := ds.collectDeducedIDs(account, device) for i := range sources { if id, ok := deducedIDs[sources[i].SourceProviderID]; ok { sources[i].ID = id } else if sources[i].SourceKeyType == "AUX" { auxID := strconv.Itoa(constants.AuxProviderID) if id, ok := deducedIDs[auxID]; ok { sources[i].ID = id sources[i].SourceProviderID = auxID } } } } func (ds *DataStore) collectDeducedIDs(account, device string) map[string]string { deducedIDs := make(map[string]string) // Check recents and presets to find source IDs for provider IDs 2, 9, 11, 25 for _, filename := range []string{constants.RecentsFile, constants.PresetsFile} { fileContent, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename)) if err != nil { continue } ds.parseIDsFromFile(fileContent, deducedIDs) } return deducedIDs } func (ds *DataStore) parseIDsFromFile(fileContent []byte, deducedIDs map[string]string) { decoder := xml.NewDecoder(bytes.NewReader(fileContent)) for { token, _ := decoder.Token() if token == nil { break } if se, ok := token.(xml.StartElement); ok { switch se.Name.Local { case "source": ds.parseSourceElement(decoder, &se, deducedIDs) case "recent", "preset": ds.parseRecentPresetElement(decoder, &se, deducedIDs) } } } } func (ds *DataStore) parseSourceElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) { var s struct { ID string `xml:"id,attr"` SourceProviderID string `xml:"sourceproviderid"` // Also check for sourceproviderid as attribute just in case SourceProviderIDAttr string `xml:"sourceproviderid,attr"` } if err := decoder.DecodeElement(&s, se); err == nil { pid := s.SourceProviderID if pid == "" { pid = s.SourceProviderIDAttr } ds.extractIDs(pid, s.ID, deducedIDs) } } func (ds *DataStore) parseRecentPresetElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) { var s struct { SourceID string `xml:"sourceid"` SourceProviderID string `xml:"sourceproviderid"` ContentItem struct { Source string `xml:"source,attr"` Type string `xml:"type,attr"` } `xml:"contentItem"` Source struct { SourceProviderID string `xml:"sourceproviderid"` } `xml:"source"` } if err := decoder.DecodeElement(&s, se); err == nil { pid := s.SourceProviderID if pid == "" { pid = s.Source.SourceProviderID } if pid == "" { // For AUX, we often don't have provider ID 9 but we know its name/source switch s.ContentItem.Source { case constants.ProviderAux: pid = strconv.Itoa(constants.AuxProviderID) case constants.ProviderInternetRadio: pid = strconv.Itoa(constants.InternetRadioProviderID) case constants.ProviderLocalInternetRadio: pid = strconv.Itoa(constants.LocalInternetRadioProviderID) case constants.ProviderTunein: pid = strconv.Itoa(constants.TuneinProviderID) } } ds.extractIDs(pid, s.SourceID, deducedIDs) } } func (ds *DataStore) extractIDs(providerID, sourceID string, deducedIDs map[string]string) { if sourceID == "" || providerID == "" { return } // Stick to the provider ids mentioned: 2, 9, 11, 25 switch providerID { case strconv.Itoa(constants.InternetRadioProviderID), strconv.Itoa(constants.AuxProviderID), strconv.Itoa(constants.LocalInternetRadioProviderID), strconv.Itoa(constants.TuneinProviderID): if _, exists := deducedIDs[providerID]; !exists { deducedIDs[providerID] = sourceID } } } // GetConfiguredSources retrieves all configured sources for the specified account and device. func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) { ds.fileMutex.RLock() defer ds.fileMutex.RUnlock() path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { sources := ds.getDefaultSources() ds.DeduceSourceIDs(account, device, sources) return sources, nil } return nil, err } type persistentSource struct { DisplayName string `xml:"displayName,attr,omitempty"` ID string `xml:"id,attr,omitempty"` Secret string `xml:"secret,attr"` SecretType string `xml:"secretType,attr"` Type string `xml:"type,attr,omitempty"` CreatedOn string `xml:"createdOn,attr,omitempty"` UpdatedOn string `xml:"updatedOn,attr,omitempty"` SourceProviderID string `xml:"sourceproviderid,attr,omitempty"` Credential struct { Type string `xml:"type,attr"` Value string `xml:",chardata"` } `xml:"credential,omitempty"` SourceKey struct { Type string `xml:"type,attr"` Account string `xml:"account,attr"` } `xml:"sourceKey"` } var sourcesWrap struct { Sources []persistentSource `xml:"source"` } if err := xml.Unmarshal(data, &sourcesWrap); err != nil { return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err) } sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources)) for i := range sourcesWrap.Sources { ps := &sourcesWrap.Sources[i] s := &sources[i] s.DisplayName = ps.DisplayName s.ID = ps.ID s.Secret = ps.Secret s.SecretType = ps.SecretType s.Type = ps.Type s.CreatedOn = ps.CreatedOn s.UpdatedOn = ps.UpdatedOn s.SourceProviderID = ps.SourceProviderID s.SourceKey.Type = ps.SourceKey.Type s.SourceKey.Account = ps.SourceKey.Account // Prioritize Credential element if present, otherwise use secret/secretType attributes if ps.Credential.Value != "" { s.Secret = ps.Credential.Value s.SecretType = ps.Credential.Type } // Ensure Secret/SecretType values are prioritized from legacy fields if still missing if s.Secret == "" && s.Credential.Value != "" { s.Secret = s.Credential.Value } if s.SecretType == "" && s.Credential.Type != "" { s.SecretType = s.Credential.Type } // Ensure SourceKey values are prioritized for legacy fields if s.SourceKey.Type != "" { s.SourceKeyType = s.SourceKey.Type } if s.SourceKey.Account != "" { s.SourceKeyAccount = s.SourceKey.Account } // Ensure Type is populated from SourceKey if missing if s.Type == "" && s.SourceKey.Type != "" { s.Type = s.SourceKey.Type } if s.ID == "" { s.ID = strconv.Itoa(2000001 + i) } } return sources, nil } // SaveConfiguredSources saves the configured sources list for the specified account and device. func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } type persistentSource struct { DisplayName string `xml:"displayName,attr,omitempty"` ID string `xml:"id,attr,omitempty"` Secret string `xml:"secret,attr"` SecretType string `xml:"secretType,attr"` Type string `xml:"type,attr,omitempty"` CreatedOn string `xml:"createdOn,attr,omitempty"` UpdatedOn string `xml:"updatedOn,attr,omitempty"` SourceProviderID string `xml:"sourceproviderid,attr,omitempty"` Credential struct { Type string `xml:"type,attr"` Value string `xml:",chardata"` } `xml:"credential,omitempty"` SourceKey struct { Type string `xml:"type,attr"` Account string `xml:"account,attr"` } `xml:"sourceKey"` } // Deduplicate by ID before saving; first occurrence wins to preserve established data seen := make(map[string]bool) deduped := make([]models.ConfiguredSource, 0, len(sources)) for i := range sources { s := &sources[i] if s.ID != "" { if seen[s.ID] { continue } seen[s.ID] = true } deduped = append(deduped, *s) } sources = deduped // Ensure SourceKey is populated from legacy fields if necessary before saving // and map to persistentSource to avoid custom MarshalXML for disk storage persistSources := make([]persistentSource, len(sources)) for i := range sources { s := sources[i] if s.SourceKey.Type == "" && s.SourceKeyType != "" { s.SourceKey.Type = s.SourceKeyType } if s.SourceKey.Account == "" && s.SourceKeyAccount != "" { s.SourceKey.Account = s.SourceKeyAccount } persistSources[i] = persistentSource{ DisplayName: s.DisplayName, ID: s.ID, Secret: s.Secret, SecretType: s.SecretType, Type: s.Type, CreatedOn: s.CreatedOn, UpdatedOn: s.UpdatedOn, SourceProviderID: s.SourceProviderID, } // Save to Credential element as well for parity with official Bose format if s.Secret != "" { persistSources[i].Credential.Value = s.Secret persistSources[i].Credential.Type = s.SecretType } else if s.Credential.Value != "" { persistSources[i].Credential.Value = s.Credential.Value persistSources[i].Credential.Type = s.Credential.Type } if persistSources[i].Secret == "" && s.Credential.Value != "" { persistSources[i].Secret = s.Credential.Value } if persistSources[i].SecretType == "" && s.Credential.Type != "" { persistSources[i].SecretType = s.Credential.Type } persistSources[i].SourceKey.Type = s.SourceKey.Type persistSources[i].SourceKey.Account = s.SourceKey.Account } wrap := struct { XMLName xml.Name `xml:"sources"` Sources []persistentSource `xml:"source"` }{ Sources: persistSources, } data, err := xml.MarshalIndent(wrap, "", " ") if err != nil { return err } header := []byte(xml.Header) return ds.atomicWriteFile(path, append(header, data...)) } // updateDeviceMappings creates bidirectional mappings for device resolution func (ds *DataStore) updateDeviceMappings(info models.ServiceDeviceInfo) { ds.idMutex.Lock() defer ds.idMutex.Unlock() deviceID := info.DeviceID macAddress := info.MacAddress deviceSerial := info.DeviceSerialNumber // If device is stored with MAC as deviceID and has a serial, create backward mapping if isMACAddressFormat(deviceID) && deviceSerial != "" && deviceSerial != deviceID { ds.deviceMappings[deviceSerial] = deviceID } // If device is stored with serial as deviceID and has a MAC, create forward mapping if !isMACAddressFormat(deviceID) && macAddress != "" { ds.deviceMappings[macAddress] = deviceID // Also store normalized MAC version normalizedMAC := normalizeMAC(macAddress) if normalizedMAC != macAddress { ds.deviceMappings[normalizedMAC] = deviceID } } } // UpdateMapping maintains backward compatibility for external callers func (ds *DataStore) UpdateMapping(mac, serial string) { if mac == "" || serial == "" { return } ds.idMutex.Lock() defer ds.idMutex.Unlock() // In the new system, MAC addresses are preferred as deviceIDs // So map the serial TO the MAC (reverse of old system) ds.deviceMappings[serial] = mac // Also map MAC to serial for any remaining legacy code ds.deviceMappings[mac] = serial normalizedMAC := normalizeMAC(mac) if normalizedMAC != mac { ds.deviceMappings[normalizedMAC] = serial } } // GenerateSerialSecret generates a base64 encoded JSON object with the specified serial. func GenerateSerialSecret(serial string) string { m := map[string]string{"serial": serial} b, err := json.Marshal(m) if err != nil { return "" } return base64.StdEncoding.EncodeToString(b) } // GetDefaultSources returns the list of default sources. func (ds *DataStore) GetDefaultSources() []models.ConfiguredSource { return ds.getDefaultSources() } func (ds *DataStore) getDefaultSources() []models.ConfiguredSource { sources := []models.ConfiguredSource{ { ID: "10001", DisplayName: "AUX IN", SourceKeyType: constants.ProviderAux, SourceKeyAccount: constants.ProviderAux, Type: "Audio", Status: "READY", CreatedOn: "2015-03-11T19:12:38.000+00:00", UpdatedOn: "2015-03-11T19:12:38.000+00:00", }, { ID: "10002", DisplayName: "", SourceKeyType: constants.ProviderInternetRadio, SourceKeyAccount: "", SourceProviderID: strconv.Itoa(constants.InternetRadioProviderID), Type: "Audio", SecretType: "token", Status: "READY", CreatedOn: "2015-03-11T19:12:38.000+00:00", UpdatedOn: "2015-03-11T19:12:38.000+00:00", }, { ID: "10003", DisplayName: "", SourceKeyType: constants.ProviderLocalInternetRadio, SourceKeyAccount: "", SourceProviderID: strconv.Itoa(constants.LocalInternetRadioProviderID), Type: "Audio", Secret: GenerateSerialSecret("local-internet-radio"), SecretType: "token", Status: "READY", CreatedOn: "2019-01-24T08:18:37.000+00:00", UpdatedOn: "2019-02-03T18:35:45.000+00:00", }, { ID: "10004", DisplayName: "", SourceKeyType: constants.ProviderTunein, SourceKeyAccount: "", SourceProviderID: strconv.Itoa(constants.TuneinProviderID), Type: "Audio", Secret: GenerateSerialSecret("tunein"), SecretType: "token", Status: "READY", CreatedOn: "2017-07-20T16:43:48.000+00:00", UpdatedOn: "2017-07-20T16:43:48.000+00:00", }, { ID: "10005", DisplayName: "", SourceKeyType: constants.ProviderRadioBrowser, SourceKeyAccount: "", SourceProviderID: strconv.Itoa(constants.RadioBrowserProviderID), Type: "Audio", SecretType: "token", Status: "READY", CreatedOn: "2026-02-16T01:01:01.000+00:00", UpdatedOn: "2026-02-16T01:01:01.000+00:00", }, } for i := range sources { sources[i].SourceKey.Type = sources[i].SourceKeyType sources[i].SourceKey.Account = sources[i].SourceKeyAccount } return sources } // isMACAddressFormat checks if a string looks like a MAC address func isMACAddressFormat(s string) bool { // AABBCCDDEEFF format if len(s) == 12 { return isHexOnly(s) } // AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) { s = strings.ReplaceAll(s, "-", ":") parts := strings.Split(s, ":") if len(parts) != 6 { return false } for _, part := range parts { if len(part) != 2 || !isHexOnly(part) { return false } } return true } return false } func isHexOnly(s string) bool { for _, r := range s { if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') { return false } } return true } // Initialize creates the necessary directory structure for the datastore and populates ID mappings. func (ds *DataStore) Initialize() error { // Ensure base data directory exists if err := os.MkdirAll(ds.DataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } // Scan for devices to populate MAC to Serial mapping _, err := ds.ListAllDevices() return err } // GetETagForPresets returns the ETag (modification time) for the presets file for a specific device. func (ds *DataStore) GetETagForPresets(account, device string) int64 { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) info, err := os.Stat(path) if err != nil { return 0 } return info.ModTime().UnixNano() / int64(time.Millisecond) } // HasConfiguredSources reports whether a Sources.xml file exists for the given account and device. func (ds *DataStore) HasConfiguredSources(account, device string) bool { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) _, err := os.Stat(path) return err == nil } // GetETagForSources returns the ETag (modification time) for the sources file for a specific device. func (ds *DataStore) GetETagForSources(account, device string) int64 { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) info, err := os.Stat(path) if err != nil { return 0 } return info.ModTime().UnixNano() / int64(time.Millisecond) } // GetETagForRecents returns the ETag (modification time) for the recents file for a specific device. func (ds *DataStore) GetETagForRecents(account, device string) int64 { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) info, err := os.Stat(path) if err != nil { return 0 } return info.ModTime().UnixNano() / int64(time.Millisecond) } // GetETagForAccount returns a content hash (SHA-256) over presets, sources, and recents for the account and device. // If device is empty, it hashes across all devices in the account. // The default sources fingerprint is always included so that newly added defaults (e.g. Amazon) // invalidate cached responses even when the stored Sources.xml has not changed. func (ds *DataStore) GetETagForAccount(account, device string) string { h := sha256.New() // Include the default sources fingerprint so mergeDefaultSources changes are visible. defaults := ds.GetDefaultSources() for i := range defaults { _, _ = io.WriteString(h, defaults[i].ID+defaults[i].SourceKeyType+defaults[i].DisplayName) } if device != "" { deviceDir := ds.AccountDeviceDir(account, device) for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} { f, err := os.Open(filepath.Join(deviceDir, name)) if err != nil { continue } _, _ = io.Copy(h, f) _ = f.Close() } return hex.EncodeToString(h.Sum(nil)) } devicesDir := ds.AccountDevicesDir(account) // Ignore error: missing directory is treated as no devices, producing a // stable non-empty hash rather than "" which would false-match an absent // If-None-Match header and return 304 on the first request. entries, _ := os.ReadDir(devicesDir) for _, entry := range entries { if entry.IsDir() { deviceDir := ds.AccountDeviceDir(account, entry.Name()) for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} { f, err := os.Open(filepath.Join(deviceDir, name)) if err != nil { continue } _, _ = io.Copy(h, f) _ = f.Close() } } } return hex.EncodeToString(h.Sum(nil)) } // Settings represents the global service settings. type Settings struct { ServerURL string `json:"server_url"` HTTPServerURL string `json:"https_server_url,omitempty"` RedactLogs bool `json:"redact_logs"` LogBodies bool `json:"log_bodies"` RecordInteractions bool `json:"record_interactions"` DiscoveryInterval string `json:"discovery_interval,omitempty"` DiscoveryEnabled bool `json:"discovery_enabled"` DNSEnabled bool `json:"dns_enabled"` DNSUpstream []string `json:"dns_upstream,omitempty"` DNSBindAddr string `json:"dns_bind_addr,omitempty"` MirrorEnabled bool `json:"mirror_enabled"` MirrorEndpoints []string `json:"mirror_endpoints,omitempty"` SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"` PreferredSource string `json:"preferred_source,omitempty"` InternalPaths []string `json:"internal_paths,omitempty"` Shortcuts map[string]int `json:"shortcuts,omitempty"` SpotifyClientID string `json:"spotify_client_id,omitempty"` SpotifyClientSecret string `json:"spotify_client_secret,omitempty"` SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"` AmazonClientID string `json:"amazon_client_id,omitempty"` AmazonClientSecret string `json:"amazon_client_secret,omitempty"` AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"` } // GetSettings retrieves the global service settings. func (ds *DataStore) GetSettings() (Settings, error) { if ds == nil || ds.DataDir == "" { return Settings{}, nil } path := filepath.Join(ds.DataDir, "settings.json") if !exists(path) { return Settings{}, nil } data, err := os.ReadFile(path) if err != nil { return Settings{}, err } var settings Settings if err := json.Unmarshal(data, &settings); err != nil { return Settings{}, err } return settings, nil } // SaveSettings saves the global service settings. func (ds *DataStore) SaveSettings(settings Settings) error { if ds == nil || ds.DataDir == "" { return nil } if err := os.MkdirAll(ds.DataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } path := filepath.Join(ds.DataDir, "settings.json") data, err := json.MarshalIndent(settings, "", " ") if err != nil { return err } return ds.atomicWriteFile(path, data) } // SaveUsageStats saves usage statistics to the datastore. func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { dir := filepath.Join(ds.DataDir, "stats", "usage") if err := os.MkdirAll(dir, 0755); err != nil { return err } filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID) path := filepath.Join(dir, filename) data, err := json.MarshalIndent(stats, "", " ") if err != nil { return err } return ds.atomicWriteFile(path, data) } // SaveErrorStats saves error statistics to the datastore. func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error { dir := filepath.Join(ds.DataDir, "stats", "error") if err := os.MkdirAll(dir, 0755); err != nil { return err } filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID) path := filepath.Join(dir, filename) data, err := json.MarshalIndent(stats, "", " ") if err != nil { return err } return ds.atomicWriteFile(path, data) } // AddDeviceEvent adds a device event to the in-memory event store. func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) { ds.eventMutex.Lock() defer ds.eventMutex.Unlock() events := ds.deviceEvents[deviceID] events = append(events, event) // Keep only last 100 events if len(events) > 100 { events = events[len(events)-100:] } ds.deviceEvents[deviceID] = events } // GetDeviceEvents retrieves all events for the specified device. func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent { ds.eventMutex.RLock() defer ds.eventMutex.RUnlock() events, ok := ds.deviceEvents[deviceID] if !ok { return []models.DeviceEvent{} } // Return a copy to avoid race conditions if the caller modifies it copiedEvents := make([]models.DeviceEvent, len(events)) copy(copiedEvents, events) return copiedEvents } // DNSDiscoveryEntry represents a persisted DNS discovery. type DNSDiscoveryEntry struct { Hostname string `json:"hostname"` FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` QueryCount int `json:"query_count"` IsBoseService bool `json:"is_bose_service"` IsIntercepted bool `json:"is_intercepted"` RemoteAddr string `json:"remote_addr,omitempty"` } // SaveDNSDiscoveries saves DNS discoveries to the datastore. func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error { if ds == nil || ds.DataDir == "" { return nil } dir := filepath.Join(ds.DataDir, "dns") if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create dns directory: %w", err) } path := filepath.Join(dir, "discoveries.json") // Sort by last seen descending sort.Slice(discoveries, func(i, j int) bool { return discoveries[i].LastSeen.After(discoveries[j].LastSeen) }) data, err := json.MarshalIndent(discoveries, "", " ") if err != nil { return err } return ds.atomicWriteFile(path, data) } // LoadDNSDiscoveries loads DNS discoveries from the datastore. func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) { if ds == nil || ds.DataDir == "" { return []DNSDiscoveryEntry{}, nil } path := filepath.Join(ds.DataDir, "dns", "discoveries.json") if !exists(path) { return []DNSDiscoveryEntry{}, nil } data, err := os.ReadFile(path) if err != nil { return nil, err } var discoveries []DNSDiscoveryEntry if err := json.Unmarshal(data, &discoveries); err != nil { return nil, err } return discoveries, nil } // ClearDNSDiscoveries removes all DNS discoveries from the datastore. func (ds *DataStore) ClearDNSDiscoveries() error { if ds == nil || ds.DataDir == "" { return nil } path := filepath.Join(ds.DataDir, "dns", "discoveries.json") if !exists(path) { return nil } return os.Remove(path) } // groupFilePath returns the on-disk path for a group file. func (ds *DataStore) groupFilePath(account, groupID string) string { return filepath.Join(ds.AccountDevicesDir(account), "Group_"+groupID+".xml") } // generateGroupID returns a unique 7-digit group ID that has no existing file. func (ds *DataStore) generateGroupID(account string) string { for { id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec if !exists(ds.groupFilePath(account, id)) { return id } } } // GetGroupForDevice returns the group containing the given device, or nil if ungrouped. func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group, error) { ds.fileMutex.RLock() defer ds.fileMutex.RUnlock() dir := ds.AccountDevicesDir(account) entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, ErrGroupNotFound } return nil, err } for _, e := range entries { if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") { continue } data, readErr := os.ReadFile(filepath.Join(dir, e.Name())) if readErr != nil { continue } var g models.Group if unmarshalErr := xml.Unmarshal(data, &g); unmarshalErr != nil { continue } for _, role := range g.Roles.Roles { if role.DeviceID == deviceID { return &g, nil } } } return nil, ErrGroupNotFound } // AddGroup saves a new group to disk and returns its generated ID. func (ds *DataStore) AddGroup(account string, group *models.Group) (string, error) { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() dir := ds.AccountDevicesDir(account) if err := os.MkdirAll(dir, 0755); err != nil { return "", err } id := ds.generateGroupID(account) group.ID = id data, err := xml.MarshalIndent(group, "", " ") if err != nil { return "", err } return id, ds.atomicWriteFile(ds.groupFilePath(account, id), append([]byte(xml.Header), data...)) } // ModifyGroup updates the name of an existing group and returns the updated group. func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Group, error) { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() path := ds.groupFilePath(account, groupID) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("group %s not found", groupID) } return nil, err } var g models.Group if xmlErr := xml.Unmarshal(data, &g); xmlErr != nil { return nil, xmlErr } g.Name = newName updated, err := xml.MarshalIndent(&g, "", " ") if err != nil { return nil, err } if err := ds.atomicWriteFile(path, append([]byte(xml.Header), updated...)); err != nil { return nil, err } return &g, nil } // DeleteGroup removes a group from disk. func (ds *DataStore) DeleteGroup(account, groupID string) error { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() err := os.Remove(ds.groupFilePath(account, groupID)) if os.IsNotExist(err) { return fmt.Errorf("group %s not found", groupID) } return err } // SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file. // File presence indicates the station is a favorite; no content is stored. func (ds *DataStore) SaveTuneInFavorite(stationID string) error { if ds == nil || ds.DataDir == "" || stationID == "" { return nil } dir := ds.safeJoin("tunein", "favorites") if err := os.MkdirAll(dir, 0755); err != nil { return err } return os.WriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644) } // DeleteTuneInFavorite removes a previously saved TuneIn favorite marker file. // Returns nil if the station was not favorited. func (ds *DataStore) DeleteTuneInFavorite(stationID string) error { if ds == nil || ds.DataDir == "" || stationID == "" { return nil } err := os.Remove(ds.safeJoin("tunein", "favorites", stationID)) if os.IsNotExist(err) { return nil } return err }