fix(epg): cancel retired error rows and pending retries

This commit is contained in:
4gray committed 2026-09-06 00:15:17 +02:00
1 parent 5720871795
commit a76050a491
8 files changed
+270 -26

No files matched your search

+4 -3
View File
@@ -180,9 +180,10 @@ Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
all enabled M3U playlist sources. Startup runs the same reconciliation after
settings load and playlist migration. Ordinary saves skip unchanged normalized
source sets; an explicitly edited EPG field can retry a failed cleanup.
Failed settings reads and incomplete
playlist migration never authorize pruning. Removed sources retire queued and
running imports before worker-owned deletion. Shared channel IDs survive while
Failed settings reads and incomplete playlist migration never authorize pruning.
Removed sources retire queued/running imports and dismiss retained error rows
before worker-owned deletion. Retry waits for reconciliation and rechecks its
error row, including after trust-setting writes. Shared channel IDs survive while
another source has programmes or per-source channel metadata. The additive
`epg_channel_sources` table preserves each imported source's name, logo, URL and
timestamp so removal restores a surviving snapshot; ambiguous legacy metadata
+4 -3
View File
@@ -1665,9 +1665,10 @@ Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
all enabled M3U playlist sources. Startup runs the same reconciliation after
settings load and playlist migration. Ordinary saves skip unchanged normalized
source sets; an explicitly edited EPG field can retry a failed cleanup.
Failed settings reads and incomplete
playlist migration never authorize pruning. Removed sources retire queued and
running imports before worker-owned deletion. Shared channel IDs survive while
Failed settings reads and incomplete playlist migration never authorize pruning.
Removed sources retire queued/running imports and dismiss retained error rows
before worker-owned deletion. Retry waits for reconciliation and rechecks its
error row, including after trust-setting writes. Shared channel IDs survive while
another source has programmes or per-source channel metadata. The additive
`epg_channel_sources` table preserves each imported source's name, logo, URL and
timestamp so removal restores a surviving snapshot; ambiguous legacy metadata
+72
View File
@@ -166,6 +166,78 @@ test.describe('Electron EPG', () => {
}
});
test('@epg @electron removes a retained error row after its import worker has finished', async ({
dataDir,
}) => {
const server = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml',
});
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.fill(server.resourceUrl);
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1, { timeout: 30000 });
// Capture the actual generation; a force-fetch also awaits termination.
const generation = await app.mainWindow.evaluate(async (url) => {
await window.electron.clearEpgDataForSource(url);
const completed = new Promise<number>((resolve) => {
window.electron.onEpgProgress?.((progress) => {
if (
progress.url === url &&
progress.status === 'complete'
)
resolve(progress.generation ?? 0);
});
});
await window.electron.forceFetchEpg(url);
return completed;
}, server.resourceUrl);
await app.electronApp.evaluate(
({ BrowserWindow }, { url, generation }) => {
BrowserWindow.getAllWindows()[0].webContents.send(
'EPG_PROGRESS_UPDATE',
{
url,
status: 'error',
generation,
errorCode: 'invalid-tls-certificate',
error: 'Synthetic retained TLS error',
}
);
},
{ url: server.resourceUrl, generation }
);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-error'
)
).toHaveCount(1);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.locator('.epg-progress-panel .import-item')
).toHaveCount(0);
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
} finally {
await closeElectronApp(app);
await server.close();
}
});
test('@epg @electron removes only the saved source, preserves shared IDs and mappings, and repairs old orphan data on restart', async ({
dataDir,
}) => {
@@ -14,6 +14,7 @@ jest.mock('../database/connection', () => ({ getDatabase: jest.fn() }));
jest.mock('./epg-worker.service', () => ({
epgWorkerService: {
clearEpgDataForSource: jest.fn().mockResolvedValue(undefined),
sendProgressToRenderer: jest.fn(),
},
}));
@@ -82,6 +83,52 @@ describe('committed EPG source reconciliation', () => {
);
});
it('cancels completed error rows even without cached data or a running worker', async () => {
rows.set(epgChannels, []);
rows.set(epgPrograms, []);
const generation = requestEpgSource('failed-import');
await reconcileEpgSources([]);
expect(epgWorkerService.sendProgressToRenderer).toHaveBeenCalledWith(
'failed-import',
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generation
);
});
it('cancels every retired row before the first source clear can fail', async () => {
const generation = epgSourceGeneration('second');
(
epgWorkerService.clearEpgDataForSource as jest.Mock
).mockImplementationOnce(async () => {
expect(
epgWorkerService.sendProgressToRenderer
).toHaveBeenCalledWith(
'second',
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generation
);
throw new Error('clear failed');
});
await expect(reconcileEpgSources(['shared'])).rejects.toThrow(
'clear failed'
);
expect(
(
epgWorkerService.sendProgressToRenderer as jest.Mock
).mock.calls.map(([url]) => url)
).not.toContain('shared');
});
it('does not clear historical request keys again after successful cleanup', async () => {
requestEpgSource('removed');
await reconcileEpgSources([]);
@@ -9,6 +9,7 @@ import {
} from '../database/schema';
import { epgWorkerService } from './epg-worker.service';
import {
epgSourceGeneration,
forgetEpgSourceRequest,
requestedEpgSources,
retireEpgSource,
@@ -76,7 +77,21 @@ export function reconcileEpgSources(globalUrls: string[]): Promise<void> {
),
];
// Fence the whole obsolete set before awaiting the first worker exit.
removed.forEach(retireEpgSource);
removed.forEach((url) => {
const generation = epgSourceGeneration(url);
retireEpgSource(url);
// Finished imports can leave actionable error rows with Retry.
epgWorkerService.sendProgressToRenderer(
url,
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generation
);
});
for (const url of removed) {
await epgWorkerService.clearEpgDataForSource(url);
forgetEpgSourceRequest(url, requested.get(url.trim()));
+5 -2
View File
@@ -1345,8 +1345,11 @@ Same-URL clears are serialized and replacement imports await the outstanding
clear, so an older cleanup cannot erase a newly re-added source. Source cleanup
also awaits the in-flight fetch promise when an error/timeout has already removed
the worker from the lookup but its termination is still pending.
Retired queued and running imports emit generation-scoped cancellation so progress
rows disappear without reporting routine worker termination as an import failure. Programmes are deleted by source; a globally keyed
Every removed source emits generation-scoped cancellation before cleanup starts,
including retained actionable errors whose workers have already finished. Retired
queued/running imports also cancel their own generation. Retry waits for source
reconciliation and rechecks the same error row; trust-setting continuations and
old dismissal timers cannot affect a removed or replacement row. Progress rows disappear without reporting routine worker termination as an import failure. Programmes are deleted by source; a globally keyed
channel is retained while another source still has programmes or channel metadata.
The additive `epg_channel_sources` table is created during database initialization
and records each imported source's name, logo, URL and timestamp. Before removing
@@ -3,11 +3,13 @@ import {
EpgImportProgress,
EpgRuntimeBridgeService,
} from './epg-runtime-bridge.service';
import { SettingsStore } from '@iptvnator/services';
import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
import { EpgProgressService } from './epg-progress.service';
describe('EpgProgressService', () => {
let epgBridge: Partial<EpgRuntimeBridgeService>;
let sources: { waitForReconciliation: jest.Mock };
let settingsStore: {
getSettings: jest.Mock;
getTrustOptions: jest.Mock;
@@ -15,11 +17,14 @@ describe('EpgProgressService', () => {
};
beforeEach(() => {
sources = {
waitForReconciliation: jest.fn().mockResolvedValue(undefined),
};
epgBridge = {
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
onProgress: jest.fn(),
supportsDataManagement: false,
supportsProgress: false,
supportsProgress: true,
};
settingsStore = {
getSettings: jest.fn(() => ({
@@ -35,6 +40,7 @@ describe('EpgProgressService', () => {
});
afterEach(() => {
jest.useRealTimers();
TestBed.resetTestingModule();
jest.restoreAllMocks();
});
@@ -43,6 +49,7 @@ describe('EpgProgressService', () => {
TestBed.configureTestingModule({
providers: [
EpgProgressService,
{ provide: EpgSourceSettingsService, useValue: sources },
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
@@ -57,7 +64,89 @@ describe('EpgProgressService', () => {
return TestBed.inject(EpgProgressService);
}
const url = 'https://example.com/epg.xml';
const emit = (progress: EpgImportProgress) =>
(epgBridge.onProgress as jest.Mock).mock.calls[0][0](progress);
const errorRow = (source = url) =>
emit({
url: source,
status: 'error',
generation: 0,
errorCode:
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate,
});
it('removes retained actionable errors when their source is cancelled', async () => {
epgBridge.supportsDataManagement = true;
const service = configureService();
errorRow();
emit({ url, status: 'cancelled', generation: 0 });
expect(service.isVisible()).toBe(false);
await service.retry(url);
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
});
it.each([true, false])(
'waits for source reconciliation before retrying (removed=%s)',
async (removed) => {
epgBridge.supportsDataManagement = true;
let finish!: () => void;
sources.waitForReconciliation.mockReturnValue(
new Promise<void>((resolve) => {
finish = resolve;
})
);
const service = configureService();
errorRow();
const retry = service.retry(url);
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
if (removed) emit({ url, status: 'cancelled', generation: 0 });
finish();
await retry;
expect(epgBridge.forceFetchEpg).toHaveBeenCalledTimes(
removed ? 0 : 1
);
}
);
it.each(['private', 'tls'])(
'does not retry a removed/replaced row after a pending %s trust write',
async (kind) => {
epgBridge.supportsDataManagement = true;
let finish!: () => void;
settingsStore.updateSettings.mockReturnValue(
new Promise<void>((resolve) => {
finish = resolve;
})
);
const service = configureService();
errorRow();
const retry =
kind === 'private'
? service.trustPrivateNetworkSourceAndRetry(url)
: service.trustInsecureTlsHostAndRetry(url);
emit({ url, status: 'cancelled', generation: 0 });
emit({ url, status: 'loading', generation: 2 });
finish();
await retry;
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
expect(service.activeCount()).toBe(1);
}
);
it('does not let an old dismissal timer remove a replacement import', () => {
jest.useFakeTimers();
const service = configureService();
emit({ url, status: 'complete', generation: 0 });
emit({ url, status: 'cancelled', generation: 0 });
emit({ url, status: 'loading', generation: 2 });
jest.advanceTimersByTime(5000);
expect(service.activeCount()).toBe(1);
jest.useRealTimers();
});
it('does not subscribe to progress events when EPG progress support is disabled', () => {
epgBridge.supportsProgress = false;
configureService();
expect(epgBridge.onProgress).not.toHaveBeenCalled();
@@ -86,19 +175,21 @@ describe('EpgProgressService', () => {
expect(service.activeCount()).toBe(1);
});
it('does not force retry when EPG data management is disabled', () => {
it('does not force retry when EPG data management is disabled', async () => {
const service = configureService();
service.retry('https://example.com/epg.xml');
errorRow();
await service.retry('https://example.com/epg.xml');
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
});
it('forces retry through the EPG runtime bridge when data management is enabled', () => {
it('forces retry through the EPG runtime bridge when data management is enabled', async () => {
epgBridge.supportsDataManagement = true;
const service = configureService();
service.retry('https://example.com/epg.xml');
errorRow();
await service.retry('https://example.com/epg.xml');
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
'https://example.com/epg.xml',
@@ -113,6 +204,7 @@ describe('EpgProgressService', () => {
epgBridge.supportsDataManagement = true;
const service = configureService();
errorRow('http://192.168.1.30/guide.xml');
await service.trustPrivateNetworkSourceAndRetry(
'http://192.168.1.30/guide.xml'
);
@@ -3,7 +3,7 @@ import {
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
normalizeHost,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
import {
EpgImportProgress,
EpgRuntimeBridgeService,
@@ -13,6 +13,7 @@ import {
export class EpgProgressService {
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
private readonly sources = inject(EpgSourceSettingsService);
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
new Map()
);
@@ -45,20 +46,27 @@ export class EpgProgressService {
this.importsMap.set(new Map());
}
retry(url: string): void {
// Clear the errored row so the backend's subsequent 'queued' event
// reappears cleanly rather than updating an existing error row.
this.removeImport(url);
if (!this.epgBridge.supportsDataManagement) {
async retry(url: string): Promise<void> {
const progress = this.importsMap().get(url);
if (
progress?.status !== 'error' ||
!this.epgBridge.supportsDataManagement
)
return;
}
void this.epgBridge.forceFetchEpg(
// A settings save may already be retiring this row. Never bypass its
// ownership reconciliation by starting a new force-fetch generation.
await this.sources.waitForReconciliation();
if (this.importsMap().get(url) !== progress) return;
this.removeImport(url);
await this.epgBridge.forceFetchEpg(
url,
this.settingsStore.getTrustOptions()
);
}
async trustPrivateNetworkSourceAndRetry(url: string): Promise<void> {
const progress = this.importsMap().get(url);
if (progress?.status !== 'error') return;
const settings = this.settingsStore.getSettings();
const trustedUrls = new Set(
settings.trustedPrivateNetworkEpgUrls ?? []
@@ -68,13 +76,15 @@ export class EpgProgressService {
await this.settingsStore.updateSettings({
trustedPrivateNetworkEpgUrls: Array.from(trustedUrls),
});
this.retry(url);
if (this.importsMap().get(url) === progress) await this.retry(url);
}
async trustInsecureTlsHostAndRetry(
url: string,
host?: string
): Promise<void> {
const progress = this.importsMap().get(url);
if (progress?.status !== 'error') return;
const trustedHost = host ?? this.getHostname(url);
if (!trustedHost) {
return;
@@ -91,7 +101,7 @@ export class EpgProgressService {
await this.settingsStore.updateSettings({
trustedInsecureTlsHosts: Array.from(trustedHosts),
});
this.retry(url);
if (this.importsMap().get(url) === progress) await this.retry(url);
}
private initializeListener(): void {
@@ -124,7 +134,10 @@ export class EpgProgressService {
progress.status === 'complete' ||
(progress.status === 'error' && !this.isActionableError(progress))
) {
setTimeout(() => this.removeImport(progress.url), 5000);
setTimeout(() => {
if (this.importsMap().get(progress.url) === progress)
this.removeImport(progress.url);
}, 5000);
}
}