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);
@@ -524,6 +524,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) =>
@@ -1114,8 +1112,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) =>
@@ -1,3 +1,4 @@
import { epgSourceGeneration } from './epg-source-generation';
import { eq } from 'drizzle-orm';
import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
@@ -45,6 +46,7 @@ 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
@@ -58,6 +60,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);
@@ -95,7 +98,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, epgSourceGeneration(url)])
);
if (validUrls.length === 0) {
return { success: false, message: 'No valid URLs provided' };
@@ -118,7 +126,9 @@ 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) {
@@ -142,6 +152,7 @@ export async function handleFetchEpg(
const errors: string[] = [];
for (const url of urlsToFetch) {
try {
if (generations.get(url) !== epgSourceGeneration(url)) continue;
await epgWorkerService.fetchEpgFromUrl(url, options);
} catch (error) {
console.error(
@@ -149,9 +160,7 @@ export async function handleFetchEpg(
`Error fetching EPG from ${url}:`,
error
);
errors.push(
error instanceof Error ? error.message : String(error)
);
errors.push(error instanceof Error ? error.message : String(error));
}
}
@@ -0,0 +1,13 @@
/** Retires queued imports as well as workers already parsing a removed URL. */
const generations = new Map<string, number>();
export function epgSourceGeneration(url: string): number {
const key = url.trim();
if (!generations.has(key)) generations.set(key, 0);
return generations.get(key)!;
}
export function retireEpgSource(url: string): void {
generations.set(url.trim(), epgSourceGeneration(url) + 1);
}
export function requestedEpgSources(): string[] {
return [...generations.keys()];
}
@@ -0,0 +1,81 @@
import {
appState,
epgChannels,
epgPrograms,
playlists,
} from '../database/schema';
import { getDatabase } from '../database/connection';
import { epgWorkerService } from './epg-worker.service';
import { epgSourceGeneration } 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),
},
}));
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('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,75 @@
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database/connection';
import {
appState,
epgChannels,
epgPrograms,
playlists,
} from '../database/schema';
import { epgWorkerService } from './epg-worker.service';
import { 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 removed = [
...new Set(
[
...channels.map((row) => row.url),
...programs.map((row) => row.url),
...requestedEpgSources(),
].filter(
(url): url is string =>
!!url?.trim() && !active.has(url.trim())
)
),
];
// Fence the whole obsolete set before awaiting the first worker exit.
removed.forEach(retireEpgSource);
for (const url of removed)
await epgWorkerService.clearEpgDataForSource(url);
});
reconciliation = next;
return next;
}
@@ -1,3 +1,4 @@
import { epgSourceGeneration, retireEpgSource } from './epg-source-generation';
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import { pathToFileURL } from 'url';
@@ -113,6 +114,7 @@ export class EpgWorkerService {
url: string,
options: ElectronBridgeTrustOptions
): Promise<void> {
const generation = epgSourceGeneration(url);
return new Promise((resolve, reject) => {
let worker: Worker;
try {
@@ -201,6 +203,7 @@ export class EpgWorkerService {
scheduleFetchTimeout();
worker.on('message', async (message: EpgWorkerMessage) => {
if (settled || generation !== epgSourceGeneration(url)) return;
try {
switch (message.type) {
case 'READY':
@@ -371,6 +374,8 @@ export class EpgWorkerService {
return;
}
retireEpgSource(normalizedSourceUrl);
this.fetchedUrls.delete(normalizedSourceUrl);
const runningWorker = this.workers.get(normalizedSourceUrl);
if (runningWorker) {
this.workers.delete(normalizedSourceUrl);
@@ -287,6 +287,60 @@ describe('EpgEvents', () => {
await flushPromises();
});
it('does not revive a retired source from late worker READY or COMPLETE messages', async () => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = 'https://removed.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;
})
);
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('exit', 1);
finishTermination();
await flushPromises();
const clearWorker = mockWorkerInstances[1];
clearWorker.emit('message', { type: 'READY' });
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
await clear;
await fetch;
});
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');
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);
fetch.mockRestore();
});
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';
@@ -381,7 +435,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 +450,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);
}
);
@@ -267,6 +267,20 @@ export class EpgDatabaseSourceClearOperation {
const clearSource = this.db.transaction((url: string) => {
this.deleteProgramsForSourceStmt.run(url);
// Channels have one legacy global ID. Transfer their ownership
// to a surviving source before a subsequent source removal.
this.db
.prepare(
`UPDATE epg_channels SET source_url = (
SELECT source_url FROM epg_programs
WHERE channel_id = epg_channels.id AND source_url IS NOT NULL
ORDER BY source_url LIMIT 1
) WHERE source_url = ? AND EXISTS (
SELECT 1 FROM epg_programs
WHERE channel_id = epg_channels.id AND source_url IS NOT NULL
)`
)
.run(url);
this.deleteOrphanChannelsForSourceStmt.run(url);
});
+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,
+13 -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,15 @@ 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 = (sources: string[]) => {
this.epgService.fetchEpg(
this.epgSources.retainCurrentSources(sources, revision)
);
};
if (!this.epgBridge.supportsSourceFreshness) {
this.epgService.fetchEpg(urls);
fetchCurrentSources(urls);
return;
}
@@ -197,7 +206,7 @@ export class AppComponent implements OnInit {
const result = await this.epgBridge.checkFreshness(urls, 12);
if (!result) {
this.epgService.fetchEpg(urls);
fetchCurrentSources(urls);
return;
}
@@ -219,12 +228,12 @@ export class AppComponent implements OnInit {
debugAppComponent(
`EPG: Fetching ${result.staleUrls.length} stale source(s)`
);
this.epgService.fetchEpg(result.staleUrls);
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);
fetchCurrentSources(urls);
}
}
@@ -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.