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:
4gray authored and GitHub committed 2026-09-06 07:32:30 +02:00
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'],
{
+43 -5
View File
@@ -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) => {