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

+422 -14
View File
@@ -1,3 +1,5 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Page } from '@playwright/test';
import {
buildM3uContent,
@@ -5,6 +7,7 @@ import {
closeElectronApp,
createMutableTextServer,
expect,
goToDashboard,
importM3uPlaylistFromUrl,
launchElectronApp,
openWorkspaceSection,
@@ -33,7 +36,8 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
function createCurrentXmltvFixture(
channelId: string,
channelName: string,
programTitle: string
programTitle: string,
iconUrl?: string
): string {
const start = new Date(Date.now() - 15 * 60 * 1000);
const stop = new Date(Date.now() + 45 * 60 * 1000);
@@ -42,6 +46,7 @@ function createCurrentXmltvFixture(
<tv>
<channel id="${channelId}">
<display-name>${channelName}</display-name>
${iconUrl ? `<icon src="${iconUrl}"/>` : ''}
</channel>
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
<title>${programTitle}</title>
@@ -65,7 +70,159 @@ function formatXmltvDate(date: Date): string {
}
test.describe('Electron EPG', () => {
test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
test('@epg @electron saves unrelated settings when EPG reconciliation is unavailable', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'general');
// After startup, make any unexpected reconciliation fail.
await app.electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
throw new Error('Synthetic migration failure');
});
});
const guard = app.mainWindow
.getByTestId('portal-connectivity-toggle')
.locator('input');
await guard.uncheck();
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).toBeVisible();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).not.toBeVisible();
await goToDashboard(app.mainWindow);
await expect(
app.mainWindow.getByTestId('unsaved-dialog-save')
).not.toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@epg @electron mirrors saved desktop settings when source cleanup fails', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await app.electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
throw new Error('Synthetic cleanup failure');
});
});
await openSettingsSection(app.mainWindow, 'general');
await app.mainWindow
.getByTestId('portal-connectivity-toggle')
.locator('input')
.uncheck();
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.fill('https://example.invalid/guide.xml');
await app.mainWindow
.getByTestId('save-settings')
.click({ noWaitAfter: true });
await expect(
app.mainWindow.getByText(
'Failed to clear EPG data. Please try again.',
{ exact: true }
)
).toBeVisible();
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).toBeVisible();
await expect
.poll(async () => {
const config = JSON.parse(
await readFile(
join(dataDir, 'config', 'config.json'),
'utf8'
)
);
return config.PORTAL_CONNECTIVITY_GUARD;
})
.toBe(false);
// Restore cleanup so the explicit retry can finish and close normally.
await app.electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
ipcMain.handle('EPG_RECONCILE_SOURCES', () => ({
success: true,
removedUrls: [],
}));
});
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).not.toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@epg @electron omits source secrets from main and parser-worker diagnostics', async ({
dataDir,
}) => {
const server = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml; charset=utf-8',
resourcePath: '/epg-path-secret/guide.xml',
});
const app = await launchElectronApp(dataDir);
const sourceUrl = `${server.resourceUrl}?custom=epg-query-secret`;
let diagnostics = '';
const capture = (chunk: Buffer) => {
diagnostics += chunk.toString();
};
const process = app.electronApp.process();
process.stdout?.on('data', capture);
process.stderr?.on('data', capture);
try {
const imported = await app.mainWindow.evaluate(async (url) => {
const result = await window.electron.forceFetchEpg(url);
await window.electron.clearEpgDataForSource(url);
return result;
}, sourceUrl);
expect(imported.success).toBe(true);
await expect
.poll(() => diagnostics)
.toContain('EPG data cleared for source via worker');
const failed = await app.mainWindow.evaluate(async (url) => {
const missing = new URL(url);
missing.pathname += '/missing';
return window.electron.forceFetchEpg(missing.toString());
}, sourceUrl);
expect(failed.success).toBe(false);
await expect
.poll(() => diagnostics)
.toContain('Request failed with status code 404');
// Worker stdout can lose its final buffered line on termination;
// the awaited import result and main-process completion are authoritative.
expect(diagnostics).toContain('[EPG Worker] Fetching EPG source');
expect(diagnostics).toContain('EPG parsing complete:');
expect(diagnostics).not.toContain('epg-path-secret');
expect(diagnostics).not.toContain('epg-query-secret');
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
} finally {
process.stdout?.off('data', capture);
process.stderr?.off('data', capture);
await closeElectronApp(app);
await server.close();
}
});
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
@@ -103,6 +260,8 @@ test.describe('Electron EPG', () => {
.first()
).toBeVisible();
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
@@ -116,18 +275,6 @@ test.describe('Electron EPG', () => {
// would block the close and leak the Electron process.
await saveSettings(app.mainWindow);
await app.mainWindow
.getByRole('button', { name: 'Clear EPG data' })
.click();
const dialog = app.mainWindow.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog
.getByRole('button', { name: 'Yes', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 20000,
@@ -139,9 +286,249 @@ 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,
}) => {
test.setTimeout(120000);
const first = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-news',
'Shared News',
'Removed Bulletin'
),
{ contentType: 'application/xml', resourcePath: '/first.xml' }
);
const second = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-news',
'Shared News',
'Retained Bulletin'
),
{ contentType: 'application/xml', resourcePath: '/second.xml' }
);
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
for (const [index, url] of [
first.resourceUrl,
second.resourceUrl,
].entries()) {
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
const inputs = app.mainWindow.locator('.epg-source-row input');
await expect(inputs).toHaveCount(index + 1);
await inputs.nth(index).fill(url);
}
await saveSettings(app.mainWindow);
const programs = () =>
app.mainWindow.evaluate(async () =>
(
await window.electron.getChannelPrograms('mapped-news')
).map((p) => p.title)
);
await app.mainWindow.evaluate(() =>
window.electron.setEpgMapping('mapped-news', 'shared-news')
);
await expect
.poll(programs, { timeout: 30000 })
.toEqual(['Removed Bulletin', 'Retained Bulletin']);
await app.mainWindow
.locator('.epg-source-row')
.first()
.locator('button')
.nth(1)
.click();
// A staged removal must not delete data before Save.
expect(await programs()).toContain('Removed Bulletin');
await saveSettings(app.mainWindow);
await expect.poll(programs).toEqual(['Retained Bulletin']);
await closeElectronApp(app);
app = await launchElectronApp(dataDir);
await expect.poll(programs).toEqual(['Retained Bulletin']);
// Simulate a cache left by 0.23: import a source absent from settings.
await app.mainWindow.evaluate(
(url) => window.electron.forceFetchEpg(url),
first.resourceUrl
);
await expect.poll(programs).toContain('Removed Bulletin');
await closeElectronApp(app);
app = await launchElectronApp(dataDir);
await expect.poll(programs).toEqual(['Retained Bulletin']);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
await expect.poll(programs).toEqual([]);
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
expect(
await app.mainWindow.evaluate(() =>
window.electron.getEpgMapping('mapped-news')
)
).toMatchObject({ epgChannelId: 'shared-news' });
} finally {
await closeElectronApp(app);
await first.close();
await second.close();
}
});
test('@epg @electron restores the first source name and logo after removing the last metadata writer', async ({
dataDir,
}) => {
const first = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-logo',
'First News',
'First Bulletin',
'https://example.com/first.png'
),
{ contentType: 'application/xml', resourcePath: '/first.xml' }
);
const second = await createMutableTextServer(
createCurrentXmltvFixture(
'shared-logo',
'Second News',
'Second Bulletin',
'https://example.com/second.png'
),
{ contentType: 'application/xml', resourcePath: '/second.xml' }
);
let app = await launchElectronApp(dataDir);
const metadata = () =>
app.mainWindow.evaluate(
async () =>
(
await window.electron.getEpgChannelMetadata([
'shared-logo',
])
)['shared-logo']
);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
for (const [index, source] of [first, second].entries()) {
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
const inputs = app.mainWindow.locator('.epg-source-row input');
await expect(inputs).toHaveCount(index + 1);
await inputs.nth(index).fill(source.resourceUrl);
await saveSettings(app.mainWindow);
await expect.poll(metadata, { timeout: 30000 }).toMatchObject({
displayName: index === 0 ? 'First News' : 'Second News',
iconUrl: `https://example.com/${index === 0 ? 'first' : 'second'}.png`,
});
}
await app.mainWindow
.locator('.epg-source-row')
.nth(1)
.locator('button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
const firstMetadata = {
displayName: 'First News',
iconUrl: 'https://example.com/first.png',
};
await expect.poll(metadata).toMatchObject(firstMetadata);
await closeElectronApp(app);
app = await launchElectronApp(dataDir);
await expect.poll(metadata).toMatchObject(firstMetadata);
expect(
await app.mainWindow.evaluate(async () =>
(
await window.electron.getChannelPrograms('shared-logo')
).map((p) => p.title)
)
).toEqual(['First Bulletin']);
} finally {
await closeElectronApp(app);
await first.close();
await second.close();
}
});
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
dataDir,
}) => {
test.setTimeout(90000);
const epgServer = await createMutableTextServer(
createCurrentXmltvFixture(
'playlist-guide-news',
@@ -212,6 +599,27 @@ test.describe('Electron EPG', () => {
'Playlist Scoped Bulletin',
{ timeout: 30000 }
);
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(epgServer.resourceUrl);
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await saveSettings(app.mainWindow);
// The same URL still belongs to the saved M3U playlist.
const retained = await app.mainWindow.evaluate(async () =>
window.electron.getChannelPrograms('playlist-guide-news')
);
expect(retained.map((program) => program.title)).toContain(
'Playlist Scoped Bulletin'
);
} finally {
await closeElectronApp(app);
await playlistServer.close();
+189 -10
View File
@@ -5,6 +5,7 @@ import {
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
createMutableTextServer,
expect,
goToDashboard,
launchElectronApp,
@@ -28,6 +29,189 @@ const epgCredentials = {
password: 'epg',
};
test('@epg @xtream @electron removes uploaded guide data and restores provider EPG without restarting', async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
const id = fixture.stream.epg_channel_id;
expect(id).toBeTruthy();
const stamp = (date: Date) =>
date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
const xml = `<tv><channel id="${id}"><display-name>Uploaded Guide</display-name></channel>
<programme channel="${id}" start="${stamp(new Date(Date.now() - 600000))} +0000" stop="${stamp(new Date(Date.now() + 3600000))} +0000"><title>Temporary XMLTV Bulletin</title></programme></tv>`;
const source = await createMutableTextServer(xml, {
contentType: 'application/xml',
resourcePath: '/temporary.xml',
});
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: epgPortalName,
...epgCredentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
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(source.resourceUrl);
await app.mainWindow
.getByTestId('toggle-prefer-uploaded-epg')
.locator('input')
.check();
await saveSettings(app.mainWindow);
await expect
.poll(
() =>
app.mainWindow.evaluate(
async (channelId) =>
(
await window.electron.getChannelPrograms(
channelId!
)
).map((p) => p.title),
id
),
{ timeout: 30000 }
)
.toContain('Temporary XMLTV Bulletin');
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const row = channelItemByTitle(
app.mainWindow,
fixture.stream.name ?? ''
).first();
await expect(row.locator('.epg-title')).toHaveText(
'Temporary XMLTV Bulletin'
);
await row.click();
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toContain('Temporary XMLTV Bulletin');
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await expect(row.locator('.epg-title')).toHaveText(
fixture.shortEpg[0].title
);
await row.click();
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.not.toContain('Temporary XMLTV Bulletin');
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toContain(fixture.fullEpg[0].title);
} finally {
await closeElectronApp(app);
await source.close();
}
});
test('@epg @stalker @electron invalidates a loaded manual XMLTV mapping after source removal', async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
await resetMockServers(request, ['stalker']);
const fixture = await fetchStalkerCategoryFixture(request, 'itv');
const item = fixture.items[0];
const stamp = (date: Date) =>
date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
const source = await createMutableTextServer(
`<tv><channel id="stalker-mapped"><display-name>Mapped Guide</display-name></channel>
<programme channel="stalker-mapped" start="${stamp(new Date(Date.now() - 600000))} +0000" stop="${stamp(new Date(Date.now() + 3600000))} +0000"><title>Retired Stalker Bulletin</title></programme></tv>`,
{
contentType: 'application/xml',
resourcePath: '/stalker.xml',
}
);
const app = await launchElectronApp(dataDir);
try {
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
// Keep external media pending while the local guide is exercised.
});
await addStalkerPortal(app.mainWindow, {
name: 'Stalker XMLTV Removal',
});
await waitForStalkerCatalog(app.mainWindow);
const playlistId = new URL(app.mainWindow.url()).pathname.match(
/\/stalker\/([^/]+)/
)?.[1];
expect(playlistId).toBeTruthy();
const key = `stalker:${decodeURIComponent(playlistId!)}:${String(item.id).trim()}`;
await app.mainWindow.evaluate(
(mappingKey) =>
window.electron.setEpgMapping(mappingKey, 'stalker-mapped'),
key
);
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(source.resourceUrl);
await saveSettings(app.mainWindow);
await expect
.poll(
() =>
app.mainWindow.evaluate(async () =>
(
await window.electron.getChannelPrograms(
'stalker-mapped'
)
).map((p) => p.title)
),
{ timeout: 30000 }
)
.toContain('Retired Stalker Bulletin');
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const row = channelItemByTitle(
app.mainWindow,
item.o_name || item.name || ''
).first();
await expect(row.locator('.epg-title')).toHaveText(
'Retired Stalker Bulletin'
);
await row.click();
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toContain('Retired Stalker Bulletin');
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await expect(row).toBeVisible();
await row.click();
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.not.toContain('Retired Stalker Bulletin');
await expect(row.locator('.epg-title')).toHaveCount(0);
expect(
await app.mainWindow.evaluate(
(mappingKey) => window.electron.getEpgMapping(mappingKey),
key
)
).toMatchObject({ epgChannelId: 'stalker-mapped' });
} finally {
await closeElectronApp(app);
await source.close();
}
});
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({
dataDir,
@@ -66,9 +250,7 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
// Provider-declared catch-up (tv_archive=1 in the fixture) is
// surfaced as a badge on the sidebar row (#1128).
await expect(
channelRow.getByTestId('catchup-badge')
).toBeVisible();
await expect(channelRow.getByTestId('catchup-badge')).toBeVisible();
// Sidebar channel list shows the per-channel "now" programme line.
await expect
@@ -263,10 +445,7 @@ test('@radio @stalker @electron keeps radio rows compact at narrow widths', asyn
await expect(categoryButton).toBeVisible();
await categoryButton.click();
const radioRow = channelItemByTitle(
app.mainWindow,
firstTitle
).first();
const radioRow = channelItemByTitle(app.mainWindow, firstTitle).first();
await expect(radioRow).toBeVisible({ timeout: 20000 });
await expect(radioRow).toHaveClass(/compact/);
await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0);
@@ -301,7 +480,8 @@ test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" b
// The guide runs an hour ahead of the real schedule, so the programme
// actually on air is the one the provider files under "an hour ago" —
// and every surface must agree on that same programme.
const providerNowSeconds = Math.floor(Date.now() / 1000) - offsetMinutes * 60;
const providerNowSeconds =
Math.floor(Date.now() / 1000) - offsetMinutes * 60;
const expectedProgram = fixture.fullEpg.find(
(listing) =>
listing.startTimestamp <= providerNowSeconds &&
@@ -341,8 +521,7 @@ test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" b
await expect
.poll(async () =>
(
(await channelRow.locator('.epg-title').textContent()) ??
''
(await channelRow.locator('.epg-title').textContent()) ?? ''
).trim()
)
.toBe(expectedProgram.title);
@@ -536,6 +536,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
channel: 'EPG_CLEAR_ALL',
forwardedArgs: [],
},
{
method: 'reconcileEpgSources',
args: [epgUrls],
channel: 'EPG_RECONCILE_SOURCES',
forwardedArgs: [{ urls: epgUrls }],
},
{
method: 'clearEpgDataForSource',
args: ['https://example.com/guide.xml'],
@@ -615,11 +615,7 @@ const electronApi: ElectronBridgeApi = {
sessionId: string,
style: EmbeddedMpvSubtitleStyle
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_SET_SUBTITLE_STYLE',
sessionId,
style
),
ipcRenderer.invoke('EMBEDDED_MPV_SET_SUBTITLE_STYLE', sessionId, style),
selectEmbeddedMpvSubtitleFile: (): Promise<string | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'),
setEmbeddedMpvSpeed: (
@@ -681,6 +677,8 @@ const electronApi: ElectronBridgeApi = {
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
reconcileEpgSources: (urls: string[]) =>
ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }),
clearEpgDataForSource: (sourceUrl: string) =>
ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }),
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
@@ -1118,8 +1116,7 @@ const electronApi: ElectronBridgeApi = {
recordingsUpdatePrograms: (
targetPath: string,
programs: RecordingProgramSnapshot[]
) =>
ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs),
) => ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs),
recordingsRevealFile: (filePath: string) =>
ipcRenderer.invoke('RECORDINGS_REVEAL_FILE', filePath),
recordingsPlayFile: (filePath: string) =>
@@ -4,40 +4,41 @@
*/
export {
// Tables
playlists,
categories,
content,
recentlyViewed,
favorites,
epgChannels,
epgChannelMappings,
epgPrograms,
playbackPositions,
downloads,
recordings,
appState,
// Types
type Playlist,
type NewPlaylist,
type AppState,
type NewAppState,
type Category,
type NewCategory,
type Content,
type NewContent,
type RecentlyViewed,
type NewRecentlyViewed,
type Favorite,
type NewFavorite,
type EpgChannel,
type NewEpgChannel,
type EpgProgramDb,
type NewEpgProgramDb,
type PlaybackPosition,
type NewPlaybackPosition,
type Download,
type NewDownload,
type Recording,
type NewRecording,
// Tables
playlists,
categories,
content,
recentlyViewed,
favorites,
epgChannels,
epgChannelSources,
epgChannelMappings,
epgPrograms,
playbackPositions,
downloads,
recordings,
appState,
// Types
type Playlist,
type NewPlaylist,
type AppState,
type NewAppState,
type Category,
type NewCategory,
type Content,
type NewContent,
type RecentlyViewed,
type NewRecentlyViewed,
type Favorite,
type NewFavorite,
type EpgChannel,
type NewEpgChannel,
type EpgProgramDb,
type NewEpgProgramDb,
type PlaybackPosition,
type NewPlaybackPosition,
type Download,
type NewDownload,
type Recording,
type NewRecording,
} from '@iptvnator/shared/database';
@@ -0,0 +1,281 @@
import { epgLogger } from '../util/epg-logger';
import type { Worker } from 'worker_threads';
import type {
ElectronBridgeSecurityErrorCode,
ElectronBridgeTrustOptions,
} from '@iptvnator/shared/interfaces';
import { epgSourceGeneration } from './epg-source-generation';
import type { EpgWorkerRuntime } from './epg-worker-runtime';
import type { EpgProgressStats, EpgWorkerService } from './epg-worker.service';
interface EpgWorkerMessage {
type: string;
error?: string;
errorCode?: ElectronBridgeSecurityErrorCode;
errorHost?: string;
url?: string;
stats?: EpgProgressStats;
}
interface FetchContext {
runtime: EpgWorkerRuntime;
workers: Map<string, Worker>;
fetchedUrls: Set<string>;
loggerLabel: string;
fetchTimeoutMs: number;
sendProgressToRenderer: EpgWorkerService['sendProgressToRenderer'];
}
/** One import's message, cancellation, timeout and exit lifecycle. */
export function runEpgFetch(
url: string,
options: ElectronBridgeTrustOptions,
context: FetchContext
): Promise<void> {
const generation = epgSourceGeneration(url);
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = context.runtime.createEpgWorker();
} catch (error) {
epgLogger.error(
context.loggerLabel,
'Failed to create worker:',
error
);
reject(error);
return;
}
context.workers.set(url, worker);
// Guards against double-settling and keeps the outer loop moving
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let lastProgressStats: EpgProgressStats = {
totalChannels: 0,
totalPrograms: 0,
};
const clearFetchTimeout = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearFetchTimeout();
fn();
};
const cancelIfRetired = (exited = false): boolean => {
if (generation === epgSourceGeneration(url)) return false;
if (context.workers.get(url) === worker)
context.workers.delete(url);
settle(() => {
context.sendProgressToRenderer(
url,
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generation
);
if (exited) resolve();
else
void context.runtime
.terminateWorker(worker, 'retired fetch')
.then(() => resolve());
});
return true;
};
const scheduleFetchTimeout = () => {
clearFetchTimeout();
timeoutId = setTimeout(() => {
handleFetchTimeout();
}, context.fetchTimeoutMs);
};
const hasProgressMoved = (stats: EpgProgressStats): boolean =>
stats.totalChannels > lastProgressStats.totalChannels ||
stats.totalPrograms > lastProgressStats.totalPrograms;
const recordProgress = (stats: EpgProgressStats): void => {
lastProgressStats = {
totalChannels: Math.max(
lastProgressStats.totalChannels,
stats.totalChannels
),
totalPrograms: Math.max(
lastProgressStats.totalPrograms,
stats.totalPrograms
),
};
};
const handleFetchTimeout = () => {
if (settled || cancelIfRetired()) return;
const errorMessage = `EPG fetch timed out after ${
context.fetchTimeoutMs / 1000
}s without progress`;
epgLogger.error(context.loggerLabel, errorMessage);
context.sendProgressToRenderer(
url,
'error',
undefined,
errorMessage
);
context.workers.delete(url);
// Settle only after the worker thread is really gone: a
// terminated-but-still-running worker can keep holding the
// SQLite lock and block the next EPG fetch.
settle(() => {
void context.runtime
.terminateWorker(worker, 'timed out fetch')
.then(() => reject(new Error(errorMessage)));
});
};
scheduleFetchTimeout();
worker.on('message', async (message: EpgWorkerMessage) => {
if (settled || generation !== epgSourceGeneration(url)) return;
try {
switch (message.type) {
case 'READY':
scheduleFetchTimeout();
context.sendProgressToRenderer(url, 'loading', {
totalChannels: 0,
totalPrograms: 0,
});
worker.postMessage({
type: 'FETCH_EPG',
url,
options,
});
break;
case 'EPG_PROGRESS':
if (message.stats) {
if (hasProgressMoved(message.stats)) {
recordProgress(message.stats);
scheduleFetchTimeout();
}
context.sendProgressToRenderer(
url,
'loading',
message.stats
);
}
break;
case 'EPG_COMPLETE':
epgLogger.log(
context.loggerLabel,
'EPG parsing complete:',
message.stats
);
context.sendProgressToRenderer(
url,
'complete',
message.stats
);
context.fetchedUrls.add(url);
context.workers.delete(url);
settle(() => {
void context.runtime
.terminateWorker(worker, 'completed fetch')
.then(() => resolve());
});
break;
case 'EPG_ERROR':
epgLogger.error(
context.loggerLabel,
'Worker error:',
message.error
);
context.sendProgressToRenderer(
url,
'error',
undefined,
message.error,
undefined,
message.errorCode,
message.errorHost
);
context.workers.delete(url);
settle(() => {
void context.runtime
.terminateWorker(worker, 'failed fetch')
.then(() =>
reject(
new Error(
message.error || 'Unknown error'
)
)
);
});
break;
}
} catch (err) {
if (settled || cancelIfRetired()) return;
epgLogger.error(
context.loggerLabel,
'Error handling message:',
err
);
context.sendProgressToRenderer(
url,
'error',
undefined,
err instanceof Error ? err.message : String(err)
);
context.workers.delete(url);
settle(() => {
void context.runtime
.terminateWorker(worker, 'failed message handling')
.then(() => reject(err));
});
}
});
worker.on('error', (error) => {
if (settled || cancelIfRetired()) return;
epgLogger.error(context.loggerLabel, 'Worker error event:', error);
context.sendProgressToRenderer(
url,
'error',
undefined,
error.message
);
context.workers.delete(url);
settle(() => {
void context.runtime
.terminateWorker(worker, 'errored fetch')
.then(() => reject(error));
});
});
worker.on('exit', (code) => {
if (settled || cancelIfRetired(true)) return;
const errorMessage = `Worker exited unexpectedly (code ${code})`;
epgLogger.error(context.loggerLabel, errorMessage);
context.sendProgressToRenderer(
url,
'error',
undefined,
errorMessage
);
context.workers.delete(url);
settle(() => reject(new Error(errorMessage)));
});
});
}
@@ -1,3 +1,5 @@
import { epgLogger } from '../util/epg-logger';
import { epgSourceGeneration, requestEpgSource } from './epg-source-generation';
import { eq } from 'drizzle-orm';
import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
@@ -45,12 +47,13 @@ export async function checkEpgFreshness(
const db = await getDatabase();
for (const url of urls) {
const generation = epgSourceGeneration(url);
if (!url?.trim()) continue;
const result = await db
.select({ updatedAt: schema.epgChannels.updatedAt })
.from(schema.epgChannels)
.where(eq(schema.epgChannels.sourceUrl, url))
.select({ updatedAt: schema.epgChannelSources.updatedAt })
.from(schema.epgChannelSources)
.where(eq(schema.epgChannelSources.sourceUrl, url))
.limit(1);
const isFresh =
@@ -58,6 +61,7 @@ export async function checkEpgFreshness(
result[0].updatedAt &&
result[0].updatedAt >= cutoffTime;
if (generation !== epgSourceGeneration(url)) continue;
if (isFresh) {
freshUrls.push(url);
epgWorkerService.markFetchedUrl(url);
@@ -66,18 +70,18 @@ export async function checkEpgFreshness(
}
}
} catch (error) {
console.error(loggerLabel, 'Error checking EPG freshness:', error);
epgLogger.error(loggerLabel, 'Error checking EPG freshness:', error);
return { staleUrls: urls, freshUrls: [] };
}
if (freshUrls.length > 0) {
console.log(
epgLogger.log(
loggerLabel,
`EPG fresh (skipping): ${freshUrls.length} source(s)`
);
}
if (staleUrls.length > 0) {
console.log(
epgLogger.log(
loggerLabel,
`EPG stale (will fetch): ${staleUrls.length} source(s)`
);
@@ -95,7 +99,12 @@ export async function handleFetchEpg(
urls: string[],
options: ElectronBridgeTrustOptions = {}
): Promise<EpgFetchResult> {
const validUrls = urls.filter((url) => url?.trim());
const validUrls = urls
.filter((url) => url?.trim())
.map((url) => url.trim());
const generations = new Map(
validUrls.map((url) => [url, requestEpgSource(url)])
);
if (validUrls.length === 0) {
return { success: false, message: 'No valid URLs provided' };
@@ -118,11 +127,13 @@ export async function handleFetchEpg(
// a 'queued' status, then fetchEpgFromUrl silently skips the URL and no
// completion update ever arrives, leaving the UI stuck at "queued".
const urlsToFetch = staleUrls.filter(
(url) => !epgWorkerService.hasFetchedUrl(url)
(url) =>
generations.get(url) === epgSourceGeneration(url) &&
!epgWorkerService.hasFetchedUrl(url)
);
if (urlsToFetch.length === 0) {
console.log(
epgLogger.log(
loggerLabel,
`All ${staleUrls.length} stale URL(s) already fetched this session; skipping`
);
@@ -142,16 +153,23 @@ export async function handleFetchEpg(
const errors: string[] = [];
for (const url of urlsToFetch) {
try {
if (generations.get(url) !== epgSourceGeneration(url)) {
epgWorkerService.sendProgressToRenderer(
url,
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generations.get(url)
);
continue;
}
await epgWorkerService.fetchEpgFromUrl(url, options);
} catch (error) {
console.error(
loggerLabel,
`Error fetching EPG from ${url}:`,
error
);
errors.push(
error instanceof Error ? error.message : String(error)
);
epgLogger.error(loggerLabel, 'Error fetching EPG source:', error);
errors.push(error instanceof Error ? error.message : String(error));
}
}
@@ -1,3 +1,4 @@
import { epgLogger } from '../util/epg-logger';
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
import {
EpgChannelMetadata,
@@ -154,7 +155,7 @@ export class EpgQueryService {
return [];
} catch (error) {
console.error(
epgLogger.error(
this.loggerLabel,
'Error getting channel programs:',
error
@@ -310,7 +311,7 @@ export class EpgQueryService {
}
return result;
} catch (error) {
console.error(
epgLogger.error(
this.loggerLabel,
'Error getting batch current programs:',
error
@@ -335,7 +336,7 @@ export class EpgQueryService {
return { channels, programs: [] };
} catch (error) {
console.error(
epgLogger.error(
this.loggerLabel,
'Error getting all channels:',
error
@@ -391,7 +392,7 @@ export class EpgQueryService {
])
);
} catch (error) {
console.error(
epgLogger.error(
this.loggerLabel,
'Error getting channel metadata:',
error
@@ -439,7 +440,7 @@ export class EpgQueryService {
})
);
} catch (error) {
console.error(
epgLogger.error(
this.loggerLabel,
'Error getting channels by range:',
error
@@ -0,0 +1,23 @@
/** Retires queued imports as well as workers already parsing a removed URL. */
const generations = new Map<string, number>();
const requests = new Map<string, symbol>();
export function epgSourceGeneration(url: string): number {
return generations.get(url.trim()) ?? 0;
}
export function requestEpgSource(url: string): number {
requests.set(url.trim(), Symbol());
return epgSourceGeneration(url);
}
export function retireEpgSource(url: string): void {
generations.set(url.trim(), epgSourceGeneration(url) + 1);
}
export function requestedEpgSources(): Map<string, symbol> {
return new Map(requests);
}
export function forgetEpgSourceRequest(
url: string,
request: symbol | undefined
): void {
// A request received during cleanup still needs reconciliation next time.
if (requests.get(url.trim()) === request) requests.delete(url.trim());
}
@@ -0,0 +1,184 @@
import {
appState,
epgChannels,
epgChannelSources,
epgPrograms,
playlists,
} from '../database/schema';
import { getDatabase } from '../database/connection';
import { epgWorkerService } from './epg-worker.service';
import { epgSourceGeneration, requestEpgSource } from './epg-source-generation';
import { reconcileEpgSources } from './epg-source-settings.service';
jest.mock('../database/connection', () => ({ getDatabase: jest.fn() }));
jest.mock('./epg-worker.service', () => ({
epgWorkerService: {
clearEpgDataForSource: jest.fn().mockResolvedValue(undefined),
sendProgressToRenderer: jest.fn(),
},
}));
describe('committed EPG source reconciliation', () => {
let rows: Map<unknown, unknown[]>;
beforeEach(() => {
jest.clearAllMocks();
rows = new Map<unknown, unknown[]>([
[appState, [{ value: '1' }]],
[playlists, []],
[epgChannels, [{ url: 'removed' }, { url: 'shared' }]],
[epgPrograms, [{ url: 'removed' }, { url: 'second' }]],
]);
const select = () => ({
from: (table: unknown) => {
const result = rows.get(table) ?? [];
return Object.assign(Promise.resolve(result), {
where: () => Promise.resolve(result),
});
},
});
(getDatabase as jest.Mock).mockResolvedValue({
select,
selectDistinct: select,
});
});
it('preserves global and every enabled M3U source, including an overlapping channel owner', async () => {
rows.set(playlists, [{ type: 'm3u-url', urls: '["shared"]' }]);
const generation = epgSourceGeneration('removed');
await reconcileEpgSources([' second ']);
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledWith(
'removed'
);
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalledWith(
'shared'
);
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalledWith(
'second'
);
expect(epgSourceGeneration('removed')).toBeGreaterThan(generation);
});
it('cleans already orphaned sources when the saved global list is empty', async () => {
await reconcileEpgSources([]);
for (const url of ['removed', 'shared', 'second']) {
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledWith(
url
);
}
});
it('finds metadata-only source owners after restart', async () => {
rows.set(epgChannels, [{ url: 'active' }]);
rows.set(epgPrograms, []);
rows.set(epgChannelSources, [
{ url: 'metadata-only' },
{ url: 'active' },
]);
await reconcileEpgSources(['active']);
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledWith(
'metadata-only'
);
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalledWith(
'active'
);
});
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([]);
rows.set(epgChannels, []);
rows.set(epgPrograms, []);
jest.clearAllMocks();
await reconcileEpgSources([]);
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
});
it('retries a failed cleanup even when the queued source had no database rows', async () => {
rows.set(epgChannels, []);
rows.set(epgPrograms, []);
requestEpgSource('queued-only');
(
epgWorkerService.clearEpgDataForSource as jest.Mock
).mockRejectedValueOnce(new Error('worker failure'));
await expect(reconcileEpgSources([])).rejects.toThrow('worker failure');
await reconcileEpgSources([]);
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledTimes(2);
});
it('preserves a new request received while the previous request is being cleared', async () => {
rows.set(epgChannels, []);
rows.set(epgPrograms, []);
requestEpgSource('requested-again');
(
epgWorkerService.clearEpgDataForSource as jest.Mock
).mockImplementationOnce(async () => {
requestEpgSource('requested-again');
});
await reconcileEpgSources([]);
await reconcileEpgSources([]);
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledTimes(2);
jest.clearAllMocks();
await reconcileEpgSources([]);
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
});
it('does not prune sources before playlist migration succeeds', async () => {
rows.set(appState, []);
await expect(reconcileEpgSources([])).rejects.toThrow(
'migrated playlists'
);
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
});
it('does not guess ownership when persisted playlist source metadata is invalid', async () => {
rows.set(playlists, [{ type: 'm3u-url', urls: '{broken' }]);
await expect(reconcileEpgSources([])).rejects.toThrow();
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,102 @@
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database/connection';
import {
appState,
epgChannels,
epgChannelSources,
epgPrograms,
playlists,
} from '../database/schema';
import { epgWorkerService } from './epg-worker.service';
import {
epgSourceGeneration,
forgetEpgSourceRequest,
requestedEpgSources,
retireEpgSource,
} from './epg-source-generation';
let reconciliation = Promise.resolve();
/** Called only with settings successfully read from or written to IndexedDB. */
export function reconcileEpgSources(globalUrls: string[]): Promise<void> {
const next = reconciliation
.catch(() => undefined)
.then(async () => {
const db = await getDatabase();
const migration = await db
.select()
.from(appState)
.where(
eq(appState.key, 'm3u-playlists-indexeddb-to-sqlite-v1')
);
// A failed/incomplete migration cannot establish all playlist owners.
if (migration[0]?.value !== '1') {
throw new Error(
'EPG source reconciliation requires migrated playlists'
);
}
const active = new Set(
globalUrls.map((url) => url.trim()).filter(Boolean)
);
const savedPlaylists = await db
.select({ type: playlists.type, urls: playlists.epgUrls })
.from(playlists);
for (const playlist of savedPlaylists) {
if (!playlist.type.startsWith('m3u-')) continue;
const urls: unknown = JSON.parse(playlist.urls || '[]');
if (
!Array.isArray(urls) ||
urls.some((url) => typeof url !== 'string')
) {
throw new Error('Invalid saved playlist EPG ownership');
}
for (const url of urls as string[])
if (url.trim()) active.add(url.trim());
}
const channels = await db
.selectDistinct({ url: epgChannels.sourceUrl })
.from(epgChannels);
const programs = await db
.selectDistinct({ url: epgPrograms.sourceUrl })
.from(epgPrograms);
const metadata = await db
.selectDistinct({ url: epgChannelSources.sourceUrl })
.from(epgChannelSources);
const requested = requestedEpgSources();
const removed = [
...new Set(
[
...channels.map((row) => row.url),
...programs.map((row) => row.url),
...metadata.map((row) => row.url),
...requested.keys(),
].filter(
(url): url is string =>
!!url?.trim() && !active.has(url.trim())
)
),
];
// Fence the whole obsolete set before awaiting the first worker exit.
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()));
}
});
reconciliation = next;
return next;
}
@@ -0,0 +1,157 @@
import { epgLogger } from '../util/epg-logger';
import { app } from 'electron';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
interface ClearWorkerMessage {
type: 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
sourceUrl?: string;
}
/** Worker bootstrap, shutdown and one-shot cache-clear protocol. */
export class EpgWorkerRuntime {
constructor(
private readonly loggerLabel: string,
private readonly fetchTimeoutMs: number
) {}
runClearWorker(options: {
timeoutLabel: string;
exitLabel: string;
readyMessage: ClearWorkerMessage;
completeWorkerLabel: string;
failedWorkerLabel: string;
erroredWorkerLabel: string;
onComplete: (worker: Worker) => Promise<void>;
}): Promise<void> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = this.createEpgWorker();
} catch (error) {
epgLogger.error(
this.loggerLabel,
'Failed to create worker for clear:',
error
);
reject(error);
return;
}
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
fn();
};
const timeoutId = setTimeout(() => {
const errorMessage = `${options.timeoutLabel} timed out after ${
this.fetchTimeoutMs / 1000
}s`;
epgLogger.error(this.loggerLabel, errorMessage);
settle(() => {
void this.terminateWorker(
worker,
`timed out ${options.timeoutLabel}`
).then(() => reject(new Error(errorMessage)));
});
}, this.fetchTimeoutMs);
worker.on(
'message',
(message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage(options.readyMessage);
} else if (message.type === 'CLEAR_COMPLETE') {
settle(() => {
void options
.onComplete(worker)
.then(() => resolve(), reject);
});
} else if (message.type === 'EPG_ERROR') {
epgLogger.error(
this.loggerLabel,
'Worker clear error:',
message.error
);
settle(() => {
void this.terminateWorker(
worker,
options.failedWorkerLabel
).then(() =>
reject(
new Error(message.error || 'Clear failed')
)
);
});
}
}
);
worker.on('error', (error) => {
epgLogger.error(
this.loggerLabel,
'Worker error during clear:',
error
);
settle(() => {
void this.terminateWorker(
worker,
options.erroredWorkerLabel
).then(() => reject(error));
});
});
worker.on('exit', (code) => {
if (settled) return;
const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`;
epgLogger.error(this.loggerLabel, errorMessage);
settle(() => reject(new Error(errorMessage)));
});
});
}
/**
* Awaits worker shutdown so callers can sequence work (e.g. the next DB
* access) after the thread has really exited. Termination failures are
* logged and swallowed — there is nothing actionable left to do.
*/
async terminateWorker(worker: Worker, context: string): Promise<void> {
try {
await worker.terminate();
} catch (error) {
epgLogger.error(
this.loggerLabel,
`Failed to terminate ${context} worker:`,
error
);
}
}
createEpgWorker(): Worker {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: app.isPackaged,
workerFilename: 'epg-parser.worker.js',
developmentWorkerDir: path.join(__dirname, 'workers'),
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
appPath: app.getAppPath(),
});
const workerURL = pathToFileURL(bootstrap.workerPath);
return new Worker(workerURL, {
resourceLimits: {
maxOldGenerationSizeMb: 4096,
maxYoungGenerationSizeMb: 512,
},
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
},
});
}
}
@@ -67,6 +67,62 @@ describe('EpgWorkerService worker lifecycle', () => {
consoleErrorSpy.mockRestore();
});
it('keeps source credentials out of completion, reuse, cache and cleanup logs', async () => {
const secretUrl =
'https://secret-user:secret-password@example.com/private-key/guide.xml?custom=secret-query#secret-fragment';
const first = service.fetchEpgFromUrl(secretUrl);
const reused = service.fetchEpgFromUrl(secretUrl);
mockWorkerInstances[0].emit('message', {
type: 'EPG_COMPLETE',
stats: { totalChannels: 1, totalPrograms: 2 },
});
await Promise.all([first, reused]);
await service.fetchEpgFromUrl(secretUrl);
const clear = service.clearEpgDataForSource(secretUrl);
mockWorkerInstances[1].emit('message', { type: 'CLEAR_COMPLETE' });
await clear;
const output = JSON.stringify(consoleLogSpy.mock.calls);
for (const secret of [
'secret-user',
'secret-password',
'private-key',
'secret-query',
'secret-fragment',
]) {
expect(output).not.toContain(secret);
}
expect(output).toContain('EPG parsing complete');
});
it('redacts worker errors without changing the rejected error', async () => {
const secretUrl =
'https://user:password@example.com/path-token/guide.xml?custom=query-token';
const error = Object.assign(new Error(`Request failed: ${secretUrl}`), {
config: {
url: secretUrl,
headers: { Authorization: 'Bearer header-secret' },
},
cause: new Error(
'Redirect failed: https://example.org/redirect-token?custom=redirect-query'
),
});
const pending = service.fetchEpgFromUrl(secretUrl);
mockWorkerInstances[0].emit('error', error);
await expect(pending).rejects.toBe(error);
const output = JSON.stringify(consoleErrorSpy.mock.calls);
for (const secret of [
'password',
'path-token',
'query-token',
'header-secret',
'redirect-token',
'redirect-query',
]) {
expect(output).not.toContain(secret);
}
expect(output).toContain('Request failed');
});
describe('worker lifecycle', () => {
it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => {
const options = { manuallyTrustedHosts: ['example.com'] } as any;
@@ -1,43 +1,40 @@
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { epgLogger } from '../util/epg-logger';
import {
epgSourceGeneration,
requestEpgSource,
retireEpgSource,
} from './epg-source-generation';
import { BrowserWindow } from 'electron';
import { EpgWorkerRuntime } from './epg-worker-runtime';
import { runEpgFetch } from './epg-fetch-operation';
import { Worker } from 'worker_threads';
import {
ElectronBridgeSecurityErrorCode,
ElectronBridgeTrustOptions,
} from '@iptvnator/shared/interfaces';
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
export type EpgProgressStatus = 'queued' | 'loading' | 'complete' | 'error';
export type EpgProgressStatus =
'queued' | 'loading' | 'complete' | 'error' | 'cancelled';
export interface EpgProgressStats {
totalChannels: number;
totalPrograms: number;
}
interface EpgWorkerMessage {
type: string;
error?: string;
errorCode?: ElectronBridgeSecurityErrorCode;
errorHost?: string;
url?: string;
stats?: EpgProgressStats;
}
interface ClearWorkerMessage {
type: 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
sourceUrl?: string;
}
export class EpgWorkerService {
private readonly fetchedUrls = new Set<string>();
private readonly workers = new Map<string, Worker>();
private readonly inFlightFetches = new Map<string, Promise<void>>();
private readonly inFlightSourceClears = new Map<string, Promise<void>>();
private readonly runtime: EpgWorkerRuntime;
constructor(
private readonly loggerLabel = '[EPG Events]',
private readonly fetchTimeoutMs = 5 * 60 * 1000
) {}
) {
this.runtime = new EpgWorkerRuntime(loggerLabel, fetchTimeoutMs);
}
hasFetchedUrl(url: string): boolean {
return this.fetchedUrls.has(url);
@@ -58,12 +55,14 @@ export class EpgWorkerService {
error?: string,
queuePosition?: number,
errorCode?: ElectronBridgeSecurityErrorCode,
errorHost?: string
errorHost?: string,
generation = epgSourceGeneration(url)
): void {
const windows = BrowserWindow.getAllWindows();
windows.forEach((win) => {
win.webContents.send('EPG_PROGRESS_UPDATE', {
url,
generation,
status,
stats,
error,
@@ -78,6 +77,26 @@ export class EpgWorkerService {
url: string,
options: ElectronBridgeTrustOptions = {}
): Promise<void> {
url = url.trim();
const generation = requestEpgSource(url);
const clear = this.inFlightSourceClears.get(url);
if (clear) {
await clear.catch(() => undefined);
if (generation !== epgSourceGeneration(url)) {
this.sendProgressToRenderer(
url,
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generation
);
return;
}
return this.fetchEpgFromUrl(url, options);
}
// A second request for an URL that is already being fetched must not
// spawn a competing worker: both would parse and write the same EPG
// data, and the late one would overwrite the early one's entry in
@@ -87,17 +106,14 @@ export class EpgWorkerService {
// must keep awaiting that termination window.
const inFlight = this.inFlightFetches.get(url);
if (inFlight) {
console.log(
this.loggerLabel,
`Reusing in-flight EPG fetch: ${url}`
);
epgLogger.log(this.loggerLabel, 'Reusing in-flight EPG fetch');
return inFlight;
}
if (this.fetchedUrls.has(url)) {
console.log(
epgLogger.log(
this.loggerLabel,
`Skipping already fetched URL: ${url}`
'Skipping already fetched EPG source'
);
return;
}
@@ -113,230 +129,18 @@ export class EpgWorkerService {
url: string,
options: ElectronBridgeTrustOptions
): Promise<void> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = this.createEpgWorker();
} catch (error) {
console.error(
this.loggerLabel,
'Failed to create worker:',
error
);
reject(error);
return;
}
this.workers.set(url, worker);
// Guards against double-settling and keeps the outer loop moving
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let lastProgressStats: EpgProgressStats = {
totalChannels: 0,
totalPrograms: 0,
};
const clearFetchTimeout = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearFetchTimeout();
fn();
};
const scheduleFetchTimeout = () => {
clearFetchTimeout();
timeoutId = setTimeout(() => {
handleFetchTimeout();
}, this.fetchTimeoutMs);
};
const hasProgressMoved = (stats: EpgProgressStats): boolean =>
stats.totalChannels > lastProgressStats.totalChannels ||
stats.totalPrograms > lastProgressStats.totalPrograms;
const recordProgress = (stats: EpgProgressStats): void => {
lastProgressStats = {
totalChannels: Math.max(
lastProgressStats.totalChannels,
stats.totalChannels
),
totalPrograms: Math.max(
lastProgressStats.totalPrograms,
stats.totalPrograms
),
};
};
const handleFetchTimeout = () => {
const errorMessage = `EPG fetch timed out after ${
this.fetchTimeoutMs / 1000
}s without progress`;
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
this.sendProgressToRenderer(
url,
'error',
undefined,
errorMessage
);
this.workers.delete(url);
// Settle only after the worker thread is really gone: a
// terminated-but-still-running worker can keep holding the
// SQLite lock and block the next EPG fetch.
settle(() => {
void this.terminateWorker(worker, 'timed out fetch').then(
() => reject(new Error(errorMessage))
);
});
};
scheduleFetchTimeout();
worker.on('message', async (message: EpgWorkerMessage) => {
try {
switch (message.type) {
case 'READY':
scheduleFetchTimeout();
this.sendProgressToRenderer(url, 'loading', {
totalChannels: 0,
totalPrograms: 0,
});
worker.postMessage({
type: 'FETCH_EPG',
url,
options,
});
break;
case 'EPG_PROGRESS':
if (message.stats) {
if (hasProgressMoved(message.stats)) {
recordProgress(message.stats);
scheduleFetchTimeout();
}
this.sendProgressToRenderer(
url,
'loading',
message.stats
);
}
break;
case 'EPG_COMPLETE':
console.log(
this.loggerLabel,
`EPG parsing complete for ${url}:`,
message.stats
);
this.sendProgressToRenderer(
url,
'complete',
message.stats
);
this.fetchedUrls.add(url);
this.workers.delete(url);
settle(() => {
void this.terminateWorker(
worker,
'completed fetch'
).then(() => resolve());
});
break;
case 'EPG_ERROR':
console.error(
this.loggerLabel,
'Worker error:',
message.error
);
this.sendProgressToRenderer(
url,
'error',
undefined,
message.error,
undefined,
message.errorCode,
message.errorHost
);
this.workers.delete(url);
settle(() => {
void this.terminateWorker(
worker,
'failed fetch'
).then(() =>
reject(
new Error(
message.error || 'Unknown error'
)
)
);
});
break;
}
} catch (err) {
console.error(
this.loggerLabel,
'Error handling message:',
err
);
this.sendProgressToRenderer(
url,
'error',
undefined,
err instanceof Error ? err.message : String(err)
);
this.workers.delete(url);
settle(() => {
void this.terminateWorker(
worker,
'failed message handling'
).then(() => reject(err));
});
}
});
worker.on('error', (error) => {
console.error(this.loggerLabel, 'Worker error event:', error);
this.sendProgressToRenderer(
url,
'error',
undefined,
error.message
);
this.workers.delete(url);
settle(() => {
void this.terminateWorker(worker, 'errored fetch').then(
() => reject(error)
);
});
});
worker.on('exit', (code) => {
if (settled) return;
const errorMessage = `Worker exited unexpectedly (code ${code})`;
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
this.sendProgressToRenderer(
url,
'error',
undefined,
errorMessage
);
this.workers.delete(url);
settle(() => reject(new Error(errorMessage)));
});
return runEpgFetch(url, options, {
runtime: this.runtime,
workers: this.workers,
fetchedUrls: this.fetchedUrls,
loggerLabel: this.loggerLabel,
fetchTimeoutMs: this.fetchTimeoutMs,
sendProgressToRenderer: this.sendProgressToRenderer.bind(this),
});
}
async clearEpgData(): Promise<void> {
return this.runClearWorker({
return this.runtime.runClearWorker({
timeoutLabel: 'EPG clear',
exitLabel: 'Clear worker',
readyMessage: { type: 'CLEAR_EPG' },
@@ -344,21 +148,21 @@ export class EpgWorkerService {
failedWorkerLabel: 'failed clear',
erroredWorkerLabel: 'errored clear',
onComplete: async (worker) => {
console.log(this.loggerLabel, 'EPG data cleared via worker');
epgLogger.log(this.loggerLabel, 'EPG data cleared via worker');
this.fetchedUrls.clear();
// Resolve only after every interrupted fetch worker has exited
// too — they may still hold the SQLite lock the caller expects
// to be free.
const terminations = [...this.workers.values()].map(
(runningWorker) =>
this.terminateWorker(
this.runtime.terminateWorker(
runningWorker,
'fetch during clear'
)
);
this.workers.clear();
terminations.push(
this.terminateWorker(worker, 'completed clear')
this.runtime.terminateWorker(worker, 'completed clear')
);
await Promise.all(terminations);
},
@@ -371,13 +175,34 @@ export class EpgWorkerService {
return;
}
retireEpgSource(normalizedSourceUrl);
this.fetchedUrls.delete(normalizedSourceUrl);
const previous = this.inFlightSourceClears.get(normalizedSourceUrl);
const operation = previous
? previous
.catch(() => undefined)
.then(() => this.startSourceClear(normalizedSourceUrl))
: this.startSourceClear(normalizedSourceUrl);
const clear = operation.finally(() => {
if (this.inFlightSourceClears.get(normalizedSourceUrl) === clear)
this.inFlightSourceClears.delete(normalizedSourceUrl);
});
this.inFlightSourceClears.set(normalizedSourceUrl, clear);
return clear;
}
private async startSourceClear(normalizedSourceUrl: string): Promise<void> {
const inFlight = this.inFlightFetches.get(normalizedSourceUrl);
const runningWorker = this.workers.get(normalizedSourceUrl);
if (runningWorker) {
this.workers.delete(normalizedSourceUrl);
await this.terminateWorker(runningWorker, 'source clear');
await this.runtime.terminateWorker(runningWorker, 'source clear');
}
// Error/timeout handlers may already have removed the worker from the
// lookup, but its fetch promise still owns asynchronous termination.
if (inFlight) await inFlight.catch(() => undefined);
return this.runClearWorker({
return this.runtime.runClearWorker({
timeoutLabel: 'EPG source clear',
exitLabel: 'Source clear worker',
readyMessage: {
@@ -388,153 +213,15 @@ export class EpgWorkerService {
failedWorkerLabel: 'failed source clear',
erroredWorkerLabel: 'errored source clear',
onComplete: async (worker) => {
console.log(
epgLogger.log(
this.loggerLabel,
`EPG data cleared for source via worker: ${normalizedSourceUrl}`
'EPG data cleared for source via worker'
);
this.fetchedUrls.delete(normalizedSourceUrl);
await this.terminateWorker(worker, 'completed source clear');
},
});
}
private runClearWorker(options: {
timeoutLabel: string;
exitLabel: string;
readyMessage: ClearWorkerMessage;
completeWorkerLabel: string;
failedWorkerLabel: string;
erroredWorkerLabel: string;
onComplete: (worker: Worker) => Promise<void>;
}): Promise<void> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = this.createEpgWorker();
} catch (error) {
console.error(
this.loggerLabel,
'Failed to create worker for clear:',
error
await this.runtime.terminateWorker(
worker,
'completed source clear'
);
reject(error);
return;
}
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
fn();
};
const timeoutId = setTimeout(() => {
const errorMessage = `${options.timeoutLabel} timed out after ${
this.fetchTimeoutMs / 1000
}s`;
console.error(this.loggerLabel, errorMessage);
settle(() => {
void this.terminateWorker(
worker,
`timed out ${options.timeoutLabel}`
).then(() => reject(new Error(errorMessage)));
});
}, this.fetchTimeoutMs);
worker.on(
'message',
(message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage(options.readyMessage);
} else if (message.type === 'CLEAR_COMPLETE') {
settle(() => {
void options
.onComplete(worker)
.then(() => resolve(), reject);
});
} else if (message.type === 'EPG_ERROR') {
console.error(
this.loggerLabel,
'Worker clear error:',
message.error
);
settle(() => {
void this.terminateWorker(
worker,
options.failedWorkerLabel
).then(() =>
reject(
new Error(message.error || 'Clear failed')
)
);
});
}
}
);
worker.on('error', (error) => {
console.error(
this.loggerLabel,
'Worker error during clear:',
error
);
settle(() => {
void this.terminateWorker(
worker,
options.erroredWorkerLabel
).then(() => reject(error));
});
});
worker.on('exit', (code) => {
if (settled) return;
const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`;
console.error(this.loggerLabel, errorMessage);
settle(() => reject(new Error(errorMessage)));
});
});
}
/**
* Awaits worker shutdown so callers can sequence work (e.g. the next DB
* access) after the thread has really exited. Termination failures are
* logged and swallowed — there is nothing actionable left to do.
*/
private async terminateWorker(
worker: Worker,
context: string
): Promise<void> {
try {
await worker.terminate();
} catch (error) {
console.error(
this.loggerLabel,
`Failed to terminate ${context} worker:`,
error
);
}
}
private createEpgWorker(): Worker {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: app.isPackaged,
workerFilename: 'epg-parser.worker.js',
developmentWorkerDir: path.join(__dirname, 'workers'),
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
appPath: app.getAppPath(),
});
const workerURL = pathToFileURL(bootstrap.workerPath);
return new Worker(workerURL, {
resourceLimits: {
maxOldGenerationSizeMb: 4096,
maxYoungGenerationSizeMb: 512,
},
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
},
});
}
@@ -78,6 +78,37 @@ describe('EpgEvents', () => {
await new Promise((resolve) => setImmediate(resolve));
}
it.each(['missing', 'stale', 'fresh'])(
'uses source-owned freshness for %s metadata',
async (state) => {
const { epgChannelSources } = await import('../database/schema');
const { checkEpgFreshness } = await import('./epg-fetch.service');
const fresh = [{ updatedAt: new Date().toISOString() }];
const snapshots =
state === 'missing'
? []
: state === 'fresh'
? fresh
: [{ updatedAt: '2000-01-01T00:00:00Z' }];
getDatabase.mockResolvedValue({
select: () => ({
from: (table: unknown) => ({
where: () => ({
limit: async () =>
table === epgChannelSources ? snapshots : fresh,
}),
}),
}),
});
const result = await checkEpgFreshness(['shared-source'], 12);
expect(result).toEqual(
state === 'fresh'
? { freshUrls: ['shared-source'], staleUrls: [] }
: { freshUrls: [], staleUrls: ['shared-source'] }
);
}
);
it('uses the shared worker bootstrap and passes native module search paths to the EPG worker', async () => {
const fetchPromise = (EpgEvents as unknown as Record<string, any>)[
'fetchEpgFromUrl'
@@ -287,6 +318,120 @@ describe('EpgEvents', () => {
await flushPromises();
});
it.each([
['exit', 1],
['error', new Error('terminated worker')],
] as const)(
'cancels a retired source on %s without reviving it from late READY or COMPLETE messages',
async (event, payload) => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = 'https://removed.example/guide.xml';
const progress = jest.spyOn(service, 'sendProgressToRenderer');
const fetch = service.fetchEpgFromUrl(url).then(
() => true,
() => false
);
const worker = mockWorkerInstances[0];
let finishTermination!: () => void;
worker.terminate.mockReturnValue(
new Promise<void>((resolve) => {
finishTermination = resolve;
})
);
const clear = service.clearEpgDataForSource(url);
worker.emit('message', { type: 'READY' });
worker.emit('message', { type: 'EPG_COMPLETE' });
expect(worker.postMessage).not.toHaveBeenCalled();
expect(service.hasFetchedUrl(url)).toBe(false);
expect(mockWorkerInstances).toHaveLength(1);
worker.emit(event, payload);
finishTermination();
await flushPromises();
const clearWorker = mockWorkerInstances[1];
clearWorker.emit('message', { type: 'READY' });
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
await clear;
expect(await fetch).toBe(true);
expect(progress.mock.calls.map((call) => call[1])).toEqual([
'cancelled',
]);
}
);
it('does not start a queued source removed while an earlier source imports', async () => {
getDatabase.mockRejectedValue(new Error('force stale for test'));
const { handleFetchEpg } = await import('./epg-fetch.service');
const { retireEpgSource } = await import('./epg-source-generation');
const { epgWorkerService } = await import('./epg-worker.service');
const progress = jest.spyOn(epgWorkerService, 'sendProgressToRenderer');
let finishFirst!: () => void;
const fetch = jest
.spyOn(epgWorkerService, 'fetchEpgFromUrl')
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishFirst = resolve;
})
)
.mockResolvedValue(undefined);
const request = handleFetchEpg([
'https://first.example/guide.xml',
'https://queued.example/guide.xml',
]);
await flushPromises();
retireEpgSource('https://queued.example/guide.xml');
finishFirst();
await request;
expect(fetch).toHaveBeenCalledTimes(1);
expect(progress).toHaveBeenCalledWith(
'https://queued.example/guide.xml',
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
expect.any(Number)
);
progress.mockRestore();
fetch.mockRestore();
});
it.each([true, false])(
'awaits a failing worker termination before source cleanup (already retired: %s)',
async (alreadyRetired) => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = `https://terminating-${alreadyRetired}.example/guide.xml`;
const fetch = service.fetchEpgFromUrl(url).catch(() => undefined);
const worker = mockWorkerInstances[0];
let finishTermination!: () => void;
worker.terminate.mockReturnValue(
new Promise<void>((resolve) => {
finishTermination = resolve;
})
);
if (alreadyRetired) {
const { retireEpgSource } =
await import('./epg-source-generation');
retireEpgSource(url);
}
worker.emit(
'error',
new Error('worker failed while another source clears')
);
const clear = service.clearEpgDataForSource(url);
const workersBeforeTermination = mockWorkerInstances.length;
finishTermination();
await fetch;
await flushPromises();
const clearWorker = mockWorkerInstances[1];
clearWorker.emit('message', { type: 'READY' });
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
await clear;
expect(workersBeforeTermination).toBe(1);
}
);
it('clears one EPG source through a worker and allows it to be fetched again', async () => {
const workerService = new EpgWorkerService('[Test EPG]', 1000);
const sourceUrl = 'https://playlist.example.com/guide.xml';
@@ -336,6 +481,66 @@ describe('EpgEvents', () => {
expect(terminated).toBe(true);
});
it('waits for source cleanup before starting a replacement import of the same normalized URL', async () => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = 'https://replacement.example/guide.xml';
const clear = service.clearEpgDataForSource(url);
const clearWorker = mockWorkerInstances[0];
const replacement = service.fetchEpgFromUrl(` ${url} `);
const workersBeforeCleanup = mockWorkerInstances.length;
clearWorker.emit('message', { type: 'READY' });
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
await clear;
await flushPromises();
const fetchWorker = mockWorkerInstances[1];
fetchWorker.emit('message', { type: 'READY' });
fetchWorker.emit('message', { type: 'EPG_COMPLETE' });
await replacement;
expect(workersBeforeCleanup).toBe(1);
expect(service.hasFetchedUrl(url)).toBe(true);
});
it('serializes repeated clears and retires a replacement waiting for the earlier clear', async () => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = 'https://twice-removed.example/guide.xml';
const firstClear = service.clearEpgDataForSource(url);
const waitingFetch = service.fetchEpgFromUrl(url);
const secondClear = service.clearEpgDataForSource(url);
expect(mockWorkerInstances).toHaveLength(1);
mockWorkerInstances[0].emit('message', { type: 'CLEAR_COMPLETE' });
await firstClear;
await waitingFetch;
await flushPromises();
expect(mockWorkerInstances).toHaveLength(2);
mockWorkerInstances[1].emit('message', { type: 'READY' });
expect(mockWorkerInstances[1].postMessage).toHaveBeenCalledWith({
type: 'CLEAR_EPG_SOURCE',
sourceUrl: url,
});
mockWorkerInstances[1].emit('message', { type: 'CLEAR_COMPLETE' });
await secondClear;
expect(service.hasFetchedUrl(url)).toBe(false);
});
it('allows a replacement import after a failed source cleanup has terminated', async () => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = 'https://retry-clear.example/guide.xml';
const clear = service.clearEpgDataForSource(url);
const outcome = clear.catch((error: Error) => error.message);
const replacement = service.fetchEpgFromUrl(url);
mockWorkerInstances[0].emit('message', {
type: 'EPG_ERROR',
error: 'clear failed',
});
expect(await outcome).toBe('clear failed');
await flushPromises();
expect(mockWorkerInstances[0].terminate).toHaveBeenCalled();
mockWorkerInstances[1].emit('message', { type: 'READY' });
mockWorkerInstances[1].emit('message', { type: 'EPG_COMPLETE' });
await replacement;
expect(service.hasFetchedUrl(url)).toBe(true);
});
it('keeps an active EPG fetch alive when worker progress keeps moving', async () => {
jest.useFakeTimers();
@@ -381,7 +586,10 @@ describe('EpgEvents', () => {
* inserts extra queries that the original test didn't anticipate.
*/
function queryChain<T>(data: T) {
const chain: Record<string, jest.Mock> = {} as Record<string, jest.Mock>;
const chain: Record<string, jest.Mock> = {} as Record<
string,
jest.Mock
>;
chain.where = jest.fn().mockReturnValue(chain);
chain.innerJoin = jest.fn().mockReturnValue(chain);
chain.groupBy = jest.fn().mockReturnValue(chain);
@@ -393,25 +601,30 @@ describe('EpgEvents', () => {
// getMapping queries (must come first — they return empty)
const from = jest
.fn()
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
// Test expectations below
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
.mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found
.mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs)
{
id: 1,
channelId: 'BBC.ONE.UK',
start: '2026-04-14T10:00:00Z',
stop: '2026-04-14T11:00:00Z',
title: 'News',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
},
]));
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
.mockReturnValueOnce(
queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])
) // 4. selectChannelById → channel found
.mockReturnValueOnce(
queryChain([
// 5. selectChannelPrograms (BBC.ONE.UK → programs)
{
id: 1,
channelId: 'BBC.ONE.UK',
start: '2026-04-14T10:00:00Z',
stop: '2026-04-14T11:00:00Z',
title: 'News',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
},
])
);
select.mockImplementation(() => ({ from }));
@@ -1,3 +1,4 @@
import { reconcileEpgSources } from './epg-source-settings.service';
import { ipcMain } from 'electron';
import {
ElectronBridgeCurrentProgramsOptions,
@@ -29,6 +30,14 @@ export default class EpgEvents {
* Bootstrap EPG events
*/
static bootstrapEpgEvents(): Electron.IpcMain {
ipcMain.handle(
'EPG_RECONCILE_SOURCES',
async (_event, args: { urls: string[] }) => {
await reconcileEpgSources(args.urls);
return { success: true };
}
);
ipcMain.handle(
'FETCH_EPG',
async (
@@ -179,10 +188,7 @@ export default class EpgEvents {
ipcMain.handle(
'EPG_CHANNEL_SEARCH',
async (
_event,
args: { searchTerm: string; limit?: number }
) => {
async (_event, args: { searchTerm: string; limit?: number }) => {
return handleSearchEpgChannels(args.searchTerm, args.limit);
}
);
@@ -0,0 +1,69 @@
import { epgLogger } from './epg-logger';
describe('EPG diagnostics', () => {
afterEach(() => jest.restoreAllMocks());
it('omits URLs with arbitrary path/query secrets, redirects and malformed authorities', () => {
const log = jest.spyOn(console, 'log').mockImplementation();
epgLogger.log('[EPG Worker]', {
message:
'Redirect: https://example.com/path-secret?custom=query-secret -> https://user:password@[/malformed-secret',
response: { url: new URL('https://example.org/redirect-secret') },
authorization: 'Bearer header-secret',
stats: { totalChannels: 12, totalPrograms: 34 },
});
const output = JSON.stringify(log.mock.calls);
for (const secret of [
'path-secret',
'query-secret',
'password',
'malformed-secret',
'redirect-secret',
'header-secret',
]) {
expect(output).not.toContain(secret);
}
expect(output).toContain('Redirect:');
expect(log.mock.calls[0][1]).toEqual(
expect.objectContaining({
stats: { totalChannels: 12, totalPrograms: 34 },
})
);
});
it('omits transport data and stacks while retaining safe error details without mutation', () => {
const log = jest.spyOn(console, 'error').mockImplementation();
const error = Object.assign(
new Error('Failed https://example.org/path-secret'),
{
code: 'ECONNRESET',
cause: new Error(
'Redirect https://example.org/redirect-secret'
),
request: {
path: '/feed/relative-path-secret?custom=relative-query-secret',
_header:
'GET /feed/header-path-secret?custom=header-query-secret HTTP/1.1',
} as { self?: unknown; path: string; _header: string },
}
);
error.request.self = error;
epgLogger.error('[EPG Worker]', error);
const output = JSON.stringify(log.mock.calls);
expect(output).not.toContain('path-secret');
expect(output).not.toContain('redirect-secret');
expect(output).toContain('ECONNRESET');
for (const secret of [
'relative-path-secret',
'relative-query-secret',
'header-path-secret',
'header-query-secret',
]) {
expect(output).not.toContain(secret);
}
expect(log.mock.calls[0][1]).not.toHaveProperty('request');
expect(log.mock.calls[0][1]).not.toHaveProperty('stack');
expect(error.message).toBe('Failed https://example.org/path-secret');
expect(error.request.self).toBe(error);
});
});
@@ -0,0 +1,61 @@
import { redactSensitiveData, REDACTED_VALUE } from '@iptvnator/shared/logging';
/** Shared redaction produces a bounded, acyclic plain-data tree. XMLTV feeds
* can also put opaque secrets in arbitrary URL paths/query keys, so omit URLs
* entirely from diagnostics (including redirects and malformed error URLs).
* IPC progress and rejected errors keep their original values for callers.
*/
function omitUrls(value: unknown): unknown {
if (typeof value === 'string') {
return value.replace(
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
REDACTED_VALUE
);
}
if (Array.isArray(value)) return value.map(omitUrls);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
omitUrls(key),
omitUrls(entry),
])
);
}
return value;
}
// Axios/Node transport objects also contain relative request paths and raw
// HTTP headers without a URL scheme. Never forward them or arbitrary error
// fields to the logger, even after generic redaction.
function summarizeError(value: unknown): unknown {
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.entries(value)
.filter(([key]) =>
[
'name',
'message',
'code',
'status',
'statusCode',
'cause',
].includes(key)
)
.map(([key, entry]) => [key, summarizeError(entry)])
);
}
export const epgLogger = {
log(...args: unknown[]): void {
console.log(
...args.map((value) => omitUrls(redactSensitiveData(value)))
);
},
error(...args: unknown[]): void {
console.error(
...args.map((value) =>
omitUrls(summarizeError(redactSensitiveData(value)))
)
);
},
};
@@ -93,6 +93,10 @@ describe('EpgDatabase', () => {
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
AND NOT EXISTS (
SELECT 1 FROM epg_channel_sources
WHERE epg_channel_sources.channel_id = epg_channels.id
)
`);
expect(preparedSql).toContain(deleteTodayAndFutureSql);
@@ -230,6 +234,7 @@ describe('EpgDatabaseClearOperation', () => {
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
'BEGIN',
'DELETE FROM epg_programs',
'DELETE FROM epg_channel_sources',
'DELETE FROM epg_channels',
'COMMIT',
]);
@@ -250,6 +255,7 @@ describe('EpgDatabaseClearOperation', () => {
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
'BEGIN',
'DELETE FROM epg_programs',
'DELETE FROM epg_channel_sources',
'DELETE FROM epg_channels',
'ROLLBACK',
]);
@@ -276,6 +282,10 @@ describe('EpgDatabaseSourceClearOperation', () => {
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
AND NOT EXISTS (
SELECT 1 FROM epg_channel_sources
WHERE epg_channel_sources.channel_id = epg_channels.id
)
`);
expect(preparedSql).toContain(deleteProgramsSql);
@@ -1,6 +1,7 @@
import type BetterSqlite3 from 'better-sqlite3';
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser';
import { restoreSurvivingChannelMetadata } from './epg-source-metadata';
/**
* Database helper for worker-owned EPG operations.
@@ -10,6 +11,8 @@ export class EpgDatabase {
private readonly db: BetterSqlite3.Database;
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertChannelSourceStmt: BetterSqlite3.Statement;
private readonly deleteChannelSourcesStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
@@ -30,6 +33,25 @@ export class EpgDatabase {
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
// Allocated under the same SQLite write transaction as the channel
// upsert: ties and clock rollbacks cannot reorder surviving writers.
this.insertChannelSourceStmt = this.db.prepare(`
INSERT INTO epg_channel_sources
(channel_id, display_name, icon_url, url, source_url, updated_at, write_order)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
(SELECT COALESCE(MAX(write_order), 0) + 1
FROM epg_channel_sources WHERE channel_id = ?))
ON CONFLICT(channel_id, source_url) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
updated_at = excluded.updated_at,
write_order = excluded.write_order
`);
this.deleteChannelSourcesStmt = this.db.prepare(
'DELETE FROM epg_channel_sources WHERE source_url = ?'
);
// Guard against duplicate entries when the clearFirst logic misses old
// rows. The same channel + start + title + source is treated as the
// same programme — a later import with a corrected stop time simply
@@ -66,6 +88,10 @@ export class EpgDatabase {
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
AND NOT EXISTS (
SELECT 1 FROM epg_channel_sources
WHERE epg_channel_sources.channel_id = epg_channels.id
)
`);
this.deleteTodayAndFutureStmt = this.db.prepare(`
@@ -88,7 +114,9 @@ export class EpgDatabase {
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearTodayAndFuture) {
restoreSurvivingChannelMetadata(this.db, sourceUrl);
this.deleteTodayAndFutureStmt.run(sourceUrl);
this.deleteChannelSourcesStmt.run(sourceUrl);
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
@@ -106,6 +134,14 @@ export class EpgDatabase {
url,
sourceUrl
);
this.insertChannelSourceStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl,
channel.id
);
this.knownChannelIds.add(channel.id);
}
});
@@ -222,6 +258,7 @@ export class EpgDatabaseClearOperation {
this.db.exec('BEGIN');
try {
this.db.exec('DELETE FROM epg_programs');
this.db.exec('DELETE FROM epg_channel_sources');
this.db.exec('DELETE FROM epg_channels');
this.db.exec('COMMIT');
} catch (error) {
@@ -256,6 +293,10 @@ export class EpgDatabaseSourceClearOperation {
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
AND NOT EXISTS (
SELECT 1 FROM epg_channel_sources
WHERE epg_channel_sources.channel_id = epg_channels.id
)
`);
}
@@ -266,7 +307,12 @@ export class EpgDatabaseSourceClearOperation {
}
const clearSource = this.db.transaction((url: string) => {
// Capture affected IDs while removed-source provenance still exists.
restoreSurvivingChannelMetadata(this.db, url);
this.deleteProgramsForSourceStmt.run(url);
this.db
.prepare('DELETE FROM epg_channel_sources WHERE source_url = ?')
.run(url);
this.deleteOrphanChannelsForSourceStmt.run(url);
});
@@ -1,3 +1,4 @@
import { epgLogger } from '../util/epg-logger';
import type BetterSqlite3 from 'better-sqlite3';
import {
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
@@ -102,7 +103,7 @@ async function fetchAndParseEpgStreaming(
url: string,
options: ElectronBridgeTrustOptions = {}
): Promise<void> {
console.log(loggerLabel, `Fetching EPG from ${url}`);
epgLogger.log(loggerLabel, 'Fetching EPG source');
// Create database connection in worker
const epgDb = new EpgDatabase(Database);
@@ -141,18 +142,15 @@ async function fetchAndParseEpgStreaming(
const contentEncoding = getEpgResponseContentEncoding(response.headers);
if (responseUrl && responseUrl !== url) {
console.log(
loggerLabel,
`Resolved EPG redirect: ${url} -> ${responseUrl}`
);
epgLogger.log(loggerLabel, 'Resolved EPG redirect');
}
console.log(
epgLogger.log(
loggerLabel,
`EPG response detected as gzipped: ${isGzipped}`
);
if (contentEncoding) {
console.log(
epgLogger.log(
loggerLabel,
`EPG response content-encoding: ${contentEncoding}`
);
@@ -200,7 +198,7 @@ async function fetchAndParseEpgStreaming(
try {
parser.write(chunk.toString('utf-8'));
} catch (err) {
console.error(loggerLabel, 'Parse error:', err);
epgLogger.error(loggerLabel, 'Parse error:', err);
epgDb.close();
reject(err);
}
@@ -209,7 +207,7 @@ async function fetchAndParseEpgStreaming(
dataStream.on('end', () => {
try {
const stats = parser.finish();
console.log(
epgLogger.log(
loggerLabel,
`Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs`
);
@@ -223,7 +221,7 @@ async function fetchAndParseEpgStreaming(
// real problem (unreachable feed, SAX parse failure, etc.).
if (stats.totalChannels === 0) {
const errorMessage = `EPG parse produced 0 channels — feed may be unreachable or unsupported`;
console.error(loggerLabel, `${errorMessage}: ${url}`);
epgLogger.error(loggerLabel, errorMessage);
const response: WorkerResponse = {
type: 'EPG_ERROR',
url,
@@ -251,7 +249,7 @@ async function fetchAndParseEpgStreaming(
});
dataStream.on('error', (err) => {
console.error(loggerLabel, 'Stream error:', err);
epgLogger.error(loggerLabel, 'Stream error:', err);
epgDb.close();
reject(err);
});
@@ -315,16 +313,16 @@ function clearAllEpgData(): void {
const clearOperation = new EpgDatabaseClearOperation(Database);
try {
console.log(loggerLabel, 'Clearing all EPG data...');
epgLogger.log(loggerLabel, 'Clearing all EPG data...');
clearOperation.run();
console.log(loggerLabel, 'All EPG data cleared');
epgLogger.log(loggerLabel, 'All EPG data cleared');
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
parentPort?.postMessage(response);
} catch (error) {
console.error(loggerLabel, 'Error clearing EPG data:', error);
epgLogger.error(loggerLabel, 'Error clearing EPG data:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
@@ -339,19 +337,16 @@ function clearEpgDataForSource(sourceUrl: string): void {
const clearOperation = new EpgDatabaseSourceClearOperation(Database);
try {
console.log(
loggerLabel,
`Clearing EPG data for source ${sourceUrl}...`
);
epgLogger.log(loggerLabel, 'Clearing EPG data for source...');
clearOperation.run(sourceUrl);
console.log(loggerLabel, `EPG data cleared for source ${sourceUrl}`);
epgLogger.log(loggerLabel, 'EPG data cleared for source');
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
parentPort?.postMessage(response);
} catch (error) {
console.error(loggerLabel, 'Error clearing EPG source data:', error);
epgLogger.error(loggerLabel, 'Error clearing EPG source data:', error);
const errorResponse: WorkerResponse = {
type: 'EPG_ERROR',
error: error instanceof Error ? error.message : String(error),
@@ -379,7 +374,7 @@ if (parentPort) {
clearEpgDataForSource(message.sourceUrl ?? '');
}
} catch (error) {
console.error(loggerLabel, 'Worker error:', error);
epgLogger.error(loggerLabel, 'Worker error:', error);
const typedError = error as {
code?: ElectronBridgeSecurityErrorCode;
host?: string;
@@ -398,5 +393,5 @@ if (parentPort) {
// Notify parent that worker is ready
parentPort.postMessage({ type: 'READY' });
} else {
console.error(loggerLabel, 'parentPort is not available!');
epgLogger.error(loggerLabel, 'parentPort is not available!');
}
@@ -0,0 +1,234 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
/** Exercise worker SQL with Electron's native SQLite binding, including upgrades. */
function runDatabaseScenario(scenario: string): unknown {
const moduleUrl = pathToFileURL(resolve(__dirname, 'epg-database.ts')).href;
const connectionUrl = pathToFileURL(
resolve(process.cwd(), 'libs/shared/database/src/lib/connection.ts')
).href;
const script = `
const { default: Database } = await import('better-sqlite3');
const { EpgDatabase, EpgDatabaseSourceClearOperation, EpgDatabaseClearOperation }
= await import(${JSON.stringify(moduleUrl)});
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
const sqlite = new Database(':memory:');
const statements = __databaseConnectionTestHooks.createTableStatements
.filter(sql => /(?:TABLE|INDEX|TRIGGER) IF NOT EXISTS (?:idx_)?epg_/.test(sql));
for (const sql of statements) sqlite.exec(sql);
class SharedDatabase { constructor() { return sqlite; } }
const worker = new EpgDatabase(SharedDatabase);
const clear = new EpgDatabaseSourceClearOperation(SharedDatabase);
const importSource = (source, programs = true, clearFirst = false) => {
worker.insertChannels([{
id: 'shared', displayName: [{ value: source + ' News' }],
icon: [{ src: source + '.png' }], url: [source + '.website']
}], source, clearFirst);
if (programs) worker.insertPrograms([{
channel: 'shared', start: '2099-01-01T00:00:00Z',
stop: '2099-01-01T01:00:00Z', title: [{value: source}]
}], source);
};
const channel = () => sqlite.prepare('SELECT * FROM epg_channels WHERE id = ?').get('shared');
${scenario}
sqlite.close();
`;
return JSON.parse(
execFileSync(
createRequire(__filename)('electron'),
['--import', 'tsx', '--eval', script],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
TSX_TSCONFIG_PATH: resolve(
process.cwd(),
'tsconfig.base.json'
),
},
}
)
);
}
describe('source-owned XMLTV channel metadata', () => {
it.each(['A', 'B'])(
'restores the survivor when removing %s, then removes the final owner',
(removed) => {
const survivor = removed === 'A' ? 'B' : 'A';
const result = runDatabaseScenario(`
importSource('A'); importSource('B');
clear.run('${removed}');
clear.run('${removed}');
const surviving = channel();
const programs = sqlite.prepare('SELECT source_url FROM epg_programs').all();
clear.run('${survivor}');
process.stdout.write(JSON.stringify({ surviving, programs, remaining: channel() ?? null }));
`);
expect(result).toEqual({
surviving: expect.objectContaining({
display_name: `${survivor} News`,
icon_url: `${survivor}.png`,
url: `${survivor}.website`,
source_url: survivor,
}),
programs: [{ source_url: survivor }],
remaining: null,
});
}
);
it.each(['same tick', 'clock rollback'])(
'restores the latest surviving write across %s and source upserts',
(clockMode) => {
expect(
runDatabaseScenario(`
let timestamp = '2026-09-06T10:00:00.000Z';
sqlite.function('strftime', { varargs: true }, () => timestamp);
importSource('A');
if ('${clockMode}' === 'clock rollback') timestamp = '2026-09-06T09:00:00.000Z';
importSource('Z');
importSource('M');
clear.run('M');
const first = channel();
importSource('A');
importSource('M');
clear.run('M');
const updated = channel();
importSource('Z', true, true);
importSource('M');
clear.run('M');
process.stdout.write(JSON.stringify({first, updated, refreshed: channel()}));
`)
).toEqual({
first: expect.objectContaining({
display_name: 'Z News',
icon_url: 'Z.png',
url: 'Z.website',
source_url: 'Z',
}),
updated: expect.objectContaining({
display_name: 'A News',
icon_url: 'A.png',
url: 'A.website',
source_url: 'A',
}),
refreshed: expect.objectContaining({
display_name: 'Z News',
icon_url: 'Z.png',
url: 'Z.website',
source_url: 'Z',
}),
});
}
);
it('retains metadata-only owners during another source refresh and removal', () => {
expect(
runDatabaseScenario(`
importSource('A', false); importSource('B', false);
worker.insertChannels([], 'A', true);
clear.run('A');
const surviving = channel();
clear.run('B');
process.stdout.write(JSON.stringify({surviving, remaining: channel() ?? null}));
`)
).toEqual({
surviving: expect.objectContaining({
display_name: 'B News',
source_url: 'B',
}),
remaining: null,
});
});
it('upgrades an existing ledger without losing snapshots and orders subsequent imports', () => {
expect(
runDatabaseScenario(`
importSource('A'); importSource('Z');
sqlite.exec('ALTER TABLE epg_channel_sources DROP COLUMN write_order');
const migration = __databaseConnectionTestHooks.columnMigrationStatements
.find(sql => sql.startsWith('ALTER TABLE epg_channel_sources'));
sqlite.exec(migration);
const snapshots = sqlite.prepare('SELECT source_url, write_order FROM epg_channel_sources ORDER BY source_url').all();
importSource('Z'); importSource('M');
clear.run('M');
process.stdout.write(JSON.stringify({ snapshots, surviving: channel() }));
`)
).toEqual({
snapshots: [
{ source_url: 'A', write_order: 0 },
{ source_url: 'Z', write_order: 0 },
],
surviving: expect.objectContaining({
display_name: 'Z News',
source_url: 'Z',
}),
});
});
it('removes an orphan whose original owner disappeared during refresh', () => {
expect(
runDatabaseScenario(`
importSource('A', false); importSource('B', false);
worker.insertChannels([], 'A', true);
clear.run('B');
process.stdout.write(JSON.stringify(channel() ?? null));
`)
).toBeNull();
});
it('restores metadata before a refresh removes the last writer provenance', () => {
expect(
runDatabaseScenario(`
importSource('A'); importSource('B');
worker.insertChannels([], 'B', true);
clear.run('B');
process.stdout.write(JSON.stringify(channel()));
`)
).toEqual(
expect.objectContaining({
display_name: 'A News',
icon_url: 'A.png',
source_url: 'A',
})
);
});
it('neutralizes ambiguous legacy metadata while preserving surviving programmes', () => {
expect(
runDatabaseScenario(`
importSource('A'); importSource('B');
sqlite.exec('DROP TABLE IF EXISTS epg_channel_sources');
for (const sql of statements) sqlite.exec(sql);
clear.run('B');
process.stdout.write(JSON.stringify(channel()));
`)
).toEqual(
expect.objectContaining({
display_name: 'shared',
icon_url: null,
url: null,
source_url: 'A',
updated_at: null,
})
);
});
it('clears all metadata and permits a clean reimport', () => {
expect(
runDatabaseScenario(`
importSource('A'); importSource('B');
new EpgDatabaseClearOperation(SharedDatabase).run();
const count = sqlite.prepare('SELECT count(*) AS count FROM epg_channel_sources').get().count;
importSource('C');
clear.run('C');
process.stdout.write(JSON.stringify({ count, remaining: channel() ?? null }));
`)
).toEqual({ count: 0, remaining: null });
});
});
@@ -0,0 +1,32 @@
import type BetterSqlite3 from 'better-sqlite3';
/** Called inside the source-clear transaction, before deleting its provenance. */
export function restoreSurvivingChannelMetadata(
db: BetterSqlite3.Database,
sourceUrl: string
): void {
// Existing global rows cannot be backfilled: their last metadata writer may
// differ from source_url. Only imports recorded in the ledger prove origin.
const survivingValue = (column: string) => `(
SELECT ${column} FROM epg_channel_sources
WHERE channel_id = epg_channels.id AND source_url != @sourceUrl
ORDER BY write_order DESC, updated_at DESC, source_url LIMIT 1
)`;
db.prepare(
`
UPDATE epg_channels SET
display_name = COALESCE(${survivingValue('display_name')}, id),
icon_url = ${survivingValue('icon_url')},
url = ${survivingValue('url')},
updated_at = ${survivingValue('updated_at')},
source_url = COALESCE(${survivingValue('source_url')}, (
SELECT source_url FROM epg_programs
WHERE channel_id = epg_channels.id AND source_url != @sourceUrl
ORDER BY source_url LIMIT 1
), @sourceUrl)
WHERE source_url = @sourceUrl
OR id IN (SELECT channel_id FROM epg_channel_sources WHERE source_url = @sourceUrl)
OR id IN (SELECT channel_id FROM epg_programs WHERE source_url = @sourceUrl)
`
).run({ sourceUrl });
}
+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.