Merge branch 'feature/full-refresh-progress-state' into develop (#40 #41)

This commit is contained in:
blomios
2026-10-08 16:20:48 +02:00
7 changed files with 139 additions and 25 deletions

View File

@ -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}%`;

View File

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

View File

@ -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()

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%');
});
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);
});

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 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, '');

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

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