fix: show live Laya analysis progress (#21)
Track the Laya identity phase separately from catalog projection progress and expose phase-specific counters through the live status API and Settings presentation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -3,21 +3,47 @@ const assert = require('node:assert/strict');
|
||||
const { layaFallbackMessage, present } = require('../public/js/services/liveAnalysisPresentation.js');
|
||||
|
||||
test('presents determinate channel analysis progress', () => {
|
||||
const view = present({ status: 'building', processed: 25, total: 100 });
|
||||
const view = present({ status: 'building', phase: 'projecting', processed: 25, total: 100 });
|
||||
assert.equal(view.busy, true);
|
||||
assert.equal(view.statusLabel, 'Building channel catalog…');
|
||||
assert.equal(view.progressIndeterminate, false);
|
||||
assert.equal(view.progressPercent, 25);
|
||||
assert.equal(view.progressText, '25 of 100 · 25%');
|
||||
assert.equal(view.progressText, '25 of 100 channels · 25%');
|
||||
});
|
||||
|
||||
test('presents Laya identity progress using AI batch counters', () => {
|
||||
const view = present({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
layaProcessed: 3,
|
||||
layaTotal: 8,
|
||||
processed: 0,
|
||||
total: 39348
|
||||
});
|
||||
assert.equal(view.statusLabel, 'Analyzing with local AI…');
|
||||
assert.equal(view.progressIndeterminate, false);
|
||||
assert.equal(view.progressPercent, 38);
|
||||
assert.equal(view.progressText, 'AI batch 3 of 8 · 38%');
|
||||
});
|
||||
|
||||
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.buttonLabel, 'Analyze channels');
|
||||
assert.equal(present({ status: 'running', processed: null, total: 100 }).progressIndeterminate, true);
|
||||
});
|
||||
|
||||
test('presents writing as an indeterminate finalization phase', () => {
|
||||
const view = present({ status: 'building', phase: 'writing', processed: 39348, total: 39348 });
|
||||
assert.equal(view.statusLabel, 'Saving channel catalog…');
|
||||
assert.equal(view.progressIndeterminate, true);
|
||||
assert.equal(view.progressPercent, null);
|
||||
assert.equal(view.progressText, 'Finalizing…');
|
||||
});
|
||||
|
||||
test('presents success and retry states', () => {
|
||||
assert.deepEqual(
|
||||
{ label: present({ status: 'success', projectedCount: 12 }).statusLabel, button: present({ status: 'success' }).buttonLabel },
|
||||
|
||||
@ -92,6 +92,43 @@ function projectedChannels() {
|
||||
};
|
||||
}
|
||||
|
||||
test('Settings renders Laya batch progress 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' } } };
|
||||
settingsPage.layaStatus = { modelPresent: true, runtimeAvailable: true };
|
||||
|
||||
settingsPage.renderLiveAnalysisStatus({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
layaProcessed: 3,
|
||||
layaTotal: 8,
|
||||
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%');
|
||||
|
||||
settingsPage.renderLiveAnalysisStatus({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
layaProcessed: 0,
|
||||
layaTotal: null,
|
||||
processed: 0,
|
||||
total: 39348
|
||||
});
|
||||
|
||||
assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'In progress…');
|
||||
assert.equal(track.classList.contains('indeterminate'), true);
|
||||
assert.equal(track.hasAttribute('aria-valuenow'), false);
|
||||
assert.equal(bar.style.width, '');
|
||||
});
|
||||
|
||||
test('user analysis click keeps visible channels after Local AI language failure', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = 0;
|
||||
|
||||
@ -36,3 +36,25 @@ test('projection success with live input but zero projected channels is exposed
|
||||
assert.equal(state.projectedCount, 0);
|
||||
assert.equal(state.total, 3);
|
||||
});
|
||||
|
||||
test('projection status exposes phase-specific Laya progress separately from catalog progress', () => {
|
||||
const state = serializeState({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
started_at: 100,
|
||||
completed_at: null,
|
||||
input_hash: null,
|
||||
projected_count: 0,
|
||||
processed_count: 0,
|
||||
total_count: 12,
|
||||
laya_processed_count: 1,
|
||||
laya_total_count: 2,
|
||||
error: null
|
||||
});
|
||||
|
||||
assert.equal(state.phase, 'laya_identity');
|
||||
assert.equal(state.processed, 0);
|
||||
assert.equal(state.total, 12);
|
||||
assert.equal(state.layaProcessed, 1);
|
||||
assert.equal(state.layaTotal, 2);
|
||||
});
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const {
|
||||
normalizeChannelName,
|
||||
projectCatalog,
|
||||
markProjectionBuilding,
|
||||
projectionState,
|
||||
rebuildWithSignals,
|
||||
serializeChannels,
|
||||
startRebuild
|
||||
} = require('../server/services/liveProjectionService');
|
||||
@ -20,6 +24,64 @@ const item = (id, name, extra = {}) => ({
|
||||
...extra
|
||||
});
|
||||
|
||||
function createProjectionDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
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
|
||||
);
|
||||
`);
|
||||
const { ensureColumns } = require('../server/db/liveProjectionSchema');
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedProjectionChannel(db, itemId, name) {
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
|
||||
VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, '{}')
|
||||
`).run(`1:${itemId}`, itemId, name, `https://streams.test/${itemId}.m3u8`);
|
||||
}
|
||||
|
||||
function waitFor(condition, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const poll = () => {
|
||||
if (condition()) return resolve();
|
||||
if (Date.now() - started > 1000) return reject(new Error(message));
|
||||
setTimeout(poll, 5);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
test('projection migration declares grouping and language columns', () => {
|
||||
assert.deepEqual(Object.keys(LIVE_PROJECTION_COLUMNS), [
|
||||
'logical_channel_id', 'grouping_fingerprint', 'grouping_input_hash',
|
||||
@ -51,6 +113,55 @@ test('manual rebuild scheduling coalesces concurrent requests', async () => {
|
||||
assert.equal(runs, 1);
|
||||
});
|
||||
|
||||
test('Laya rebuild progress advances after a successful identity batch while analysis is still running', async () => {
|
||||
const db = createProjectionDb();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
seedProjectionChannel(db, `bein-${index}`, `beIN Sports 1 regional ${index}`);
|
||||
}
|
||||
|
||||
let calls = 0;
|
||||
let secondBatchStarted = false;
|
||||
let releaseSecondBatch;
|
||||
const secondBatchGate = new Promise(resolve => { releaseSecondBatch = resolve; });
|
||||
const layaClient = {
|
||||
identityHintBatch: async pairs => {
|
||||
calls += 1;
|
||||
if (calls === 2) {
|
||||
secondBatchStarted = true;
|
||||
await secondBatchGate;
|
||||
}
|
||||
return pairs.map(pair => ({
|
||||
id: pair.id,
|
||||
sameIdentityProbability: 0.9,
|
||||
confidence: 0.9,
|
||||
family: 'bein sports',
|
||||
number: '1'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
markProjectionBuilding(db);
|
||||
const rebuild = rebuildWithSignals(db, {
|
||||
mode: 'laya',
|
||||
layaClient,
|
||||
stateAlreadyBuilding: true
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => secondBatchStarted, 'Expected the second Laya identity batch to start');
|
||||
const state = projectionState(db);
|
||||
assert.equal(state.status, 'building');
|
||||
assert.equal(state.phase, 'laya_identity');
|
||||
assert.equal(state.total_count, 12);
|
||||
assert.equal(state.processed_count, 0);
|
||||
assert.equal(state.laya_processed_count, 1);
|
||||
assert.equal(state.laya_total_count, 2);
|
||||
} finally {
|
||||
releaseSecondBatch();
|
||||
await rebuild;
|
||||
}
|
||||
});
|
||||
|
||||
test('projects beIN quality variants into one logical channel', () => {
|
||||
const groups = projectCatalog([
|
||||
item('uhd', '|FR| BEIN SPORTS 1 UHD'),
|
||||
|
||||
Reference in New Issue
Block a user