From 697eab6e73446cdededdad748847e9f3d5f63f04 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 24 May 2026 13:45:51 +0300 Subject: [PATCH] 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. --- apps/web/src/app/app.component.spec.ts | 46 ++-- apps/web/src/app/app.component.ts | 17 +- .../app/settings/settings.component.spec.ts | 24 +- .../src/app/settings/settings.component.ts | 18 +- docs/architecture/pwa-self-hosted.md | 21 +- libs/epg/data-access/src/index.ts | 1 + .../src/lib/epg-progress.service.spec.ts | 66 ++--- .../src/lib/epg-progress.service.ts | 23 +- .../lib/epg-runtime-bridge.service.spec.ts | 160 +++++++++++++ .../src/lib/epg-runtime-bridge.service.ts | 225 ++++++++++++++++++ .../data-access/src/lib/epg.service.spec.ts | 84 ++++--- libs/epg/data-access/src/lib/epg.service.ts | 55 ++--- .../stream-resolver.service.spec.ts | 24 +- .../lib/collection/stream-resolver.service.ts | 31 ++- .../lib/runtime-capabilities.service.spec.ts | 68 ++++++ .../src/lib/runtime-capabilities.service.ts | 56 ++++- .../epg-source-status.component.spec.ts | 48 ++-- .../epg-source-status.component.ts | 14 +- .../multi-epg-container.component.spec.ts | 59 ++--- .../multi-epg-container.component.ts | 12 +- 20 files changed, 767 insertions(+), 285 deletions(-) create mode 100644 libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts create mode 100644 libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 9df495935..d8c6dd97f 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -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; - const originalElectron = window.electron; + let epgBridge: Partial; 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(); }); }); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 6c0bc7410..a26f63e50 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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 { + 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( diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index ef993ca6d..9e5419d51 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -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; const originalElectron = window.electron; const importDate = '2026-04-21T00:00:00.000Z'; @@ -162,6 +163,13 @@ describe('SettingsComponent', () => { }) as unknown as ReturnType; 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 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( diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 8f763d291..3b8fef08a 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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 => { 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'); } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index a1268e694..fccdc48e4 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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 diff --git a/libs/epg/data-access/src/index.ts b/libs/epg/data-access/src/index.ts index 9100678a5..488c35f52 100644 --- a/libs/epg/data-access/src/index.ts +++ b/libs/epg/data-access/src/index.ts @@ -1,2 +1,3 @@ +export * from './lib/epg-runtime-bridge.service'; export * from './lib/epg-progress.service'; export * from './lib/epg.service'; diff --git a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts index 746d0a4f4..19c2b0f5a 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts @@ -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; 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', diff --git a/libs/epg/data-access/src/lib/epg-progress.service.ts b/libs/epg/data-access/src/lib/epg-progress.service.ts index 6c19f88e2..173344415 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.ts @@ -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>( 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); }); } diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts new file mode 100644 index 000000000..f73c40f68 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts @@ -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; + 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); + }); +}); diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts new file mode 100644 index 000000000..3390c448d --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -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; + clearEpgData: () => Promise; + fetchEpg: (urls: string[]) => Promise; + forceFetchEpg: (url: string) => Promise; + getChannelPrograms: (channelId: string) => Promise; + getCurrentProgramsBatch: ( + channelIds: string[] + ) => Promise>; + getEpgChannelMetadata: ( + channelIds: string[] + ) => Promise>; + getEpgChannelsByRange: ( + skip: number, + limit: number + ) => Promise; + onEpgProgress: (callback: (data: EpgImportProgress) => void) => void; + searchEpgPrograms: ( + searchTerm: string, + limit?: number + ) => Promise; + } +>; + +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 { + if (!this.supportsImport) { + return Promise.resolve(null); + } + + return this.bridge?.fetchEpg?.(urls) ?? Promise.resolve(null); + } + + forceFetchEpg(url: string): Promise { + if (!this.supportsDataManagement) { + return Promise.resolve(null); + } + + return this.bridge?.forceFetchEpg?.(url) ?? Promise.resolve(null); + } + + clearEpgData(): Promise { + if (!this.supportsDataManagement) { + return Promise.resolve(null); + } + + return this.bridge?.clearEpgData?.() ?? Promise.resolve(null); + } + + getChannelPrograms(channelId: string): Promise { + if (!this.supportsProgramLookup) { + return Promise.resolve(null); + } + + return this.bridge?.getChannelPrograms?.(channelId) ?? + Promise.resolve(null); + } + + getCurrentProgramsBatch( + channelIds: string[] + ): Promise | null> { + if (!this.supportsCurrentProgramBatch) { + return Promise.resolve(null); + } + + return this.bridge?.getCurrentProgramsBatch?.(channelIds) ?? + Promise.resolve(null); + } + + getChannelMetadata( + channelIds: string[] + ): Promise | null> { + if (!this.supportsChannelMetadata) { + return Promise.resolve(null); + } + + return this.bridge?.getEpgChannelMetadata?.(channelIds) ?? + Promise.resolve(null); + } + + checkFreshness( + urls: string[], + maxAgeHours?: number + ): Promise { + if (!this.supportsSourceFreshness) { + return Promise.resolve(null); + } + + return this.bridge?.checkEpgFreshness?.(urls, maxAgeHours) ?? + Promise.resolve(null); + } + + getChannelsByRange( + skip: number, + limit: number + ): Promise { + if (!this.supportsChannelBrowser) { + return Promise.resolve(null); + } + + return this.bridge?.getEpgChannelsByRange?.(skip, limit) ?? + Promise.resolve(null); + } + + searchPrograms( + searchTerm: string, + limit?: number + ): Promise { + 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; + } +} diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 661c0df3c..44331afd0 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -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; + 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(); }); }); diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index c3a857fde..4a07c337c 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -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>; -}; - 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(false); private currentEpgPrograms = new BehaviorSubject([]); @@ -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 { - 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> { - 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>; - }; - 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> { - 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( normalizedChannelIds.map((channelId) => [ channelId, - metadataByChannelId[channelId] ?? null, + metadataByChannelId?.[channelId] ?? null, ]) ); }), diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index abab84386..9578dc38b 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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; 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) ); - (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', diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 53a9c5f8a..9c1de9540 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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> { const epgMap = new Map(); - if (!this.supportsEpg) { + if (!this.supportsProgramLookup) { return epgMap; } @@ -212,7 +209,7 @@ export class StreamResolverService { item: UnifiedCollectionItem ): Promise { 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 { - 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 { - if (!this.supportsEpg) { + if (!this.supportsProgramLookup) { return []; } @@ -746,7 +745,7 @@ export class StreamResolverService { channelId: string, size: number ): Promise { - if (!this.supportsEpg) { + if (!this.supportsProgramLookup) { return []; } diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 3215e7c93..160815da6 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -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(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 9832ff6d9..4f683059a 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -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 { diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts index 552917aed..924e1898a 100644 --- a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts @@ -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; let component: EpgSourceStatusComponent; - let runtimeCapabilities: { supportsEpg: boolean }; + let epgBridge: Partial; 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'); }); }); diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts index 910fff2d4..6c36d4137 100644 --- a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts @@ -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(); 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 { - 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); } } diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts index 7e57fab02..20af0ab29 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts @@ -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; let component: MultiEpgContainerComponent; - let runtimeCapabilities: { supportsEpg: boolean }; - const originalElectron = window.electron; + let epgBridge: Partial; 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); }); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 1f0e2f3eb..ba7c3498b 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -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(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 { - 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 );