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

+2
View File
@@ -21,3 +21,5 @@ export * from './lib/tmdb';
export * from './lib/xtream-pending-restore.service';
export * from './lib/stream-probe.service';
export * from './lib/vod-source-pin.service';
export * from './lib/epg-source-settings.service';
@@ -0,0 +1,112 @@
import { Injector } from '@angular/core';
import { firstValueFrom, of, Subject } from 'rxjs';
import { EpgSourceSettingsService } from './epg-source-settings.service';
import { PlaylistsService } from './playlists.service';
describe('EPG source settings synchronization', () => {
const original = window.electron;
afterEach(() => {
window.electron = original;
});
it('waits for playlist migration, normalizes URLs and invalidates pending lookups after success', async () => {
const playlists = new Subject<never[]>();
const reconcileEpgSources = jest
.fn()
.mockResolvedValue({ success: true });
window.electron = {
reconcileEpgSources,
} as unknown as typeof window.electron;
const injector = Injector.create({
providers: [
EpgSourceSettingsService,
{
provide: PlaylistsService,
useValue: { getAllPlaylists: () => playlists },
},
],
});
const service = injector.get(EpgSourceSettingsService);
const pending = new Subject<string>();
const observer = jest.fn();
const lookup = pending.pipe(service.guard());
lookup.subscribe(observer);
const synchronization = service.synchronize([' a ', '', 'a']);
expect(reconcileEpgSources).not.toHaveBeenCalled();
pending.next('response during playlist migration');
expect(observer).not.toHaveBeenCalled();
playlists.next([]);
await synchronization;
pending.next('old programme');
// Subscribing to a pre-deletion request later also cannot revive it.
lookup.subscribe(observer);
pending.next('late programme');
expect(observer).not.toHaveBeenCalled();
expect(service.revision()).toBe(2);
expect(reconcileEpgSources).toHaveBeenCalledWith(['a']);
expect(await firstValueFrom(of('new').pipe(service.guard()))).toBe(
'new'
);
});
it('invalidates possibly partially deleted data and reports reconciliation failure', async () => {
window.electron = {
reconcileEpgSources: jest.fn().mockRejectedValue(new Error('disk')),
} as unknown as typeof window.electron;
const injector = Injector.create({
providers: [
EpgSourceSettingsService,
{
provide: PlaylistsService,
useValue: { getAllPlaylists: () => of([]) },
},
],
});
const service = injector.get(EpgSourceSettingsService);
await expect(service.synchronize(['current'])).rejects.toThrow(
'Failed to reconcile EPG sources'
);
expect(service.revision()).toBe(2);
expect(service.retainCurrentSources(['current', 'removed'], 0)).toEqual(
['current']
);
});
it('serializes overlapping saves and waits for the latest committed source set', async () => {
let finishFirst!: (result: { success: boolean }) => void;
const reconcileEpgSources = jest
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirst = resolve;
})
)
.mockResolvedValue({ success: true });
window.electron = {
reconcileEpgSources,
} as unknown as typeof window.electron;
const injector = Injector.create({
providers: [
EpgSourceSettingsService,
{
provide: PlaylistsService,
useValue: { getAllPlaylists: () => of([]) },
},
],
});
const service = injector.get(EpgSourceSettingsService);
const first = service.synchronize(['a']);
const second = service.synchronize(['b']);
const waiter = service.waitForReconciliation();
await Promise.resolve();
expect(reconcileEpgSources).toHaveBeenCalledTimes(1);
finishFirst({ success: true });
await Promise.all([first, second, waiter]);
expect(reconcileEpgSources.mock.calls.map(([urls]) => urls)).toEqual([
['a'],
['b'],
]);
expect(service.retainCurrentSources(['a', 'b'], 0)).toEqual(['b']);
});
});
@@ -0,0 +1,118 @@
import { inject, Injectable, Injector, signal } from '@angular/core';
import {
firstValueFrom,
Subject,
Observable,
MonoTypeOperatorFunction,
filter,
takeUntil,
} from 'rxjs';
import { PlaylistsService } from './playlists.service';
export class EpgSourceReconciliationError extends Error {
constructor() {
super('Failed to reconcile EPG sources');
}
}
function normalizeEpgSourceUrls(urls: string[] | string | undefined): string[] {
return [
...new Set(
(Array.isArray(urls) ? urls : [urls ?? ''])
.map((url) => url.trim())
.filter(Boolean)
),
];
}
export function epgSourceUrlsChanged(
previous: string[] | string | undefined,
next: string[] | string | undefined
): boolean {
return (
next !== undefined &&
JSON.stringify(normalizeEpgSourceUrls(previous).sort()) !==
JSON.stringify(normalizeEpgSourceUrls(next).sort())
);
}
/** Synchronizes committed global XMLTV settings, never unsaved form edits. */
@Injectable({ providedIn: 'root' })
export class EpgSourceSettingsService {
private readonly injector = inject(Injector);
private activeUrls = new Set<string>();
private reconciliation: Promise<void> | undefined;
readonly revision = signal(0);
readonly changed$ = new Subject<void>();
retainCurrentSources(urls: string[], requestedRevision: number): string[] {
return requestedRevision === this.revision()
? urls
: urls.filter((url) => this.activeUrls.has(url));
}
guard<T>(): MonoTypeOperatorFunction<T> {
const revision = this.revision();
return (source: Observable<T>) =>
source.pipe(
takeUntil(this.changed$),
filter(() => revision === this.revision())
);
}
async waitForReconciliation(): Promise<void> {
while (this.reconciliation) {
await this.reconciliation.catch(() => undefined);
}
}
async synchronize(urls: string[] | string | undefined): Promise<void> {
if (
typeof window === 'undefined' ||
!window.electron?.reconcileEpgSources
)
return;
// Fence existing lookups before playlist migration or IPC can yield.
this.revision.update((revision) => revision + 1);
const previous = this.reconciliation;
const operation = previous
? previous.catch(() => undefined).then(() => this.reconcile(urls))
: this.reconcile(urls);
const pending = operation.finally(() => {
if (this.reconciliation === pending)
this.reconciliation = undefined;
});
this.reconciliation = pending;
return pending;
}
private async reconcile(
urls: string[] | string | undefined
): Promise<void> {
const normalized = normalizeEpgSourceUrls(urls);
// These globals are committed even if playlist ownership or cleanup
// cannot be read. Never keep the previous global list on failure.
this.activeUrls = new Set(normalized);
try {
// This includes the legacy IndexedDB → SQLite playlist migration.
const playlists = await firstValueFrom(
this.injector.get(PlaylistsService).getAllPlaylists()
);
for (const playlist of playlists) {
if (playlist.serverUrl || playlist.macAddress) continue;
for (const url of playlist.epgUrls ?? []) {
if (url.trim()) this.activeUrls.add(url.trim());
}
}
const result =
await window.electron.reconcileEpgSources(normalized);
if (!result.success)
throw new Error('EPG source reconciliation failed');
} catch {
throw new EpgSourceReconciliationError();
} finally {
this.revision.update((revision) => revision + 1);
this.changed$.next();
}
}
}
@@ -1,3 +1,4 @@
import { EpgSourceSettingsService } from './epg-source-settings.service';
import { Injector } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of, Subject } from 'rxjs';
@@ -46,6 +47,7 @@ describe('SettingsStore dashboard rail settings', () => {
injector = Injector.create({
providers: [
SettingsStore,
EpgSourceSettingsService,
{
provide: StorageMap,
useValue: storage,
@@ -54,6 +56,118 @@ describe('SettingsStore dashboard rail settings', () => {
});
});
it('reconciles only after persistence and restores the previous EPG list on a failed save', async () => {
storedSettings = { epgUrl: ['https://old.example/guide.xml'] };
const sources = injector.get(EpgSourceSettingsService);
const reconcile = jest
.spyOn(sources, 'synchronize')
.mockResolvedValue(undefined);
const store = injector.get(SettingsStore);
await store.loadSettings();
reconcile.mockClear();
const write = new Subject<void>();
storage.set.mockReturnValue(write);
const saving = store.updateSettings({ epgUrl: [] });
expect(reconcile).not.toHaveBeenCalled();
write.error(new Error('storage unavailable'));
await expect(saving).rejects.toThrow('storage unavailable');
expect(reconcile).not.toHaveBeenCalled();
expect(store.epgUrl()).toEqual(['https://old.example/guide.xml']);
storage.set.mockReturnValue(of(undefined));
await store.updateSettings({ epgUrl: [] });
expect(reconcile).toHaveBeenCalledWith([]);
});
it('never reconciles empty defaults after settings storage fails to load', async () => {
const pending = new Subject<unknown>();
storage.get.mockReturnValue(pending);
const reconcile = jest.spyOn(
injector.get(EpgSourceSettingsService),
'synchronize'
);
const store = injector.get(SettingsStore);
pending.error(new Error('cannot read settings'));
await store.loadSettings();
expect(reconcile).not.toHaveBeenCalled();
});
it('keeps persisted URLs authoritative when subsequent EPG cleanup fails', async () => {
const sources = injector.get(EpgSourceSettingsService);
const reconcile = jest
.spyOn(sources, 'synchronize')
.mockResolvedValue(undefined);
const store = injector.get(SettingsStore);
await store.loadSettings();
reconcile.mockRejectedValue(new Error('cleanup failed'));
await expect(
store.updateSettings({ epgUrl: ['new-source'] })
).rejects.toThrow('cleanup failed');
expect(store.epgUrl()).toEqual(['new-source']);
expect(store.storageFailure()).toBeNull();
expect(storage.set).toHaveBeenLastCalledWith(
STORE_KEY.Settings,
expect.objectContaining({ epgUrl: ['new-source'] })
);
});
it.each([
{ epgUrl: ['second', 'first'] },
{ epgUrl: [' first ', 'second', 'first', ''] },
])(
'persists unrelated settings without reconciling an unchanged normalized source set: %j',
async ({ epgUrl }) => {
storedSettings = { epgUrl: ['first', 'second'] };
const reconcile = jest
.spyOn(injector.get(EpgSourceSettingsService), 'synchronize')
.mockResolvedValue(undefined);
const store = injector.get(SettingsStore);
await store.loadSettings();
reconcile
.mockClear()
.mockRejectedValue(new Error('migration unavailable'));
await expect(
store.updateSettings({
...store.getSettings(),
language: Language.FRENCH,
epgUrl,
})
).resolves.toBeUndefined();
expect(reconcile).not.toHaveBeenCalled();
expect(store.storageFailure()).toBeNull();
expect(storage.set).toHaveBeenLastCalledWith(
STORE_KEY.Settings,
expect.objectContaining({ language: Language.FRENCH })
);
}
);
it('keeps failed cleanup retryable only when an EPG save explicitly requests it', async () => {
storedSettings = { epgUrl: ['removed'] };
const reconcile = jest
.spyOn(injector.get(EpgSourceSettingsService), 'synchronize')
.mockResolvedValue(undefined);
const store = injector.get(SettingsStore);
await store.loadSettings();
reconcile.mockClear().mockRejectedValue(new Error('cleanup failed'));
await expect(store.updateSettings({ epgUrl: [] })).rejects.toThrow(
'cleanup failed'
);
reconcile.mockClear();
await expect(
store.updateSettings({
...store.getSettings(),
language: Language.FRENCH,
})
).resolves.toBeUndefined();
expect(reconcile).not.toHaveBeenCalled();
await expect(
store.updateSettings({ epgUrl: [] }, { retryEpgCleanup: true })
).rejects.toThrow('cleanup failed');
reconcile.mockResolvedValue(undefined);
await store.updateSettings({ epgUrl: [] }, { retryEpgCleanup: true });
expect(reconcile).toHaveBeenCalledTimes(2);
});
it('defaults portal request pauses on and persists an explicit opt-out', async () => {
const store = injector.get(SettingsStore);
expect(store.getSettings().portalConnectivityGuard).toBe(true);
@@ -448,6 +562,7 @@ describe('SettingsStore storage failure reporting', () => {
injector = Injector.create({
providers: [
SettingsStore,
EpgSourceSettingsService,
{
provide: StorageMap,
useValue: storage,
@@ -1,3 +1,7 @@
import {
EpgSourceSettingsService,
epgSourceUrlsChanged,
} from './epg-source-settings.service';
import { computed, inject } from '@angular/core';
import {
patchState,
@@ -143,6 +147,7 @@ export const SettingsStore = signalStore(
),
})),
withMethods((store, storage = inject(StorageMap)) => {
const epgSources = inject(EpgSourceSettingsService);
let settingsLoadPromise: Promise<void> | undefined;
return {
@@ -190,6 +195,14 @@ export const SettingsStore = signalStore(
}
);
}
await epgSources
.synchronize(this.getSettings().epgUrl)
.catch((error) => {
console.warn(
'Could not reconcile cached EPG sources on startup.',
error
);
});
})().catch((error) => {
settingsLoadPromise = undefined;
console.error('Failed to load settings:', error);
@@ -202,7 +215,11 @@ export const SettingsStore = signalStore(
return settingsLoadPromise;
},
async updateSettings(settings: Partial<Settings>) {
async updateSettings(
settings: Partial<Settings>,
options: { retryEpgCleanup?: boolean } = {}
) {
const previousEpgUrls = store.epgUrl();
patchState(store, {
...settings,
...(settings.webPlayerSharedControls !== undefined
@@ -252,9 +269,18 @@ export const SettingsStore = signalStore(
console.error('Failed to save settings:', error);
// The in-memory patch above already applied, so without
// this flag the change looks saved until the next restart.
patchState(store, { storageFailure: 'save' });
patchState(store, {
storageFailure: 'save',
epgUrl: previousEpgUrls,
});
throw error;
}
if (
epgSourceUrlsChanged(previousEpgUrls, settings.epgUrl) ||
options.retryEpgCleanup
) {
await epgSources.synchronize(completeSettings.epgUrl);
}
},
getSettings() {