refactor(epg): route renderer calls through runtime bridge

Add typed EPG runtime bridge, split EPG runtime capabilities, migrate renderer EPG callers away from direct window.electron access, and address Greptile review feedback.
This commit is contained in:
4gray authored and GitHub committed 2026-05-24 13:45:51 +03:00
1 parent 87c1c6240d
commit 697eab6e73
20 files changed
+767 -285

No files matched your search

+22 -24
View File
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
import { Actions } from '@ngrx/effects';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import { MockProvider } from 'ng-mocks';
import { EMPTY, of } from 'rxjs';
@@ -65,13 +65,20 @@ describe('AppComponent', () => {
let store: MockStore;
let translateService: TranslateService;
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
const originalElectron = window.electron;
let epgBridge: Partial<EpgRuntimeBridgeService>;
beforeEach(waitForAsync(() => {
runtimeCapabilities = {
isElectron: true,
isMacOS: false,
supportsEpg: true,
};
epgBridge = {
checkFreshness: jest.fn().mockResolvedValue({
freshUrls: [],
staleUrls: [],
}),
supportsImport: true,
supportsSourceFreshness: true,
};
TestBed.configureTestingModule({
@@ -97,6 +104,10 @@ describe('AppComponent', () => {
MockProvider(EpgService, {
fetchEpg: jest.fn(),
}),
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
MockProvider(Router, {
navigateByUrl: jest.fn(),
}),
@@ -128,13 +139,6 @@ describe('AppComponent', () => {
}));
beforeEach(() => {
window.electron = {
checkEpgFreshness: jest.fn().mockResolvedValue({
freshUrls: [],
staleUrls: [],
}),
} as unknown as typeof window.electron;
fixture = TestBed.createComponent(AppComponent);
epgService = TestBed.inject(EpgService);
router = TestBed.inject(Router);
@@ -147,10 +151,6 @@ describe('AppComponent', () => {
component = fixture.componentInstance;
});
afterEach(() => {
window.electron = originalElectron;
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
@@ -199,15 +199,10 @@ describe('AppComponent', () => {
language: Language.SPANISH,
theme: Theme.DarkTheme,
};
const checkEpgFreshness = jest.fn().mockResolvedValue({
epgBridge.checkFreshness = jest.fn().mockResolvedValue({
freshUrls: [],
staleUrls: settings.epgUrl,
});
window.electron = {
...window.electron,
checkEpgFreshness,
} as unknown as typeof window.electron;
settingsService.getValueFromLocalStorage.mockReturnValue(of(settings));
jest.spyOn(settingsService, 'changeTheme');
jest.spyOn(translateService, 'use');
@@ -219,23 +214,26 @@ describe('AppComponent', () => {
expect(settingsService.changeTheme).toHaveBeenCalledWith(
Theme.DarkTheme
);
expect(checkEpgFreshness).toHaveBeenCalledWith(settings.epgUrl, 12);
expect(epgBridge.checkFreshness).toHaveBeenCalledWith(
settings.epgUrl,
12
);
expect(epgService.fetchEpg).toHaveBeenCalledWith(settings.epgUrl);
expect(snackBar.open).not.toHaveBeenCalled();
});
it('does not fetch EPG settings when runtime does not support EPG', async () => {
it('does not fetch EPG settings when the EPG bridge cannot import EPG', async () => {
const settings: Settings = {
...DEFAULT_SETTINGS,
epgUrl: ['https://example.com/epg.xml'],
};
Object.assign(runtimeCapabilities, { supportsEpg: false });
epgBridge.supportsImport = false;
settingsService.getValueFromLocalStorage.mockReturnValue(of(settings));
component.initSettings();
await fixture.whenStable();
expect(window.electron?.checkEpgFreshness).not.toHaveBeenCalled();
expect(epgBridge.checkFreshness).not.toHaveBeenCalled();
expect(epgService.fetchEpg).not.toHaveBeenCalled();
});
});
+14 -3
View File
@@ -4,7 +4,7 @@ import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
@@ -38,6 +38,7 @@ export class AppComponent implements OnInit {
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
private epgBridge = inject(EpgRuntimeBridgeService);
private epgService = inject(EpgService);
private snackBar = inject(MatSnackBar);
private router = inject(Router);
@@ -131,7 +132,7 @@ export class AppComponent implements OnInit {
// Fetch EPG if URLs are configured (only fetch stale data)
if (
this.runtime.supportsEpg &&
this.epgBridge.supportsImport &&
settings.epgUrl?.length > 0 &&
settings.epgUrl?.some((u) => u !== '')
) {
@@ -169,8 +170,18 @@ export class AppComponent implements OnInit {
* Data is considered fresh if updated within the last 12 hours.
*/
private async fetchStaleEpgData(urls: string[]): Promise<void> {
if (!this.epgBridge.supportsSourceFreshness) {
this.epgService.fetchEpg(urls);
return;
}
try {
const result = await window.electron.checkEpgFreshness(urls, 12);
const result = await this.epgBridge.checkFreshness(urls, 12);
if (!result) {
this.epgService.fetchEpg(urls);
return;
}
if (result.freshUrls.length > 0) {
debugAppComponent(
@@ -18,7 +18,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { EpgService } from '@iptvnator/epg/data-access';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { Store } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@@ -142,6 +142,7 @@ describe('SettingsComponent', () => {
let mockStore: MockStore;
let databaseService: DatabaseService;
let snackBar: MatSnackBarStub;
let epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron;
const importDate = '2026-04-21T00:00:00.000Z';
@@ -162,6 +163,13 @@ describe('SettingsComponent', () => {
}) as unknown as ReturnType<MatDialog['open']>;
beforeEach(waitForAsync(() => {
epgBridge = {
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
supportsDataManagement: true,
supportsImport: true,
};
TestBed.configureTestingModule({
providers: [
UntypedFormBuilder,
@@ -169,6 +177,10 @@ describe('SettingsComponent', () => {
MockProvider(EpgService, {
fetchEpg: jest.fn(),
}),
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
MockProvider(DialogService, {
openConfirmDialog: jest.fn(),
}),
@@ -480,6 +492,7 @@ describe('SettingsComponent', () => {
it('hides external player path settings when the Electron bridge is incomplete', async () => {
fixture.destroy();
epgBridge.supportsImport = false;
window.electron = {
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
platform: 'linux',
@@ -797,6 +810,7 @@ describe('SettingsComponent', () => {
it('falls back to PlaylistsService.removeAll outside Electron', async () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
epgBridge.supportsImport = false;
const browserFixture = TestBed.createComponent(SettingsComponent);
const browserComponent = browserFixture.componentInstance;
@@ -863,7 +877,7 @@ describe('SettingsComponent', () => {
it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => {
const url = 'http://epg-url-here/data.xml';
component.refreshEpg(url);
expect(window.electron.forceFetchEpg).toHaveBeenCalledWith(url);
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url);
});
it('clears EPG data with a busy state and refreshes all sources on success', async () => {
@@ -871,9 +885,7 @@ describe('SettingsComponent', () => {
const clearPromise = new Promise<{ success: boolean }>((resolve) => {
resolveClear = () => resolve({ success: true });
});
(window.electron.clearEpgData as jest.Mock).mockReturnValue(
clearPromise
);
(epgBridge.clearEpgData as jest.Mock).mockReturnValue(clearPromise);
(dialogService.openConfirmDialog as jest.Mock).mockImplementation(
({ onConfirm }: { onConfirm: () => Promise<void> }) => {
void onConfirm();
@@ -1014,7 +1026,7 @@ describe('SettingsComponent', () => {
});
it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => {
(window.electron.clearEpgData as jest.Mock).mockRejectedValueOnce(
(epgBridge.clearEpgData as jest.Mock).mockRejectedValueOnce(
new Error('boom')
);
(dialogService.openConfirmDialog as jest.Mock).mockImplementation(
@@ -25,7 +25,7 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@@ -123,6 +123,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private playlistBackupService = inject(PlaylistBackupService);
private readonly databaseService = inject(DatabaseService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly dialogData = inject<{ isDialog: boolean } | null>(
MAT_DIALOG_DATA,
{ optional: true }
@@ -138,7 +139,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Flag that indicates whether the app runs in electron environment */
readonly isDesktop = this.runtime.isElectron;
readonly supportsDesktopFileSave = this.runtime.supportsDesktopFileSave;
readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsEpg =
this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement;
readonly supportsManagedExternalPlayers =
this.runtime.supportsManagedExternalPlayers;
readonly supportsExternalPlayerPathSettings =
@@ -611,10 +613,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
* intends when clicking "Refresh".
*/
refreshEpg(url: string): void {
if (!this.supportsEpg || !url) {
if (!this.epgBridge.supportsDataManagement || !url) {
return;
}
void window.electron.forceFetchEpg(url);
void this.epgBridge.forceFetchEpg(url);
}
/**
@@ -623,11 +625,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
* gets visible per-URL feedback.
*/
refreshAllEpg(): void {
if (!this.supportsEpg) return;
if (!this.epgBridge.supportsDataManagement) return;
const urls = (this.epgUrl.value as string[])
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
urls.forEach((url) => void window.electron.forceFetchEpg(url));
urls.forEach((url) => void this.epgBridge.forceFetchEpg(url));
}
/**
@@ -660,7 +662,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
),
onConfirm: async (): Promise<void> => {
if (
!this.supportsEpg ||
!this.epgBridge.supportsDataManagement ||
this.isClearingEpgData()
) {
return;
@@ -668,7 +670,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.isClearingEpgData.set(true);
try {
const result = await window.electron.clearEpgData();
const result = await this.epgBridge.clearEpgData();
if (result && result.success === false) {
throw new Error('Clear EPG returned success=false');
}