mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -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
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user