mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix(epg): remove cached XMLTV data after source deletion
This commit is contained in:
1 parent
b55a3cc322
commit
ffcbee8140
32 files changed
+1051
-82
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { MockProvider } from 'ng-mocks';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
DataService,
|
||||
EpgSourceSettingsService,
|
||||
SettingsStore,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
Language,
|
||||
Settings,
|
||||
@@ -235,6 +240,37 @@ describe('AppComponent', () => {
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reimport a deleted source from a late startup freshness response', async () => {
|
||||
await TestBed.inject(SettingsStore).loadSettings();
|
||||
let finishFreshness!: (value: {
|
||||
freshUrls: string[];
|
||||
staleUrls: string[];
|
||||
}) => void;
|
||||
epgBridge.checkFreshness = jest.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishFreshness = resolve;
|
||||
})
|
||||
);
|
||||
const pending = (
|
||||
component as unknown as {
|
||||
fetchStaleEpgData(urls: string[]): Promise<void>;
|
||||
}
|
||||
).fetchStaleEpgData(['https://removed.example/guide.xml']);
|
||||
await Promise.resolve();
|
||||
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||
sources.revision.update((value) => value + 1);
|
||||
sources.changed$.next();
|
||||
finishFreshness({
|
||||
freshUrls: [],
|
||||
staleUrls: ['https://removed.example/guide.xml'],
|
||||
});
|
||||
await pending;
|
||||
expect(epgService.fetchEpg).not.toHaveBeenCalledWith([
|
||||
'https://removed.example/guide.xml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fetch EPG settings when the EPG bridge cannot import EPG', async () => {
|
||||
const settings: Settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DataService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
EpgSourceSettingsService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
@@ -63,6 +64,7 @@ export class AppComponent implements OnInit {
|
||||
private translate = inject(TranslateService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private readonly epgSources = inject(EpgSourceSettingsService);
|
||||
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
|
||||
private playlistOpenRequests = inject(PlaylistOpenRequestService);
|
||||
private runtime = inject(RuntimeCapabilitiesService);
|
||||
@@ -188,8 +190,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.
|
||||
|
||||
Reference in new issue
Block a user