fix(web): show a sources count in Sync results, render as a list

syncSources never reported how many sources it actually saved, so the
Admin UI's success message always said the meaningless "sources:
synced" regardless of outcome. syncSources now returns the count saved
(-1 if the fetch failed), threaded through SyncResult.SourcesCount.

Also replaces the single run-on results string (which visually mashed
presets/recents/sources together with no separator) with a real <ul>
list, one <li> per resource, matching the presets/recents diff lines.
Built via DOM APIs rather than innerHTML string concatenation, since
preset/recent names ultimately come from user-editable station names
on the speaker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-08-23 14:19:40 +02:00
co-authored by Claude Sonnet 5
parent 7fa70d725a
commit 18e6c32220
2 changed files with 72 additions and 37 deletions
+26 -5
View File
@@ -892,6 +892,26 @@ function buildSyncConfirmMessage(result) {
return lines.join("\n");
}
// renderSyncResultList builds a <ul> summarising a successful SyncResult —
// one <li> per resource, e.g. "presets: 6 → 6", plus a sources count. Built
// via DOM APIs (not innerHTML string concatenation) since preset/recent
// names ultimately come from user-editable station names on the speaker.
function renderSyncResultList(result) {
const ul = document.createElement("ul");
for (const diff of result.diffs || []) {
const li = document.createElement("li");
li.textContent = diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount;
ul.appendChild(li);
}
const sourcesLi = document.createElement("li");
sourcesLi.textContent = "sources: " + (result.sourcesCount >= 0 ? result.sourcesCount : "sync failed");
ul.appendChild(sourcesLi);
return ul;
}
async function requestSync(deviceId, confirmed) {
let url = "/api/setup/sync/" + encodeURIComponent(deviceId);
if (confirmed) {
@@ -942,11 +962,12 @@ async function startSync() {
status.style.backgroundColor = "#dfd";
status.textContent = "✅ Sync completed successfully for " + display + "!";
results.style.display = "block";
const lines = (result.diffs || []).map(
(diff) => diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount,
);
lines.push("sources: synced");
log.textContent = "Data fetched and saved to local datastore for " + display + ".\n" + lines.join("\n");
log.innerHTML = "";
const intro = document.createElement("p");
intro.textContent = "Data fetched and saved to local datastore for " + display + ".";
log.appendChild(intro);
log.appendChild(renderSyncResultList(result));
} else {
const err = result ? JSON.stringify(result) : await response.text();
throw new Error(err);
+46 -32
View File
@@ -2652,6 +2652,11 @@ type SyncResult struct {
Applied bool `json:"applied"`
Destructive bool `json:"destructive"`
Diffs []SyncResourceDiff `json:"diffs"`
// SourcesCount is the number of configured sources saved for this
// device, or -1 if the sources fetch failed. Sources are synced
// unconditionally (see syncSources) — there's no diff/confirm gate for
// them — so this is a plain count rather than a SyncResourceDiff.
SourcesCount int `json:"sourcesCount"`
}
// SyncDeviceData fetches presets, recents and sources from the device and
@@ -2745,7 +2750,7 @@ func (m *Manager) SyncDeviceData(deviceIP string, confirmed bool) (SyncResult, e
}
// 4. Fetch Sources
m.syncSources(deviceIP, accountID, deviceID)
result.SourcesCount = m.syncSources(deviceIP, accountID, deviceID)
// 5. Nudge the device to re-render its source list. After a factory
// reset (issue #234) the speaker's /sources only lists the always-on
@@ -2988,7 +2993,12 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
_ = m.DataStore.SaveRecents(accountID, deviceID, recents)
}
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
// syncSources fetches the device's configured sources (via SSH first, then
// falling back to :8090/sources) and persists them. It returns the number
// of sources actually saved, or -1 if neither path produced anything to
// save (so the caller/UI can distinguish "synced zero sources" from "sync
// didn't run").
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) int {
client := m.NewSSH(deviceIP)
sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml")
@@ -3013,7 +3023,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)
return
return len(srs.Sources)
}
}
@@ -3025,46 +3035,50 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
resp, err := m.HTTPGet(sourcesURL)
if err != nil {
return
return -1
}
defer func() { _ = resp.Body.Close() }()
var srs models.Sources
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil {
var configuredSources []models.ConfiguredSource
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr != nil {
return -1
}
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{
DisplayName: s.DisplayName,
Secret: "",
SecretType: "",
}
if s.Status == "READY" {
cs.SecretType = "token"
}
var configuredSources []models.ConfiguredSource
if s.Source == constants.ProviderSpotify {
cs.SecretType = "token_version_3"
}
cs.SourceKey.Type = s.Source
cs.SourceKey.Account = s.SourceAccount
// Also set legacy fields for now
cs.SourceKeyType = s.Source
cs.SourceKeyAccount = s.SourceAccount
configuredSources = append(configuredSources, cs)
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{
DisplayName: s.DisplayName,
Secret: "",
SecretType: "",
}
if s.Status == "READY" {
cs.SecretType = "token"
}
// Drop device-local/transient sources without a resolvable
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
// Persisting them causes /full to emit an empty <sourceproviderid>
// which the speaker rejects as INVALID_SOURCE (#334).
configuredSources = filterServableSources(configuredSources, deviceID)
if s.Source == constants.ProviderSpotify {
cs.SecretType = "token_version_3"
}
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
cs.SourceKey.Type = s.Source
cs.SourceKey.Account = s.SourceAccount
// Also set legacy fields for now
cs.SourceKeyType = s.Source
cs.SourceKeyAccount = s.SourceAccount
configuredSources = append(configuredSources, cs)
}
// Drop device-local/transient sources without a resolvable
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
// Persisting them causes /full to emit an empty <sourceproviderid>
// which the speaker rejects as INVALID_SOURCE (#334).
configuredSources = filterServableSources(configuredSources, deviceID)
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
return len(configuredSources)
}
// filterServableSources returns a copy of srcs containing only sources that