mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
fix(epg): remove cached XMLTV data after source deletion
This commit is contained in:
1 parent
b55a3cc322
commit
ffcbee8140
32 files changed
+1051
-82
No files matched your search
@@ -2,7 +2,7 @@ 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 { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
import { EpgService } from './epg.service';
|
||||
|
||||
@@ -11,6 +11,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 +30,7 @@ describe('EpgService', () => {
|
||||
open: jest.fn(),
|
||||
};
|
||||
settingsStore = {
|
||||
loadSettings: jest.fn().mockResolvedValue(undefined),
|
||||
getSettings: jest.fn(() => ({
|
||||
epgUrl: [],
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
@@ -68,13 +70,81 @@ 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('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([
|
||||
@@ -84,6 +154,7 @@ describe('EpgService', () => {
|
||||
' 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,16 @@ 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();
|
||||
const validUrls = this.sourceSettings.retainCurrentSources(
|
||||
normalizeEpgUrls(urls),
|
||||
revision
|
||||
);
|
||||
if (validUrls.length === 0) return;
|
||||
|
||||
from(
|
||||
@@ -93,6 +107,7 @@ export class EpgService {
|
||||
)
|
||||
)
|
||||
.pipe(
|
||||
this.sourceSettings.guard(),
|
||||
tap((result) => {
|
||||
if (result === null) return;
|
||||
|
||||
@@ -123,6 +138,7 @@ export class EpgService {
|
||||
|
||||
from(this.epgBridge.getChannelPrograms(channelId))
|
||||
.pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(3000),
|
||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||
catchError((err) => {
|
||||
@@ -187,6 +203,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 +304,7 @@ export class EpgService {
|
||||
nowMs: this.epgClockMs(),
|
||||
})
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
map((batchResult) => {
|
||||
const cacheTimestamp = Date.now();
|
||||
@@ -311,6 +329,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 +337,7 @@ export class EpgService {
|
||||
);
|
||||
|
||||
return forkJoin(fetchObservables).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((results) => {
|
||||
results.forEach((result) => {
|
||||
resultMap.set(result.channelId, result.program);
|
||||
@@ -372,6 +392,7 @@ export class EpgService {
|
||||
sourceUrls,
|
||||
fallbackSourceUrls
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
tap((fetchedMap) => {
|
||||
const cacheTimestamp = Date.now();
|
||||
channelsToFetch.forEach((channelId) => {
|
||||
@@ -386,7 +407,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 +424,7 @@ export class EpgService {
|
||||
}
|
||||
|
||||
return request$.pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((fetchedMap) => {
|
||||
const mergedResultMap = new Map(resultMap);
|
||||
channelsToFetch.forEach((channelId) => {
|
||||
@@ -421,6 +450,7 @@ export class EpgService {
|
||||
nowMs,
|
||||
})
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
switchMap((scopedResult) => {
|
||||
const resultMap = new Map<string, EpgProgram | null>();
|
||||
@@ -448,6 +478,7 @@ export class EpgService {
|
||||
nowMs,
|
||||
})
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
map((globalResult) => {
|
||||
fallbackChannelIds.forEach((channelId) => {
|
||||
@@ -500,6 +531,7 @@ export class EpgService {
|
||||
normalizedChannelIds,
|
||||
effectiveSourceUrls
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
switchMap((metadataMap) => {
|
||||
const fallbackChannelIds =
|
||||
sourceUrls.length > 0 && globalSourceUrls.length > 0
|
||||
@@ -516,6 +548,7 @@ export class EpgService {
|
||||
fallbackChannelIds,
|
||||
globalSourceUrls
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((globalMetadataMap) => {
|
||||
fallbackChannelIds.forEach((channelId) => {
|
||||
metadataMap.set(
|
||||
@@ -561,6 +594,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 +675,7 @@ export class EpgService {
|
||||
|
||||
const offsetMinutes = this.epgOffsetMinutes();
|
||||
const request$ = fetchProgram().pipe(
|
||||
this.sourceSettings.guard(),
|
||||
tap((program) => {
|
||||
this.programCache.set(cacheKey, {
|
||||
program,
|
||||
@@ -649,7 +684,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 +704,7 @@ export class EpgService {
|
||||
from(
|
||||
this.epgBridge.getChannelPrograms(channelId, { sourceUrls })
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(3000),
|
||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||
switchMap((programs) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
EpgItem,
|
||||
windowEpgItemsAtProviderClock,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
||||
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
@@ -50,6 +50,20 @@ export class EpgQueueService implements OnDestroy {
|
||||
private readonly apiService = inject(XtreamApiService);
|
||||
private readonly fallbackService = inject(XtreamXmltvFallbackService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly sourceSubscription = inject(
|
||||
EpgSourceSettingsService
|
||||
).changed$.subscribe(() => {
|
||||
this.enqueueGeneration++;
|
||||
this.queue = [];
|
||||
for (const id of new Set([
|
||||
...this.cache.keys(),
|
||||
...this.inFlight,
|
||||
...this.visibleSet,
|
||||
])) {
|
||||
this.invalidate(id);
|
||||
this.epgResult$.next({ streamId: id, items: [] });
|
||||
}
|
||||
});
|
||||
private readonly logger = createLogger('EpgQueueService');
|
||||
private readonly previewLimit = 3;
|
||||
|
||||
@@ -494,6 +508,7 @@ export class EpgQueueService implements OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sourceSubscription.unsubscribe();
|
||||
this.epgResult$.complete();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
epgProviderClockMs,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||
|
||||
type ElectronEpgBridge = {
|
||||
getChannelPrograms?: (channelId: string) => Promise<EpgProgram[]>;
|
||||
@@ -19,6 +19,7 @@ type ElectronEpgBridge = {
|
||||
export class XtreamXmltvFallbackService {
|
||||
private readonly logger = createLogger('XtreamXmltvFallback');
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly sources = inject(EpgSourceSettingsService);
|
||||
|
||||
/**
|
||||
* `DataService.isElectron` is intentionally not consulted here: it
|
||||
@@ -44,6 +45,7 @@ export class XtreamXmltvFallbackService {
|
||||
async getProgramsForChannel(
|
||||
epgChannelId: string | null | undefined
|
||||
): Promise<EpgItem[]> {
|
||||
const revision = this.sources.revision();
|
||||
const id = (epgChannelId ?? '').trim();
|
||||
if (!id) return [];
|
||||
|
||||
@@ -52,6 +54,7 @@ export class XtreamXmltvFallbackService {
|
||||
|
||||
try {
|
||||
const programs = await fn.call(this.bridge, id);
|
||||
if (revision !== this.sources.revision()) return [];
|
||||
return (programs ?? []).map((p) => mapEpgProgramToEpgItem(p, id));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load XMLTV programs for ${id}`, error);
|
||||
@@ -62,6 +65,7 @@ export class XtreamXmltvFallbackService {
|
||||
async getCurrentProgramsBatch(
|
||||
epgChannelIds: ReadonlyArray<string | null | undefined>
|
||||
): Promise<Record<string, EpgItem>> {
|
||||
const revision = this.sources.revision();
|
||||
const fn = this.bridge?.getCurrentProgramsBatch;
|
||||
if (typeof fn !== 'function') return {};
|
||||
|
||||
@@ -83,6 +87,7 @@ export class XtreamXmltvFallbackService {
|
||||
this.settingsStore.resolvedEpgOffsetMinutes()
|
||||
),
|
||||
});
|
||||
if (revision !== this.sources.revision()) return {};
|
||||
const out: Record<string, EpgItem> = {};
|
||||
for (const id of ids) {
|
||||
const row = rows?.[id];
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
signalStoreFeature,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withHooks,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import {
|
||||
@@ -11,7 +12,11 @@ import {
|
||||
EpgItem,
|
||||
epgProviderClockMs,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
EpgSourceSettingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
@@ -104,6 +109,7 @@ export function withEpg() {
|
||||
const fallbackService = inject(XtreamXmltvFallbackService);
|
||||
const runtime = inject(RuntimeCapabilitiesService);
|
||||
const settingsStore = inject(SettingsStore);
|
||||
const sources = inject(EpgSourceSettingsService);
|
||||
|
||||
const supportsEpg = (): boolean => runtime.supportsEpg;
|
||||
|
||||
@@ -146,6 +152,7 @@ export function withEpg() {
|
||||
* sets `preferUploadedEpgOverXtream`.
|
||||
*/
|
||||
async loadEpg(): Promise<EpgItem[]> {
|
||||
const sourceRevision = sources.revision();
|
||||
if (!supportsEpg()) {
|
||||
patchState(store, {
|
||||
epgItems: [],
|
||||
@@ -203,6 +210,7 @@ export function withEpg() {
|
||||
fetchFullProvider(credentials, xtreamId),
|
||||
});
|
||||
|
||||
if (sourceRevision !== sources.revision()) return [];
|
||||
patchState(store, {
|
||||
epgItems,
|
||||
isLoadingEpg: false,
|
||||
@@ -210,6 +218,7 @@ export function withEpg() {
|
||||
|
||||
return epgItems;
|
||||
} catch (error) {
|
||||
if (sourceRevision !== sources.revision()) return [];
|
||||
logger.error('Error loading EPG', error);
|
||||
patchState(store, {
|
||||
epgItems: [],
|
||||
@@ -255,6 +264,19 @@ export function withEpg() {
|
||||
patchState(store, initialEpgState);
|
||||
},
|
||||
};
|
||||
}),
|
||||
withHooks((store) => {
|
||||
const sources = inject(EpgSourceSettingsService);
|
||||
let subscription: { unsubscribe(): void } | undefined;
|
||||
return {
|
||||
onInit: () => {
|
||||
subscription = sources.changed$.subscribe(() => {
|
||||
store.clearEpg();
|
||||
void store.loadEpg();
|
||||
});
|
||||
},
|
||||
onDestroy: () => subscription?.unsubscribe(),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
+10
@@ -1,3 +1,4 @@
|
||||
import { EpgSourceSettingsService } from '@iptvnator/services';
|
||||
import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
@@ -170,6 +171,15 @@ describe('PortalChannelsListComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clears visible cached XMLTV previews and re-enqueues after a source changes', () => {
|
||||
const component = fixture.componentInstance;
|
||||
component.epgPrograms.set(50, { title: 'Removed programme' } as never);
|
||||
component.currentProgramsProgress.set(50, 20);
|
||||
TestBed.inject(EpgSourceSettingsService).changed$.next();
|
||||
expect(component.epgPrograms.size).toBe(0);
|
||||
expect(component.currentProgramsProgress.size).toBe(0);
|
||||
});
|
||||
|
||||
it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
+10
-1
@@ -49,7 +49,11 @@ import { EpgQueueService } from '@iptvnator/portal/xtream/data-access';
|
||||
import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access';
|
||||
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
EpgSourceSettingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { XtreamFavoriteMarksService } from './xtream-favorite-marks.service';
|
||||
|
||||
export interface XtreamChannelListItem {
|
||||
@@ -164,6 +168,11 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) {
|
||||
this.subscriptions.add(
|
||||
inject(EpgSourceSettingsService).changed$.subscribe(() => {
|
||||
this.repickPreviewsForOffsetChange();
|
||||
})
|
||||
);
|
||||
// A changed display offset moves "now" in the provider's clock, so
|
||||
// the visible previews are re-picked from the cached EPG. The first
|
||||
// run only records the initial value.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -340,8 +340,7 @@ export interface ElectronBridgeTrustOptions {
|
||||
trustedInsecureTlsHosts?: string[];
|
||||
}
|
||||
|
||||
export interface ElectronBridgePlaylistFetchOptions
|
||||
extends ElectronBridgeTrustOptions {
|
||||
export interface ElectronBridgePlaylistFetchOptions extends ElectronBridgeTrustOptions {
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
@@ -375,8 +374,7 @@ export interface ElectronBridgeEpgLookupOptions {
|
||||
* renderer will also present as "now". Absent, the main process uses its own
|
||||
* clock.
|
||||
*/
|
||||
export interface ElectronBridgeCurrentProgramsOptions
|
||||
extends ElectronBridgeEpgLookupOptions {
|
||||
export interface ElectronBridgeCurrentProgramsOptions extends ElectronBridgeEpgLookupOptions {
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
@@ -812,6 +810,7 @@ export interface ElectronBridgeApi {
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
clearEpgData: () => Promise<ElectronBridgeResult>;
|
||||
reconcileEpgSources: (urls: string[]) => Promise<ElectronBridgeResult>;
|
||||
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
|
||||
checkEpgFreshness: (
|
||||
urls: string[],
|
||||
@@ -1287,7 +1286,9 @@ export interface ElectronBridgeApi {
|
||||
recordingsGet?: (
|
||||
recordingId: number
|
||||
) => Promise<ElectronRecordingItem | null>;
|
||||
recordingsStop?: (recordingId: number) => Promise<ElectronBridgeErrorResult>;
|
||||
recordingsStop?: (
|
||||
recordingId: number
|
||||
) => Promise<ElectronBridgeErrorResult>;
|
||||
recordingsRemove?: (
|
||||
recordingId: number
|
||||
) => Promise<ElectronBridgeErrorResult>;
|
||||
|
||||
Reference in new issue
Block a user