fix(live): expose full refresh busy and phase progress

Ensure full refresh marks the live projection as building before source refresh awaits, and expose phase-specific counters so language detection progress is determinate.

Covers #40 and #41.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-08 16:20:36 +02:00
parent 09bdaa3ec1
commit ddd7a3f295
7 changed files with 139 additions and 25 deletions

View File

@ -12,15 +12,15 @@
function progressValues(status, phase) { function progressValues(status, phase) {
if (phase === 'laya_identity') { if (phase === 'laya_identity') {
return { return {
processed: status.layaProcessed ?? status.phaseProcessed ?? status.batchProcessed ?? status.processedBatches, processed: status.phaseProcessed ?? status.layaProcessed ?? status.batchProcessed ?? status.processedBatches,
total: status.layaTotal ?? status.phaseTotal ?? status.batchTotal ?? status.totalBatches, total: status.phaseTotal ?? status.layaTotal ?? status.batchTotal ?? status.totalBatches,
prefix: 'AI batch' prefix: 'AI batch'
}; };
} }
if (phase === 'laya_language') { if (phase === 'laya_language') {
return { return {
processed: status.languageProcessed, processed: status.phaseProcessed ?? status.layaProcessed ?? status.languageProcessed,
total: status.languageTotal, total: status.phaseTotal ?? status.layaTotal ?? status.languageTotal,
prefix: 'Language batch' prefix: 'Language batch'
}; };
} }
@ -66,7 +66,7 @@
const progressText = ['writing', 'writing_epg_matches'].includes(phase) && busy const progressText = ['writing', 'writing_epg_matches'].includes(phase) && busy
? 'Finalizing…' ? 'Finalizing…'
: progressPercent === null : progressPercent === null
? (busy ? 'In progress…' : '') ? (busy ? 'In progress — this step may take several minutes.' : '')
: progress.prefix : progress.prefix
? `${progress.prefix} ${processed} of ${total} · ${progressPercent}%` ? `${progress.prefix} ${processed} of ${total} · ${progressPercent}%`
: `${processed} of ${total}${progress.suffix || ''} · ${progressPercent}%`; : `${processed} of ${total}${progress.suffix || ''} · ${progressPercent}%`;

View File

@ -9,6 +9,7 @@ const router = express.Router();
function serializeState(state) { function serializeState(state) {
const emptyProjectionWithLiveInput = state.total_count > 0 && state.projected_count === 0; const emptyProjectionWithLiveInput = state.total_count > 0 && state.projected_count === 0;
const isLayaPhase = state.phase === 'laya_identity' || state.phase === 'laya_language';
return { return {
status: state.status, status: state.status,
stale: state.status !== 'success' || emptyProjectionWithLiveInput, stale: state.status !== 'success' || emptyProjectionWithLiveInput,
@ -21,6 +22,8 @@ function serializeState(state) {
total: state.total_count, total: state.total_count,
layaProcessed: state.laya_processed_count, layaProcessed: state.laya_processed_count,
layaTotal: state.laya_total_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 error: state.error
}; };
} }

View File

@ -296,7 +296,7 @@ function updateProjectionProgress(database, phase, progress = {}) {
`).run(phase, progress.layaProcessed ?? null, progress.layaTotal ?? null); `).run(phase, progress.layaProcessed ?? null, progress.layaTotal ?? null);
} }
function markProjectionBuilding(database = null) { function markProjectionBuilding(database = null, phase = 'preparing') {
database ||= defaultDatabase(); database ||= defaultDatabase();
const total = database.prepare("SELECT COUNT(*) AS count FROM playlist_items WHERE type = 'live'").get().count; const total = database.prepare("SELECT COUNT(*) AS count FROM playlist_items WHERE type = 'live'").get().count;
const startedAt = Date.now(); const startedAt = Date.now();
@ -304,12 +304,12 @@ function markProjectionBuilding(database = null) {
INSERT INTO catalog_projection_state INSERT INTO catalog_projection_state
(projection, status, started_at, completed_at, input_hash, projected_count, phase, (projection, status, started_at, completed_at, input_hash, projected_count, phase,
processed_count, total_count, laya_processed_count, laya_total_count, error) 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, 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, total_count = excluded.total_count, laya_processed_count = 0,
laya_total_count = 0, error = NULL laya_total_count = 0, error = NULL
`).run(startedAt, total); `).run(startedAt, phase, total);
return projectionState(database); return projectionState(database);
} }
@ -392,8 +392,9 @@ async function fullRebuildOrchestrator(database = null, dependencies = {}) {
database ||= defaultDatabase(); database ||= defaultDatabase();
const syncService = dependencies.syncService || require('./syncService'); const syncService = dependencies.syncService || require('./syncService');
const sourceRepository = dependencies.sources || require('../db').sources; const sourceRepository = dependencies.sources || require('../db').sources;
markProjectionBuilding(database, 'refreshing_live_sources');
await syncService.refreshEnabledLiveSources({ rebuild: false }); await syncService.refreshEnabledLiveSources({ rebuild: false });
await startRebuild(database, { analyzeAll: true }).promise; await startRebuild(database, { analyzeAll: true, stateAlreadyBuilding: true }).promise;
await syncService.refreshEnabledEpgSources(); await syncService.refreshEnabledEpgSources();
const epgSources = await sourceRepository.getByType('epg'); const epgSources = await sourceRepository.getByType('epg');
epgMatching.rebuildPersistedEpgMatches(database, epgSources); epgMatching.rebuildPersistedEpgMatches(database, epgSources);
@ -441,9 +442,10 @@ function startRebuild(database = null, options = {}) {
mark = markProjectionBuilding, mark = markProjectionBuilding,
runner = rebuildWithSignals, runner = rebuildWithSignals,
schedule = setImmediate, schedule = setImmediate,
stateAlreadyBuilding = false,
...rebuildOptions ...rebuildOptions
} = options; } = options;
activeState = mark(database); activeState = stateAlreadyBuilding ? projectionState(database) : mark(database);
activeRebuild = new Promise((resolve, reject) => { activeRebuild = new Promise((resolve, reject) => {
schedule(() => { schedule(() => {
Promise.resolve() Promise.resolve()

View File

@ -26,12 +26,14 @@ test('presents Laya identity progress using AI batch counters', () => {
assert.equal(view.progressText, 'AI batch 3 of 8 · 38%'); 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({ const view = present({
status: 'building', status: 'building',
phase: 'laya_language', phase: 'laya_language',
languageProcessed: 5, phaseProcessed: 5,
languageTotal: 10 phaseTotal: 10,
layaProcessed: 2,
layaTotal: 20
}); });
assert.equal(view.statusLabel, 'Detecting stream languages…'); assert.equal(view.statusLabel, 'Detecting stream languages…');
assert.equal(view.progressIndeterminate, false); 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%'); 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', () => { test('presents indeterminate progress when totals are unavailable', () => {
const view = present({ status: 'queued', processed: null, total: null }); const view = present({ status: 'queued', processed: null, total: null });
assert.equal(view.busy, true); assert.equal(view.busy, true);
assert.equal(view.progressIndeterminate, true); assert.equal(view.progressIndeterminate, true);
assert.equal(view.progressPercent, null); 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(view.buttonLabel, 'Analyze channels');
assert.equal(present({ status: 'running', processed: null, total: 100 }).progressIndeterminate, true); assert.equal(present({ status: 'running', processed: null, total: 100 }).progressIndeterminate, true);
}); });

View File

@ -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 { window } = createDom();
const settingsPage = Object.create(window.SettingsPage.prototype); const settingsPage = Object.create(window.SettingsPage.prototype);
settingsPage.app = { player: { settings: { languageDetectionMode: 'hybrid' } } }; settingsPage.app = { player: { settings: { languageDetectionMode: 'hybrid' } } };
@ -112,30 +112,33 @@ test('Settings renders Laya batch progress and removes misleading values when it
settingsPage.renderLiveAnalysisStatus({ settingsPage.renderLiveAnalysisStatus({
status: 'building', status: 'building',
phase: 'laya_identity', phase: 'laya_language',
layaProcessed: 3, phaseProcessed: 5,
layaTotal: 8, phaseTotal: 10,
processed: 0, processed: 0,
total: 39348 total: 39348
}); });
const track = window.document.getElementById('live-analysis-progress-track'); const track = window.document.getElementById('live-analysis-progress-track');
const bar = window.document.getElementById('live-analysis-progress-bar'); 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-status').textContent, 'Detecting stream languages…');
assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'AI batch 3 of 8 · 38%'); assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'Language batch 5 of 10 · 50%');
assert.equal(track.getAttribute('aria-valuenow'), '38'); assert.equal(track.getAttribute('aria-valuemin'), '0');
assert.equal(bar.style.width, '38%'); 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({ settingsPage.renderLiveAnalysisStatus({
status: 'building', status: 'building',
phase: 'laya_identity', phase: 'laya_language',
layaProcessed: 0, layaProcessed: 0,
layaTotal: null, layaTotal: null,
processed: 0, processed: 0,
total: 39348 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.classList.contains('indeterminate'), true);
assert.equal(track.hasAttribute('aria-valuenow'), false); assert.equal(track.hasAttribute('aria-valuenow'), false);
assert.equal(bar.style.width, ''); assert.equal(bar.style.width, '');

View File

@ -78,4 +78,25 @@ test('projection status exposes phase-specific Laya progress separately from cat
assert.equal(state.total, 12); assert.equal(state.total, 12);
assert.equal(state.layaProcessed, 1); assert.equal(state.layaProcessed, 1);
assert.equal(state.layaTotal, 2); 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);
}); });

View File

@ -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);
});