From 92f3d22d2f809ac781ec334c7982b31aae8bf0a7 Mon Sep 17 00:00:00 2001 From: blomios Date: Thu, 8 Oct 2026 22:16:00 +0200 Subject: [PATCH] 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 --- public/js/pages/Settings.js | 20 ++- ...ysisExternalSyncProgressRegression.test.js | 134 ++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 test/t47LiveAnalysisExternalSyncProgressRegression.test.js 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(); + } + } +});