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:
@ -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}%`;
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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, '');
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
71
test/t40FullRefreshImmediateStateRegression.test.js
Normal file
71
test/t40FullRefreshImmediateStateRegression.test.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user