diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js
index 4b10395..e2e4324 100644
--- a/public/js/pages/Settings.js
+++ b/public/js/pages/Settings.js
@@ -129,6 +129,24 @@ class SettingsPage {
} catch (err) {
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);
const presentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus);
if (presentation.busy) this.startLiveAnalysisPolling();
@@ -154,7 +172,7 @@ class SettingsPage {
const presentation = LiveAnalysisPresentation.present(status);
buttons.forEach(button => { button.disabled = presentation.busy; });
const rebuildButton = document.getElementById('live-rebuild-catalog-button');
- const fullRefreshBusy = presentation.busy &&
+ const fullRefreshBusy = presentation.busy && !status.externalSourceSync &&
(this.currentLiveAnalysisAction === 'rebuild' ||
LiveAnalysisPresentation.isFullRefreshPhase(presentation.phase));
if (rebuildButton) rebuildButton.textContent = fullRefreshBusy ? 'Refreshing…' : 'Run full refresh';
diff --git a/test/t47LiveAnalysisExternalSyncProgressRegression.test.js b/test/t47LiveAnalysisExternalSyncProgressRegression.test.js
new file mode 100644
index 0000000..c9b9e30
--- /dev/null
+++ b/test/t47LiveAnalysisExternalSyncProgressRegression.test.js
@@ -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(`
+
+
+
+
+
+
+
+
+ `, {
+ 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();
+ }
+ }
+});