mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
fix(epg): add retry functionality for failed EPG fetches and enhance UI for error handling
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,7 +269,8 @@
|
||||
"QUEUED": "Σε ουρά",
|
||||
"TOTAL_CHANNELS": "Σύνολο καναλιών",
|
||||
"TOTAL_PROGRAMS": "Σύνολο προγραμμάτων",
|
||||
"UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός"
|
||||
"UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός",
|
||||
"RETRY": "Επανάληψη"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user