Merge branch 'feature/t38-full-rebuild-source-epg-refresh' into develop (#38)
This commit is contained in:
@ -891,6 +891,14 @@ h4 {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.live-analysis-action {
|
||||
display: flex;
|
||||
max-width: 19rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.live-analysis-heading {
|
||||
flex-direction: column;
|
||||
|
||||
@ -490,11 +490,16 @@
|
||||
<div class="setting-hint">Group matching streams into channels and update detected languages.</div>
|
||||
</div>
|
||||
<div class="live-analysis-actions">
|
||||
<button type="button" class="btn btn-primary" id="live-check-new-button">Check for new streams</button>
|
||||
<button type="button" class="btn btn-secondary" id="live-rebuild-catalog-button">Rebuild entire catalog</button>
|
||||
<div class="live-analysis-action">
|
||||
<button type="button" class="btn btn-primary" id="live-check-new-button">Check for new streams</button>
|
||||
<span class="setting-hint">Sync IPTV sources and analyze only streams that are not already in the catalog.</span>
|
||||
</div>
|
||||
<div class="live-analysis-action">
|
||||
<button type="button" class="btn btn-secondary" id="live-rebuild-catalog-button">Run full refresh</button>
|
||||
<span class="setting-hint">Refresh IPTV and EPG data, re-analyze every stream, and rebuild channel-to-EPG matches.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="setting-hint">Check newly added streams, or rebuild the entire catalog if channel matching needs to be refreshed.</p>
|
||||
<div id="live-analysis-status" class="setting-hint" role="status">Checking status…</div>
|
||||
<div id="live-analysis-progress" class="laya-progress" hidden>
|
||||
<div id="live-analysis-progress-track" class="laya-progress-track" role="progressbar" aria-label="Channel analysis progress">
|
||||
|
||||
@ -108,8 +108,8 @@ class SettingsPage {
|
||||
|
||||
initLiveAnalysis() {
|
||||
document.getElementById('live-check-new-button')?.addEventListener('click', () => void this.runLiveAnalysis('check-new'));
|
||||
document.getElementById('live-rebuild-catalog-button')?.addEventListener('click', () => {
|
||||
if (confirm('Rebuild the entire channel catalog? This can take a while.')) void this.runLiveAnalysis('rebuild');
|
||||
document.getElementById('live-rebuild-catalog-button')?.addEventListener('click', async () => {
|
||||
if (await this.confirmFullRefresh()) void this.runLiveAnalysis('rebuild');
|
||||
});
|
||||
// Compatibility with pages cached before the actions were split.
|
||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis('rebuild'));
|
||||
@ -153,6 +153,14 @@ class SettingsPage {
|
||||
|
||||
const presentation = LiveAnalysisPresentation.present(status);
|
||||
buttons.forEach(button => { button.disabled = presentation.busy; });
|
||||
const rebuildButton = document.getElementById('live-rebuild-catalog-button');
|
||||
const fullRefreshPhases = new Set([
|
||||
'refreshing_live_sources', 'refreshing_epg_sources', 'matching_epg',
|
||||
'laya_epg_matching', 'writing_epg_matches'
|
||||
]);
|
||||
const fullRefreshBusy = presentation.busy &&
|
||||
(this.currentLiveAnalysisAction === 'rebuild' || fullRefreshPhases.has(presentation.phase));
|
||||
if (rebuildButton) rebuildButton.textContent = fullRefreshBusy ? 'Refreshing…' : 'Run full refresh';
|
||||
statusElement.textContent = presentation.statusLabel;
|
||||
progress.hidden = !presentation.busy;
|
||||
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||
@ -181,6 +189,7 @@ class SettingsPage {
|
||||
const buttons = ['live-check-new-button', 'live-rebuild-catalog-button', 'live-analysis-button']
|
||||
.map(id => document.getElementById(id)).filter(Boolean);
|
||||
if (buttons.some(button => button.disabled)) return;
|
||||
this.currentLiveAnalysisAction = action;
|
||||
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
try {
|
||||
@ -194,10 +203,39 @@ class SettingsPage {
|
||||
this.startLiveAnalysisPolling();
|
||||
} catch (err) {
|
||||
this.liveAnalysisStatus = { status: 'error', error: err.message };
|
||||
this.currentLiveAnalysisAction = '';
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
}
|
||||
|
||||
confirmFullRefresh() {
|
||||
const titleText = 'Run a full refresh?';
|
||||
const bodyText = 'Nodecast will refresh IPTV sources, re-analyze every channel and language, refresh EPG data, and rebuild all EPG matches. This may take several minutes.';
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const body = document.getElementById('modal-body');
|
||||
const footer = document.getElementById('modal-footer');
|
||||
const close = modal?.querySelector('.modal-close');
|
||||
if (!modal || !title || !body || !footer || !close) {
|
||||
return Promise.resolve(confirm(`${titleText}\n\n${bodyText}`));
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
title.textContent = titleText;
|
||||
body.textContent = bodyText;
|
||||
footer.innerHTML = '<button type="button" class="btn btn-secondary" id="full-refresh-cancel">Cancel</button>' +
|
||||
'<button type="button" class="btn btn-primary" id="full-refresh-confirm">Start full refresh</button>';
|
||||
modal.classList.add('active');
|
||||
const finish = confirmed => {
|
||||
modal.classList.remove('active');
|
||||
close.onclick = null;
|
||||
resolve(confirmed);
|
||||
};
|
||||
close.onclick = () => finish(false);
|
||||
document.getElementById('full-refresh-cancel').onclick = () => finish(false);
|
||||
document.getElementById('full-refresh-confirm').onclick = () => finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
startLiveAnalysisPolling() {
|
||||
if (this.liveAnalysisPollTimer) return;
|
||||
this.liveAnalysisPollTimer = setInterval(async () => {
|
||||
@ -211,6 +249,7 @@ class SettingsPage {
|
||||
this.liveAnalysisPollTimer = null;
|
||||
if (presentation.complete) {
|
||||
await this.app.channelList?.loadChannels();
|
||||
this.currentLiveAnalysisAction = '';
|
||||
this.liveAnalysisStatus = status;
|
||||
this.renderLiveAnalysisStatus(status);
|
||||
}
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const BUSY_STATUSES = new Set(['queued', 'building', 'running']);
|
||||
const FAILED_STATUSES = new Set(['error', 'failed']);
|
||||
const INDETERMINATE_PHASES = new Set([
|
||||
'writing', 'refreshing_live_sources', 'refreshing_epg_sources', 'writing_epg_matches'
|
||||
]);
|
||||
|
||||
function progressValues(status, phase) {
|
||||
if (phase === 'laya_identity') {
|
||||
@ -33,7 +36,7 @@
|
||||
const progress = progressValues(status, phase);
|
||||
const processed = Number(progress.processed);
|
||||
const total = Number(progress.total);
|
||||
const determined = busy && phase !== 'writing' &&
|
||||
const determined = busy && !INDETERMINATE_PHASES.has(phase) &&
|
||||
progress.processed !== null && progress.processed !== undefined &&
|
||||
progress.total !== null && progress.total !== undefined &&
|
||||
Number.isFinite(processed) && Number.isFinite(total) && total > 0;
|
||||
@ -42,7 +45,15 @@
|
||||
: null;
|
||||
const previouslyCompleted = Boolean(status.completedAt) || Number(status.projectedCount) > 0;
|
||||
const statusLabel = busy
|
||||
? phase === 'laya_identity'
|
||||
? phase === 'refreshing_live_sources'
|
||||
? 'Refreshing IPTV sources…'
|
||||
: phase === 'refreshing_epg_sources'
|
||||
? 'Refreshing EPG data…'
|
||||
: ['matching_epg', 'laya_epg_matching'].includes(phase)
|
||||
? 'Matching channels to EPG data…'
|
||||
: phase === 'writing_epg_matches'
|
||||
? 'Finalizing…'
|
||||
: phase === 'laya_identity'
|
||||
? 'Analyzing with local AI…'
|
||||
: phase === 'laya_language'
|
||||
? 'Detecting stream languages…'
|
||||
@ -52,7 +63,7 @@
|
||||
? 'Saving channel catalog…'
|
||||
: 'Analyzing channels…'
|
||||
: complete ? 'Analysis complete' : failed ? 'Analysis failed' : 'Ready to analyze';
|
||||
const progressText = phase === 'writing' && busy
|
||||
const progressText = ['writing', 'writing_epg_matches'].includes(phase) && busy
|
||||
? 'Finalizing…'
|
||||
: progressPercent === null
|
||||
? (busy ? 'In progress…' : '')
|
||||
|
||||
@ -54,6 +54,20 @@ const LIVE_PROJECTION_SCHEMA_SQL = `
|
||||
item_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_epg_matches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
logical_channel_id TEXT NOT NULL,
|
||||
language_code TEXT NOT NULL,
|
||||
source_id INTEGER,
|
||||
channel_id TEXT,
|
||||
match_confidence REAL NOT NULL DEFAULT 0,
|
||||
data JSON,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE (logical_channel_id, language_code)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_epg_matches_channel
|
||||
ON live_epg_matches(logical_channel_id, language_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_projection_state (
|
||||
projection TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
|
||||
@ -91,13 +91,13 @@ router.get('/projection/status', requireAuth, (req, res) => {
|
||||
|
||||
router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const started = liveProjection.startRebuild(null, { analyzeAll: true });
|
||||
started.promise.catch(error => {
|
||||
const fullRebuild = liveProjection.fullRebuildOrchestrator();
|
||||
fullRebuild.catch(error => {
|
||||
console.error('[LiveProjection] Manual rebuild failed:', error.message);
|
||||
});
|
||||
return res.status(202).json({
|
||||
alreadyRunning: started.alreadyRunning,
|
||||
projection: serializeState(started.state)
|
||||
started: true,
|
||||
projection: serializeState(liveProjection.projectionState())
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] Unable to start manual rebuild:', error.message);
|
||||
|
||||
@ -58,4 +58,54 @@ function findBestMatch(database, channel, epgSources, now = Date.now()) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { candidateScore, chooseBestMatch, findBestMatch, normalize };
|
||||
function rebuildPersistedEpgMatches(database, epgSources, now = Date.now()) {
|
||||
const rows = database.prepare(`
|
||||
SELECT p.*, l.display_name, c.name AS category_name
|
||||
FROM playlist_items p
|
||||
JOIN logical_channels l ON l.id = p.logical_channel_id
|
||||
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
||||
WHERE p.type = 'live' AND p.logical_channel_id IS NOT NULL
|
||||
ORDER BY p.logical_channel_id, p.language_code, p.variant_rank
|
||||
`).all();
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const languageCode = row.language_code || 'und';
|
||||
const key = `${row.logical_channel_id}\0${languageCode}`;
|
||||
if (!groups.has(key)) groups.set(key, {
|
||||
logicalChannelId: row.logical_channel_id,
|
||||
displayName: row.display_name,
|
||||
standardName: row.display_name,
|
||||
language: { code: languageCode },
|
||||
variants: []
|
||||
});
|
||||
let data = {};
|
||||
try { data = row.data ? JSON.parse(row.data) : {}; } catch {}
|
||||
groups.get(key).variants.push({
|
||||
name: row.name,
|
||||
tvgId: data.tvgId || data.tvg_id || null,
|
||||
epg_channel_id: data.epg_channel_id || null
|
||||
});
|
||||
}
|
||||
const replace = database.prepare(`
|
||||
INSERT INTO live_epg_matches
|
||||
(logical_channel_id, language_code, source_id, channel_id, match_confidence, data, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(logical_channel_id, language_code) DO UPDATE SET
|
||||
source_id = excluded.source_id, channel_id = excluded.channel_id,
|
||||
match_confidence = excluded.match_confidence, data = excluded.data,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const persist = database.transaction(() => {
|
||||
database.prepare('DELETE FROM live_epg_matches').run();
|
||||
for (const channel of groups.values()) {
|
||||
const match = findBestMatch(database, channel, epgSources, now);
|
||||
replace.run(channel.logicalChannelId, channel.language.code,
|
||||
match?.sourceId ?? null, match?.channelId ?? null,
|
||||
match?.matchConfidence ?? 0, match ? JSON.stringify(match) : null, now);
|
||||
}
|
||||
});
|
||||
persist();
|
||||
return { matched: [...groups.values()].length };
|
||||
}
|
||||
|
||||
module.exports = { candidateScore, chooseBestMatch, findBestMatch, normalize, rebuildPersistedEpgMatches };
|
||||
|
||||
@ -339,6 +339,18 @@ function rebuild(database = null, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fullRebuildOrchestrator(database = null, dependencies = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const syncService = dependencies.syncService || require('./syncService');
|
||||
const sourceRepository = dependencies.sources || require('../db').sources;
|
||||
await syncService.refreshEnabledLiveSources({ rebuild: false });
|
||||
await startRebuild(database, { analyzeAll: true }).promise;
|
||||
await syncService.refreshEnabledEpgSources();
|
||||
const epgSources = await sourceRepository.getByType('epg');
|
||||
epgMatching.rebuildPersistedEpgMatches(database, epgSources);
|
||||
return projectionState(database);
|
||||
}
|
||||
|
||||
async function rebuildWithSignals(database = null, options = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const items = loadProjectionItems(database);
|
||||
@ -389,9 +401,7 @@ function startRebuild(database = null, options = {}) {
|
||||
return { alreadyRunning: false, state: activeState, promise: activeRebuild };
|
||||
}
|
||||
|
||||
function queueRebuild(database = null) {
|
||||
return startRebuild(database, { mode: 'algo' }).promise;
|
||||
}
|
||||
const queueRebuild = (database = null) => startRebuild(database, { mode: 'algo' }).promise;
|
||||
|
||||
function parseData(value) {
|
||||
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
||||
@ -435,10 +445,9 @@ function listChannel(channel) {
|
||||
};
|
||||
}
|
||||
|
||||
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) {
|
||||
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
||||
const epgMatcher = dependencies.epgMatching || epgMatching;
|
||||
const params = [];
|
||||
let sourceFilter = '';
|
||||
if (sourceId !== null) {
|
||||
@ -471,6 +480,24 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
||||
const filtered = serialized.filter(channel =>
|
||||
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
||||
);
|
||||
const persistedEpg = new Map(database.prepare(`
|
||||
SELECT logical_channel_id, language_code, source_id, channel_id, match_confidence, data
|
||||
FROM live_epg_matches
|
||||
`).all().map(match => [`${match.logical_channel_id}\0${match.language_code}`, match]));
|
||||
for (const channel of filtered) {
|
||||
const match = persistedEpg.get(`${channel.logicalChannelId}\0${channel.language.code}`);
|
||||
if (!match) continue;
|
||||
let data = {};
|
||||
try { data = match.data ? JSON.parse(match.data) : {}; } catch {}
|
||||
channel.epg = {
|
||||
...channel.epg,
|
||||
...data,
|
||||
sourceId: match.source_id,
|
||||
channelId: match.channel_id,
|
||||
matchConfidence: match.match_confidence,
|
||||
language: channel.language.code
|
||||
};
|
||||
}
|
||||
const bounds = pageBounds(page, limit);
|
||||
const paged = filtered.slice(bounds.offset, bounds.offset + bounds.limit);
|
||||
// Catalog loading must stay bounded: both enrichers perform synchronous SQLite work,
|
||||
@ -483,8 +510,6 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
||||
channel.standardName = match.name;
|
||||
channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence };
|
||||
}
|
||||
const epgMatch = epgMatcher.findBestMatch(database, channel, epgSources);
|
||||
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
|
||||
return channel;
|
||||
});
|
||||
return {
|
||||
@ -517,7 +542,7 @@ function getChannelVariants({ logicalChannelId, includeHidden = false, userId =
|
||||
}
|
||||
|
||||
function checkNewStreams() {
|
||||
return require('./syncService').syncAll();
|
||||
return require('./syncService').refreshEnabledLiveSources({ rebuild: true });
|
||||
}
|
||||
|
||||
function getChannels(options, database = null) {
|
||||
@ -600,6 +625,7 @@ module.exports = {
|
||||
collectOptionalIdentityHints,
|
||||
displayNameForFingerprint,
|
||||
checkNewStreams,
|
||||
fullRebuildOrchestrator,
|
||||
getChannelPage,
|
||||
getChannelVariants,
|
||||
getChannels,
|
||||
|
||||
@ -115,6 +115,24 @@ class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshEnabledLiveSources({ rebuild = false } = {}) {
|
||||
const allSources = await sources.getAll();
|
||||
for (const source of allSources) {
|
||||
if (!source.enabled || !['xtream', 'm3u'].includes(source.type)) continue;
|
||||
if (source.type === 'xtream') {
|
||||
const newIds = await this.syncXtreamLive(source);
|
||||
if (rebuild) await rebuildLiveProjection(newIds);
|
||||
} else {
|
||||
await this.syncM3u(source, { rebuild });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshEnabledEpgSources() {
|
||||
const epgSources = await sources.getByType('epg');
|
||||
for (const source of epgSources) await this.syncEpg(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sync for a source
|
||||
*/
|
||||
@ -185,17 +203,7 @@ class SyncService {
|
||||
*/
|
||||
async syncXtream(source) {
|
||||
const api = xtreamApi.createFromSource(source);
|
||||
const db = getDb();
|
||||
|
||||
// 1. Live Categories
|
||||
console.log(`[Sync] Fetching Live Categories for ${source.name}`);
|
||||
const liveCats = await api.getLiveCategories();
|
||||
await this.saveCategories(source.id, 'live', liveCats);
|
||||
|
||||
// 2. Live Streams
|
||||
console.log(`[Sync] Fetching Live Streams for ${source.name}`);
|
||||
const liveStreams = await api.getLiveStreams();
|
||||
const { newIds } = await this.saveStreams(source.id, 'live', liveStreams);
|
||||
const newIds = await this.syncXtreamLive(source, api);
|
||||
await rebuildLiveProjection(newIds);
|
||||
|
||||
// 3. VOD Categories
|
||||
@ -229,6 +237,16 @@ class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
async syncXtreamLive(source, api = xtreamApi.createFromSource(source)) {
|
||||
console.log(`[Sync] Fetching Live Categories for ${source.name}`);
|
||||
const liveCats = await api.getLiveCategories();
|
||||
await this.saveCategories(source.id, 'live', liveCats);
|
||||
console.log(`[Sync] Fetching Live Streams for ${source.name}`);
|
||||
const liveStreams = await api.getLiveStreams();
|
||||
const { newIds } = await this.saveStreams(source.id, 'live', liveStreams);
|
||||
return newIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch save categories
|
||||
*/
|
||||
@ -513,7 +531,7 @@ class SyncService {
|
||||
* M3U Sync Logic (Streaming - Memory Efficient)
|
||||
* Processes M3U files in batches to avoid OOM on large playlists
|
||||
*/
|
||||
async syncM3u(source) {
|
||||
async syncM3u(source, { rebuild = true } = {}) {
|
||||
console.log(`[Sync] Fetching M3U playlist for ${source.name}`);
|
||||
|
||||
// Temporary memory logging for verification
|
||||
@ -578,8 +596,9 @@ class SyncService {
|
||||
}));
|
||||
|
||||
await this.saveCategories(source.id, 'live', categories);
|
||||
await rebuildLiveProjection(allNewIds);
|
||||
if (rebuild) await rebuildLiveProjection(allNewIds);
|
||||
console.log(`[Sync] M3U sync complete for ${source.name}`);
|
||||
return allNewIds;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
168
test/t38FullRebuildOrchestrationRegression.test.js
Normal file
168
test/t38FullRebuildOrchestrationRegression.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user