From e63f7d5353a4542b1c4e7ebc04206d2ebf2da632 Mon Sep 17 00:00:00 2001 From: Cameron Bulock Date: Sun, 6 Sep 2026 11:22:24 -0400 Subject: [PATCH 1/2] fix: retain guide data across refresh failures --- server/config.js | 16 +++++- server/epg.js | 100 +++++++++++++++++++++++++++++---- server/mcp.js | 22 ++++++-- server/scheduler.js | 8 ++- test/integration/guide.test.js | 34 ++++++++++- 5 files changed, 160 insertions(+), 20 deletions(-) diff --git a/server/config.js b/server/config.js index 063a6e2..6051906 100644 --- a/server/config.js +++ b/server/config.js @@ -276,11 +276,21 @@ router.post('/api/reload/channels', requireAuth, async (req, res) => { router.post('/api/reload/epg', requireAuth, async (req, res) => { try { - await refreshEPG(); - notifyWebhooks('epg.refreshed', {}).catch(err => + const outcome = await refreshEPG(); + if (outcome.status === 'failed') { + return res.status(503).json({ + error: outcome.message, + status: outcome.status, + sources: outcome.sourceResults, + }); + } + notifyWebhooks('epg.refreshed', { status: outcome.status }).catch(err => console.warn('[Config] Webhook notification failed:', err.message) ); - res.json({ status: 'reloaded' }); + res.status(outcome.status === 'degraded' ? 207 : 200).json({ + status: outcome.status, + sources: outcome.sourceResults, + }); } catch (e) { res.status(500).json({ error: 'Failed to reload EPG', diff --git a/server/epg.js b/server/epg.js index cd67ca4..c4a834c 100644 --- a/server/epg.js +++ b/server/epg.js @@ -29,12 +29,31 @@ const epgLimiter = RateLimit({ // Module-level refresher that will be set when routes are initialized let refreshImpl = null; +let refreshPromise = null; +let requestedRefreshRevision = 0; +let completedRefreshRevision = 0; export async function refreshEPG() { - if (typeof refreshImpl === 'function') { - await refreshImpl(); - } else { + if (typeof refreshImpl !== 'function') { throw new Error('EPG refresher not initialized'); } + + requestedRefreshRevision += 1; + if (!refreshPromise) { + refreshPromise = (async () => { + let outcome; + // Calls made while a refresh is in progress request one follow-up pass, + // ensuring an old channel/guide selection never becomes the final state. + while (completedRefreshRevision < requestedRefreshRevision) { + const revision = requestedRefreshRevision; + outcome = await refreshImpl(revision); + completedRefreshRevision = revision; + } + return outcome; + })().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; } export function hasEPGRefresh() { @@ -56,6 +75,8 @@ let epgCache = null; // Module-level merged EPG XML string (set by setupEPGRoutes) let mergedEPG = null; let lastEPGSourceResults = []; +let lastEPGRefreshOutcome = null; +const lastGoodSourceXml = new Map(); // Maximum number of programmes returned per guide query (matches /api/guide behaviour) const MAX_GUIDE_PROGRAMMES = 20; @@ -203,6 +224,15 @@ export function getGuideData(tvgId, hours = 24) { export function _resetMergedEPGForTesting() { mergedEPG = null; lastEPGSourceResults = []; + lastEPGRefreshOutcome = null; + lastGoodSourceXml.clear(); + refreshPromise = null; + requestedRefreshRevision = 0; + completedRefreshRevision = 0; +} + +export function getEPGRefreshOutcome() { + return lastEPGRefreshOutcome; } function loadOutputChannels(slug = '') { @@ -341,7 +371,7 @@ export async function setupEPGRoutes(app) { epgCache = cacheManager.createCache('epg', epgTTL); console.log(`EPG cache initialized with TTL: ${epgTTL / 1000}s`); - async function fetchAndMergeEPGs() { + async function fetchAndMergeEPGs(revision) { const startTime = Date.now(); const allChannels = loadChannelsForGuideMerge(); const { bindingsBySource: guideBindingsBySource, selectedCanonicalIds } = @@ -364,6 +394,8 @@ export async function setupEPGRoutes(app) { const fallbackTvgIds = new Set(fallbackChannels.map(ch => ch.tvg_id).filter(Boolean)); const fallbackNames = new Set(fallbackChannels.map(ch => ch.name).filter(Boolean)); + let usedStaleSource = false; + let sourceFetchError = null; try { console.log(`Loading EPG: ${sourceName} (${sourceUrl})`); @@ -375,7 +407,13 @@ export async function setupEPGRoutes(app) { try { xmlData = fs.readFileSync(path, 'utf-8'); } catch (fileErr) { - throw new Error(`Failed to read file: ${fileErr.message}`); + sourceFetchError = new Error(`Failed to read file: ${fileErr.message}`); + const lastGood = lastGoodSourceXml.get(sourceName); + if (!lastGood || lastGood.url !== sourceUrl) { + throw sourceFetchError; + } + usedStaleSource = true; + xmlData = lastGood.xmlData; } } else { try { @@ -385,7 +423,13 @@ export async function setupEPGRoutes(app) { }); xmlData = response.data; } catch (httpErr) { - throw new Error(`Failed to fetch EPG: ${httpErr.message}`); + sourceFetchError = new Error(`Failed to fetch EPG: ${httpErr.message}`); + const lastGood = lastGoodSourceXml.get(sourceName); + if (!lastGood || lastGood.url !== sourceUrl) { + throw sourceFetchError; + } + usedStaleSource = true; + xmlData = lastGood.xmlData; } } @@ -412,6 +456,10 @@ export async function setupEPGRoutes(app) { throw new Error('Invalid XMLTV structure - missing root element'); } + if (!usedStaleSource) { + lastGoodSourceXml.set(sourceName, { url: sourceUrl, xmlData }); + } + if (parsed.tv?.channel) { const channels = [] .concat(parsed.tv.channel) @@ -448,7 +496,8 @@ export async function setupEPGRoutes(app) { sourceResults.push({ source: sourceName, url: sourceUrl, - status: 'ok', + status: usedStaleSource ? 'stale' : 'ok', + ...(sourceFetchError ? { error: sourceFetchError.message } : {}), channelCount: channels.length, programmeCount: 0, }); @@ -491,7 +540,8 @@ export async function setupEPGRoutes(app) { sourceResults.push({ source: sourceName, url: sourceUrl, - status: 'ok', + status: usedStaleSource ? 'stale' : 'ok', + ...(sourceFetchError ? { error: sourceFetchError.message } : {}), channelCount: 0, programmeCount: sourceProgrammeCount, }); @@ -556,6 +606,24 @@ export async function setupEPGRoutes(app) { } lastEPGSourceResults = sourceResults; + const usableSources = sourceResults.filter(result => result.status !== 'error'); + const staleSources = sourceResults.filter(result => result.status === 'stale'); + const errors = sourceResults.filter(result => result.status === 'error'); + + if (usableSources.length === 0) { + const message = epgSources.length + ? 'All configured EPG sources failed; retained the previous guide.' + : 'No EPG sources are configured.'; + lastEPGRefreshOutcome = { + status: 'failed', + revision, + message, + sourceResults, + refreshedAt: new Date().toISOString(), + }; + console.error(`[EPG] ${message}`); + return lastEPGRefreshOutcome; + } try { mergedEPG = builder.build(merged); @@ -573,17 +641,26 @@ export async function setupEPGRoutes(app) { } const duration = Date.now() - startTime; + lastEPGRefreshOutcome = { + status: errors.length || staleSources.length ? 'degraded' : 'ok', + revision, + message: + errors.length || staleSources.length + ? 'Guide refreshed with stale or unavailable source data.' + : 'Guide refreshed successfully.', + sourceResults, + refreshedAt: new Date().toISOString(), + }; console.log( `EPG merge completed in ${duration}ms (${merged.tv.channel.length} channels, ${merged.tv.programme.length} programmes)` ); + return lastEPGRefreshOutcome; } // Expose refresher refreshImpl = fetchAndMergeEPGs; - await fetchAndMergeEPGs(); - // unref() so the interval does not prevent the process from exiting cleanly - setInterval(fetchAndMergeEPGs, 6 * 60 * 60 * 1000).unref(); + await refreshEPG(); const handleXmltvRequest = asyncHandler(async (req, res) => { const profileSlug = req.params.slug ? String(req.params.slug).trim() : ''; @@ -669,6 +746,7 @@ export async function setupEPGRoutes(app) { const sources = { total: lastEPGSourceResults.length, valid: lastEPGSourceResults.filter(source => source.status === 'ok').length, + stale: lastEPGSourceResults.filter(source => source.status === 'stale').length, failed: lastEPGSourceResults.filter(source => source.status === 'error').length, results: lastEPGSourceResults, }; diff --git a/server/mcp.js b/server/mcp.js index 196e70e..d84969f 100644 --- a/server/mcp.js +++ b/server/mcp.js @@ -1041,13 +1041,27 @@ function createMcpServer() { {}, async () => { try { - await refreshEPG(); + const outcome = await refreshEPG(); + if (outcome.status === 'failed') { + return createError('reload_epg', { + code: 'epg-reload-failed', + message: outcome.message, + nextSuggestedTools: ['get_status', 'list_guide_bindings'], + }); + } return createSuccess( 'reload_epg', - { triggered: true }, + { triggered: true, status: outcome.status, sources: outcome.sourceResults }, { - summary: 'EPG reload triggered successfully.', - sideEffects: ['Guide data was reloaded from configured XMLTV sources.'], + summary: + outcome.status === 'degraded' + ? 'EPG reload completed with stale source data retained.' + : 'EPG reload triggered successfully.', + sideEffects: [ + outcome.status === 'degraded' + ? 'Guide data was rebuilt while retaining last-known-good data for failed sources.' + : 'Guide data was reloaded from configured XMLTV sources.', + ], nextSuggestedTools: ['get_status', 'get_guide', 'diagnose_agent_readiness'], } ); diff --git a/server/scheduler.js b/server/scheduler.js index 9bc4972..9040307 100644 --- a/server/scheduler.js +++ b/server/scheduler.js @@ -177,7 +177,13 @@ export function initDefaultJobs() { registerJob( 'EPG Refresh', '0 */6 * * *', - refreshEPG, + async () => { + const outcome = await refreshEPG(); + if (outcome.status === 'failed') { + throw new Error(outcome.message); + } + return outcome; + }, false // EPG is already loaded during setupEPGRoutes ); diff --git a/test/integration/guide.test.js b/test/integration/guide.test.js index c950238..68a14ae 100644 --- a/test/integration/guide.test.js +++ b/test/integration/guide.test.js @@ -81,6 +81,8 @@ describe('GET /api/guide', () => { let epgFilePath; let server; let baseUrl; + let refreshEPG; + let resetMergedEPG; before(async () => { tmpDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iptv-guide-data-')); process.env.DATA_PATH = tmpDataDir; @@ -109,7 +111,10 @@ describe('GET /api/guide', () => { const app = express(); app.use(express.json()); - const { setupEPGRoutes } = await import('../../server/epg.js'); + const epgModule = await import('../../server/epg.js'); + const { setupEPGRoutes } = epgModule; + refreshEPG = epgModule.refreshEPG; + resetMergedEPG = epgModule._resetMergedEPGForTesting; const { errorHandler } = await import('../../server/error-handler.js'); await setupEPGRoutes(app); @@ -173,4 +178,31 @@ describe('GET /api/guide', () => { expect(res.data.programmes).to.be.an('array').with.lengthOf(0); expect(res.data.total).to.equal(0); }); + + it('retains last-known-good guide data on source failure and reports recovery', async () => { + await fs.unlink(epgFilePath); + + const degraded = await refreshEPG(); + expect(degraded.status).to.equal('degraded'); + const staleSource = degraded.sourceResults.find(source => source.source === 'GuideProvider'); + expect(staleSource).to.include({ + url: pathToFileURL(epgFilePath).href, + status: 'stale', + channelCount: 2, + programmeCount: 3, + }); + expect(staleSource.error).to.be.a('string'); + + const retained = await axios.get(`${baseUrl}/api/guide?tvgId=${TVG_ID}`); + expect(retained.data.programmes.map(programme => programme.title)).to.include('Current Show'); + + await fs.writeFile(epgFilePath, buildFutureXMLTV(TVG_ID), 'utf8'); + const recovered = await refreshEPG(); + expect(recovered.status).to.equal('ok'); + + resetMergedEPG(); + await fs.unlink(epgFilePath); + const failed = await refreshEPG(); + expect(failed.status).to.equal('failed'); + }); }); From f242632322d60da17f209954b465cddde899649f Mon Sep 17 00:00:00 2001 From: Cameron Bulock Date: Sun, 6 Sep 2026 14:47:33 -0400 Subject: [PATCH 2/2] Update EPG reload success message Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/mcp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mcp.js b/server/mcp.js index d84969f..2d98643 100644 --- a/server/mcp.js +++ b/server/mcp.js @@ -1056,7 +1056,7 @@ function createMcpServer() { summary: outcome.status === 'degraded' ? 'EPG reload completed with stale source data retained.' - : 'EPG reload triggered successfully.', + : 'EPG reload completed successfully.', sideEffects: [ outcome.status === 'degraded' ? 'Guide data was rebuilt while retaining last-known-good data for failed sources.'