feat: run full refresh with EPG rematching

Run full live catalog refreshes through live source sync, complete channel analysis, EPG source refresh, and persisted EPG rematching while keeping check-new incremental.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-08 00:05:00 +02:00
parent b81bc10d49
commit b3dbb3cfba
14 changed files with 404 additions and 39 deletions

View File

@ -57,6 +57,29 @@ test('presents writing as an indeterminate finalization phase', () => {
assert.equal(view.progressText, 'Finalizing…');
});
test('presents full refresh and EPG matching phases with user-facing labels', () => {
assert.equal(
present({ status: 'building', phase: 'refreshing_live_sources' }).statusLabel,
'Refreshing IPTV sources…'
);
assert.equal(
present({ status: 'building', phase: 'refreshing_epg_sources' }).statusLabel,
'Refreshing EPG data…'
);
assert.equal(
present({ status: 'building', phase: 'matching_epg', processed: 4, total: 10 }).statusLabel,
'Matching channels to EPG data…'
);
assert.equal(
present({ status: 'building', phase: 'laya_epg_matching', processed: 4, total: 10 }).statusLabel,
'Matching channels to EPG data…'
);
const finalizing = present({ status: 'building', phase: 'writing_epg_matches', processed: 10, total: 10 });
assert.equal(finalizing.statusLabel, 'Finalizing…');
assert.equal(finalizing.progressIndeterminate, true);
assert.equal(finalizing.progressText, 'Finalizing…');
});
test('presents success and retry states', () => {
assert.deepEqual(
{ label: present({ status: 'success', projectedCount: 12 }).statusLabel, button: present({ status: 'success' }).buttonLabel },

View File

@ -15,8 +15,8 @@ test('projection rebuild route requires authentication and admin authorization',
assert.match(routeSource, /router\.post\('\/projection\/rebuild', requireAuth, requireAdmin,/);
});
test('projection rebuild route requests a complete analysis', () => {
assert.match(routeSource, /liveProjection\.startRebuild\(null, \{ analyzeAll: true \}\)/);
test('projection rebuild route requests a complete orchestration', () => {
assert.match(routeSource, /liveProjection\.fullRebuildOrchestrator\(\)/);
});
test('live channels route accepts pagination and forwards page bounds to the projection service', () => {

View File

@ -291,7 +291,7 @@ test('API serialization preserves an M3U groupTitle over the stored category fal
assert.equal(channel.variants[0].categoryName, 'M3U Sports France');
});
test('search and language filters run before expensive standard and EPG enrichment', () => {
test('search and language filters run before standard enrichment without read-time EPG matching', () => {
const db = createProjectionDb();
seedProjectionChannel(db, 'bein-fr', '|FR| beIN Sports 1 HD');
seedProjectionChannel(db, 'bein-en', '|UK| beIN Sports 1 4K');
@ -309,7 +309,7 @@ test('search and language filters run before expensive standard and EPG enrichme
assert.equal(page.channels[0].language.code, 'fr');
assert.deepEqual(page.availableLanguages.map(language => language.code).sort(), ['en', 'fr']);
assert.equal(standardCalls, 1);
assert.equal(epgCalls, 1);
assert.equal(epgCalls, 0);
});
test('full catalog listing skips synchronous per-channel enrichment', () => {

View File

@ -9,7 +9,9 @@ const settingsSource = fs.readFileSync(path.join(root, 'public', 'js', 'pages',
test('Settings exposes separate live check-new and full rebuild actions', () => {
assert.ok(/id="live-check-new-button"[^>]*>\s*Check for new streams\s*</.test(indexHtml), 'Settings should expose a Check for new streams button');
assert.ok(/id="live-rebuild-catalog-button"[^>]*>\s*Rebuild entire catalog\s*</.test(indexHtml), 'Settings should expose a Rebuild entire catalog button');
assert.ok(/id="live-rebuild-catalog-button"[^>]*>\s*Run full refresh\s*</.test(indexHtml), 'Settings should expose a Run full refresh button');
assert.match(indexHtml, /Sync IPTV sources and analyze only streams that are not already in the catalog\./);
assert.match(indexHtml, /Refresh IPTV and EPG data, re-analyze every stream, and rebuild channel-to-EPG matches\./);
});
test('Settings wires check-new and full rebuild buttons to distinct API actions', () => {

View File

@ -0,0 +1,168 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const liveProjection = require('../server/services/liveProjectionService');
const {
LIVE_PROJECTION_COLUMNS,
LIVE_PROJECTION_SCHEMA_SQL,
ensureColumns
} = require('../server/db/liveProjectionSchema');
const repoRoot = path.join(__dirname, '..');
const routeSource = fs.readFileSync(path.join(repoRoot, 'server', 'routes', 'live.js'), 'utf8');
const projectionSource = fs.readFileSync(path.join(repoRoot, 'server', 'services', 'liveProjectionService.js'), 'utf8');
const syncSource = fs.readFileSync(path.join(repoRoot, 'server', 'services', 'syncService.js'), 'utf8');
function routeBlock(endpoint) {
const marker = `router.post('${endpoint}'`;
const start = routeSource.indexOf(marker);
assert.notEqual(start, -1, `${endpoint} route should exist`);
const next = routeSource.indexOf('\nrouter.', start + marker.length);
return routeSource.slice(start, next === -1 ? undefined : next);
}
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
);
CREATE TABLE favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL
);
`);
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
return db;
}
function seedProjectedChannel(db) {
db.prepare(`
INSERT INTO categories (id, source_id, category_id, type, name)
VALUES ('1:sports', 1, 'sports', 'live', 'Sports')
`).run();
db.prepare(`
INSERT INTO playlist_items
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
language_code, language_confidence, language_detector, language_input_hash)
VALUES ('1:bein-fr', 1, 'bein-fr', 'live', '|FR| beIN Sports 1 HD', 'sports',
'', 'https://streams.test/bein-fr.m3u8', '{"tvgId":"bein.fr"}',
'fr', 0.99, 'laya', 'fixture-hash')
`).run();
liveProjection.rebuild(db);
}
test('manual full rebuild route delegates to a complete orchestration service, not startRebuild directly', () => {
const rebuildRoute = routeBlock('/projection/rebuild');
assert.match(
rebuildRoute,
/(?:orchestr|full).*rebuild|rebuild.*(?:orchestr|full)/i,
'POST /api/live/projection/rebuild should call a full rebuild orchestrator'
);
assert.doesNotMatch(
rebuildRoute,
/liveProjection\.startRebuild\(/,
'full rebuild route should not bypass orchestration with liveProjection.startRebuild'
);
});
test('full rebuild orchestration contract orders live refresh, analyzeAll, EPG refresh, then persisted EPG match rebuild', () => {
const combinedSource = `${routeSource}\n${projectionSource}\n${syncSource}`;
const liveRefresh = combinedSource.search(/refresh(?:Enabled)?Live|syncLive|syncLiveSources|refreshLiveSources/i);
const analyzeAll = combinedSource.search(/analyzeAll\s*:\s*true/);
const epgRefresh = combinedSource.search(/refresh(?:Enabled)?Epg|syncEpg|refreshEpgSources/i);
const epgMatchRebuild = combinedSource.search(/rebuild(?:Persisted)?EpgMatch|persist(?:ed)?.*epg.*match|epg.*match.*persist/i);
assert.ok(liveRefresh >= 0, 'full rebuild should refresh enabled live sources first');
assert.ok(analyzeAll > liveRefresh, 'full rebuild should analyze all live channels after live refresh');
assert.ok(epgRefresh > analyzeAll, 'full rebuild should refresh enabled EPG sources after analyzeAll');
assert.ok(epgMatchRebuild > epgRefresh, 'full rebuild should rebuild persisted EPG matches after EPG refresh');
});
test('full rebuild orchestration does not use per-source queueRebuild or Xtream VOD/series sync', () => {
const rebuildRelatedSource = `${routeBlock('/projection/rebuild')}\n${projectionSource}`;
assert.match(
rebuildRelatedSource,
/refresh(?:Enabled)?Live|syncLive|syncLiveSources|refreshLiveSources/i,
'full rebuild should expose a live-only source refresh step'
);
assert.doesNotMatch(rebuildRelatedSource, /queueRebuild\(/, 'full rebuild should not enqueue per-source projection rebuilds');
assert.doesNotMatch(rebuildRelatedSource, /getVod(?:Categories|Streams)\(/, 'full rebuild should not sync Xtream VOD');
assert.doesNotMatch(rebuildRelatedSource, /getSeries(?:Categories)?\(/, 'full rebuild should not sync Xtream series');
});
test('/api/live/channels reads persisted EPG matches instead of opportunistic heavy matching', () => {
const db = createProjectionDb();
seedProjectedChannel(db);
try {
assert.doesNotThrow(() => liveProjection.getChannelPage({
q: 'bein sports 1',
epgSources: [{ id: 7, enabled: true, type: 'epg' }]
}, db, {
iptvOrgCatalog: { findMatch: () => null },
epgMatching: {
findBestMatch: () => {
throw new Error('opportunistic EPG matching should not run while reading live channels');
}
}
}));
} finally {
db.close();
}
});
test('check-new performs only incremental live analysis, without full EPG refresh or analyzeAll', () => {
const checkNewRoute = routeBlock('/projection/check-new');
const checkNewImplementation = projectionSource.slice(
projectionSource.indexOf('function checkNewStreams'),
projectionSource.indexOf('function getChannels')
);
const checkNewSource = `${checkNewRoute}\n${checkNewImplementation}`;
assert.doesNotMatch(checkNewSource, /analyzeAll\s*:\s*true/, 'check-new must not analyze the full catalog');
assert.doesNotMatch(checkNewSource, /\.syncAll\(/, 'check-new must not run global sync');
assert.doesNotMatch(checkNewSource, /syncEpg|refresh(?:Enabled)?Epg/i, 'check-new must not refresh all EPG sources');
});
test('EPG matches are persisted by logical channel and language', () => {
assert.match(
LIVE_PROJECTION_SCHEMA_SQL,
/CREATE TABLE IF NOT EXISTS .*epg.*match/i,
'projection schema should declare a persisted EPG match table'
);
assert.match(
LIVE_PROJECTION_SCHEMA_SQL,
/UNIQUE\s*\(\s*logical_channel_id\s*,\s*language_code\s*\)/i,
'persisted EPG matches should be unique per logical channel and language'
);
});