fix(epg): remove cached XMLTV data after source deletion

This commit is contained in:
4gray committed 2026-09-05 22:15:20 +02:00
1 parent b55a3cc322
commit ffcbee8140
32 files changed
+1051 -82

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,72 @@
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();
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(1);
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(1);
expect(service.retainCurrentSources(['current', 'removed'], 0)).toEqual(
['current']
);
});
});
@@ -0,0 +1,79 @@
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');
}
}
/** 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>();
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 synchronize(urls: string[] | string | undefined): Promise<void> {
if (
typeof window === 'undefined' ||
!window.electron?.reconcileEpgSources
)
return;
const normalized = [
...new Set(
(Array.isArray(urls) ? urls : [urls ?? ''])
.map((url) => url.trim())
.filter(Boolean)
),
];
// 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,60 @@ 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('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 +504,7 @@ describe('SettingsStore storage failure reporting', () => {
injector = Injector.create({
providers: [
SettingsStore,
EpgSourceSettingsService,
{
provide: StorageMap,
useValue: storage,
@@ -1,3 +1,4 @@
import { EpgSourceSettingsService } from './epg-source-settings.service';
import { computed, inject } from '@angular/core';
import {
patchState,
@@ -143,6 +144,7 @@ export const SettingsStore = signalStore(
),
})),
withMethods((store, storage = inject(StorageMap)) => {
const epgSources = inject(EpgSourceSettingsService);
let settingsLoadPromise: Promise<void> | undefined;
return {
@@ -190,6 +192,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);
@@ -203,6 +213,7 @@ export const SettingsStore = signalStore(
},
async updateSettings(settings: Partial<Settings>) {
const previousEpgUrls = store.epgUrl();
patchState(store, {
...settings,
...(settings.webPlayerSharedControls !== undefined
@@ -252,9 +263,15 @@ 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 (settings.epgUrl !== undefined) {
await epgSources.synchronize(completeSettings.epgUrl);
}
},
getSettings() {