Improve source sync by adding deduction of known source IDs (#167)

This commit is contained in:
Tobias Gesellchen
2026-04-17 18:50:51 +02:00
committed by GitHub
parent ffe61dd7a6
commit 0e2f05e6e5
6 changed files with 430 additions and 42 deletions
+18 -18
View File
@@ -37,16 +37,16 @@ func TestFetchTuneInMetadata(t *testing.T) {
if metadata == nil {
t.Fatal("fetchTuneInMetadata() returned nil metadata")
}
} else {
expectedName := "WDR 2 Rheinland"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedName := "WDR 2 Rheinland"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
}
}
@@ -192,15 +192,15 @@ func TestFetchSpotifyMetadata(t *testing.T) {
if metadata == nil {
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
}
} else {
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
}
}
+123 -4
View File
@@ -2,6 +2,7 @@
package datastore
import (
"bytes"
"encoding/base64"
"encoding/json"
"encoding/xml"
@@ -1076,6 +1077,124 @@ 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" {
if id, ok := deducedIDs["9"]; ok {
sources[i].ID = id
sources[i].SourceProviderID = "9"
}
}
}
}
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 "AUX":
pid = "9"
case "INTERNET_RADIO":
pid = "2"
case "LOCAL_INTERNET_RADIO":
pid = "11"
case "TUNEIN":
pid = "25"
}
}
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 "2", "9", "11", "25":
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()
@@ -1086,7 +1205,10 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ds.getDefaultSources(), nil
sources := ds.getDefaultSources()
ds.DeduceSourceIDs(account, device, sources)
return sources, nil
}
return nil, err
@@ -1139,9 +1261,6 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
if ps.Credential.Value != "" {
s.Secret = ps.Credential.Value
s.SecretType = ps.Credential.Type
} else {
s.Secret = ps.Secret
s.SecretType = ps.SecretType
}
// Ensure Secret/SecretType values are prioritized from legacy fields if still missing
@@ -0,0 +1,168 @@
package datastore
import (
"os"
"path/filepath"
"testing"
)
func TestGetConfiguredSources_DeduceIDs(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "test-account"
device := "test-device"
// Create recents with specific source IDs for provider IDs
// Let's create a manual Recents.xml and Presets.xml in the temp directory to simulate the state.
deviceDir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
recentsXML := `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent id="2184615630">
<contentItemType></contentItemType>
<createdOn>2017-02-07T11:22:00.000+00:00</createdOn>
<lastplayedat>2017-05-17T13:18:57.000+00:00</lastplayedat>
<location>52349</location>
<name>Lounge FM Digital</name>
<source id="9330201" type="Audio">
<createdOn>2015-03-11T19:12:38.000+00:00</createdOn>
<credential type="token"></credential>
<name>9330201</name>
<sourceproviderid>2</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
<updatedOn>2015-03-11T19:12:38.000+00:00</updatedOn>
<username></username>
</source>
<sourceid>9330201</sourceid>
<updatedOn>2017-05-17T17:18:58.000+00:00</updatedOn>
<username>Lounge FM Digital</username>
</recent>
</recents>`
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
t.Fatalf("Failed to write Recents.xml: %v", err)
}
// Now call GetConfiguredSources and expect it to have "9330201" for provider ID "2"
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
foundDeducted := false
for _, s := range sources {
if s.SourceProviderID == "2" {
if s.ID == "9330201" {
foundDeducted = true
} else {
t.Errorf("Expected source ID 9330201 for provider 2, got %s", s.ID)
}
}
}
if !foundDeducted {
t.Errorf("Did not find source with provider ID 2 and deducted ID 9330201")
}
}
func TestGetConfiguredSources_DeduceIDs_AllProviders(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-test-all-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "test-account"
device := "test-device"
deviceDir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
// 2: INTERNET_RADIO
// 9: AUX
// 11: LOCAL_INTERNET_RADIO
// 25: TUNEIN
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1">
<contentItem source="INTERNET_RADIO" sourceAccount="" isPresetable="true" type="station" itemName="Station 2">
<containerArt>http://example.com/art2.png</containerArt>
</contentItem>
<source id="ID2" type="Audio" sourceproviderid="2" />
<sourceid>ID2</sourceid>
</preset>
<preset id="2">
<contentItem source="AUX" sourceAccount="AUX" isPresetable="true" type="station" itemName="Station 9">
<containerArt>http://example.com/art9.png</containerArt>
</contentItem>
<source id="ID9" type="Audio" sourceproviderid="9" />
<sourceid>ID9</sourceid>
</preset>
<preset id="3">
<contentItem source="LOCAL_INTERNET_RADIO" sourceAccount="" isPresetable="true" type="station" itemName="Station 11">
<containerArt>http://example.com/art11.png</containerArt>
</contentItem>
<source id="ID11" type="Audio" sourceproviderid="11" />
<sourceid>ID11</sourceid>
</preset>
<preset id="4">
<contentItem source="TUNEIN" sourceAccount="" isPresetable="true" type="station" itemName="Station 25">
<containerArt>http://example.com/art25.png</containerArt>
</contentItem>
<source id="ID25" type="Audio" sourceproviderid="25" />
<sourceid>ID25</sourceid>
</preset>
</presets>`
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
t.Fatalf("Failed to write Presets.xml: %v", err)
}
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
expected := map[string]string{
"2": "ID2",
"9": "ID9",
"11": "ID11",
"25": "ID25",
}
found := make(map[string]bool)
for _, s := range sources {
if expID, ok := expected[s.SourceProviderID]; ok {
if s.ID != expID {
t.Errorf("Expected source ID %s for provider %s, got %s", expID, s.SourceProviderID, s.ID)
}
found[s.SourceProviderID] = true
} else if s.SourceKeyType == "AUX" && s.SourceProviderID == "" {
// Special case for AUX if it doesn't have provider ID 9 by default
if expID, ok := expected["9"]; ok {
if s.ID != expID {
t.Errorf("Expected source ID %s for AUX, got %s", expID, s.ID)
}
found["9"] = true
}
}
}
for pid := range expected {
if !found[pid] {
t.Errorf("Did not find source with provider ID %s", pid)
}
}
}
@@ -394,17 +394,17 @@ func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
if gesellixSource == nil {
t.Fatal("gesellix source not found")
}
// It should have fallen back to Account name "gesellix" because DisplayName was generic "Audio"
if gesellixSource.DisplayName != "gesellix" {
t.Errorf("Expected display_name 'gesellix', got '%s'", gesellixSource.DisplayName)
}
if gesellixSource.Name != "gesellix" {
t.Errorf("Expected name 'gesellix', got '%s'", gesellixSource.Name)
}
if gesellixSource.Type != "Audio" {
t.Errorf("Expected type 'Audio', got '%s'", gesellixSource.Type)
} else {
// It should have fallen back to Account name "gesellix" because DisplayName was generic "Audio"
if gesellixSource.DisplayName != "gesellix" {
t.Errorf("Expected display_name 'gesellix', got '%s'", gesellixSource.DisplayName)
}
if gesellixSource.Name != "gesellix" {
t.Errorf("Expected name 'gesellix', got '%s'", gesellixSource.Name)
}
if gesellixSource.Type != "Audio" {
t.Errorf("Expected type 'Audio', got '%s'", gesellixSource.Type)
}
}
// Find the generic audio source
@@ -417,9 +417,10 @@ func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
}
if audioSource == nil {
t.Fatal("audio source not found")
}
// It should still be "Audio" as there is no account fallback
if audioSource.DisplayName != "Audio" {
t.Errorf("Expected display_name 'Audio', got '%s'", audioSource.DisplayName)
} else {
// It should still be "Audio" as there is no account fallback
if audioSource.DisplayName != "Audio" {
t.Errorf("Expected display_name 'Audio', got '%s'", audioSource.DisplayName)
}
}
}
+8 -5
View File
@@ -33,14 +33,14 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
// 1. Update Device Info
syncDeviceInfo(ds, accountID, dev)
// 2. Update Configured Sources for this device
syncConfiguredSources(ds, accountID, deviceID, resp.Sources, dev)
// 3. Update Presets
// 2. Update Presets
syncPresets(ds, accountID, deviceID, dev.Presets)
// 4. Update Recents
// 3. Update Recents
syncRecents(ds, accountID, deviceID, dev.Recents)
// 4. Update Configured Sources for this device (requires presets and recents to be on disk for deduction)
syncConfiguredSources(ds, accountID, deviceID, resp.Sources, dev)
}
log.Printf("[SYNC] Synchronization completed for account %s", accountID)
@@ -166,6 +166,9 @@ func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string,
}
}
// 4. Add deduction based on local presets/recents
ds.DeduceSourceIDs(accountID, deviceID, deviceSources)
if err := ds.SaveConfiguredSources(accountID, deviceID, deviceSources); err != nil {
log.Printf("[SYNC_ERR] Failed to save sources for %s: %v", deviceID, err)
}
+97
View File
@@ -0,0 +1,97 @@
package marge
import (
"encoding/xml"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestSyncFromAccountFull_DeduceIDs(t *testing.T) {
// Setup a temporary datastore
tmpDir, err := os.MkdirTemp("", "sync_deduce_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(tmpDir)
accountID := "USER_123"
deviceID := "DEVICE_ABC"
// Mock AccountFullResponse with generic source IDs (e.g., from a fresh sync or default mapping)
// and specific source IDs in presets/recents that we want to "deduce" and use.
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<account id="USER_123">
<devices>
<device deviceid="DEVICE_ABC">
<presets>
<preset buttonNumber="1">
<contentItem itemName="Lounge FM Digital" location="52349" source="INTERNET_RADIO" type="station" />
<source id="9330201" type="Audio">
<sourceproviderid>2</sourceproviderid>
</source>
<sourceid>9330201</sourceid>
</preset>
</presets>
<recents>
<recent id="RECENT_1">
<contentItem itemName="TuneIn Station" source="TUNEIN" type="station" />
<source id="DEDUCED_TUNEIN" type="Audio">
<sourceproviderid>25</sourceproviderid>
</source>
<sourceid>DEDUCED_TUNEIN</sourceid>
</recent>
</recents>
</device>
</devices>
<sources>
<source id="GENERIC_2" type="Audio" sourceproviderid="2" />
<source id="GENERIC_25" type="Audio" sourceproviderid="25" />
</sources>
</account>`
var resp models.AccountFullResponse
if err := xml.Unmarshal([]byte(xmlData), &resp); err != nil {
t.Fatalf("Failed to unmarshal mock data: %v", err)
}
// Run Sync
if err := SyncFromAccountFull(ds, &resp); err != nil {
t.Fatalf("SyncFromAccountFull failed: %v", err)
}
// Verify Sources
sources, err := ds.GetConfiguredSources(accountID, deviceID)
if err != nil {
t.Errorf("Failed to get sources: %v", err)
}
found2 := false
found25 := false
for _, s := range sources {
if s.SourceProviderID == "2" {
if s.ID == "9330201" {
found2 = true
} else {
t.Errorf("Expected source ID 9330201 for provider 2, got %s", s.ID)
}
}
if s.SourceProviderID == "25" {
if s.ID == "DEDUCED_TUNEIN" {
found25 = true
} else {
t.Errorf("Expected source ID DEDUCED_TUNEIN for provider 25, got %s", s.ID)
}
}
}
if !found2 {
t.Errorf("Did not find source with provider ID 2 and deducted ID 9330201")
}
if !found25 {
t.Errorf("Did not find source with provider ID 25 and deducted ID DEDUCED_TUNEIN")
}
}