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

+37 -1
View File
@@ -14,7 +14,12 @@ import {
} from '@iptvnator/workspace/shell/util';
import { MockProvider } from 'ng-mocks';
import { EMPTY, of } from 'rxjs';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
DataService,
EpgSourceSettingsService,
SettingsStore,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
Language,
Settings,
@@ -235,6 +240,37 @@ describe('AppComponent', () => {
expect(snackBar.open).not.toHaveBeenCalled();
});
it('does not reimport a deleted source from a late startup freshness response', async () => {
await TestBed.inject(SettingsStore).loadSettings();
let finishFreshness!: (value: {
freshUrls: string[];
staleUrls: string[];
}) => void;
epgBridge.checkFreshness = jest.fn(
() =>
new Promise((resolve) => {
finishFreshness = resolve;
})
);
const pending = (
component as unknown as {
fetchStaleEpgData(urls: string[]): Promise<void>;
}
).fetchStaleEpgData(['https://removed.example/guide.xml']);
await Promise.resolve();
const sources = TestBed.inject(EpgSourceSettingsService);
sources.revision.update((value) => value + 1);
sources.changed$.next();
finishFreshness({
freshUrls: [],
staleUrls: ['https://removed.example/guide.xml'],
});
await pending;
expect(epgService.fetchEpg).not.toHaveBeenCalledWith([
'https://removed.example/guide.xml',
]);
});
it('does not fetch EPG settings when the EPG bridge cannot import EPG', async () => {
const settings: Settings = {
...DEFAULT_SETTINGS,
+14 -4
View File
@@ -20,6 +20,7 @@ import {
DataService,
RuntimeCapabilitiesService,
SettingsStore,
EpgSourceSettingsService,
} from '@iptvnator/services';
import {
AUTO_UPDATE_PLAYLISTS,
@@ -63,6 +64,7 @@ export class AppComponent implements OnInit {
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
private settingsStore = inject(SettingsStore);
private readonly epgSources = inject(EpgSourceSettingsService);
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
private playlistOpenRequests = inject(PlaylistOpenRequestService);
private runtime = inject(RuntimeCapabilitiesService);
@@ -188,8 +190,16 @@ export class AppComponent implements OnInit {
* Data is considered fresh if updated within the last 12 hours.
*/
private async fetchStaleEpgData(urls: string[]): Promise<void> {
await this.settingsStore.loadSettings();
const revision = this.epgSources.revision();
const fetchCurrentSources = async (sources: string[]) => {
await this.epgSources.waitForReconciliation();
this.epgService.fetchEpg(
this.epgSources.retainCurrentSources(sources, revision)
);
};
if (!this.epgBridge.supportsSourceFreshness) {
this.epgService.fetchEpg(urls);
await fetchCurrentSources(urls);
return;
}
@@ -197,7 +207,7 @@ export class AppComponent implements OnInit {
const result = await this.epgBridge.checkFreshness(urls, 12);
if (!result) {
this.epgService.fetchEpg(urls);
await fetchCurrentSources(urls);
return;
}
@@ -219,12 +229,12 @@ export class AppComponent implements OnInit {
debugAppComponent(
`EPG: Fetching ${result.staleUrls.length} stale source(s)`
);
this.epgService.fetchEpg(result.staleUrls);
await fetchCurrentSources(result.staleUrls);
}
} catch (error) {
console.error('Error checking EPG freshness, fetching all:', error);
// Fallback: fetch all URLs if freshness check fails
this.epgService.fetchEpg(urls);
await fetchCurrentSources(urls);
}
}
@@ -2,7 +2,10 @@ import { DestroyRef, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder } from '@angular/forms';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgSourceReconciliationError,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
CoverSize,
EpgViewMode,
@@ -146,6 +149,7 @@ export class SettingsFormFacade {
*/
removeEpgSource(index: number): void {
this.epgUrl.removeAt(index);
this.epgUrl.markAsDirty();
this.form.markAsDirty();
}
@@ -161,19 +165,28 @@ export class SettingsFormFacade {
this.settingsStore.getSettings()
);
await this.settingsStore.updateSettings(settings);
onSaved();
if (!window.electron) {
return;
let cleanupError: EpgSourceReconciliationError | undefined;
try {
await this.settingsStore.updateSettings(settings, {
retryEpgCleanup: this.epgUrl?.dirty ?? false,
});
} catch (error) {
if (!(error instanceof EpgSourceReconciliationError)) throw error;
// This error follows a successful settings write. Mirror the
// committed values, but retain the dirty form for cleanup retry.
cleanupError = error;
}
if (!cleanupError) onSaved();
window.electron.updateSettings(settings);
if (window.electron) {
window.electron.updateSettings(settings);
if (this.runtime.supportsExternalPlayerPathSettings) {
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
if (this.runtime.supportsExternalPlayerPathSettings) {
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
}
}
if (cleanupError) throw cleanupError;
}
/** Applies the saved language/theme and resets the dirty state */
@@ -351,11 +351,14 @@ describe('SettingsComponent form', () => {
component.onSubmit();
await fixture.whenStable();
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
{
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
},
{ retryEpgCleanup: false }
);
expect(updateSettings).toHaveBeenCalledWith({
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
@@ -375,7 +378,8 @@ describe('SettingsComponent form', () => {
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
webPlayerSharedControls: false,
})
}),
{ retryEpgCleanup: false }
);
});
@@ -421,7 +425,8 @@ describe('SettingsComponent form', () => {
expect.objectContaining({
mpvPlayerArguments: '--screen=1\n--geometry=1280x720',
vlcPlayerArguments: '--qt-fullscreen-screennumber=1',
})
}),
{ retryEpgCleanup: false }
);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
@@ -456,7 +461,8 @@ describe('SettingsComponent form', () => {
epgUrl: ['https://example.com/guide.xml'],
preferUploadedEpgOverXtream: true,
theme: Theme.DarkTheme,
})
}),
{ retryEpgCleanup: false }
);
webFixture.destroy();
@@ -2,6 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { EpgSourceReconciliationError } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsComponent } from './settings.component';
@@ -86,6 +87,9 @@ describe('SettingsComponent storage failures', () => {
// write must not mark the form pristine — that is what keeps the
// retry path on screen.
expect(component.settingsForm.dirty).toBe(true);
expect(window.electron.updateSettings).not.toHaveBeenCalled();
expect(window.electron.setMpvPlayerPath).not.toHaveBeenCalled();
expect(window.electron.setVlcPlayerPath).not.toHaveBeenCalled();
});
it('marks the form pristine only after the settings write succeeded', async () => {
@@ -103,6 +107,51 @@ describe('SettingsComponent storage failures', () => {
expect(component.settingsForm.pristine).toBe(true);
});
it('retries failed source cleanup on an explicit EPG save and clears dirty state only on success', async () => {
await fixture.whenStable();
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
component.form.setEpgUrls(['removed-source']);
component.form.removeEpgSource(0);
component.settingsForm.patchValue({
remoteControl: true,
mpvPlayerPath: '/saved/mpv',
vlcPlayerPath: '/saved/vlc',
});
settingsStore.updateSettings
.mockRejectedValueOnce(new EpgSourceReconciliationError())
.mockResolvedValue(undefined);
component.onSubmit();
await fixture.whenStable();
expect(component.form.epgUrl.dirty).toBe(true);
expect(window.electron.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteControl: true,
mpvPlayerPath: '/saved/mpv',
vlcPlayerPath: '/saved/vlc',
epgUrl: [],
})
);
expect(window.electron.setMpvPlayerPath).toHaveBeenCalledWith(
'/saved/mpv'
);
expect(window.electron.setVlcPlayerPath).toHaveBeenCalledWith(
'/saved/vlc'
);
expect(settingsStore.updateSettings).toHaveBeenLastCalledWith(
expect.objectContaining({ epgUrl: [] }),
{ retryEpgCleanup: true }
);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.EPG_DATA_CLEAR_FAILED',
undefined,
expect.any(Object)
);
component.onSubmit();
await fixture.whenStable();
expect(settingsStore.updateSettings).toHaveBeenCalledTimes(2);
expect(component.form.epgUrl.pristine).toBe(true);
});
it('keeps the user in settings when save-and-leave cannot persist', async () => {
settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable')
@@ -124,5 +173,4 @@ describe('SettingsComponent storage failures', () => {
);
expect(component.settingsForm.dirty).toBe(true);
});
});
@@ -16,7 +16,10 @@ import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgSourceReconciliationError,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
import { firstValueFrom, map } from 'rxjs';
@@ -307,7 +310,13 @@ export class SettingsComponent
try {
await this.form.save(() => this.applyChangedSettings());
return true;
} catch {
} catch (error) {
if (error instanceof EpgSourceReconciliationError) {
this.settingsSnackbar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED')
);
return false;
}
// The store already applied the change in memory, so without
// this the save looks successful until the next restart. The
// unsaved-changes bar stays visible so it can be retried.