diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 5b174dfc2..9707059c0 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -17,6 +17,7 @@ export default class EpgEvents { private static fetchedUrls: Set = new Set(); private static workers: Map = new Map(); private static readonly loggerLabel = '[EPG Events]'; + private static readonly FETCH_TIMEOUT_MS = 5 * 60 * 1000; private static createEpgWorker(): Worker { const bootstrap = resolveWorkerRuntimeBootstrap({ @@ -215,8 +216,23 @@ export default class EpgEvents { }; } - // Send queued status for all stale URLs first - staleUrls.forEach((url, index) => { + // Exclude URLs already processed this session — otherwise the loop sends + // a 'queued' status, then fetchEpgFromUrl silently skips the URL and no + // completion update ever arrives, leaving the UI stuck at "queued". + const urlsToFetch = staleUrls.filter( + (url) => !this.fetchedUrls.has(url) + ); + + if (urlsToFetch.length === 0) { + console.log( + this.loggerLabel, + `All ${staleUrls.length} stale URL(s) already fetched this session; skipping` + ); + return { success: true, skipped: freshUrls }; + } + + // Send queued status for URLs that will actually be fetched + urlsToFetch.forEach((url, index) => { this.sendProgressToRenderer( url, 'queued', @@ -226,10 +242,10 @@ export default class EpgEvents { ); }); - // Process only stale URLs sequentially to avoid database locking + // Process URLs sequentially to avoid database locking const errors: string[] = []; - for (let i = 0; i < staleUrls.length; i++) { - const url = staleUrls[i]; + for (let i = 0; i < urlsToFetch.length; i++) { + const url = urlsToFetch[i]; try { await this.fetchEpgFromUrl(url); } catch (error) { @@ -246,7 +262,7 @@ export default class EpgEvents { if (errors.length > 0) { return { - success: errors.length < staleUrls.length, // Partial success if some worked + success: errors.length < urlsToFetch.length, // Partial success if some worked message: errors.join('; '), skipped: freshUrls, }; @@ -285,6 +301,32 @@ export default class EpgEvents { this.workers.set(url, worker); + // Guards against double-settling and keeps the outer loop moving + // when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR. + let settled = false; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + fn(); + }; + + const timeoutId = setTimeout(() => { + const errorMessage = `EPG fetch timed out after ${ + this.FETCH_TIMEOUT_MS / 1000 + }s`; + console.error(this.loggerLabel, `${errorMessage}: ${url}`); + this.sendProgressToRenderer( + url, + 'error', + undefined, + errorMessage + ); + worker.terminate(); + this.workers.delete(url); + settle(() => reject(new Error(errorMessage))); + }, this.FETCH_TIMEOUT_MS); + worker.on( 'message', async (message: { @@ -330,7 +372,7 @@ export default class EpgEvents { this.fetchedUrls.add(url); worker.terminate(); this.workers.delete(url); - resolve(); + settle(() => resolve()); break; case 'EPG_ERROR': @@ -348,8 +390,12 @@ export default class EpgEvents { ); worker.terminate(); this.workers.delete(url); - reject( - new Error(message.error || 'Unknown error') + settle(() => + reject( + new Error( + message.error || 'Unknown error' + ) + ) ); break; } @@ -359,25 +405,47 @@ export default class EpgEvents { 'Error handling message:', err ); - reject(err); + this.sendProgressToRenderer( + url, + 'error', + undefined, + err instanceof Error ? err.message : String(err) + ); + worker.terminate(); + this.workers.delete(url); + settle(() => reject(err)); } } ); worker.on('error', (error) => { console.error(this.loggerLabel, 'Worker error event:', error); + this.sendProgressToRenderer( + url, + 'error', + undefined, + error.message + ); worker.terminate(); this.workers.delete(url); - reject(error); + settle(() => reject(error)); }); worker.on('exit', (code) => { - if (code !== 0) { - console.error( - this.loggerLabel, - `Worker stopped with exit code ${code}` - ); - } + // If the worker exits without emitting EPG_COMPLETE/EPG_ERROR, + // settle the promise so the outer sequential loop can advance + // instead of hanging forever. + if (settled) return; + const errorMessage = `Worker exited unexpectedly (code ${code})`; + console.error(this.loggerLabel, `${errorMessage}: ${url}`); + this.sendProgressToRenderer( + url, + 'error', + undefined, + errorMessage + ); + this.workers.delete(url); + settle(() => reject(new Error(errorMessage))); }); }); } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 1f8b365c8..ed313bba9 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -269,7 +269,8 @@ "QUEUED": "Σε ουρά", "TOTAL_CHANNELS": "Σύνολο καναλιών", "TOTAL_PROGRAMS": "Σύνολο προγραμμάτων", - "UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός" + "UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός", + "RETRY": "Επανάληψη" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 673abfeb6..f12479dba 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -382,7 +382,8 @@ "QUEUED": "Queued", "TOTAL_CHANNELS": "Total channels", "TOTAL_PROGRAMS": "Total programs", - "UP_TO_DATE": "EPG is up-to-date, no sync needed" + "UP_TO_DATE": "EPG is up-to-date, no sync needed", + "RETRY": "Retry" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index e880983f4..321a906cb 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -277,7 +277,8 @@ "QUEUED": "Sırada", "TOTAL_CHANNELS": "Toplam kanal", "TOTAL_PROGRAMS": "Toplam program", - "UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok" + "UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok", + "RETRY": "Tekrar Dene" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/libs/epg/data-access/src/lib/epg-progress.service.ts b/libs/epg/data-access/src/lib/epg-progress.service.ts index 37caa2d2d..277bca07c 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.ts @@ -44,6 +44,13 @@ export class EpgProgressService { this.importsMap.set(new Map()); } + retry(url: string): void { + // Clear the errored row so the backend's subsequent 'queued' event + // reappears cleanly rather than updating an existing error row. + this.removeImport(url); + void window.electron?.forceFetchEpg?.(url); + } + private initializeListener(): void { if (this.initialized) { return; diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html index 38224289b..cc22a7629 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html @@ -59,6 +59,17 @@ {{ getDisplayUrl(item.url) }} + @if (item.status === 'error') { + + }