mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat: implement EPG source status component and enhance EPG data management in settings
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
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,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user