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

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

View File

@ -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">

View File

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

View File

@ -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…' : '')