mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user