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');
}
+12 -9
View File
@@ -73,18 +73,21 @@ feature decisions expressed as capabilities such as `supportsEpg`,
from one shared boundary. `supportsSqlite` requires the complete playlist
storage preload API surface used by `PlaylistsService`, `supportsDownloads`
requires the complete downloads preload API surface used by `DownloadsService`,
`supportsEpg` requires the Electron EPG preload methods used by the shared EPG
panels (`fetchEpg`, `getChannelPrograms`, `checkEpgFreshness`,
`forceFetchEpg`, `clearEpgData`, `getEpgChannelsByRange`, and
`searchEpgPrograms`), `supportsPlaylistRefresh` requires the native playlist
and EPG renderer code should use `EpgRuntimeBridgeService` from
`@iptvnator/epg/data-access` instead of calling `window.electron` directly.
`supportsEpg` remains the aggregate full EPG capability, while narrower runtime
capabilities cover individual EPG surfaces: import/progress, current-program
lookup, optional current-program batch reads, optional channel metadata,
freshness checks, clear/force-fetch management, channel browsing, and program
search. `supportsPlaylistRefresh` requires the native playlist
refresh/cancel/progress bridge, `supportsXtreamSectionNavigation` is available
in PWA and in Electron when either the SQLite Xtream data source or the Xtream
API transport is available, `supportsDesktopFileSave` requires both
`saveFileDialog` and `writeFile`, and
`supportsManagedExternalPlayers` requires the MPV and VLC preload launch and
path-setting methods (`openInMpv`, `openInVlc`, `setMpvPlayerPath`, and
`setVlcPlayerPath`); a partial Electron bridge must not expose desktop-only
actions in the PWA/shared UI.
`saveFileDialog` and `writeFile`, `supportsManagedExternalPlayers` requires the
MPV and VLC preload launch methods (`openInMpv` and `openInVlc`), and
`supportsExternalPlayerPathSettings` requires the path-setting methods
(`setMpvPlayerPath` and `setVlcPlayerPath`); a partial Electron bridge must not
expose desktop-only actions in the PWA/shared UI.
## Runtime Limitations
+1
View File
@@ -1,2 +1,3 @@
export * from './lib/epg-runtime-bridge.service';
export * from './lib/epg-progress.service';
export * from './lib/epg.service';
@@ -1,20 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgImportProgress,
EpgProgressService,
} from './epg-progress.service';
EpgRuntimeBridgeService,
} from './epg-runtime-bridge.service';
import { EpgProgressService } from './epg-progress.service';
describe('EpgProgressService', () => {
let runtimeCapabilities: { supportsEpg: boolean };
const originalElectron = window.electron;
let epgBridge: Partial<EpgRuntimeBridgeService>;
beforeEach(() => {
runtimeCapabilities = { supportsEpg: false };
epgBridge = {
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
onProgress: jest.fn(),
supportsDataManagement: false,
supportsProgress: false,
};
});
afterEach(() => {
window.electron = originalElectron;
TestBed.resetTestingModule();
jest.restoreAllMocks();
});
@@ -24,8 +27,8 @@ describe('EpgProgressService', () => {
providers: [
EpgProgressService,
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
],
});
@@ -33,56 +36,37 @@ describe('EpgProgressService', () => {
return TestBed.inject(EpgProgressService);
}
it('does not subscribe to Electron progress events when runtime EPG support is disabled', () => {
const onEpgProgress = jest.fn();
window.electron = {
...window.electron,
onEpgProgress,
} as unknown as typeof window.electron;
it('does not subscribe to progress events when EPG progress support is disabled', () => {
configureService();
expect(onEpgProgress).not.toHaveBeenCalled();
expect(epgBridge.onProgress).not.toHaveBeenCalled();
});
it('does not force retry when runtime EPG support is disabled', () => {
const forceFetchEpg = jest.fn();
window.electron = {
...window.electron,
forceFetchEpg,
} as unknown as typeof window.electron;
it('does not force retry when EPG data management is disabled', () => {
const service = configureService();
service.retry('https://example.com/epg.xml');
expect(forceFetchEpg).not.toHaveBeenCalled();
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
});
it('forces retry through the Electron bridge when runtime EPG support is enabled', () => {
const forceFetchEpg = jest.fn();
window.electron = {
...window.electron,
forceFetchEpg,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
it('forces retry through the EPG runtime bridge when data management is enabled', () => {
epgBridge.supportsDataManagement = true;
const service = configureService();
service.retry('https://example.com/epg.xml');
expect(forceFetchEpg).toHaveBeenCalledWith(
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
'https://example.com/epg.xml'
);
});
it('updates imports from Electron progress events when runtime EPG support is enabled', () => {
it('updates imports from EPG runtime bridge progress events', () => {
let listener: ((progress: EpgImportProgress) => void) | undefined;
window.electron = {
...window.electron,
onEpgProgress: jest.fn((callback) => {
listener = callback;
}),
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
epgBridge.onProgress = jest.fn((callback) => {
listener = callback;
});
epgBridge.supportsProgress = true;
const service = configureService();
listener?.({
@@ -90,7 +74,7 @@ describe('EpgProgressService', () => {
status: 'loading',
});
expect(window.electron?.onEpgProgress).toHaveBeenCalledTimes(1);
expect(epgBridge.onProgress).toHaveBeenCalledTimes(1);
expect(service.imports()).toEqual([
{
url: 'https://example.com/epg.xml',
@@ -1,17 +1,12 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
export interface EpgImportProgress {
url: string;
status: 'queued' | 'loading' | 'complete' | 'error';
stats?: { totalChannels: number; totalPrograms: number };
error?: string;
queuePosition?: number;
}
import {
EpgImportProgress,
EpgRuntimeBridgeService,
} from './epg-runtime-bridge.service';
@Injectable({ providedIn: 'root' })
export class EpgProgressService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
new Map()
);
@@ -50,10 +45,10 @@ export class EpgProgressService {
// Clear the errored row so the backend's subsequent 'queued' event
// reappears cleanly rather than updating an existing error row.
this.removeImport(url);
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsDataManagement) {
return;
}
void window.electron.forceFetchEpg(url);
void this.epgBridge.forceFetchEpg(url);
}
private initializeListener(): void {
@@ -62,8 +57,8 @@ export class EpgProgressService {
}
this.initialized = true;
if (this.runtime.supportsEpg && window.electron?.onEpgProgress) {
window.electron.onEpgProgress((data) => {
if (this.epgBridge.supportsProgress) {
this.epgBridge.onProgress((data) => {
this.updateProgress(data);
});
}
@@ -0,0 +1,160 @@
import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
describe('EpgRuntimeBridgeService', () => {
let service: EpgRuntimeBridgeService;
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
const originalElectron = window.electron;
beforeEach(() => {
runtimeCapabilities = {
supportsEpgImport: false,
supportsEpgProgress: false,
supportsEpgProgramLookup: false,
supportsEpgCurrentProgramBatch: false,
supportsEpgChannelMetadata: false,
supportsEpgSourceFreshness: false,
supportsEpgDataManagement: false,
supportsEpgChannelBrowser: false,
supportsEpgProgramSearch: false,
};
TestBed.configureTestingModule({
providers: [
EpgRuntimeBridgeService,
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
},
],
});
service = TestBed.inject(EpgRuntimeBridgeService);
});
afterEach(() => {
window.electron = originalElectron;
TestBed.resetTestingModule();
jest.restoreAllMocks();
});
it('does not call Electron EPG methods when the matching capability is unavailable', async () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: [],
staleUrls: [],
});
window.electron = {
...window.electron,
fetchEpg,
checkEpgFreshness,
} as unknown as typeof window.electron;
await expect(
service.fetchEpg(['https://example.com/epg.xml'])
).resolves.toBeNull();
await expect(
service.checkFreshness(['https://example.com/epg.xml'], 12)
).resolves.toBeNull();
expect(fetchEpg).not.toHaveBeenCalled();
expect(checkEpgFreshness).not.toHaveBeenCalled();
});
it('delegates import and data-management calls through the typed Electron bridge', async () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
const forceFetchEpg = jest.fn().mockResolvedValue({ success: true });
const clearEpgData = jest.fn().mockResolvedValue({ success: true });
window.electron = {
...window.electron,
fetchEpg,
forceFetchEpg,
clearEpgData,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgImport = true;
runtimeCapabilities.supportsEpgDataManagement = true;
await expect(
service.fetchEpg(['https://example.com/epg.xml'])
).resolves.toEqual({ success: true });
await expect(
service.forceFetchEpg('https://example.com/epg.xml')
).resolves.toEqual({ success: true });
await expect(service.clearEpgData()).resolves.toEqual({
success: true,
});
expect(fetchEpg).toHaveBeenCalledWith(['https://example.com/epg.xml']);
expect(forceFetchEpg).toHaveBeenCalledWith(
'https://example.com/epg.xml'
);
expect(clearEpgData).toHaveBeenCalledTimes(1);
});
it('delegates read-side EPG calls through the typed Electron bridge', async () => {
const getChannelPrograms = jest.fn().mockResolvedValue([]);
const getCurrentProgramsBatch = jest.fn().mockResolvedValue({
'channel-1': null,
});
const getEpgChannelMetadata = jest.fn().mockResolvedValue({
'channel-1': null,
});
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
});
const getEpgChannelsByRange = jest.fn().mockResolvedValue([]);
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
getChannelPrograms,
getCurrentProgramsBatch,
getEpgChannelMetadata,
checkEpgFreshness,
getEpgChannelsByRange,
searchEpgPrograms,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgProgramLookup = true;
runtimeCapabilities.supportsEpgCurrentProgramBatch = true;
runtimeCapabilities.supportsEpgChannelMetadata = true;
runtimeCapabilities.supportsEpgSourceFreshness = true;
runtimeCapabilities.supportsEpgChannelBrowser = true;
runtimeCapabilities.supportsEpgProgramSearch = true;
await service.getChannelPrograms('channel-1');
await service.getCurrentProgramsBatch(['channel-1']);
await service.getChannelMetadata(['channel-1']);
await service.checkFreshness(['https://example.com/epg.xml'], 12);
await service.getChannelsByRange(0, 20);
await service.searchPrograms('news', 20);
expect(getChannelPrograms).toHaveBeenCalledWith('channel-1');
expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1']);
expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1']);
expect(checkEpgFreshness).toHaveBeenCalledWith(
['https://example.com/epg.xml'],
12
);
expect(getEpgChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
});
it('subscribes to EPG progress only when progress events are supported', () => {
const onEpgProgress = jest.fn();
window.electron = {
...window.electron,
onEpgProgress,
} as unknown as typeof window.electron;
service.onProgress(jest.fn());
expect(onEpgProgress).not.toHaveBeenCalled();
runtimeCapabilities.supportsEpgProgress = true;
const callback = jest.fn();
service.onProgress(callback);
expect(onEpgProgress).toHaveBeenCalledWith(callback);
});
});
@@ -0,0 +1,225 @@
import { inject, Injectable } from '@angular/core';
import {
EpgChannel,
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
export const EPG_IMPORT_STATUS = {
Complete: 'complete',
Error: 'error',
Loading: 'loading',
Queued: 'queued',
} as const;
export type EpgImportStatus =
(typeof EPG_IMPORT_STATUS)[keyof typeof EPG_IMPORT_STATUS];
export interface EpgImportStats {
totalChannels: number;
totalPrograms: number;
}
export interface EpgImportProgress {
url: string;
status: EpgImportStatus;
stats?: EpgImportStats;
error?: string;
queuePosition?: number;
}
export interface EpgFetchResult {
success: boolean;
message?: string;
skipped?: string[];
}
export interface EpgFreshnessResult {
staleUrls: string[];
freshUrls: string[];
}
export interface EpgClearResult {
success: boolean;
}
type EpgElectronBridge = Partial<
{
checkEpgFreshness: (
urls: string[],
maxAgeHours?: number
) => Promise<EpgFreshnessResult>;
clearEpgData: () => Promise<EpgClearResult>;
fetchEpg: (urls: string[]) => Promise<EpgFetchResult>;
forceFetchEpg: (url: string) => Promise<EpgFetchResult>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[]
) => Promise<Record<string, EpgChannelMetadata | null>>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<EpgChannel[]>;
onEpgProgress: (callback: (data: EpgImportProgress) => void) => void;
searchEpgPrograms: (
searchTerm: string,
limit?: number
) => Promise<EpgProgram[]>;
}
>;
type EpgRuntimeWindow = Window & {
electron?: EpgElectronBridge;
};
@Injectable({ providedIn: 'root' })
export class EpgRuntimeBridgeService {
private readonly runtime = inject(RuntimeCapabilitiesService);
get supportsImport(): boolean {
return this.runtime.supportsEpgImport;
}
get supportsProgress(): boolean {
return this.runtime.supportsEpgProgress;
}
get supportsProgramLookup(): boolean {
return this.runtime.supportsEpgProgramLookup;
}
get supportsCurrentProgramBatch(): boolean {
return this.runtime.supportsEpgCurrentProgramBatch;
}
get supportsChannelMetadata(): boolean {
return this.runtime.supportsEpgChannelMetadata;
}
get supportsSourceFreshness(): boolean {
return this.runtime.supportsEpgSourceFreshness;
}
get supportsDataManagement(): boolean {
return this.runtime.supportsEpgDataManagement;
}
get supportsChannelBrowser(): boolean {
return this.runtime.supportsEpgChannelBrowser;
}
get supportsProgramSearch(): boolean {
return this.runtime.supportsEpgProgramSearch;
}
fetchEpg(urls: string[]): Promise<EpgFetchResult | null> {
if (!this.supportsImport) {
return Promise.resolve(null);
}
return this.bridge?.fetchEpg?.(urls) ?? Promise.resolve(null);
}
forceFetchEpg(url: string): Promise<EpgFetchResult | null> {
if (!this.supportsDataManagement) {
return Promise.resolve(null);
}
return this.bridge?.forceFetchEpg?.(url) ?? Promise.resolve(null);
}
clearEpgData(): Promise<EpgClearResult | null> {
if (!this.supportsDataManagement) {
return Promise.resolve(null);
}
return this.bridge?.clearEpgData?.() ?? Promise.resolve(null);
}
getChannelPrograms(channelId: string): Promise<EpgProgram[] | null> {
if (!this.supportsProgramLookup) {
return Promise.resolve(null);
}
return this.bridge?.getChannelPrograms?.(channelId) ??
Promise.resolve(null);
}
getCurrentProgramsBatch(
channelIds: string[]
): Promise<Record<string, EpgProgram | null> | null> {
if (!this.supportsCurrentProgramBatch) {
return Promise.resolve(null);
}
return this.bridge?.getCurrentProgramsBatch?.(channelIds) ??
Promise.resolve(null);
}
getChannelMetadata(
channelIds: string[]
): Promise<Record<string, EpgChannelMetadata | null> | null> {
if (!this.supportsChannelMetadata) {
return Promise.resolve(null);
}
return this.bridge?.getEpgChannelMetadata?.(channelIds) ??
Promise.resolve(null);
}
checkFreshness(
urls: string[],
maxAgeHours?: number
): Promise<EpgFreshnessResult | null> {
if (!this.supportsSourceFreshness) {
return Promise.resolve(null);
}
return this.bridge?.checkEpgFreshness?.(urls, maxAgeHours) ??
Promise.resolve(null);
}
getChannelsByRange(
skip: number,
limit: number
): Promise<EpgChannel[] | null> {
if (!this.supportsChannelBrowser) {
return Promise.resolve(null);
}
return this.bridge?.getEpgChannelsByRange?.(skip, limit) ??
Promise.resolve(null);
}
searchPrograms(
searchTerm: string,
limit?: number
): Promise<EpgProgram[] | null> {
if (!this.supportsProgramSearch) {
return Promise.resolve(null);
}
return this.bridge?.searchEpgPrograms?.(searchTerm, limit) ??
Promise.resolve(null);
}
onProgress(callback: (data: EpgImportProgress) => void): void {
if (!this.supportsProgress) {
return;
}
this.bridge?.onEpgProgress?.(callback);
}
private get bridge(): EpgElectronBridge | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return (window as EpgRuntimeWindow).electron;
}
}
@@ -2,29 +2,37 @@ import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
import { EpgService } from './epg.service';
describe('EpgService', () => {
let service: EpgService;
let runtimeCapabilities: { supportsEpg: boolean };
const originalElectron = window.electron;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let snackBar: { open: jest.Mock };
beforeEach(() => {
runtimeCapabilities = { supportsEpg: false };
epgBridge = {
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
getChannelPrograms: jest.fn().mockResolvedValue([]),
getCurrentProgramsBatch: jest.fn().mockResolvedValue({}),
supportsCurrentProgramBatch: false,
supportsImport: false,
supportsProgramLookup: false,
};
snackBar = {
open: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
EpgService,
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
useValue: snackBar,
},
{
provide: TranslateService,
@@ -38,29 +46,14 @@ describe('EpgService', () => {
service = TestBed.inject(EpgService);
});
afterEach(() => {
window.electron = originalElectron;
});
it('does not fetch EPG when runtime EPG support is disabled', () => {
const fetchEpg = jest.fn();
window.electron = {
...window.electron,
fetchEpg,
} as unknown as typeof window.electron;
it('does not fetch EPG when bridge import support is disabled', () => {
service.fetchEpg(['https://example.com/epg.xml']);
expect(fetchEpg).not.toHaveBeenCalled();
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
});
it('fetches EPG through the Electron bridge when runtime EPG support is enabled', () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
window.electron = {
...window.electron,
fetchEpg,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
it('fetches EPG through the EPG runtime bridge when import support is enabled', () => {
epgBridge.supportsImport = true;
service.fetchEpg([
'https://example.com/epg.xml',
@@ -68,23 +61,56 @@ describe('EpgService', () => {
'https://example.com/other.xml',
]);
expect(fetchEpg).toHaveBeenCalledWith([
expect(epgBridge.fetchEpg).toHaveBeenCalledWith([
'https://example.com/epg.xml',
'https://example.com/other.xml',
]);
});
it('does not show a fetch error when the bridge returns no result', async () => {
epgBridge.supportsImport = true;
(epgBridge.fetchEpg as jest.Mock).mockResolvedValue(null);
service.fetchEpg(['https://example.com/epg.xml']);
await Promise.resolve();
expect(snackBar.open).not.toHaveBeenCalled();
});
it('returns an empty batch result when the desktop bridge is unavailable', async () => {
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['channel-1'])
);
expect(result).toEqual(new Map());
expect(epgBridge.getCurrentProgramsBatch).not.toHaveBeenCalled();
});
it('returns null for current program lookup when the desktop bridge is unavailable', async () => {
await expect(
firstValueFrom(service.getCurrentProgramForChannel('channel-1'))
).resolves.toBeNull();
expect(epgBridge.getChannelPrograms).not.toHaveBeenCalled();
});
it('uses the EPG runtime bridge for current program lookup when supported', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([
{
channel: 'channel-1',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Morning News',
},
]);
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
await expect(
firstValueFrom(service.getCurrentProgramForChannel('channel-1'))
).resolves.toMatchObject({ title: 'Morning News' });
expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith('channel-1');
jest.useRealTimers();
});
});
+20 -35
View File
@@ -8,7 +8,7 @@ import {
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
interface CachedProgram {
@@ -16,12 +16,6 @@ interface CachedProgram {
timestamp: number;
}
type EpgChannelMetadataApi = {
getEpgChannelMetadata?: (
channelIds: string[]
) => Promise<Record<string, EpgChannelMetadata | null>>;
};
const debugEpgService = createDevLogger('EpgService');
@Injectable({
@@ -30,7 +24,7 @@ const debugEpgService = createDevLogger('EpgService');
export class EpgService {
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private epgAvailable = new BehaviorSubject<boolean>(false);
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
@@ -46,15 +40,17 @@ export class EpgService {
* Fetches EPG from the given URLs
*/
fetchEpg(urls: string[]): void {
if (!this.runtime.supportsEpg) return;
if (!this.epgBridge.supportsImport) return;
// Filter out empty URLs and send all URLs at once
const validUrls = urls.filter((url) => url?.trim());
if (validUrls.length === 0) return;
from(window.electron.fetchEpg(validUrls))
from(this.epgBridge.fetchEpg(validUrls))
.pipe(
tap((result) => {
if (result === null) return;
if (result.success) {
this.epgAvailable.next(true);
} else {
@@ -76,13 +72,13 @@ export class EpgService {
* Gets EPG programs for a specific channel
*/
getChannelPrograms(channelId: string): void {
if (!this.runtime.supportsEpg) return;
if (!this.epgBridge.supportsProgramLookup) return;
debugEpgService('Fetching EPG for channel ID:', channelId);
from(window.electron.getChannelPrograms(channelId))
from(this.epgBridge.getChannelPrograms(channelId))
.pipe(
timeout(3000),
map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)),
map((programs) => normalizeEpgPrograms(programs ?? [])),
catchError((err) => {
console.error('EPG get programs error:', err);
this.showErrorSnackbar();
@@ -116,7 +112,7 @@ export class EpgService {
getCurrentProgramForChannel(
channelId: string
): Observable<EpgProgram | null> {
if (!this.runtime.supportsEpg || !channelId) {
if (!this.epgBridge.supportsProgramLookup || !channelId) {
return of(null);
}
@@ -129,8 +125,8 @@ export class EpgService {
}
// Fetch from backend
return from(window.electron.getChannelPrograms(channelId)).pipe(
map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)),
return from(this.epgBridge.getChannelPrograms(channelId)).pipe(
map((programs) => normalizeEpgPrograms(programs ?? [])),
map((programs: EpgProgram[]) => {
if (!programs.length) {
this.programCache.set(channelId, {
@@ -184,7 +180,7 @@ export class EpgService {
getCurrentProgramsForChannels(
channelIds: string[]
): Observable<Map<string, EpgProgram | null>> {
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsProgramLookup) {
return of(new Map());
}
@@ -214,13 +210,10 @@ export class EpgService {
// Single batched IPC + SQL query when the backend supports it.
// Replaces the legacy N+1 forkJoin where each channel fired its own
// GET_CHANNEL_PROGRAMS round-trip.
const batchApi = window.electron as Window['electron'] & {
getCurrentProgramsBatch?: (
channelIds: string[]
) => Promise<Record<string, EpgProgram | null>>;
};
if (typeof batchApi?.getCurrentProgramsBatch === 'function') {
return from(batchApi.getCurrentProgramsBatch(channelsToFetch)).pipe(
if (this.epgBridge.supportsCurrentProgramBatch) {
return from(
this.epgBridge.getCurrentProgramsBatch(channelsToFetch)
).pipe(
timeout(5000),
map((batchResult) => {
const cacheTimestamp = Date.now();
@@ -263,7 +256,7 @@ export class EpgService {
getChannelMetadataForChannels(
channelIds: string[]
): Observable<Map<string, EpgChannelMetadata | null>> {
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsChannelMetadata) {
return of(new Map());
}
@@ -279,20 +272,12 @@ export class EpgService {
return of(new Map());
}
const getEpgChannelMetadata = (
window.electron as EpgChannelMetadataApi | undefined
)?.getEpgChannelMetadata;
if (typeof getEpgChannelMetadata !== 'function') {
return of(new Map());
}
return from(getEpgChannelMetadata(normalizedChannelIds)).pipe(
return from(this.epgBridge.getChannelMetadata(normalizedChannelIds)).pipe(
map((metadataByChannelId) => {
return new Map<string, EpgChannelMetadata | null>(
normalizedChannelIds.map((channelId) => [
channelId,
metadataByChannelId[channelId] ?? null,
metadataByChannelId?.[channelId] ?? null,
])
);
}),
@@ -5,10 +5,10 @@ import {
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
@@ -24,6 +24,7 @@ describe('StreamResolverService', () => {
let xtreamUrl: { constructLiveUrl: jest.Mock };
let dataService: { sendIpcEvent: jest.Mock };
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
let epgBridge: Partial<EpgRuntimeBridgeService>;
beforeEach(() => {
playlistsService = {
@@ -41,11 +42,10 @@ describe('StreamResolverService', () => {
stalkerSession = {
makeAuthenticatedRequest: jest.fn(),
};
window.electron = {
...window.electron,
epgBridge = {
getChannelPrograms: jest.fn(),
} as typeof window.electron;
supportsProgramLookup: true,
};
TestBed.configureTestingModule({
providers: [
@@ -55,12 +55,8 @@ describe('StreamResolverService', () => {
{ provide: XtreamUrlService, useValue: xtreamUrl },
{ provide: DataService, useValue: dataService },
{
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
},
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{ provide: StalkerSessionService, useValue: stalkerSession },
],
@@ -99,7 +95,7 @@ describe('StreamResolverService', () => {
},
} satisfies Partial<Playlist>)
);
(window.electron.getChannelPrograms as jest.Mock).mockResolvedValue([
(epgBridge.getChannelPrograms as jest.Mock).mockResolvedValue([
{
start: '2026-03-26T11:00:00.000Z',
stop: '2026-03-26T12:00:00.000Z',
@@ -137,6 +133,7 @@ describe('StreamResolverService', () => {
}),
});
expect(detail.epgPrograms).toHaveLength(1);
expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith('news-id');
});
it('resolves M3U playback detail without blocking on channel-program lookup', async () => {
@@ -185,7 +182,7 @@ describe('StreamResolverService', () => {
expect(detail.playback.streamUrl).toBe('https://example.com/live.m3u8');
expect(detail.epgPrograms).toEqual([]);
expect(window.electron.getChannelPrograms).not.toHaveBeenCalled();
expect(epgBridge.getChannelPrograms).not.toHaveBeenCalled();
});
it('preserves the radio flag when M3U playback falls back to item metadata', async () => {
@@ -279,6 +276,7 @@ describe('StreamResolverService', () => {
it('skips portal EPG lookups in browser/PWA mode', async () => {
window.electron = undefined as unknown as typeof window.electron;
epgBridge.supportsProgramLookup = false;
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
@@ -1,10 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
Channel,
EpgItem,
@@ -63,7 +60,7 @@ export class StreamResolverService {
private readonly xtreamApi = inject(XtreamApiService);
private readonly xtreamUrl = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly m3uEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 3000;
@@ -72,8 +69,8 @@ export class StreamResolverService {
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
private get supportsEpg(): boolean {
return this.runtime.supportsEpg;
private get supportsProgramLookup(): boolean {
return this.epgBridge.supportsProgramLookup;
}
private async getElectronPlaylist(
@@ -146,7 +143,7 @@ export class StreamResolverService {
items: UnifiedCollectionItem[]
): Promise<Map<string, EpgProgram | null>> {
const epgMap = new Map<string, EpgProgram | null>();
if (!this.supportsEpg) {
if (!this.supportsProgramLookup) {
return epgMap;
}
@@ -212,7 +209,7 @@ export class StreamResolverService {
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
const playback = await this.resolveXtream(item);
if (!this.supportsEpg) {
if (!this.supportsProgramLookup) {
return {
playback,
epgMode: 'portal',
@@ -246,7 +243,7 @@ export class StreamResolverService {
};
}
if (!this.supportsEpg) {
if (!this.supportsProgramLookup) {
return {
playback,
epgMode: 'portal',
@@ -489,7 +486,7 @@ export class StreamResolverService {
this.findM3uChannel(playlist?.playlist?.items ?? [], item) ??
this.buildFallbackM3uChannel(item);
const epgPrograms =
includePrograms && this.supportsEpg
includePrograms && this.supportsProgramLookup
? await this.fetchM3uPrograms(
this.getM3uEpgLookupKey(channel, item)
)
@@ -519,12 +516,14 @@ export class StreamResolverService {
private async fetchM3uPrograms(
epgLookupKey?: string | null
): Promise<EpgProgram[]> {
if (!window.electron?.getChannelPrograms || !epgLookupKey) {
if (!this.epgBridge.supportsProgramLookup || !epgLookupKey) {
return [];
}
return this.withFallbackTimeout(
window.electron.getChannelPrograms(epgLookupKey),
this.epgBridge
.getChannelPrograms(epgLookupKey)
.then((programs) => programs ?? []),
this.m3uEpgTimeoutMs,
[]
);
@@ -662,7 +661,7 @@ export class StreamResolverService {
streamId: number,
limit: number
): Promise<EpgItem[]> {
if (!this.supportsEpg) {
if (!this.supportsProgramLookup) {
return [];
}
@@ -746,7 +745,7 @@ export class StreamResolverService {
channelId: string,
size: number
): Promise<EpgItem[]> {
if (!this.supportsEpg) {
if (!this.supportsProgramLookup) {
return [];
}
@@ -34,6 +34,15 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgProgress).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgCurrentProgramBatch).toBe(false);
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
it('reports Electron capabilities from the available preload bridge methods', () => {
@@ -111,7 +120,10 @@ describe('RuntimeCapabilitiesService', () => {
xtreamRequest: jest.fn(),
fetchEpg: jest.fn(),
getChannelPrograms: jest.fn(),
getCurrentProgramsBatch: jest.fn(),
getEpgChannelMetadata: jest.fn(),
checkEpgFreshness: jest.fn(),
onEpgProgress: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
getEpgChannelsByRange: jest.fn(),
@@ -139,6 +151,15 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsDesktopFileSave).toBe(true);
expect(service.supportsRemoteControl).toBe(true);
expect(service.supportsXtreamSectionNavigation).toBe(true);
expect(service.supportsEpgImport).toBe(true);
expect(service.supportsEpgProgress).toBe(true);
expect(service.supportsEpgProgramLookup).toBe(true);
expect(service.supportsEpgCurrentProgramBatch).toBe(true);
expect(service.supportsEpgChannelMetadata).toBe(true);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgChannelBrowser).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
});
it('keeps feature-specific capabilities false when an Electron bridge is partial', () => {
@@ -165,6 +186,15 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(false);
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgProgress).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgCurrentProgramBatch).toBe(false);
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
it('reads the bridge dynamically so tests and late preload setup stay accurate', () => {
@@ -275,6 +305,44 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpg).toBe(true);
});
it('exposes EPG capabilities by the specific preload surface they need', () => {
testWindow.electron = {
fetchEpg: jest.fn(),
onEpgProgress: jest.fn(),
getChannelPrograms: jest.fn(),
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.supportsEpg).toBe(true);
expect(service.supportsEpgImport).toBe(true);
expect(service.supportsEpgProgress).toBe(true);
expect(service.supportsEpgProgramLookup).toBe(true);
expect(service.supportsEpgCurrentProgramBatch).toBe(false);
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgChannelBrowser).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
testWindow.electron = {
checkEpgFreshness: jest.fn(),
};
expect(service.supportsEpg).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
it('requires the complete downloads preload surface', () => {
testWindow.electron = {
downloadsGetList: jest.fn(),
@@ -32,15 +32,53 @@ export class RuntimeCapabilitiesService {
}
get supportsEpg(): boolean {
return [
'fetchEpg',
'getChannelPrograms',
'checkEpgFreshness',
'forceFetchEpg',
'clearEpgData',
'getEpgChannelsByRange',
'searchEpgPrograms',
].every((methodName) => this.hasElectronMethod(methodName));
return (
this.supportsEpgImport &&
this.supportsEpgProgramLookup &&
this.supportsEpgSourceFreshness &&
this.supportsEpgDataManagement &&
this.supportsEpgChannelBrowser &&
this.supportsEpgProgramSearch
);
}
get supportsEpgImport(): boolean {
return this.hasElectronMethod('fetchEpg');
}
get supportsEpgProgress(): boolean {
return this.hasElectronMethod('onEpgProgress');
}
get supportsEpgProgramLookup(): boolean {
return this.hasElectronMethod('getChannelPrograms');
}
get supportsEpgCurrentProgramBatch(): boolean {
return this.hasElectronMethod('getCurrentProgramsBatch');
}
get supportsEpgChannelMetadata(): boolean {
return this.hasElectronMethod('getEpgChannelMetadata');
}
get supportsEpgSourceFreshness(): boolean {
return this.hasElectronMethod('checkEpgFreshness');
}
get supportsEpgDataManagement(): boolean {
return (
this.hasElectronMethod('forceFetchEpg') &&
this.hasElectronMethod('clearEpgData')
);
}
get supportsEpgChannelBrowser(): boolean {
return this.hasElectronMethod('getEpgChannelsByRange');
}
get supportsEpgProgramSearch(): boolean {
return this.hasElectronMethod('searchEpgPrograms');
}
get supportsSqlite(): boolean {
@@ -1,19 +1,26 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { EpgProgressService } from '@iptvnator/epg/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgProgressService,
EpgRuntimeBridgeService,
} from '@iptvnator/epg/data-access';
import { EpgSourceStatusComponent } from './epg-source-status.component';
describe('EpgSourceStatusComponent', () => {
let fixture: ComponentFixture<EpgSourceStatusComponent>;
let component: EpgSourceStatusComponent;
let runtimeCapabilities: { supportsEpg: boolean };
let epgBridge: Partial<EpgRuntimeBridgeService>;
const imports = signal([]);
const originalElectron = window.electron;
beforeEach(async () => {
runtimeCapabilities = { supportsEpg: false };
epgBridge = {
checkFreshness: jest.fn().mockResolvedValue({
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
}),
supportsSourceFreshness: false,
};
imports.set([]);
await TestBed.configureTestingModule({
@@ -24,15 +31,14 @@ describe('EpgSourceStatusComponent', () => {
useValue: { imports },
},
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
],
}).compileComponents();
});
afterEach(() => {
window.electron = originalElectron;
fixture?.destroy();
});
@@ -42,41 +48,25 @@ describe('EpgSourceStatusComponent', () => {
fixture.componentRef.setInput('url', url);
}
it('does not check source freshness when runtime EPG support is disabled', async () => {
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
});
window.electron = {
...window.electron,
checkEpgFreshness,
} as unknown as typeof window.electron;
it('does not check source freshness when the EPG bridge cannot check freshness', async () => {
createComponent();
fixture.detectChanges();
await fixture.whenStable();
expect(checkEpgFreshness).not.toHaveBeenCalled();
expect(epgBridge.checkFreshness).not.toHaveBeenCalled();
expect(component.status()).toBe('unknown');
});
it('loads source freshness when runtime EPG support is enabled', async () => {
it('loads source freshness through the EPG runtime bridge', async () => {
const url = 'https://example.com/epg.xml';
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: [url],
staleUrls: [],
});
window.electron = {
...window.electron,
checkEpgFreshness,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
epgBridge.supportsSourceFreshness = true;
createComponent(url);
fixture.detectChanges();
await fixture.whenStable();
expect(checkEpgFreshness).toHaveBeenCalledWith([url], 12);
expect(epgBridge.checkFreshness).toHaveBeenCalledWith([url], 12);
expect(component.status()).toBe('fresh');
});
});
@@ -11,8 +11,10 @@ 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';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgProgressService,
EpgRuntimeBridgeService,
} from '@iptvnator/epg/data-access';
type BadgeStatus =
| 'loading'
@@ -38,7 +40,7 @@ export class EpgSourceStatusComponent implements OnInit {
readonly url = input.required<string>();
private readonly epgProgress = inject(EpgProgressService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly freshnessLoaded = signal(false);
private readonly isFresh = signal(false);
@@ -80,14 +82,14 @@ export class EpgSourceStatusComponent implements OnInit {
}
async ngOnInit(): Promise<void> {
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsSourceFreshness) {
return;
}
const url = this.url();
if (!url) return;
const result = await window.electron.checkEpgFreshness([url], 12);
this.isFresh.set(result.freshUrls.includes(url));
const result = await this.epgBridge.checkFreshness([url], 12);
this.isFresh.set(result?.freshUrls.includes(url) ?? false);
this.freshnessLoaded.set(true);
}
}
@@ -3,7 +3,7 @@ import { OverlayRef } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
MultiEpgContainerComponent,
isSelectedEpgDayToday,
@@ -23,11 +23,15 @@ describe('isSelectedEpgDayToday', () => {
describe('MultiEpgContainerComponent runtime gates', () => {
let fixture: ComponentFixture<MultiEpgContainerComponent>;
let component: MultiEpgContainerComponent;
let runtimeCapabilities: { supportsEpg: boolean };
const originalElectron = window.electron;
let epgBridge: Partial<EpgRuntimeBridgeService>;
beforeEach(async () => {
runtimeCapabilities = { supportsEpg: false };
epgBridge = {
getChannelsByRange: jest.fn().mockResolvedValue([]),
searchPrograms: jest.fn().mockResolvedValue([]),
supportsChannelBrowser: false,
supportsProgramSearch: false,
};
await TestBed.configureTestingModule({
imports: [MultiEpgContainerComponent],
@@ -42,8 +46,8 @@ describe('MultiEpgContainerComponent runtime gates', () => {
useValue: { detach: jest.fn() },
},
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{
provide: TranslateService,
@@ -65,65 +69,50 @@ describe('MultiEpgContainerComponent runtime gates', () => {
});
afterEach(() => {
window.electron = originalElectron;
fixture.destroy();
jest.restoreAllMocks();
jest.useRealTimers();
});
it('does not request EPG channel ranges when runtime EPG support is disabled', async () => {
const getEpgChannelsByRange = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
getEpgChannelsByRange,
} as unknown as typeof window.electron;
it('does not request EPG channel ranges when the EPG bridge cannot browse channels', async () => {
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await component.requestPrograms();
expect(getEpgChannelsByRange).not.toHaveBeenCalled();
expect(epgBridge.getChannelsByRange).not.toHaveBeenCalled();
expect(component.isLoading()).toBe(false);
});
it('requests EPG channel ranges when runtime EPG support is enabled', async () => {
const getEpgChannelsByRange = jest.fn().mockResolvedValue([
it('requests EPG channel ranges through the EPG runtime bridge', async () => {
epgBridge.getChannelsByRange = jest.fn().mockResolvedValue([
{
channel_id: 'channel-1',
display_name: 'Channel One',
programs: [],
},
]);
window.electron = {
...window.electron,
getEpgChannelsByRange,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
epgBridge.supportsChannelBrowser = true;
await component.requestPrograms();
expect(getEpgChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(epgBridge.getChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(component.isLoading()).toBe(false);
});
it('does not search EPG programs when runtime EPG support is disabled', () => {
it('does not search EPG programs when the EPG bridge cannot search programs', () => {
jest.useFakeTimers();
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
searchEpgPrograms,
} as unknown as typeof window.electron;
component.onProgramSearchInput({
target: { value: 'news' },
} as unknown as Event);
jest.advanceTimersByTime(600);
expect(searchEpgPrograms).not.toHaveBeenCalled();
expect(epgBridge.searchPrograms).not.toHaveBeenCalled();
expect(component.isSearchingPrograms()).toBe(false);
expect(component.programSearchResults()).toEqual([]);
});
it('searches EPG programs when runtime EPG support is enabled', async () => {
it('searches EPG programs through the EPG runtime bridge', async () => {
jest.useFakeTimers();
const results = [
{
@@ -133,12 +122,8 @@ describe('MultiEpgContainerComponent runtime gates', () => {
title: 'News',
},
];
const searchEpgPrograms = jest.fn().mockResolvedValue(results);
window.electron = {
...window.electron,
searchEpgPrograms,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
epgBridge.searchPrograms = jest.fn().mockResolvedValue(results);
epgBridge.supportsProgramSearch = true;
component.onProgramSearchInput({
target: { value: 'news' },
@@ -146,7 +131,7 @@ describe('MultiEpgContainerComponent runtime gates', () => {
jest.advanceTimersByTime(500);
await Promise.resolve();
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
expect(epgBridge.searchPrograms).toHaveBeenCalledWith('news', 20);
expect(component.programSearchResults()).toEqual(results);
expect(component.isSearchingPrograms()).toBe(false);
});
@@ -29,7 +29,7 @@ import {
EpgChannelWithPrograms,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
@@ -182,7 +182,7 @@ export class MultiEpgContainerComponent
private readonly dialog = inject(MatDialog);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
ngOnInit() {
// Update current time line every minute
@@ -269,7 +269,7 @@ export class MultiEpgContainerComponent
}
async requestPrograms(): Promise<void> {
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsChannelBrowser) {
console.warn('Multi-EPG not available in this runtime');
return;
}
@@ -281,7 +281,7 @@ export class MultiEpgContainerComponent
this.isLoading.set(true);
try {
const response = await window.electron.getEpgChannelsByRange(
const response = await this.epgBridge.getChannelsByRange(
this.channelsLowerRange,
this.visibleChannels
);
@@ -462,7 +462,7 @@ export class MultiEpgContainerComponent
return;
}
if (!this.runtime.supportsEpg) {
if (!this.epgBridge.supportsProgramSearch) {
this.programSearchResults.set([]);
this.isSearchingPrograms.set(false);
return;
@@ -472,7 +472,7 @@ export class MultiEpgContainerComponent
this.searchDebounceTimer = setTimeout(async () => {
this.isSearchingPrograms.set(true);
try {
const results = await window.electron.searchEpgPrograms(
const results = await this.epgBridge.searchPrograms(
query,
20
);