feat: implement EPG source status component and enhance EPG data management in settings

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-20 01:09:33 +02:00
1 parent f312100056
commit 7b46509d21
9 files changed
+396 -19

No files matched your search

@@ -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<void> {
// 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<void> {
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<void> {
// 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,
@@ -446,6 +446,11 @@
| translate
}}</mat-error>
</mat-form-field>
@if (epgField.value) {
<app-epg-source-status
[url]="epgField.value"
/>
}
<button
mat-icon-button
color="accent"
@@ -467,7 +472,7 @@
mat-icon-button
color="accent"
[matTooltip]="
'SETTINGS.REFRESH_EPG'
'SETTINGS.REMOVE_EPG_SOURCE'
| translate
"
type="button"
@@ -492,13 +497,35 @@
>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-button
type="button"
[disabled]="
epgUrl.length === 0 ||
isClearingEpgData()
"
(click)="refreshAllEpg()"
>
<mat-icon>refresh</mat-icon>
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
</button>
<button
mat-button
color="warn"
type="button"
[disabled]="isClearingEpgData()"
(click)="clearEpgData()"
>
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
@if (isClearingEpgData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
} @else {
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
}
</button>
</div>
</footer>
@@ -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 {
@@ -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> }) => {
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> }) => {
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', () => {
@@ -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<string | null>(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<void> => {
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);
}
},
});
+1
View File
@@ -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';
@@ -0,0 +1,41 @@
@switch (status()) {
@case ('loading') {
<span class="status status--loading">
<mat-progress-spinner diameter="14" mode="indeterminate" />
<span class="status__label">{{ 'EPG.LOADING' | translate }}</span>
</span>
}
@case ('queued') {
<span class="status status--queued">
<mat-icon>schedule</mat-icon>
<span class="status__label">{{ 'EPG.QUEUED' | translate }}</span>
</span>
}
@case ('fresh') {
<span class="status status--fresh">
<mat-icon>check_circle</mat-icon>
<span class="status__label">{{
'SETTINGS.EPG_STATUS_FRESH' | translate
}}</span>
</span>
}
@case ('stale') {
<span class="status status--stale">
<mat-icon>schedule</mat-icon>
<span class="status__label">{{
'SETTINGS.EPG_STATUS_STALE' | translate
}}</span>
</span>
}
@case ('error') {
<span
class="status status--error"
[matTooltip]="errorMessage() ?? ''"
>
<mat-icon>error</mat-icon>
<span class="status__label">{{
'SETTINGS.EPG_STATUS_ERROR' | translate
}}</span>
</span>
}
}
@@ -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;
}
}
@@ -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<string>();
private readonly epgProgress = inject(EpgProgressService);
private readonly freshnessLoaded = signal(false);
private readonly isFresh = signal(false);
readonly status = computed<BadgeStatus>(() => {
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<void> {
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);
}
}