fix(settings): show external live sync progress

Read source sync status when projection analysis is idle so Settings can keep Channel Analysis progress visible during startup or externally-triggered live source refreshes.

Refs #47

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-08 22:16:00 +02:00
parent 266ff5d4ea
commit 92f3d22d2f
2 changed files with 153 additions and 1 deletions

View File

@ -129,6 +129,24 @@ class SettingsPage {
} catch (err) { } catch (err) {
this.liveAnalysisStatus = { status: 'error', error: err.message }; this.liveAnalysisStatus = { status: 'error', error: err.message };
} }
const projectionPresentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus);
if (!projectionPresentation.busy && API.sources?.getStatus) {
try {
const sourceStatuses = await API.sources.getStatus();
const liveSourceSyncing = Array.isArray(sourceStatuses) && sourceStatuses.some(source =>
source?.status === 'syncing' && ['live', 'all'].includes(source?.type)
);
if (liveSourceSyncing) {
this.liveAnalysisStatus = {
status: 'building',
phase: 'refreshing_live_sources',
externalSourceSync: true
};
}
} catch (err) {
// Source status is supplementary; projection status remains authoritative.
}
}
this.renderLiveAnalysisStatus(this.liveAnalysisStatus); this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
const presentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus); const presentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus);
if (presentation.busy) this.startLiveAnalysisPolling(); if (presentation.busy) this.startLiveAnalysisPolling();
@ -154,7 +172,7 @@ class SettingsPage {
const presentation = LiveAnalysisPresentation.present(status); const presentation = LiveAnalysisPresentation.present(status);
buttons.forEach(button => { button.disabled = presentation.busy; }); buttons.forEach(button => { button.disabled = presentation.busy; });
const rebuildButton = document.getElementById('live-rebuild-catalog-button'); const rebuildButton = document.getElementById('live-rebuild-catalog-button');
const fullRefreshBusy = presentation.busy && const fullRefreshBusy = presentation.busy && !status.externalSourceSync &&
(this.currentLiveAnalysisAction === 'rebuild' || (this.currentLiveAnalysisAction === 'rebuild' ||
LiveAnalysisPresentation.isFullRefreshPhase(presentation.phase)); LiveAnalysisPresentation.isFullRefreshPhase(presentation.phase));
if (rebuildButton) rebuildButton.textContent = fullRefreshBusy ? 'Refreshing…' : 'Run full refresh'; if (rebuildButton) rebuildButton.textContent = fullRefreshBusy ? 'Refreshing…' : 'Run full refresh';

View File

@ -0,0 +1,134 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const root = path.resolve(__dirname, '..');
function readPublicScript(relativePath, exposeGlobal = '') {
const source = fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
return exposeGlobal ? `${source}\nwindow.${exposeGlobal} = ${exposeGlobal};` : source;
}
function createSettingsDom() {
const dom = new JSDOM(`<!doctype html>
<button type="button" id="live-check-new-button">Check new streams</button>
<button type="button" id="live-rebuild-catalog-button">Run full refresh</button>
<button type="button" id="live-analysis-button">Analyze channels</button>
<div id="live-analysis-status" role="status"></div>
<div id="live-analysis-progress" hidden>
<div id="live-analysis-progress-track"><span id="live-analysis-progress-bar"></span></div>
<div id="live-analysis-progress-text"></div>
</div>
<div id="live-analysis-fallback"></div>
<button type="button" id="live-analysis-details-toggle" hidden></button>
<pre id="live-analysis-error-details" hidden></pre>
`, {
url: 'http://nodecast.test/',
runScripts: 'outside-only',
pretendToBeVisual: true
});
const { window } = dom;
window.console = console;
window.eval(readPublicScript('js/services/liveAnalysisPresentation.js'));
window.eval(readPublicScript('js/pages/Settings.js', 'SettingsPage'));
return window;
}
test('Channel Analysis keeps progress visible for a live source sync started outside the current UI action', async () => {
const window = createSettingsDom();
let sourceStatusRead = false;
window.API = {
live: {
getProjectionStatus: async () => ({
status: 'success',
phase: 'complete',
projectedCount: 42,
processed: 42,
total: 42
})
},
sources: {
getStatus: async () => {
sourceStatusRead = true;
return [{
source_id: 1,
type: 'all',
status: 'syncing',
last_sync: Date.now(),
error: null
}];
}
}
};
const settingsPage = Object.create(window.SettingsPage.prototype);
settingsPage.app = {
player: { settings: { languageDetectionMode: 'algo' } },
channelList: { loadChannels() {} }
};
settingsPage.layaStatus = {};
settingsPage.currentLiveAnalysisAction = '';
try {
await settingsPage.loadLiveAnalysisStatus();
assert.equal(sourceStatusRead, true, 'Settings should consult /api/sources/status when projection status is not busy');
assert.equal(window.document.getElementById('live-analysis-progress').hidden, false);
assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Refreshing IPTV sources…');
assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'In progress — this step may take several minutes.');
assert.equal(window.document.getElementById('live-analysis-progress-track').classList.contains('indeterminate'), true);
const rebuildButton = window.document.getElementById('live-rebuild-catalog-button');
assert.equal(rebuildButton.disabled, true);
assert.equal(rebuildButton.textContent, 'Run full refresh');
} finally {
window.close();
}
});
test('busy projection status remains authoritative over a simultaneous source sync', async () => {
const window = createSettingsDom();
window.API = {
live: { getProjectionStatus: async () => ({ status: 'building', phase: 'laya_language', phaseProcessed: 1, phaseTotal: 2 }) },
sources: { getStatus: async () => { throw new Error('should not be called'); } }
};
const settingsPage = Object.create(window.SettingsPage.prototype);
settingsPage.app = { player: { settings: { languageDetectionMode: 'laya' } } };
settingsPage.layaStatus = {};
settingsPage.currentLiveAnalysisAction = '';
try {
await settingsPage.loadLiveAnalysisStatus();
assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Detecting stream languages…');
assert.equal(window.document.getElementById('live-rebuild-catalog-button').textContent, 'Refreshing…');
} finally {
window.close();
}
});
test('EPG-only source sync is ignored and source status failures preserve projection status', async () => {
for (const getStatus of [
async () => [{ type: 'epg', status: 'syncing' }],
async () => { throw new Error('source status unavailable'); }
]) {
const window = createSettingsDom();
window.API = {
live: { getProjectionStatus: async () => ({ status: 'success', phase: 'complete', projectedCount: 42 }) },
sources: { getStatus }
};
const settingsPage = Object.create(window.SettingsPage.prototype);
settingsPage.app = { player: { settings: { languageDetectionMode: 'algo' } } };
settingsPage.layaStatus = {};
settingsPage.currentLiveAnalysisAction = '';
try {
const status = await settingsPage.loadLiveAnalysisStatus();
assert.equal(status.phase, 'complete');
assert.equal(window.document.getElementById('live-analysis-progress').hidden, true);
assert.equal(window.document.getElementById('live-rebuild-catalog-button').disabled, false);
} finally {
window.close();
}
}
});