From 7b46509d211330627229194ff56f917bbe3e195d Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 20 Apr 2026 01:09:33 +0200 Subject: [PATCH] feat: implement EPG source status component and enhance EPG data management in settings Entire-Checkpoint: c6e522b4276c --- .../src/app/workers/epg-parser.worker.ts | 47 ++++++++-- .../src/app/settings/settings.component.html | 31 ++++++- .../src/app/settings/settings.component.scss | 10 ++ .../app/settings/settings.component.spec.ts | 66 +++++++++++++- .../src/app/settings/settings.component.ts | 55 +++++++++-- libs/ui/epg/src/index.ts | 1 + .../epg-source-status.component.html | 41 +++++++++ .../epg-source-status.component.scss | 73 +++++++++++++++ .../epg-source-status.component.ts | 91 +++++++++++++++++++ 9 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.html create mode 100644 libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.scss create mode 100644 libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index fd2fffa62..07517082c 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -129,10 +129,20 @@ class EpgDatabase { } /** - * Insert a batch of channels + * Insert a batch of channels. When `clearFirst` is true, the existing rows + * for `sourceUrl` are deleted inside the same transaction as the insert so + * old data is preserved if the fetch/parse never produces any channels. */ - insertChannels(channels: ParsedChannel[], sourceUrl: string): void { + insertChannels( + channels: ParsedChannel[], + sourceUrl: string, + clearFirst = false + ): void { const insertMany = this.db.transaction((channels: ParsedChannel[]) => { + if (clearFirst) { + this.deleteChannelsStmt.run(sourceUrl); + this.knownChannelIds.clear(); + } for (const channel of channels) { const displayName = channel.displayName?.[0]?.value || channel.id; @@ -213,11 +223,13 @@ async function fetchAndParseEpgStreaming(url: string): Promise { // Create database connection in worker const epgDb = new EpgDatabase(); - try { - // Clear existing data for this source - console.log(loggerLabel, `Clearing existing data for ${url}`); - epgDb.clearSourceData(url); + // Old rows for this source are retained until the first successful insert + // batch arrives — see `hasClearedSource` below. That way, a fetch or parse + // that yields zero channels leaves the existing data intact instead of + // wiping it and leaving the URL permanently stale. + let hasClearedSource = false; + try { const response = await fetch(url.trim()); const isGzipped = shouldGunzipEpgResponse(url, response); @@ -243,8 +255,10 @@ async function fetchAndParseEpgStreaming(url: string): Promise { const parser = new StreamingEpgParser( (channels) => { - // Insert channels directly into database - epgDb.insertChannels(channels, url); + // Clear old rows in the same transaction as the first insert + // so we never end up with zero rows on a failed/empty parse. + epgDb.insertChannels(channels, url, !hasClearedSource); + hasClearedSource = true; }, (programs) => { // Insert programs directly into database @@ -300,6 +314,23 @@ async function fetchAndParseEpgStreaming(url: string): Promise { // Close database connection epgDb.close(); + // Treat empty results as failure rather than silently + // marking the URL as fetched — that causes the freshness + // check to re-trigger a fetch every session and hides the + // real problem (unreachable feed, SAX parse failure, etc.). + if (stats.totalChannels === 0) { + const errorMessage = `EPG parse produced 0 channels — feed may be unreachable or unsupported`; + console.error(loggerLabel, `${errorMessage}: ${url}`); + const response: WorkerResponse = { + type: 'EPG_ERROR', + url, + error: errorMessage, + }; + parentPort?.postMessage(response); + reject(new Error(errorMessage)); + return; + } + const response: WorkerResponse = { type: 'EPG_COMPLETE', url, diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 77fff3f1e..63cd2fb6e 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -446,6 +446,11 @@ | translate }} + @if (epgField.value) { + + } + diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 8e46362c7..cebf25fa7 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -314,6 +314,10 @@ .w-full { width: 100%; } + + app-epg-source-status { + flex-shrink: 0; + } } .inline-actions { @@ -321,6 +325,12 @@ justify-content: flex-end; flex-wrap: wrap; gap: 6px; + + .button-spinner { + display: inline-block; + margin-right: 6px; + vertical-align: middle; + } } .remote-control-urls { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index d540533f1..88275daf0 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -172,6 +172,9 @@ describe('SettingsComponent', () => { staleUrls: [], }), clearEpgData: jest.fn().mockResolvedValue({ success: true }), + forceFetchEpg: jest + .fn() + .mockResolvedValue({ success: true }), getAppVersion: jest.fn().mockResolvedValue('1.0.0'), getLocalIpAddresses: jest.fn().mockResolvedValue([]), platform: 'linux', @@ -434,11 +437,68 @@ describe('SettingsComponent', () => { ); }); - it('should send epg refresh command', () => { - jest.spyOn(epgService, 'fetchEpg'); + it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => { const url = 'http://epg-url-here/data.xml'; component.refreshEpg(url); - expect(epgService.fetchEpg).toHaveBeenCalledWith([url]); + expect(window.electron.forceFetchEpg).toHaveBeenCalledWith(url); + }); + + it('clears EPG data with a busy state and refreshes all sources on success', async () => { + let resolveClear: () => void = () => undefined; + const clearPromise = new Promise<{ success: boolean }>((resolve) => { + resolveClear = () => resolve({ success: true }); + }); + (window.electron.clearEpgData as jest.Mock).mockReturnValue( + clearPromise + ); + (dialogService.openConfirmDialog as jest.Mock).mockImplementation( + ({ onConfirm }: { onConfirm: () => Promise }) => { + void onConfirm(); + } + ); + const refreshSpy = jest.spyOn(component, 'refreshAllEpg'); + jest.spyOn(translate, 'instant').mockImplementation((key) => key); + + component.clearEpgData(); + + expect(component.isClearingEpgData()).toBe(true); + expect(refreshSpy).not.toHaveBeenCalled(); + + resolveClear(); + await fixture.whenStable(); + + expect(component.isClearingEpgData()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.EPG_DATA_CLEARED', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => { + (window.electron.clearEpgData as jest.Mock).mockRejectedValueOnce( + new Error('boom') + ); + (dialogService.openConfirmDialog as jest.Mock).mockImplementation( + ({ onConfirm }: { onConfirm: () => Promise }) => { + void onConfirm(); + } + ); + const refreshSpy = jest.spyOn(component, 'refreshAllEpg'); + jest.spyOn(translate, 'instant').mockImplementation((key) => key); + jest.spyOn(console, 'error').mockImplementation(); + + component.clearEpgData(); + await fixture.whenStable(); + + expect(component.isClearingEpgData()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.EPG_DATA_CLEAR_FAILED', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); + expect(refreshSpy).not.toHaveBeenCalled(); }); it('should navigate back to home page', () => { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index a514be353..4027f800d 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -39,6 +39,7 @@ import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { EpgService } from '@iptvnator/epg/data-access'; +import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; import { QRCodeComponent } from 'angularx-qrcode'; import { DialogService } from 'components'; import { PlaylistActions, selectIsEpgAvailable } from 'm3u-state'; @@ -84,6 +85,7 @@ interface StartupBehaviorOption { styleUrls: ['./settings.component.scss'], imports: [ CommonModule, + EpgSourceStatusComponent, FormsModule, MatButtonModule, MatCheckboxModule, @@ -238,6 +240,7 @@ export class SettingsComponent implements OnInit, OnDestroy { /** Currently visible QR code IP (null = none visible) */ visibleQrCodeIp = signal(null); readonly isRemovingAllPlaylists = signal(false); + readonly isClearingEpgData = signal(false); private settingsStore = inject(SettingsStore); private sectionObserver?: IntersectionObserver; @@ -544,11 +547,27 @@ export class SettingsComponent implements OnInit, OnDestroy { } /** - * Fetches and updates EPG from the given URL - * @param url epg source url + * Force-fetch EPG for a single URL, bypassing the 12-hour freshness check. + * The plain fetchEpg would short-circuit on fresh data and click the + * refresh button would be a no-op — that's exactly not what the user + * intends when clicking "Refresh". */ refreshEpg(url: string): void { - this.epgService.fetchEpg([url]); + if (!url || !window.electron?.forceFetchEpg) return; + void window.electron.forceFetchEpg(url); + } + + /** + * Force-fetch every configured EPG URL sequentially. Empty fields are + * skipped. Each URL flows through the normal progress panel so the user + * gets visible per-URL feedback. + */ + refreshAllEpg(): void { + if (!window.electron?.forceFetchEpg) return; + const urls = (this.epgUrl.value as string[]) + .map((url) => url?.trim()) + .filter((url): url is string => Boolean(url)); + urls.forEach((url) => window.electron.forceFetchEpg(url)); } /** @@ -575,7 +594,10 @@ export class SettingsComponent implements OnInit, OnDestroy { } /** - * Clears all EPG data from database + * Clears all EPG data from database and immediately re-fetches every + * configured URL so the user isn't left staring at an empty state. + * Tracks progress with `isClearingEpgData` so the UI can show a spinner + * and block double-clicks, and surfaces failures via a dedicated snackbar. */ clearEpgData(): void { this.dialogService.openConfirmDialog({ @@ -584,11 +606,32 @@ export class SettingsComponent implements OnInit, OnDestroy { 'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE' ), onConfirm: async (): Promise => { - if (window.electron?.clearEpgData) { - await window.electron.clearEpgData(); + if ( + !window.electron?.clearEpgData || + this.isClearingEpgData() + ) { + return; + } + + this.isClearingEpgData.set(true); + try { + const result = await window.electron.clearEpgData(); + if (result && result.success === false) { + throw new Error('Clear EPG returned success=false'); + } this.openSettingsSnackbar( this.translate.instant('SETTINGS.EPG_DATA_CLEARED') ); + this.refreshAllEpg(); + } catch (error) { + console.error('Failed to clear EPG data:', error); + this.openSettingsSnackbar( + this.translate.instant( + 'SETTINGS.EPG_DATA_CLEAR_FAILED' + ) + ); + } finally { + this.isClearingEpgData.set(false); } }, }); diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index 7e3903464..1020292cc 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/epg-list/epg-list.component'; export * from './lib/epg-list/epg-item-description/epg-item-description.component'; export * from './lib/epg-progress-panel/epg-progress-panel.component'; +export * from './lib/epg-source-status/epg-source-status.component'; export * from './lib/multi-epg/multi-epg-container.component'; export * from './lib/multi-epg/overlay-ref.token'; diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.html b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.html new file mode 100644 index 000000000..fc0ea2edb --- /dev/null +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.html @@ -0,0 +1,41 @@ +@switch (status()) { + @case ('loading') { + + + {{ 'EPG.LOADING' | translate }} + + } + @case ('queued') { + + schedule + {{ 'EPG.QUEUED' | translate }} + + } + @case ('fresh') { + + check_circle + {{ + 'SETTINGS.EPG_STATUS_FRESH' | translate + }} + + } + @case ('stale') { + + schedule + {{ + 'SETTINGS.EPG_STATUS_STALE' | translate + }} + + } + @case ('error') { + + error + {{ + 'SETTINGS.EPG_STATUS_ERROR' | translate + }} + + } +} diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.scss b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.scss new file mode 100644 index 000000000..c18a19769 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.scss @@ -0,0 +1,73 @@ +:host { + // Theme-aware tokens + --badge-fresh: #22c55e; + --badge-stale: #f59e0b; + --badge-error: #ef4444; + --badge-queued: #a78bfa; + --badge-accent: var(--app-selection-color); + + display: inline-flex; + align-items: center; + min-height: 22px; +} + +.status { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + line-height: 1.2; + white-space: nowrap; + background: color-mix( + in srgb, + var(--badge-color, var(--app-body-color)) 14%, + transparent + ); + color: var(--badge-color, var(--app-body-color)); + border: 1px solid + color-mix( + in srgb, + var(--badge-color, var(--app-body-color)) 28%, + transparent + ); + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + mat-progress-spinner { + flex-shrink: 0; + } + + &__label { + line-height: 1.2; + } + + &--loading { + --badge-color: var(--badge-accent); + } + + &--queued { + --badge-color: var(--badge-queued); + } + + &--fresh { + --badge-color: var(--badge-fresh); + } + + &--stale { + --badge-color: var(--badge-stale); + } + + &--error { + --badge-color: var(--badge-error); + cursor: help; + } +} diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts new file mode 100644 index 000000000..7356a5502 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts @@ -0,0 +1,91 @@ +import { + Component, + OnInit, + computed, + effect, + inject, + input, + signal, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { EpgProgressService } from '@iptvnator/epg/data-access'; + +type BadgeStatus = + | 'loading' + | 'queued' + | 'fresh' + | 'stale' + | 'error' + | 'unknown'; + +@Component({ + selector: 'app-epg-source-status', + standalone: true, + imports: [ + MatIconModule, + MatProgressSpinnerModule, + MatTooltipModule, + TranslatePipe, + ], + templateUrl: './epg-source-status.component.html', + styleUrl: './epg-source-status.component.scss', +}) +export class EpgSourceStatusComponent implements OnInit { + readonly url = input.required(); + + private readonly epgProgress = inject(EpgProgressService); + + private readonly freshnessLoaded = signal(false); + private readonly isFresh = signal(false); + + readonly status = computed(() => { + const progress = this.epgProgress + .imports() + .find((item) => item.url === this.url()); + + if (progress) { + if (progress.status === 'loading') return 'loading'; + if (progress.status === 'queued') return 'queued'; + if (progress.status === 'error') return 'error'; + if (progress.status === 'complete') return 'fresh'; + } + + if (!this.freshnessLoaded()) return 'unknown'; + return this.isFresh() ? 'fresh' : 'stale'; + }); + + readonly errorMessage = computed(() => + this.epgProgress + .imports() + .find((item) => item.url === this.url())?.error + ); + + constructor() { + // Optimistically mark as fresh when a live fetch completes for our URL, + // so the badge updates without waiting for another IPC round-trip. + effect(() => { + const progress = this.epgProgress + .imports() + .find((item) => item.url === this.url()); + if (progress?.status === 'complete') { + this.freshnessLoaded.set(true); + this.isFresh.set(true); + } + }); + } + + async ngOnInit(): Promise { + if (!window.electron?.checkEpgFreshness) { + return; + } + const url = this.url(); + if (!url) return; + + const result = await window.electron.checkEpgFreshness([url], 12); + this.isFresh.set(result.freshUrls.includes(url)); + this.freshnessLoaded.set(true); + } +}