fix(epg): remove cached XMLTV data after source deletion

This commit is contained in:
4gray committed 2026-09-05 22:15:20 +02:00
1 parent b55a3cc322
commit ffcbee8140
32 files changed
+1051 -82

No files matched your search

+114 -13
View File
@@ -65,7 +65,7 @@ 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 adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
@@ -103,6 +103,8 @@ test.describe('Electron EPG', () => {
.first()
).toBeVisible();
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
@@ -116,18 +118,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 +129,99 @@ test.describe('Electron EPG', () => {
}
});
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 url of [first.resourceUrl, second.resourceUrl]) {
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.last()
.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 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 +292,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();
+93 -10
View File
@@ -5,6 +5,7 @@ import {
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
createMutableTextServer,
expect,
goToDashboard,
launchElectronApp,
@@ -28,6 +29,93 @@ 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();
}
});
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 +154,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 +349,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 +384,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 +425,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);