mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
1 parent
87c1c6240d
commit
697eab6e73
20 files changed
+767
-285
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
])
|
||||
);
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user