mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user