mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(epg): remove cached XMLTV data after source deletion (#1548)
* fix(epg): remove cached XMLTV data after source deletion * fix(epg): close source reconciliation review races * fix(epg): serialize cleanup with replacement imports * fix(epg): report retired worker exits as cancellations * fix(epg): preserve source metadata through cache cleanup * refactor(epg): separate worker runtime and import lifecycle * fix(epg): skip cleanup for unchanged source settings * fix(epg): cancel retired error rows and pending retries * fix(epg): redact diagnostics and mirror committed settings after cleanup errors * fix(epg): preserve metadata writer order independently of timestamps
This commit is contained in:
1 parent
baba0529ef
commit
9de480826c
55 files changed
+3538
-615
No files matched your search
@@ -3,11 +3,13 @@ import {
|
||||
EpgImportProgress,
|
||||
EpgRuntimeBridgeService,
|
||||
} from './epg-runtime-bridge.service';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
|
||||
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
|
||||
import { EpgProgressService } from './epg-progress.service';
|
||||
|
||||
describe('EpgProgressService', () => {
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let sources: { waitForReconciliation: jest.Mock };
|
||||
let settingsStore: {
|
||||
getSettings: jest.Mock;
|
||||
getTrustOptions: jest.Mock;
|
||||
@@ -15,11 +17,14 @@ describe('EpgProgressService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sources = {
|
||||
waitForReconciliation: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
epgBridge = {
|
||||
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
||||
onProgress: jest.fn(),
|
||||
supportsDataManagement: false,
|
||||
supportsProgress: false,
|
||||
supportsProgress: true,
|
||||
};
|
||||
settingsStore = {
|
||||
getSettings: jest.fn(() => ({
|
||||
@@ -35,6 +40,7 @@ describe('EpgProgressService', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
TestBed.resetTestingModule();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -43,6 +49,7 @@ describe('EpgProgressService', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EpgProgressService,
|
||||
{ provide: EpgSourceSettingsService, useValue: sources },
|
||||
{
|
||||
provide: EpgRuntimeBridgeService,
|
||||
useValue: epgBridge,
|
||||
@@ -57,25 +64,132 @@ describe('EpgProgressService', () => {
|
||||
return TestBed.inject(EpgProgressService);
|
||||
}
|
||||
|
||||
const url = 'https://example.com/epg.xml';
|
||||
const emit = (progress: EpgImportProgress) =>
|
||||
(epgBridge.onProgress as jest.Mock).mock.calls[0][0](progress);
|
||||
const errorRow = (source = url) =>
|
||||
emit({
|
||||
url: source,
|
||||
status: 'error',
|
||||
generation: 0,
|
||||
errorCode:
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate,
|
||||
});
|
||||
|
||||
it('removes retained actionable errors when their source is cancelled', async () => {
|
||||
epgBridge.supportsDataManagement = true;
|
||||
const service = configureService();
|
||||
errorRow();
|
||||
emit({ url, status: 'cancelled', generation: 0 });
|
||||
expect(service.isVisible()).toBe(false);
|
||||
await service.retry(url);
|
||||
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'waits for source reconciliation before retrying (removed=%s)',
|
||||
async (removed) => {
|
||||
epgBridge.supportsDataManagement = true;
|
||||
let finish!: () => void;
|
||||
sources.waitForReconciliation.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const service = configureService();
|
||||
errorRow();
|
||||
const retry = service.retry(url);
|
||||
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||
if (removed) emit({ url, status: 'cancelled', generation: 0 });
|
||||
finish();
|
||||
await retry;
|
||||
expect(epgBridge.forceFetchEpg).toHaveBeenCalledTimes(
|
||||
removed ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['private', 'tls'])(
|
||||
'does not retry a removed/replaced row after a pending %s trust write',
|
||||
async (kind) => {
|
||||
epgBridge.supportsDataManagement = true;
|
||||
let finish!: () => void;
|
||||
settingsStore.updateSettings.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const service = configureService();
|
||||
errorRow();
|
||||
const retry =
|
||||
kind === 'private'
|
||||
? service.trustPrivateNetworkSourceAndRetry(url)
|
||||
: service.trustInsecureTlsHostAndRetry(url);
|
||||
emit({ url, status: 'cancelled', generation: 0 });
|
||||
emit({ url, status: 'loading', generation: 2 });
|
||||
finish();
|
||||
await retry;
|
||||
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||
expect(service.activeCount()).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not let an old dismissal timer remove a replacement import', () => {
|
||||
jest.useFakeTimers();
|
||||
const service = configureService();
|
||||
emit({ url, status: 'complete', generation: 0 });
|
||||
emit({ url, status: 'cancelled', generation: 0 });
|
||||
emit({ url, status: 'loading', generation: 2 });
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(service.activeCount()).toBe(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not subscribe to progress events when EPG progress support is disabled', () => {
|
||||
epgBridge.supportsProgress = false;
|
||||
configureService();
|
||||
|
||||
expect(epgBridge.onProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not force retry when EPG data management is disabled', () => {
|
||||
it('removes a retired queued import immediately on cancellation', () => {
|
||||
epgBridge.supportsProgress = true;
|
||||
const service = configureService();
|
||||
const listener = (epgBridge.onProgress as jest.Mock).mock.calls[0][0];
|
||||
const url = 'https://removed.example/guide.xml';
|
||||
listener({ url, status: 'queued' });
|
||||
expect(service.queuedCount()).toBe(1);
|
||||
listener({ url, status: 'cancelled' });
|
||||
expect(service.queuedCount()).toBe(0);
|
||||
expect(service.isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a replacement import when an older queued batch finally cancels', () => {
|
||||
epgBridge.supportsProgress = true;
|
||||
const service = configureService();
|
||||
const listener = (epgBridge.onProgress as jest.Mock).mock.calls[0][0];
|
||||
const url = 'https://readded.example/guide.xml';
|
||||
listener({ url, status: 'queued', generation: 0 });
|
||||
listener({ url, status: 'loading', generation: 2 });
|
||||
listener({ url, status: 'cancelled', generation: 0 });
|
||||
expect(service.activeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('does not force retry when EPG data management is disabled', async () => {
|
||||
const service = configureService();
|
||||
|
||||
service.retry('https://example.com/epg.xml');
|
||||
errorRow();
|
||||
await service.retry('https://example.com/epg.xml');
|
||||
|
||||
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces retry through the EPG runtime bridge when data management is enabled', () => {
|
||||
it('forces retry through the EPG runtime bridge when data management is enabled', async () => {
|
||||
epgBridge.supportsDataManagement = true;
|
||||
const service = configureService();
|
||||
|
||||
service.retry('https://example.com/epg.xml');
|
||||
errorRow();
|
||||
await service.retry('https://example.com/epg.xml');
|
||||
|
||||
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
|
||||
'https://example.com/epg.xml',
|
||||
@@ -90,6 +204,7 @@ describe('EpgProgressService', () => {
|
||||
epgBridge.supportsDataManagement = true;
|
||||
const service = configureService();
|
||||
|
||||
errorRow('http://192.168.1.30/guide.xml');
|
||||
await service.trustPrivateNetworkSourceAndRetry(
|
||||
'http://192.168.1.30/guide.xml'
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
normalizeHost,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
|
||||
import {
|
||||
EpgImportProgress,
|
||||
EpgRuntimeBridgeService,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export class EpgProgressService {
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly sources = inject(EpgSourceSettingsService);
|
||||
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -45,20 +46,27 @@ export class EpgProgressService {
|
||||
this.importsMap.set(new Map());
|
||||
}
|
||||
|
||||
retry(url: string): void {
|
||||
// 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.epgBridge.supportsDataManagement) {
|
||||
async retry(url: string): Promise<void> {
|
||||
const progress = this.importsMap().get(url);
|
||||
if (
|
||||
progress?.status !== 'error' ||
|
||||
!this.epgBridge.supportsDataManagement
|
||||
)
|
||||
return;
|
||||
}
|
||||
void this.epgBridge.forceFetchEpg(
|
||||
// A settings save may already be retiring this row. Never bypass its
|
||||
// ownership reconciliation by starting a new force-fetch generation.
|
||||
await this.sources.waitForReconciliation();
|
||||
if (this.importsMap().get(url) !== progress) return;
|
||||
this.removeImport(url);
|
||||
await this.epgBridge.forceFetchEpg(
|
||||
url,
|
||||
this.settingsStore.getTrustOptions()
|
||||
);
|
||||
}
|
||||
|
||||
async trustPrivateNetworkSourceAndRetry(url: string): Promise<void> {
|
||||
const progress = this.importsMap().get(url);
|
||||
if (progress?.status !== 'error') return;
|
||||
const settings = this.settingsStore.getSettings();
|
||||
const trustedUrls = new Set(
|
||||
settings.trustedPrivateNetworkEpgUrls ?? []
|
||||
@@ -68,13 +76,15 @@ export class EpgProgressService {
|
||||
await this.settingsStore.updateSettings({
|
||||
trustedPrivateNetworkEpgUrls: Array.from(trustedUrls),
|
||||
});
|
||||
this.retry(url);
|
||||
if (this.importsMap().get(url) === progress) await this.retry(url);
|
||||
}
|
||||
|
||||
async trustInsecureTlsHostAndRetry(
|
||||
url: string,
|
||||
host?: string
|
||||
): Promise<void> {
|
||||
const progress = this.importsMap().get(url);
|
||||
if (progress?.status !== 'error') return;
|
||||
const trustedHost = host ?? this.getHostname(url);
|
||||
if (!trustedHost) {
|
||||
return;
|
||||
@@ -91,7 +101,7 @@ export class EpgProgressService {
|
||||
await this.settingsStore.updateSettings({
|
||||
trustedInsecureTlsHosts: Array.from(trustedHosts),
|
||||
});
|
||||
this.retry(url);
|
||||
if (this.importsMap().get(url) === progress) await this.retry(url);
|
||||
}
|
||||
|
||||
private initializeListener(): void {
|
||||
@@ -108,6 +118,12 @@ export class EpgProgressService {
|
||||
}
|
||||
|
||||
private updateProgress(progress: EpgImportProgress): void {
|
||||
const current = this.importsMap().get(progress.url);
|
||||
if ((progress.generation ?? 0) < (current?.generation ?? 0)) return;
|
||||
if (progress.status === 'cancelled') {
|
||||
this.removeImport(progress.url);
|
||||
return;
|
||||
}
|
||||
this.importsMap.update((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(progress.url, progress);
|
||||
@@ -118,7 +134,10 @@ export class EpgProgressService {
|
||||
progress.status === 'complete' ||
|
||||
(progress.status === 'error' && !this.isActionableError(progress))
|
||||
) {
|
||||
setTimeout(() => this.removeImport(progress.url), 5000);
|
||||
setTimeout(() => {
|
||||
if (this.importsMap().get(progress.url) === progress)
|
||||
this.removeImport(progress.url);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { firstValueFrom, skip } from 'rxjs';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { firstValueFrom, of, skip } from 'rxjs';
|
||||
import {
|
||||
EpgSourceSettingsService,
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
import { EpgService } from './epg.service';
|
||||
|
||||
@@ -11,6 +15,7 @@ describe('EpgService', () => {
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let snackBar: { open: jest.Mock };
|
||||
let settingsStore: {
|
||||
loadSettings: jest.Mock;
|
||||
getSettings: jest.Mock;
|
||||
getTrustOptions: jest.Mock;
|
||||
resolvedEpgOffsetMinutes: jest.Mock;
|
||||
@@ -29,6 +34,7 @@ describe('EpgService', () => {
|
||||
open: jest.fn(),
|
||||
};
|
||||
settingsStore = {
|
||||
loadSettings: jest.fn().mockResolvedValue(undefined),
|
||||
getSettings: jest.fn(() => ({
|
||||
epgUrl: [],
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
@@ -44,6 +50,19 @@ describe('EpgService', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EpgService,
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getAllPlaylists: () =>
|
||||
of([
|
||||
{
|
||||
epgUrls: [
|
||||
'https://playlist.example/guide.xml',
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgRuntimeBridgeService,
|
||||
useValue: epgBridge,
|
||||
@@ -68,22 +87,126 @@ describe('EpgService', () => {
|
||||
service = TestBed.inject(EpgService);
|
||||
});
|
||||
|
||||
it('observes startup import completion after initial source reconciliation', async () => {
|
||||
epgBridge.supportsImport = true;
|
||||
let loaded!: () => void;
|
||||
settingsStore.loadSettings.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
loaded = resolve;
|
||||
})
|
||||
);
|
||||
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||
jest.spyOn(sources, 'retainCurrentSources').mockImplementation(
|
||||
(urls) => urls
|
||||
);
|
||||
const pending = service.fetchEpg([
|
||||
'https://configured.example/guide.xml',
|
||||
]);
|
||||
sources.revision.update((revision) => revision + 1);
|
||||
sources.changed$.next();
|
||||
const availability: boolean[] = [];
|
||||
const subscription = service.epgAvailable$.subscribe((value) =>
|
||||
availability.push(value)
|
||||
);
|
||||
loaded();
|
||||
await pending;
|
||||
await Promise.resolve();
|
||||
expect(epgBridge.fetchEpg).toHaveBeenCalledTimes(1);
|
||||
expect(availability.filter(Boolean)).toHaveLength(2);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('does not launch an obsolete import deferred behind settings initialization', async () => {
|
||||
epgBridge.supportsImport = true;
|
||||
let loaded!: () => void;
|
||||
settingsStore.loadSettings.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
loaded = resolve;
|
||||
})
|
||||
);
|
||||
const pending = service.fetchEpg(['https://removed.example/guide.xml']);
|
||||
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||
sources.revision.update((revision) => revision + 1);
|
||||
sources.changed$.next();
|
||||
loaded();
|
||||
await pending;
|
||||
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents pending programmes from repopulating a cache after source deletion', async () => {
|
||||
epgBridge.supportsProgramLookup = true;
|
||||
let resolveOld!: (programs: unknown[]) => void;
|
||||
(epgBridge.getChannelPrograms as jest.Mock).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
})
|
||||
);
|
||||
const stale = jest.fn();
|
||||
service.getCurrentProgramForChannel('deleted-channel').subscribe(stale);
|
||||
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||
sources.revision.update((revision) => revision + 1);
|
||||
sources.changed$.next();
|
||||
resolveOld([]);
|
||||
await Promise.resolve();
|
||||
expect(stale).not.toHaveBeenCalled();
|
||||
await firstValueFrom(
|
||||
service.getCurrentProgramForChannel('deleted-channel')
|
||||
);
|
||||
expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits for ongoing reconciliation and filters imports against committed owners', async () => {
|
||||
epgBridge.supportsImport = true;
|
||||
const original = window.electron;
|
||||
let complete!: (result: { success: boolean }) => void;
|
||||
window.electron = {
|
||||
reconcileEpgSources: () =>
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
} as typeof window.electron;
|
||||
try {
|
||||
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||
const reconciliation = sources.synchronize([
|
||||
'https://kept.example/guide.xml',
|
||||
]);
|
||||
await Promise.resolve();
|
||||
const pending = service.fetchEpg([
|
||||
'https://removed.example/guide.xml',
|
||||
'https://playlist.example/guide.xml',
|
||||
]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
|
||||
complete({ success: true });
|
||||
await reconciliation;
|
||||
await pending;
|
||||
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
|
||||
['https://playlist.example/guide.xml'],
|
||||
expect.anything()
|
||||
);
|
||||
} finally {
|
||||
window.electron = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not fetch EPG when bridge import support is disabled', () => {
|
||||
service.fetchEpg(['https://example.com/epg.xml']);
|
||||
|
||||
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches EPG through the EPG runtime bridge when import support is enabled', () => {
|
||||
it('fetches EPG through the EPG runtime bridge when import support is enabled', async () => {
|
||||
epgBridge.supportsImport = true;
|
||||
|
||||
service.fetchEpg([
|
||||
await service.fetchEpg([
|
||||
'https://example.com/epg.xml',
|
||||
'',
|
||||
'https://example.com/other.xml',
|
||||
' https://example.com/epg.xml ',
|
||||
]);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
|
||||
['https://example.com/epg.xml', 'https://example.com/other.xml'],
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
EpgProgram,
|
||||
epgProviderClockMs,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
EpgLookupOptions,
|
||||
EpgRuntimeBridgeService,
|
||||
@@ -42,6 +42,15 @@ export class EpgService {
|
||||
private translate = inject(TranslateService);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly sourceSettings = inject(EpgSourceSettingsService);
|
||||
|
||||
constructor() {
|
||||
this.sourceSettings.changed$.subscribe(() => {
|
||||
this.clearCache();
|
||||
this.currentEpgPrograms.next([]);
|
||||
this.epgAvailable.next(true);
|
||||
});
|
||||
}
|
||||
|
||||
private epgAvailable = new BehaviorSubject<boolean>(false);
|
||||
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
|
||||
@@ -79,11 +88,17 @@ export class EpgService {
|
||||
/**
|
||||
* Fetches EPG from the given URLs
|
||||
*/
|
||||
fetchEpg(urls: string[]): void {
|
||||
async fetchEpg(urls: string[]): Promise<void> {
|
||||
if (!this.epgBridge.supportsImport) return;
|
||||
|
||||
// Filter out empty and duplicate URLs and send all URLs at once.
|
||||
const validUrls = normalizeEpgUrls(urls);
|
||||
const revision = this.sourceSettings.revision();
|
||||
await this.settingsStore.loadSettings();
|
||||
await this.sourceSettings.waitForReconciliation();
|
||||
const validUrls = this.sourceSettings.retainCurrentSources(
|
||||
normalizeEpgUrls(urls),
|
||||
revision
|
||||
);
|
||||
if (validUrls.length === 0) return;
|
||||
|
||||
from(
|
||||
@@ -93,6 +108,7 @@ export class EpgService {
|
||||
)
|
||||
)
|
||||
.pipe(
|
||||
this.sourceSettings.guard(),
|
||||
tap((result) => {
|
||||
if (result === null) return;
|
||||
|
||||
@@ -123,6 +139,7 @@ export class EpgService {
|
||||
|
||||
from(this.epgBridge.getChannelPrograms(channelId))
|
||||
.pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(3000),
|
||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||
catchError((err) => {
|
||||
@@ -187,6 +204,7 @@ export class EpgService {
|
||||
// Fetch from backend
|
||||
return this.getCachedOrFetchCurrentProgram(cacheKey, () =>
|
||||
from(this.epgBridge.getChannelPrograms(channelId)).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||
map((programs: EpgProgram[]) =>
|
||||
this.findCurrentProgram(programs)
|
||||
@@ -287,6 +305,7 @@ export class EpgService {
|
||||
nowMs: this.epgClockMs(),
|
||||
})
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
map((batchResult) => {
|
||||
const cacheTimestamp = Date.now();
|
||||
@@ -311,6 +330,7 @@ export class EpgService {
|
||||
// Fallback for older preload bundles without the batch endpoint.
|
||||
const fetchObservables = channelsToFetch.map((channelId) =>
|
||||
this.getCurrentProgramForChannel(channelId).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
map((program) => ({ channelId, program })),
|
||||
catchError(() => of({ channelId, program: null }))
|
||||
@@ -318,6 +338,7 @@ export class EpgService {
|
||||
);
|
||||
|
||||
return forkJoin(fetchObservables).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((results) => {
|
||||
results.forEach((result) => {
|
||||
resultMap.set(result.channelId, result.program);
|
||||
@@ -372,6 +393,7 @@ export class EpgService {
|
||||
sourceUrls,
|
||||
fallbackSourceUrls
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
tap((fetchedMap) => {
|
||||
const cacheTimestamp = Date.now();
|
||||
channelsToFetch.forEach((channelId) => {
|
||||
@@ -386,7 +408,14 @@ export class EpgService {
|
||||
});
|
||||
}),
|
||||
finalize(() => {
|
||||
this.fetchingCurrentProgramBatches.delete(batchCacheKey);
|
||||
if (
|
||||
this.fetchingCurrentProgramBatches.get(
|
||||
batchCacheKey
|
||||
) === request$
|
||||
)
|
||||
this.fetchingCurrentProgramBatches.delete(
|
||||
batchCacheKey
|
||||
);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
@@ -396,6 +425,7 @@ export class EpgService {
|
||||
}
|
||||
|
||||
return request$.pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((fetchedMap) => {
|
||||
const mergedResultMap = new Map(resultMap);
|
||||
channelsToFetch.forEach((channelId) => {
|
||||
@@ -421,6 +451,7 @@ export class EpgService {
|
||||
nowMs,
|
||||
})
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
switchMap((scopedResult) => {
|
||||
const resultMap = new Map<string, EpgProgram | null>();
|
||||
@@ -448,6 +479,7 @@ export class EpgService {
|
||||
nowMs,
|
||||
})
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
map((globalResult) => {
|
||||
fallbackChannelIds.forEach((channelId) => {
|
||||
@@ -500,6 +532,7 @@ export class EpgService {
|
||||
normalizedChannelIds,
|
||||
effectiveSourceUrls
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
switchMap((metadataMap) => {
|
||||
const fallbackChannelIds =
|
||||
sourceUrls.length > 0 && globalSourceUrls.length > 0
|
||||
@@ -516,6 +549,7 @@ export class EpgService {
|
||||
fallbackChannelIds,
|
||||
globalSourceUrls
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((globalMetadataMap) => {
|
||||
fallbackChannelIds.forEach((channelId) => {
|
||||
metadataMap.set(
|
||||
@@ -561,6 +595,7 @@ export class EpgService {
|
||||
sourceUrls.length > 0 ? { sourceUrls } : undefined
|
||||
)
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((metadataByChannelId) => {
|
||||
return new Map<string, EpgChannelMetadata | null>(
|
||||
channelIds.map((channelId) => [
|
||||
@@ -641,6 +676,7 @@ export class EpgService {
|
||||
|
||||
const offsetMinutes = this.epgOffsetMinutes();
|
||||
const request$ = fetchProgram().pipe(
|
||||
this.sourceSettings.guard(),
|
||||
tap((program) => {
|
||||
this.programCache.set(cacheKey, {
|
||||
program,
|
||||
@@ -649,7 +685,8 @@ export class EpgService {
|
||||
});
|
||||
}),
|
||||
finalize(() => {
|
||||
this.fetchingCurrentPrograms.delete(cacheKey);
|
||||
if (this.fetchingCurrentPrograms.get(cacheKey) === request$)
|
||||
this.fetchingCurrentPrograms.delete(cacheKey);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
@@ -668,6 +705,7 @@ export class EpgService {
|
||||
from(
|
||||
this.epgBridge.getChannelPrograms(channelId, { sourceUrls })
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(3000),
|
||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||
switchMap((programs) => {
|
||||
|
||||
Reference in new issue
Block a user