diff --git a/public/js/services/liveAnalysisPresentation.js b/public/js/services/liveAnalysisPresentation.js index 673d79e..9e218ac 100644 --- a/public/js/services/liveAnalysisPresentation.js +++ b/public/js/services/liveAnalysisPresentation.js @@ -12,15 +12,15 @@ function progressValues(status, phase) { if (phase === 'laya_identity') { return { - processed: status.layaProcessed ?? status.phaseProcessed ?? status.batchProcessed ?? status.processedBatches, - total: status.layaTotal ?? status.phaseTotal ?? status.batchTotal ?? status.totalBatches, + processed: status.phaseProcessed ?? status.layaProcessed ?? status.batchProcessed ?? status.processedBatches, + total: status.phaseTotal ?? status.layaTotal ?? status.batchTotal ?? status.totalBatches, prefix: 'AI batch' }; } if (phase === 'laya_language') { return { - processed: status.languageProcessed, - total: status.languageTotal, + processed: status.phaseProcessed ?? status.layaProcessed ?? status.languageProcessed, + total: status.phaseTotal ?? status.layaTotal ?? status.languageTotal, prefix: 'Language batch' }; } @@ -66,7 +66,7 @@ const progressText = ['writing', 'writing_epg_matches'].includes(phase) && busy ? 'Finalizing…' : progressPercent === null - ? (busy ? 'In progress…' : '') + ? (busy ? 'In progress — this step may take several minutes.' : '') : progress.prefix ? `${progress.prefix} ${processed} of ${total} · ${progressPercent}%` : `${processed} of ${total}${progress.suffix || ''} · ${progressPercent}%`; diff --git a/server/routes/live.js b/server/routes/live.js index 90a1afb..591f8f8 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -9,6 +9,7 @@ const router = express.Router(); function serializeState(state) { const emptyProjectionWithLiveInput = state.total_count > 0 && state.projected_count === 0; + const isLayaPhase = state.phase === 'laya_identity' || state.phase === 'laya_language'; return { status: state.status, stale: state.status !== 'success' || emptyProjectionWithLiveInput, @@ -21,6 +22,8 @@ function serializeState(state) { total: state.total_count, layaProcessed: state.laya_processed_count, layaTotal: state.laya_total_count, + phaseProcessed: isLayaPhase ? state.laya_processed_count : state.processed_count, + phaseTotal: isLayaPhase ? state.laya_total_count : state.total_count, error: state.error }; } diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index b3ff970..22741f3 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -296,7 +296,7 @@ function updateProjectionProgress(database, phase, progress = {}) { `).run(phase, progress.layaProcessed ?? null, progress.layaTotal ?? null); } -function markProjectionBuilding(database = null) { +function markProjectionBuilding(database = null, phase = 'preparing') { database ||= defaultDatabase(); const total = database.prepare("SELECT COUNT(*) AS count FROM playlist_items WHERE type = 'live'").get().count; const startedAt = Date.now(); @@ -304,12 +304,12 @@ function markProjectionBuilding(database = null) { INSERT INTO catalog_projection_state (projection, status, started_at, completed_at, input_hash, projected_count, phase, processed_count, total_count, laya_processed_count, laya_total_count, error) - VALUES ('live', 'building', ?, NULL, NULL, 0, 'preparing', 0, ?, 0, 0, NULL) + VALUES ('live', 'building', ?, NULL, NULL, 0, ?, 0, ?, 0, 0, NULL) ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at, - completed_at = NULL, phase = 'preparing', processed_count = 0, + completed_at = NULL, phase = excluded.phase, processed_count = 0, total_count = excluded.total_count, laya_processed_count = 0, laya_total_count = 0, error = NULL - `).run(startedAt, total); + `).run(startedAt, phase, total); return projectionState(database); } @@ -392,8 +392,9 @@ async function fullRebuildOrchestrator(database = null, dependencies = {}) { database ||= defaultDatabase(); const syncService = dependencies.syncService || require('./syncService'); const sourceRepository = dependencies.sources || require('../db').sources; + markProjectionBuilding(database, 'refreshing_live_sources'); await syncService.refreshEnabledLiveSources({ rebuild: false }); - await startRebuild(database, { analyzeAll: true }).promise; + await startRebuild(database, { analyzeAll: true, stateAlreadyBuilding: true }).promise; await syncService.refreshEnabledEpgSources(); const epgSources = await sourceRepository.getByType('epg'); epgMatching.rebuildPersistedEpgMatches(database, epgSources); @@ -441,9 +442,10 @@ function startRebuild(database = null, options = {}) { mark = markProjectionBuilding, runner = rebuildWithSignals, schedule = setImmediate, + stateAlreadyBuilding = false, ...rebuildOptions } = options; - activeState = mark(database); + activeState = stateAlreadyBuilding ? projectionState(database) : mark(database); activeRebuild = new Promise((resolve, reject) => { schedule(() => { Promise.resolve() diff --git a/test/liveAnalysisPresentation.test.js b/test/liveAnalysisPresentation.test.js index de5d39a..71e00b6 100644 --- a/test/liveAnalysisPresentation.test.js +++ b/test/liveAnalysisPresentation.test.js @@ -26,12 +26,14 @@ test('presents Laya identity progress using AI batch counters', () => { assert.equal(view.progressText, 'AI batch 3 of 8 · 38%'); }); -test('presents local Laya language batch progress after projection', () => { +test('presents local Laya language batch progress from generic phase counters', () => { const view = present({ status: 'building', phase: 'laya_language', - languageProcessed: 5, - languageTotal: 10 + phaseProcessed: 5, + phaseTotal: 10, + layaProcessed: 2, + layaTotal: 20 }); assert.equal(view.statusLabel, 'Detecting stream languages…'); assert.equal(view.progressIndeterminate, false); @@ -39,12 +41,24 @@ test('presents local Laya language batch progress after projection', () => { assert.equal(view.progressText, 'Language batch 5 of 10 · 50%'); }); +test('falls back to Laya counters for language batch progress', () => { + const view = present({ + status: 'building', + phase: 'laya_language', + layaProcessed: 5, + layaTotal: 10 + }); + assert.equal(view.progressIndeterminate, false); + assert.equal(view.progressPercent, 50); + assert.equal(view.progressText, 'Language batch 5 of 10 · 50%'); +}); + test('presents indeterminate progress when totals are unavailable', () => { const view = present({ status: 'queued', processed: null, total: null }); assert.equal(view.busy, true); assert.equal(view.progressIndeterminate, true); assert.equal(view.progressPercent, null); - assert.equal(view.progressText, 'In progress…'); + assert.equal(view.progressText, 'In progress — this step may take several minutes.'); assert.equal(view.buttonLabel, 'Analyze channels'); assert.equal(present({ status: 'running', processed: null, total: 100 }).progressIndeterminate, true); }); diff --git a/test/liveAnalysisUserFlowDom.test.js b/test/liveAnalysisUserFlowDom.test.js index 0be1997..8aa3fce 100644 --- a/test/liveAnalysisUserFlowDom.test.js +++ b/test/liveAnalysisUserFlowDom.test.js @@ -104,7 +104,7 @@ function waitFor(condition, message) { }); } -test('Settings renders Laya batch progress and removes misleading values when its total is unknown', () => { +test('Settings renders language phase progress accessibly and removes misleading values when its total is unknown', () => { const { window } = createDom(); const settingsPage = Object.create(window.SettingsPage.prototype); settingsPage.app = { player: { settings: { languageDetectionMode: 'hybrid' } } }; @@ -112,30 +112,33 @@ test('Settings renders Laya batch progress and removes misleading values when it settingsPage.renderLiveAnalysisStatus({ status: 'building', - phase: 'laya_identity', - layaProcessed: 3, - layaTotal: 8, + phase: 'laya_language', + phaseProcessed: 5, + phaseTotal: 10, processed: 0, total: 39348 }); const track = window.document.getElementById('live-analysis-progress-track'); const bar = window.document.getElementById('live-analysis-progress-bar'); - assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Analyzing with local AI…'); - assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'AI batch 3 of 8 · 38%'); - assert.equal(track.getAttribute('aria-valuenow'), '38'); - assert.equal(bar.style.width, '38%'); + assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Detecting stream languages…'); + assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'Language batch 5 of 10 · 50%'); + assert.equal(track.getAttribute('aria-valuemin'), '0'); + assert.equal(track.getAttribute('aria-valuemax'), '100'); + assert.equal(track.getAttribute('aria-valuenow'), '50'); + assert.equal(track.getAttribute('aria-valuetext'), 'Language batch 5 of 10 · 50%'); + assert.equal(bar.style.width, '50%'); settingsPage.renderLiveAnalysisStatus({ status: 'building', - phase: 'laya_identity', + phase: 'laya_language', layaProcessed: 0, layaTotal: null, processed: 0, total: 39348 }); - assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'In progress…'); + assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'In progress — this step may take several minutes.'); assert.equal(track.classList.contains('indeterminate'), true); assert.equal(track.hasAttribute('aria-valuenow'), false); assert.equal(bar.style.width, ''); diff --git a/test/liveProjectionRoutes.test.js b/test/liveProjectionRoutes.test.js index f6969ee..cee2bd6 100644 --- a/test/liveProjectionRoutes.test.js +++ b/test/liveProjectionRoutes.test.js @@ -78,4 +78,25 @@ test('projection status exposes phase-specific Laya progress separately from cat assert.equal(state.total, 12); assert.equal(state.layaProcessed, 1); assert.equal(state.layaTotal, 2); + assert.equal(state.phaseProcessed, 1); + assert.equal(state.phaseTotal, 2); +}); + +test('projection status exposes generic phase progress outside Laya analysis', () => { + const state = serializeState({ + status: 'building', + phase: 'projecting', + started_at: 100, + completed_at: null, + input_hash: null, + projected_count: 0, + processed_count: 7, + total_count: 12, + laya_processed_count: 0, + laya_total_count: 0, + error: null + }); + + assert.equal(state.phaseProcessed, 7); + assert.equal(state.phaseTotal, 12); }); diff --git a/test/t40FullRefreshImmediateStateRegression.test.js b/test/t40FullRefreshImmediateStateRegression.test.js new file mode 100644 index 0000000..b3f5cb6 --- /dev/null +++ b/test/t40FullRefreshImmediateStateRegression.test.js @@ -0,0 +1,71 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const liveProjection = require('../server/services/liveProjectionService'); +const { LIVE_PROJECTION_SCHEMA_SQL } = require('../server/db/liveProjectionSchema'); + +function createDb() { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE playlist_items ( + id TEXT PRIMARY KEY, + source_id INTEGER NOT NULL, + item_id TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + category_id TEXT, + parent_id TEXT, + stream_icon TEXT, + stream_url TEXT, + container_extension TEXT, + rating REAL, + year TEXT, + added_at TEXT, + is_hidden INTEGER DEFAULT 0, + is_favorite INTEGER DEFAULT 0, + data JSON, + logical_channel_id TEXT, + grouping_fingerprint TEXT, + grouping_input_hash TEXT, + quality_label TEXT, + quality_rank INTEGER, + variant_rank INTEGER, + language_code TEXT, + language_confidence REAL, + language_scores JSON, + language_evidence JSON, + language_detector TEXT, + language_input_hash TEXT + ); + `); + db.exec(LIVE_PROJECTION_SCHEMA_SQL); + return db; +} + +test('full refresh marks projection busy before awaiting source refresh work', () => { + const db = createDb(); + db.prepare(` + INSERT INTO catalog_projection_state + (projection, status, started_at, completed_at, input_hash, projected_count, + phase, processed_count, total_count, laya_processed_count, laya_total_count, error) + VALUES ('live', 'success', 100, 200, 'old-hash', 12, 'complete', 12, 12, 0, 0, NULL) + `).run(); + + let liveRefreshStarted = false; + liveProjection.fullRebuildOrchestrator(db, { + syncService: { + refreshEnabledLiveSources: () => { + liveRefreshStarted = true; + return new Promise(() => {}); + }, + refreshEnabledEpgSources: async () => {} + }, + sources: { getByType: async () => [] } + }); + + const state = liveProjection.projectionState(db); + assert.equal(liveRefreshStarted, true); + assert.equal(state.status, 'building'); + assert.equal(state.phase, 'refreshing_live_sources'); + assert.equal(state.completed_at, null); +});