fix(epg): add retry functionality for failed EPG fetches and enhance UI for error handling

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-17 21:30:50 +02:00
1 parent 15bfd5808c
commit af8bb19d6c
8 files changed
+146 -23

No files matched your search

@@ -17,6 +17,7 @@ export default class EpgEvents {
private static fetchedUrls: Set<string> = new Set();
private static workers: Map<string, Worker> = 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)));
});
});
}
+2 -1
View File
@@ -269,7 +269,8 @@
"QUEUED": "Σε ουρά",
"TOTAL_CHANNELS": "Σύνολο καναλιών",
"TOTAL_PROGRAMS": "Σύνολο προγραμμάτων",
"UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός"
"UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός",
"RETRY": "Επανάληψη"
},
"LANGUAGES": {
"ARABIC": "العربية",
+2 -1
View File
@@ -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": "العربية",
+2 -1
View File
@@ -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": "العربية",
@@ -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;
@@ -59,6 +59,17 @@
<span class="item-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
@if (item.status === 'error') {
<button
mat-icon-button
class="retry-btn"
(click)="retry(item.url)"
[attr.aria-label]="'EPG.RETRY' | translate"
[matTooltip]="'EPG.RETRY' | translate"
>
<mat-icon>refresh</mat-icon>
</button>
}
<button
mat-icon-button
class="dismiss-btn"
@@ -28,7 +28,11 @@
max-width: calc(100vw - 96px);
background: var(--epg-panel-bg);
border-radius: 12px;
box-shadow: var(--epg-shadow);
border: 1px solid var(--epg-separator);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.08),
0 8px 24px rgba(0, 0, 0, 0.18),
0 16px 48px rgba(0, 0, 0, 0.12);
z-index: 900;
overflow: hidden;
animation: slideInFromLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1);
@@ -154,14 +158,17 @@
color: var(--epg-text-secondary);
}
.dismiss-btn {
.dismiss-btn,
.retry-btn {
width: 24px;
height: 24px;
padding: 0;
line-height: 24px;
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s ease;
transition:
opacity 0.15s ease,
color 0.15s ease;
color: var(--epg-text-tertiary);
mat-icon {
@@ -170,6 +177,21 @@
height: 16px;
}
}
// Retry is a deliberate action — keep it discoverable, not hover-only,
// and use the accent color so it reads as primary.
.retry-btn {
opacity: 1;
color: var(--epg-accent);
&:hover {
color: color-mix(
in srgb,
var(--epg-accent) 80%,
var(--epg-text-primary)
);
}
}
}
&:hover .dismiss-btn {
@@ -234,6 +256,14 @@
margin: 2px 0 0;
padding: 6px 10px 8px;
border-top: 1px solid var(--epg-separator);
// When queue is the only content (no active imports above),
// the panel-header border-bottom already provides separation —
// suppress this border to avoid a visible double rule.
&:first-child {
margin-top: 0;
border-top: none;
}
}
.queue-header {
@@ -84,4 +84,8 @@ export class EpgProgressPanelComponent {
dismissAll(): void {
this.epgProgress.dismissAll();
}
retry(url: string): void {
this.epgProgress.retry(url);
}
}