mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
baba0529ef
commit
9de480826c
55 files changed
+3538
-615
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
type: fix
|
||||||
|
area: epg
|
||||||
|
---
|
||||||
|
|
||||||
|
Removing and saving an EPG source now clears its cached programmes, including data left by previously removed sources on restart. Other configured sources and playlist guides are preserved, and Live TV stops showing programmes from the removed source.
|
||||||
@@ -185,6 +185,28 @@ Button states use the matching group; "Total selected" counts the whole catalog.
|
|||||||
Save persists the complete draft, Close discards it, and refresh restores hidden
|
Save persists the complete draft, Close discards it, and refresh restores hidden
|
||||||
categories by provider ID and type. See `docs/architecture/category-management.md`.
|
categories by provider ID and type. See `docs/architecture/category-management.md`.
|
||||||
|
|
||||||
|
## XMLTV Source Removal
|
||||||
|
|
||||||
|
Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
|
||||||
|
all enabled M3U playlist sources. Startup runs the same reconciliation after
|
||||||
|
settings load and playlist migration. Ordinary saves skip unchanged normalized
|
||||||
|
source sets; an explicitly edited EPG field can retry a failed cleanup.
|
||||||
|
A cleanup failure after persistence still mirrors committed settings to Electron;
|
||||||
|
the form stays dirty for retry. Failed storage writes never mirror to main.
|
||||||
|
Failed settings reads and incomplete playlist migration never authorize pruning.
|
||||||
|
Removed sources retire queued/running imports and dismiss retained error rows
|
||||||
|
before worker-owned deletion. Retry waits for reconciliation and rechecks its
|
||||||
|
error row, including after trust-setting writes. Shared channel IDs survive while
|
||||||
|
another source has programmes or per-source channel metadata. The additive
|
||||||
|
`epg_channel_sources` table preserves each imported source's name, logo, URL and
|
||||||
|
timestamp plus transaction-ordered `write_order`, so removal restores the latest
|
||||||
|
surviving snapshot even when import timestamps tie; ambiguous legacy metadata
|
||||||
|
falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
|
||||||
|
longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
|
||||||
|
invalidation prevent late results from restoring removed programmes. Provider
|
||||||
|
EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||||
|
("XMLTV source lifecycle").
|
||||||
|
|
||||||
## Portal Connectivity Preference
|
## Portal Connectivity Preference
|
||||||
|
|
||||||
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
|||||||
- `database.events.ts` - Database CRUD operations
|
- `database.events.ts` - Database CRUD operations
|
||||||
- `playlist.events.ts` - Playlist import/update
|
- `playlist.events.ts` - Playlist import/update
|
||||||
- `playlist-open.events.ts` - Playlist files handed over by the OS (argv, file association, macOS `open-file`); the queue itself lives in `services/playlist-open-request.ts`
|
- `playlist-open.events.ts` - Playlist files handed over by the OS (argv, file association, macOS `open-file`); the queue itself lives in `services/playlist-open-request.ts`
|
||||||
- `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`
|
- `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, source orchestration in `epg-worker.service.ts`, per-import lifecycle in `epg-fetch-operation.ts`, worker bootstrap/shutdown and clear protocol in `epg-worker-runtime.ts`, DB lookups in `epg-query.service.ts`
|
||||||
- `xtream.events.ts` - Xtream Codes API
|
- `xtream.events.ts` - Xtream Codes API
|
||||||
- `stalker.events.ts` - Stalker portal API
|
- `stalker.events.ts` - Stalker portal API
|
||||||
- `connectivity-guard.events.ts` - `CONNECTIVITY_GUARD_RESET`: forgets the connection failures recorded for a portal host. Both portal handlers above run every request through the per-host circuit breaker (rules in `@iptvnator/shared/host-health`, process-wide instance in `util/host-connectivity-guard.ts`; the web backend runs the same breaker over its proxy routes) — after 2 consecutive connection-level failures (no HTTP response; `ETIMEDOUT`/`ENOTFOUND`/`ECONNREFUSED`/… but never `ECONNRESET`) requests to that endpoint fail immediately for 30 s. The key is `URL.origin`, not `URL.host`, which would give `http://panel` and `https://panel` one shared record and let a dead TLS listener fast-fail the working HTTP one instead of hanging the full 30 s/15 s axios timeout again, with one half-open trial request afterwards. Any HTTP response (4xx and 5xx included) clears the record. The refusal is a real `Error` whose wording is a renderer contract (`buildHostConnectivityFastFailMessage` in `libs/shared/interfaces`): it must carry no `HTTP Error <code>`, no timeout wording and none of the auth phrases, or Stalker endpoint discovery misclassifies it and lazy portal repair fires against a host just declared dead. Discovery probes are exempt via the `skipConnectionGuard` payload flag (bypass + no failure counting, but successes still clear the record). Every user-driven retry/refresh that issues portal requests must reset BEFORE its first request, or the affordance fast-fails and looks broken; automatic and first-load paths deliberately do not reset. Current senders: Xtream content-gate Retry, Stalker catalog append retry (`retryContentPage`), Stalker search-page retry, `StalkerItvCacheService.refresh()` (Live TV refresh), both account-info dialogs' Retry, the destructive Xtream refresh (`XtreamRefreshFlowService`, before it deletes the cached catalog — one flow shared by both entry points, `PlaylistRefreshActionService.refreshXtream()` and `RecentPlaylistsComponent.refreshXtreamPlaylist()`, which supply only a progress reporter), `StalkerPortalDiscoveryService.discover()`, and `PortalStatusService` on `skipCache`. Kill switch: `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1`. Contract: `docs/architecture/host-connectivity-guard.md`
|
- `connectivity-guard.events.ts` - `CONNECTIVITY_GUARD_RESET`: forgets the connection failures recorded for a portal host. Both portal handlers above run every request through the per-host circuit breaker (rules in `@iptvnator/shared/host-health`, process-wide instance in `util/host-connectivity-guard.ts`; the web backend runs the same breaker over its proxy routes) — after 2 consecutive connection-level failures (no HTTP response; `ETIMEDOUT`/`ENOTFOUND`/`ECONNREFUSED`/… but never `ECONNRESET`) requests to that endpoint fail immediately for 30 s. The key is `URL.origin`, not `URL.host`, which would give `http://panel` and `https://panel` one shared record and let a dead TLS listener fast-fail the working HTTP one instead of hanging the full 30 s/15 s axios timeout again, with one half-open trial request afterwards. Any HTTP response (4xx and 5xx included) clears the record. The refusal is a real `Error` whose wording is a renderer contract (`buildHostConnectivityFastFailMessage` in `libs/shared/interfaces`): it must carry no `HTTP Error <code>`, no timeout wording and none of the auth phrases, or Stalker endpoint discovery misclassifies it and lazy portal repair fires against a host just declared dead. Discovery probes are exempt via the `skipConnectionGuard` payload flag (bypass + no failure counting, but successes still clear the record). Every user-driven retry/refresh that issues portal requests must reset BEFORE its first request, or the affordance fast-fails and looks broken; automatic and first-load paths deliberately do not reset. Current senders: Xtream content-gate Retry, Stalker catalog append retry (`retryContentPage`), Stalker search-page retry, `StalkerItvCacheService.refresh()` (Live TV refresh), both account-info dialogs' Retry, the destructive Xtream refresh (`XtreamRefreshFlowService`, before it deletes the cached catalog — one flow shared by both entry points, `PlaylistRefreshActionService.refreshXtream()` and `RecentPlaylistsComponent.refreshXtreamPlaylist()`, which supply only a progress reporter), `StalkerPortalDiscoveryService.discover()`, and `PortalStatusService` on `skipCache`. Kill switch: `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1`. Contract: `docs/architecture/host-connectivity-guard.md`
|
||||||
@@ -1670,6 +1670,28 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c
|
|||||||
|
|
||||||
<!-- nx configuration end-->
|
<!-- nx configuration end-->
|
||||||
|
|
||||||
|
## XMLTV Source Removal
|
||||||
|
|
||||||
|
Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
|
||||||
|
all enabled M3U playlist sources. Startup runs the same reconciliation after
|
||||||
|
settings load and playlist migration. Ordinary saves skip unchanged normalized
|
||||||
|
source sets; an explicitly edited EPG field can retry a failed cleanup.
|
||||||
|
A cleanup failure after persistence still mirrors committed settings to Electron;
|
||||||
|
the form stays dirty for retry. Failed storage writes never mirror to main.
|
||||||
|
Failed settings reads and incomplete playlist migration never authorize pruning.
|
||||||
|
Removed sources retire queued/running imports and dismiss retained error rows
|
||||||
|
before worker-owned deletion. Retry waits for reconciliation and rechecks its
|
||||||
|
error row, including after trust-setting writes. Shared channel IDs survive while
|
||||||
|
another source has programmes or per-source channel metadata. The additive
|
||||||
|
`epg_channel_sources` table preserves each imported source's name, logo, URL and
|
||||||
|
timestamp plus transaction-ordered `write_order`, so removal restores the latest
|
||||||
|
surviving snapshot even when import timestamps tie; ambiguous legacy metadata
|
||||||
|
falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
|
||||||
|
longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
|
||||||
|
invalidation prevent late results from restoring removed programmes. Provider
|
||||||
|
EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||||
|
("XMLTV source lifecycle").
|
||||||
|
|
||||||
## Portal Connectivity Preference
|
## Portal Connectivity Preference
|
||||||
|
|
||||||
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { Page } from '@playwright/test';
|
import { Page } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
buildM3uContent,
|
buildM3uContent,
|
||||||
@@ -5,6 +7,7 @@ import {
|
|||||||
closeElectronApp,
|
closeElectronApp,
|
||||||
createMutableTextServer,
|
createMutableTextServer,
|
||||||
expect,
|
expect,
|
||||||
|
goToDashboard,
|
||||||
importM3uPlaylistFromUrl,
|
importM3uPlaylistFromUrl,
|
||||||
launchElectronApp,
|
launchElectronApp,
|
||||||
openWorkspaceSection,
|
openWorkspaceSection,
|
||||||
@@ -33,7 +36,8 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
function createCurrentXmltvFixture(
|
function createCurrentXmltvFixture(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
channelName: string,
|
channelName: string,
|
||||||
programTitle: string
|
programTitle: string,
|
||||||
|
iconUrl?: string
|
||||||
): string {
|
): string {
|
||||||
const start = new Date(Date.now() - 15 * 60 * 1000);
|
const start = new Date(Date.now() - 15 * 60 * 1000);
|
||||||
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
||||||
@@ -42,6 +46,7 @@ function createCurrentXmltvFixture(
|
|||||||
<tv>
|
<tv>
|
||||||
<channel id="${channelId}">
|
<channel id="${channelId}">
|
||||||
<display-name>${channelName}</display-name>
|
<display-name>${channelName}</display-name>
|
||||||
|
${iconUrl ? `<icon src="${iconUrl}"/>` : ''}
|
||||||
</channel>
|
</channel>
|
||||||
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
|
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
|
||||||
<title>${programTitle}</title>
|
<title>${programTitle}</title>
|
||||||
@@ -65,7 +70,159 @@ function formatXmltvDate(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Electron EPG', () => {
|
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,
|
dataDir,
|
||||||
}) => {
|
}) => {
|
||||||
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
||||||
@@ -103,6 +260,8 @@ test.describe('Electron EPG', () => {
|
|||||||
.first()
|
.first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
|
await saveSettings(app.mainWindow);
|
||||||
|
|
||||||
await app.mainWindow
|
await app.mainWindow
|
||||||
.locator('.epg-source-row button')
|
.locator('.epg-source-row button')
|
||||||
.nth(1)
|
.nth(1)
|
||||||
@@ -116,18 +275,6 @@ test.describe('Electron EPG', () => {
|
|||||||
// would block the close and leak the Electron process.
|
// would block the close and leak the Electron process.
|
||||||
await saveSettings(app.mainWindow);
|
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
|
await expect
|
||||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||||
timeout: 20000,
|
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 ({
|
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
|
||||||
dataDir,
|
dataDir,
|
||||||
}) => {
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
const epgServer = await createMutableTextServer(
|
const epgServer = await createMutableTextServer(
|
||||||
createCurrentXmltvFixture(
|
createCurrentXmltvFixture(
|
||||||
'playlist-guide-news',
|
'playlist-guide-news',
|
||||||
@@ -212,6 +599,27 @@ test.describe('Electron EPG', () => {
|
|||||||
'Playlist Scoped Bulletin',
|
'Playlist Scoped Bulletin',
|
||||||
{ timeout: 30000 }
|
{ 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 {
|
} finally {
|
||||||
await closeElectronApp(app);
|
await closeElectronApp(app);
|
||||||
await playlistServer.close();
|
await playlistServer.close();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
channelItemByTitle,
|
channelItemByTitle,
|
||||||
clickCategoryByNameExact,
|
clickCategoryByNameExact,
|
||||||
closeElectronApp,
|
closeElectronApp,
|
||||||
|
createMutableTextServer,
|
||||||
expect,
|
expect,
|
||||||
goToDashboard,
|
goToDashboard,
|
||||||
launchElectronApp,
|
launchElectronApp,
|
||||||
@@ -28,6 +29,189 @@ const epgCredentials = {
|
|||||||
password: 'epg',
|
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) {
|
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||||
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({
|
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({
|
||||||
dataDir,
|
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
|
// Provider-declared catch-up (tv_archive=1 in the fixture) is
|
||||||
// surfaced as a badge on the sidebar row (#1128).
|
// surfaced as a badge on the sidebar row (#1128).
|
||||||
await expect(
|
await expect(channelRow.getByTestId('catchup-badge')).toBeVisible();
|
||||||
channelRow.getByTestId('catchup-badge')
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Sidebar channel list shows the per-channel "now" programme line.
|
// Sidebar channel list shows the per-channel "now" programme line.
|
||||||
await expect
|
await expect
|
||||||
@@ -263,10 +445,7 @@ test('@radio @stalker @electron keeps radio rows compact at narrow widths', asyn
|
|||||||
await expect(categoryButton).toBeVisible();
|
await expect(categoryButton).toBeVisible();
|
||||||
await categoryButton.click();
|
await categoryButton.click();
|
||||||
|
|
||||||
const radioRow = channelItemByTitle(
|
const radioRow = channelItemByTitle(app.mainWindow, firstTitle).first();
|
||||||
app.mainWindow,
|
|
||||||
firstTitle
|
|
||||||
).first();
|
|
||||||
await expect(radioRow).toBeVisible({ timeout: 20000 });
|
await expect(radioRow).toBeVisible({ timeout: 20000 });
|
||||||
await expect(radioRow).toHaveClass(/compact/);
|
await expect(radioRow).toHaveClass(/compact/);
|
||||||
await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0);
|
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
|
// 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" —
|
// actually on air is the one the provider files under "an hour ago" —
|
||||||
// and every surface must agree on that same programme.
|
// 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(
|
const expectedProgram = fixture.fullEpg.find(
|
||||||
(listing) =>
|
(listing) =>
|
||||||
listing.startTimestamp <= providerNowSeconds &&
|
listing.startTimestamp <= providerNowSeconds &&
|
||||||
@@ -341,8 +521,7 @@ test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" b
|
|||||||
await expect
|
await expect
|
||||||
.poll(async () =>
|
.poll(async () =>
|
||||||
(
|
(
|
||||||
(await channelRow.locator('.epg-title').textContent()) ??
|
(await channelRow.locator('.epg-title').textContent()) ?? ''
|
||||||
''
|
|
||||||
).trim()
|
).trim()
|
||||||
)
|
)
|
||||||
.toBe(expectedProgram.title);
|
.toBe(expectedProgram.title);
|
||||||
|
|||||||
@@ -536,6 +536,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
|
|||||||
channel: 'EPG_CLEAR_ALL',
|
channel: 'EPG_CLEAR_ALL',
|
||||||
forwardedArgs: [],
|
forwardedArgs: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'reconcileEpgSources',
|
||||||
|
args: [epgUrls],
|
||||||
|
channel: 'EPG_RECONCILE_SOURCES',
|
||||||
|
forwardedArgs: [{ urls: epgUrls }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'clearEpgDataForSource',
|
method: 'clearEpgDataForSource',
|
||||||
args: ['https://example.com/guide.xml'],
|
args: ['https://example.com/guide.xml'],
|
||||||
|
|||||||
@@ -615,11 +615,7 @@ const electronApi: ElectronBridgeApi = {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
style: EmbeddedMpvSubtitleStyle
|
style: EmbeddedMpvSubtitleStyle
|
||||||
): Promise<EmbeddedMpvSession | null> =>
|
): Promise<EmbeddedMpvSession | null> =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke('EMBEDDED_MPV_SET_SUBTITLE_STYLE', sessionId, style),
|
||||||
'EMBEDDED_MPV_SET_SUBTITLE_STYLE',
|
|
||||||
sessionId,
|
|
||||||
style
|
|
||||||
),
|
|
||||||
selectEmbeddedMpvSubtitleFile: (): Promise<string | null> =>
|
selectEmbeddedMpvSubtitleFile: (): Promise<string | null> =>
|
||||||
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'),
|
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'),
|
||||||
setEmbeddedMpvSpeed: (
|
setEmbeddedMpvSpeed: (
|
||||||
@@ -681,6 +677,8 @@ const electronApi: ElectronBridgeApi = {
|
|||||||
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
|
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
|
||||||
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
|
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
|
||||||
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
|
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
|
||||||
|
reconcileEpgSources: (urls: string[]) =>
|
||||||
|
ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }),
|
||||||
clearEpgDataForSource: (sourceUrl: string) =>
|
clearEpgDataForSource: (sourceUrl: string) =>
|
||||||
ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }),
|
ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }),
|
||||||
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
|
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
|
||||||
@@ -1118,8 +1116,7 @@ const electronApi: ElectronBridgeApi = {
|
|||||||
recordingsUpdatePrograms: (
|
recordingsUpdatePrograms: (
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
programs: RecordingProgramSnapshot[]
|
programs: RecordingProgramSnapshot[]
|
||||||
) =>
|
) => ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs),
|
||||||
ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs),
|
|
||||||
recordingsRevealFile: (filePath: string) =>
|
recordingsRevealFile: (filePath: string) =>
|
||||||
ipcRenderer.invoke('RECORDINGS_REVEAL_FILE', filePath),
|
ipcRenderer.invoke('RECORDINGS_REVEAL_FILE', filePath),
|
||||||
recordingsPlayFile: (filePath: string) =>
|
recordingsPlayFile: (filePath: string) =>
|
||||||
|
|||||||
@@ -4,40 +4,41 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Tables
|
// Tables
|
||||||
playlists,
|
playlists,
|
||||||
categories,
|
categories,
|
||||||
content,
|
content,
|
||||||
recentlyViewed,
|
recentlyViewed,
|
||||||
favorites,
|
favorites,
|
||||||
epgChannels,
|
epgChannels,
|
||||||
epgChannelMappings,
|
epgChannelSources,
|
||||||
epgPrograms,
|
epgChannelMappings,
|
||||||
playbackPositions,
|
epgPrograms,
|
||||||
downloads,
|
playbackPositions,
|
||||||
recordings,
|
downloads,
|
||||||
appState,
|
recordings,
|
||||||
// Types
|
appState,
|
||||||
type Playlist,
|
// Types
|
||||||
type NewPlaylist,
|
type Playlist,
|
||||||
type AppState,
|
type NewPlaylist,
|
||||||
type NewAppState,
|
type AppState,
|
||||||
type Category,
|
type NewAppState,
|
||||||
type NewCategory,
|
type Category,
|
||||||
type Content,
|
type NewCategory,
|
||||||
type NewContent,
|
type Content,
|
||||||
type RecentlyViewed,
|
type NewContent,
|
||||||
type NewRecentlyViewed,
|
type RecentlyViewed,
|
||||||
type Favorite,
|
type NewRecentlyViewed,
|
||||||
type NewFavorite,
|
type Favorite,
|
||||||
type EpgChannel,
|
type NewFavorite,
|
||||||
type NewEpgChannel,
|
type EpgChannel,
|
||||||
type EpgProgramDb,
|
type NewEpgChannel,
|
||||||
type NewEpgProgramDb,
|
type EpgProgramDb,
|
||||||
type PlaybackPosition,
|
type NewEpgProgramDb,
|
||||||
type NewPlaybackPosition,
|
type PlaybackPosition,
|
||||||
type Download,
|
type NewPlaybackPosition,
|
||||||
type NewDownload,
|
type Download,
|
||||||
type Recording,
|
type NewDownload,
|
||||||
type NewRecording,
|
type Recording,
|
||||||
|
type NewRecording,
|
||||||
} from '@iptvnator/shared/database';
|
} 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 { eq } from 'drizzle-orm';
|
||||||
import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces';
|
import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces';
|
||||||
import { getDatabase } from '../database/connection';
|
import { getDatabase } from '../database/connection';
|
||||||
@@ -45,12 +47,13 @@ export async function checkEpgFreshness(
|
|||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
|
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
|
const generation = epgSourceGeneration(url);
|
||||||
if (!url?.trim()) continue;
|
if (!url?.trim()) continue;
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({ updatedAt: schema.epgChannels.updatedAt })
|
.select({ updatedAt: schema.epgChannelSources.updatedAt })
|
||||||
.from(schema.epgChannels)
|
.from(schema.epgChannelSources)
|
||||||
.where(eq(schema.epgChannels.sourceUrl, url))
|
.where(eq(schema.epgChannelSources.sourceUrl, url))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const isFresh =
|
const isFresh =
|
||||||
@@ -58,6 +61,7 @@ export async function checkEpgFreshness(
|
|||||||
result[0].updatedAt &&
|
result[0].updatedAt &&
|
||||||
result[0].updatedAt >= cutoffTime;
|
result[0].updatedAt >= cutoffTime;
|
||||||
|
|
||||||
|
if (generation !== epgSourceGeneration(url)) continue;
|
||||||
if (isFresh) {
|
if (isFresh) {
|
||||||
freshUrls.push(url);
|
freshUrls.push(url);
|
||||||
epgWorkerService.markFetchedUrl(url);
|
epgWorkerService.markFetchedUrl(url);
|
||||||
@@ -66,18 +70,18 @@ export async function checkEpgFreshness(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(loggerLabel, 'Error checking EPG freshness:', error);
|
epgLogger.error(loggerLabel, 'Error checking EPG freshness:', error);
|
||||||
return { staleUrls: urls, freshUrls: [] };
|
return { staleUrls: urls, freshUrls: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (freshUrls.length > 0) {
|
if (freshUrls.length > 0) {
|
||||||
console.log(
|
epgLogger.log(
|
||||||
loggerLabel,
|
loggerLabel,
|
||||||
`EPG fresh (skipping): ${freshUrls.length} source(s)`
|
`EPG fresh (skipping): ${freshUrls.length} source(s)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (staleUrls.length > 0) {
|
if (staleUrls.length > 0) {
|
||||||
console.log(
|
epgLogger.log(
|
||||||
loggerLabel,
|
loggerLabel,
|
||||||
`EPG stale (will fetch): ${staleUrls.length} source(s)`
|
`EPG stale (will fetch): ${staleUrls.length} source(s)`
|
||||||
);
|
);
|
||||||
@@ -95,7 +99,12 @@ export async function handleFetchEpg(
|
|||||||
urls: string[],
|
urls: string[],
|
||||||
options: ElectronBridgeTrustOptions = {}
|
options: ElectronBridgeTrustOptions = {}
|
||||||
): Promise<EpgFetchResult> {
|
): 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) {
|
if (validUrls.length === 0) {
|
||||||
return { success: false, message: 'No valid URLs provided' };
|
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
|
// a 'queued' status, then fetchEpgFromUrl silently skips the URL and no
|
||||||
// completion update ever arrives, leaving the UI stuck at "queued".
|
// completion update ever arrives, leaving the UI stuck at "queued".
|
||||||
const urlsToFetch = staleUrls.filter(
|
const urlsToFetch = staleUrls.filter(
|
||||||
(url) => !epgWorkerService.hasFetchedUrl(url)
|
(url) =>
|
||||||
|
generations.get(url) === epgSourceGeneration(url) &&
|
||||||
|
!epgWorkerService.hasFetchedUrl(url)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (urlsToFetch.length === 0) {
|
if (urlsToFetch.length === 0) {
|
||||||
console.log(
|
epgLogger.log(
|
||||||
loggerLabel,
|
loggerLabel,
|
||||||
`All ${staleUrls.length} stale URL(s) already fetched this session; skipping`
|
`All ${staleUrls.length} stale URL(s) already fetched this session; skipping`
|
||||||
);
|
);
|
||||||
@@ -142,16 +153,23 @@ export async function handleFetchEpg(
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
for (const url of urlsToFetch) {
|
for (const url of urlsToFetch) {
|
||||||
try {
|
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);
|
await epgWorkerService.fetchEpgFromUrl(url, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
epgLogger.error(loggerLabel, 'Error fetching EPG source:', error);
|
||||||
loggerLabel,
|
errors.push(error instanceof Error ? error.message : String(error));
|
||||||
`Error fetching EPG from ${url}:`,
|
|
||||||
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 { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
EpgChannelMetadata,
|
EpgChannelMetadata,
|
||||||
@@ -154,7 +155,7 @@ export class EpgQueryService {
|
|||||||
|
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
epgLogger.error(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
'Error getting channel programs:',
|
'Error getting channel programs:',
|
||||||
error
|
error
|
||||||
@@ -310,7 +311,7 @@ export class EpgQueryService {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
epgLogger.error(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
'Error getting batch current programs:',
|
'Error getting batch current programs:',
|
||||||
error
|
error
|
||||||
@@ -335,7 +336,7 @@ export class EpgQueryService {
|
|||||||
|
|
||||||
return { channels, programs: [] };
|
return { channels, programs: [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
epgLogger.error(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
'Error getting all channels:',
|
'Error getting all channels:',
|
||||||
error
|
error
|
||||||
@@ -391,7 +392,7 @@ export class EpgQueryService {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
epgLogger.error(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
'Error getting channel metadata:',
|
'Error getting channel metadata:',
|
||||||
error
|
error
|
||||||
@@ -439,7 +440,7 @@ export class EpgQueryService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
epgLogger.error(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
'Error getting channels by range:',
|
'Error getting channels by range:',
|
||||||
error
|
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();
|
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', () => {
|
describe('worker lifecycle', () => {
|
||||||
it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => {
|
it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => {
|
||||||
const options = { manuallyTrustedHosts: ['example.com'] } as any;
|
const options = { manuallyTrustedHosts: ['example.com'] } as any;
|
||||||
|
|||||||
@@ -1,43 +1,40 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { epgLogger } from '../util/epg-logger';
|
||||||
import * as path from 'path';
|
import {
|
||||||
import { pathToFileURL } from 'url';
|
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 { Worker } from 'worker_threads';
|
||||||
import {
|
import {
|
||||||
ElectronBridgeSecurityErrorCode,
|
ElectronBridgeSecurityErrorCode,
|
||||||
ElectronBridgeTrustOptions,
|
ElectronBridgeTrustOptions,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} 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 {
|
export interface EpgProgressStats {
|
||||||
totalChannels: number;
|
totalChannels: number;
|
||||||
totalPrograms: 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 {
|
export class EpgWorkerService {
|
||||||
private readonly fetchedUrls = new Set<string>();
|
private readonly fetchedUrls = new Set<string>();
|
||||||
private readonly workers = new Map<string, Worker>();
|
private readonly workers = new Map<string, Worker>();
|
||||||
private readonly inFlightFetches = new Map<string, Promise<void>>();
|
private readonly inFlightFetches = new Map<string, Promise<void>>();
|
||||||
|
private readonly inFlightSourceClears = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
private readonly runtime: EpgWorkerRuntime;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly loggerLabel = '[EPG Events]',
|
private readonly loggerLabel = '[EPG Events]',
|
||||||
private readonly fetchTimeoutMs = 5 * 60 * 1000
|
private readonly fetchTimeoutMs = 5 * 60 * 1000
|
||||||
) {}
|
) {
|
||||||
|
this.runtime = new EpgWorkerRuntime(loggerLabel, fetchTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
hasFetchedUrl(url: string): boolean {
|
hasFetchedUrl(url: string): boolean {
|
||||||
return this.fetchedUrls.has(url);
|
return this.fetchedUrls.has(url);
|
||||||
@@ -58,12 +55,14 @@ export class EpgWorkerService {
|
|||||||
error?: string,
|
error?: string,
|
||||||
queuePosition?: number,
|
queuePosition?: number,
|
||||||
errorCode?: ElectronBridgeSecurityErrorCode,
|
errorCode?: ElectronBridgeSecurityErrorCode,
|
||||||
errorHost?: string
|
errorHost?: string,
|
||||||
|
generation = epgSourceGeneration(url)
|
||||||
): void {
|
): void {
|
||||||
const windows = BrowserWindow.getAllWindows();
|
const windows = BrowserWindow.getAllWindows();
|
||||||
windows.forEach((win) => {
|
windows.forEach((win) => {
|
||||||
win.webContents.send('EPG_PROGRESS_UPDATE', {
|
win.webContents.send('EPG_PROGRESS_UPDATE', {
|
||||||
url,
|
url,
|
||||||
|
generation,
|
||||||
status,
|
status,
|
||||||
stats,
|
stats,
|
||||||
error,
|
error,
|
||||||
@@ -78,6 +77,26 @@ export class EpgWorkerService {
|
|||||||
url: string,
|
url: string,
|
||||||
options: ElectronBridgeTrustOptions = {}
|
options: ElectronBridgeTrustOptions = {}
|
||||||
): Promise<void> {
|
): 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
|
// 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
|
// 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
|
// 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.
|
// must keep awaiting that termination window.
|
||||||
const inFlight = this.inFlightFetches.get(url);
|
const inFlight = this.inFlightFetches.get(url);
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
console.log(
|
epgLogger.log(this.loggerLabel, 'Reusing in-flight EPG fetch');
|
||||||
this.loggerLabel,
|
|
||||||
`Reusing in-flight EPG fetch: ${url}`
|
|
||||||
);
|
|
||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.fetchedUrls.has(url)) {
|
if (this.fetchedUrls.has(url)) {
|
||||||
console.log(
|
epgLogger.log(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
`Skipping already fetched URL: ${url}`
|
'Skipping already fetched EPG source'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -113,230 +129,18 @@ export class EpgWorkerService {
|
|||||||
url: string,
|
url: string,
|
||||||
options: ElectronBridgeTrustOptions
|
options: ElectronBridgeTrustOptions
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return runEpgFetch(url, options, {
|
||||||
let worker: Worker;
|
runtime: this.runtime,
|
||||||
try {
|
workers: this.workers,
|
||||||
worker = this.createEpgWorker();
|
fetchedUrls: this.fetchedUrls,
|
||||||
} catch (error) {
|
loggerLabel: this.loggerLabel,
|
||||||
console.error(
|
fetchTimeoutMs: this.fetchTimeoutMs,
|
||||||
this.loggerLabel,
|
sendProgressToRenderer: this.sendProgressToRenderer.bind(this),
|
||||||
'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)));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearEpgData(): Promise<void> {
|
async clearEpgData(): Promise<void> {
|
||||||
return this.runClearWorker({
|
return this.runtime.runClearWorker({
|
||||||
timeoutLabel: 'EPG clear',
|
timeoutLabel: 'EPG clear',
|
||||||
exitLabel: 'Clear worker',
|
exitLabel: 'Clear worker',
|
||||||
readyMessage: { type: 'CLEAR_EPG' },
|
readyMessage: { type: 'CLEAR_EPG' },
|
||||||
@@ -344,21 +148,21 @@ export class EpgWorkerService {
|
|||||||
failedWorkerLabel: 'failed clear',
|
failedWorkerLabel: 'failed clear',
|
||||||
erroredWorkerLabel: 'errored clear',
|
erroredWorkerLabel: 'errored clear',
|
||||||
onComplete: async (worker) => {
|
onComplete: async (worker) => {
|
||||||
console.log(this.loggerLabel, 'EPG data cleared via worker');
|
epgLogger.log(this.loggerLabel, 'EPG data cleared via worker');
|
||||||
this.fetchedUrls.clear();
|
this.fetchedUrls.clear();
|
||||||
// Resolve only after every interrupted fetch worker has exited
|
// Resolve only after every interrupted fetch worker has exited
|
||||||
// too — they may still hold the SQLite lock the caller expects
|
// too — they may still hold the SQLite lock the caller expects
|
||||||
// to be free.
|
// to be free.
|
||||||
const terminations = [...this.workers.values()].map(
|
const terminations = [...this.workers.values()].map(
|
||||||
(runningWorker) =>
|
(runningWorker) =>
|
||||||
this.terminateWorker(
|
this.runtime.terminateWorker(
|
||||||
runningWorker,
|
runningWorker,
|
||||||
'fetch during clear'
|
'fetch during clear'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
this.workers.clear();
|
this.workers.clear();
|
||||||
terminations.push(
|
terminations.push(
|
||||||
this.terminateWorker(worker, 'completed clear')
|
this.runtime.terminateWorker(worker, 'completed clear')
|
||||||
);
|
);
|
||||||
await Promise.all(terminations);
|
await Promise.all(terminations);
|
||||||
},
|
},
|
||||||
@@ -371,13 +175,34 @@ export class EpgWorkerService {
|
|||||||
return;
|
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);
|
const runningWorker = this.workers.get(normalizedSourceUrl);
|
||||||
if (runningWorker) {
|
if (runningWorker) {
|
||||||
this.workers.delete(normalizedSourceUrl);
|
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',
|
timeoutLabel: 'EPG source clear',
|
||||||
exitLabel: 'Source clear worker',
|
exitLabel: 'Source clear worker',
|
||||||
readyMessage: {
|
readyMessage: {
|
||||||
@@ -388,153 +213,15 @@ export class EpgWorkerService {
|
|||||||
failedWorkerLabel: 'failed source clear',
|
failedWorkerLabel: 'failed source clear',
|
||||||
erroredWorkerLabel: 'errored source clear',
|
erroredWorkerLabel: 'errored source clear',
|
||||||
onComplete: async (worker) => {
|
onComplete: async (worker) => {
|
||||||
console.log(
|
epgLogger.log(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
`EPG data cleared for source via worker: ${normalizedSourceUrl}`
|
'EPG data cleared for source via worker'
|
||||||
);
|
);
|
||||||
this.fetchedUrls.delete(normalizedSourceUrl);
|
this.fetchedUrls.delete(normalizedSourceUrl);
|
||||||
await this.terminateWorker(worker, 'completed source clear');
|
await this.runtime.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
|
|
||||||
);
|
);
|
||||||
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));
|
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 () => {
|
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>)[
|
const fetchPromise = (EpgEvents as unknown as Record<string, any>)[
|
||||||
'fetchEpgFromUrl'
|
'fetchEpgFromUrl'
|
||||||
@@ -287,6 +318,120 @@ describe('EpgEvents', () => {
|
|||||||
await flushPromises();
|
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 () => {
|
it('clears one EPG source through a worker and allows it to be fetched again', async () => {
|
||||||
const workerService = new EpgWorkerService('[Test EPG]', 1000);
|
const workerService = new EpgWorkerService('[Test EPG]', 1000);
|
||||||
const sourceUrl = 'https://playlist.example.com/guide.xml';
|
const sourceUrl = 'https://playlist.example.com/guide.xml';
|
||||||
@@ -336,6 +481,66 @@ describe('EpgEvents', () => {
|
|||||||
expect(terminated).toBe(true);
|
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 () => {
|
it('keeps an active EPG fetch alive when worker progress keeps moving', async () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
|
||||||
@@ -381,7 +586,10 @@ describe('EpgEvents', () => {
|
|||||||
* inserts extra queries that the original test didn't anticipate.
|
* inserts extra queries that the original test didn't anticipate.
|
||||||
*/
|
*/
|
||||||
function queryChain<T>(data: T) {
|
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.where = jest.fn().mockReturnValue(chain);
|
||||||
chain.innerJoin = jest.fn().mockReturnValue(chain);
|
chain.innerJoin = jest.fn().mockReturnValue(chain);
|
||||||
chain.groupBy = jest.fn().mockReturnValue(chain);
|
chain.groupBy = jest.fn().mockReturnValue(chain);
|
||||||
@@ -393,25 +601,30 @@ describe('EpgEvents', () => {
|
|||||||
// getMapping queries (must come first — they return empty)
|
// getMapping queries (must come first — they return empty)
|
||||||
const from = jest
|
const from = jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
|
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
|
||||||
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
|
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
|
||||||
// Test expectations below
|
// Test expectations below
|
||||||
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
|
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
|
||||||
.mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found
|
.mockReturnValueOnce(
|
||||||
.mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs)
|
queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])
|
||||||
{
|
) // 4. selectChannelById → channel found
|
||||||
id: 1,
|
.mockReturnValueOnce(
|
||||||
channelId: 'BBC.ONE.UK',
|
queryChain([
|
||||||
start: '2026-04-14T10:00:00Z',
|
// 5. selectChannelPrograms (BBC.ONE.UK → programs)
|
||||||
stop: '2026-04-14T11:00:00Z',
|
{
|
||||||
title: 'News',
|
id: 1,
|
||||||
description: null,
|
channelId: 'BBC.ONE.UK',
|
||||||
category: null,
|
start: '2026-04-14T10:00:00Z',
|
||||||
iconUrl: null,
|
stop: '2026-04-14T11:00:00Z',
|
||||||
rating: null,
|
title: 'News',
|
||||||
episodeNum: null,
|
description: null,
|
||||||
},
|
category: null,
|
||||||
]));
|
iconUrl: null,
|
||||||
|
rating: null,
|
||||||
|
episodeNum: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
select.mockImplementation(() => ({ from }));
|
select.mockImplementation(() => ({ from }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { reconcileEpgSources } from './epg-source-settings.service';
|
||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import {
|
import {
|
||||||
ElectronBridgeCurrentProgramsOptions,
|
ElectronBridgeCurrentProgramsOptions,
|
||||||
@@ -29,6 +30,14 @@ export default class EpgEvents {
|
|||||||
* Bootstrap EPG events
|
* Bootstrap EPG events
|
||||||
*/
|
*/
|
||||||
static bootstrapEpgEvents(): Electron.IpcMain {
|
static bootstrapEpgEvents(): Electron.IpcMain {
|
||||||
|
ipcMain.handle(
|
||||||
|
'EPG_RECONCILE_SOURCES',
|
||||||
|
async (_event, args: { urls: string[] }) => {
|
||||||
|
await reconcileEpgSources(args.urls);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'FETCH_EPG',
|
'FETCH_EPG',
|
||||||
async (
|
async (
|
||||||
@@ -179,10 +188,7 @@ export default class EpgEvents {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'EPG_CHANNEL_SEARCH',
|
'EPG_CHANNEL_SEARCH',
|
||||||
async (
|
async (_event, args: { searchTerm: string; limit?: number }) => {
|
||||||
_event,
|
|
||||||
args: { searchTerm: string; limit?: number }
|
|
||||||
) => {
|
|
||||||
return handleSearchEpgChannels(args.searchTerm, args.limit);
|
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
|
FROM epg_programs
|
||||||
WHERE epg_programs.channel_id = epg_channels.id
|
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);
|
expect(preparedSql).toContain(deleteTodayAndFutureSql);
|
||||||
@@ -230,6 +234,7 @@ describe('EpgDatabaseClearOperation', () => {
|
|||||||
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
|
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
|
||||||
'BEGIN',
|
'BEGIN',
|
||||||
'DELETE FROM epg_programs',
|
'DELETE FROM epg_programs',
|
||||||
|
'DELETE FROM epg_channel_sources',
|
||||||
'DELETE FROM epg_channels',
|
'DELETE FROM epg_channels',
|
||||||
'COMMIT',
|
'COMMIT',
|
||||||
]);
|
]);
|
||||||
@@ -250,6 +255,7 @@ describe('EpgDatabaseClearOperation', () => {
|
|||||||
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
|
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
|
||||||
'BEGIN',
|
'BEGIN',
|
||||||
'DELETE FROM epg_programs',
|
'DELETE FROM epg_programs',
|
||||||
|
'DELETE FROM epg_channel_sources',
|
||||||
'DELETE FROM epg_channels',
|
'DELETE FROM epg_channels',
|
||||||
'ROLLBACK',
|
'ROLLBACK',
|
||||||
]);
|
]);
|
||||||
@@ -276,6 +282,10 @@ describe('EpgDatabaseSourceClearOperation', () => {
|
|||||||
FROM epg_programs
|
FROM epg_programs
|
||||||
WHERE epg_programs.channel_id = epg_channels.id
|
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);
|
expect(preparedSql).toContain(deleteProgramsSql);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type BetterSqlite3 from 'better-sqlite3';
|
import type BetterSqlite3 from 'better-sqlite3';
|
||||||
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
|
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
|
||||||
import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser';
|
import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser';
|
||||||
|
import { restoreSurvivingChannelMetadata } from './epg-source-metadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database helper for worker-owned EPG operations.
|
* Database helper for worker-owned EPG operations.
|
||||||
@@ -10,6 +11,8 @@ export class EpgDatabase {
|
|||||||
private readonly db: BetterSqlite3.Database;
|
private readonly db: BetterSqlite3.Database;
|
||||||
private readonly knownChannelIds = new Set<string>();
|
private readonly knownChannelIds = new Set<string>();
|
||||||
private readonly insertChannelStmt: BetterSqlite3.Statement;
|
private readonly insertChannelStmt: BetterSqlite3.Statement;
|
||||||
|
private readonly insertChannelSourceStmt: BetterSqlite3.Statement;
|
||||||
|
private readonly deleteChannelSourcesStmt: BetterSqlite3.Statement;
|
||||||
private readonly insertProgramStmt: BetterSqlite3.Statement;
|
private readonly insertProgramStmt: BetterSqlite3.Statement;
|
||||||
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
|
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
|
||||||
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
|
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
|
||||||
@@ -30,6 +33,25 @@ export class EpgDatabase {
|
|||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
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
|
// Guard against duplicate entries when the clearFirst logic misses old
|
||||||
// rows. The same channel + start + title + source is treated as the
|
// rows. The same channel + start + title + source is treated as the
|
||||||
// same programme — a later import with a corrected stop time simply
|
// same programme — a later import with a corrected stop time simply
|
||||||
@@ -66,6 +88,10 @@ export class EpgDatabase {
|
|||||||
FROM epg_programs
|
FROM epg_programs
|
||||||
WHERE epg_programs.channel_id = epg_channels.id
|
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(`
|
this.deleteTodayAndFutureStmt = this.db.prepare(`
|
||||||
@@ -88,7 +114,9 @@ export class EpgDatabase {
|
|||||||
): void {
|
): void {
|
||||||
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
|
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
|
||||||
if (clearTodayAndFuture) {
|
if (clearTodayAndFuture) {
|
||||||
|
restoreSurvivingChannelMetadata(this.db, sourceUrl);
|
||||||
this.deleteTodayAndFutureStmt.run(sourceUrl);
|
this.deleteTodayAndFutureStmt.run(sourceUrl);
|
||||||
|
this.deleteChannelSourcesStmt.run(sourceUrl);
|
||||||
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
|
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
|
||||||
this.knownChannelIds.clear();
|
this.knownChannelIds.clear();
|
||||||
}
|
}
|
||||||
@@ -106,6 +134,14 @@ export class EpgDatabase {
|
|||||||
url,
|
url,
|
||||||
sourceUrl
|
sourceUrl
|
||||||
);
|
);
|
||||||
|
this.insertChannelSourceStmt.run(
|
||||||
|
channel.id,
|
||||||
|
displayName,
|
||||||
|
iconUrl,
|
||||||
|
url,
|
||||||
|
sourceUrl,
|
||||||
|
channel.id
|
||||||
|
);
|
||||||
this.knownChannelIds.add(channel.id);
|
this.knownChannelIds.add(channel.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -222,6 +258,7 @@ export class EpgDatabaseClearOperation {
|
|||||||
this.db.exec('BEGIN');
|
this.db.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
this.db.exec('DELETE FROM epg_programs');
|
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('DELETE FROM epg_channels');
|
||||||
this.db.exec('COMMIT');
|
this.db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -256,6 +293,10 @@ export class EpgDatabaseSourceClearOperation {
|
|||||||
FROM epg_programs
|
FROM epg_programs
|
||||||
WHERE epg_programs.channel_id = epg_channels.id
|
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) => {
|
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.deleteProgramsForSourceStmt.run(url);
|
||||||
|
this.db
|
||||||
|
.prepare('DELETE FROM epg_channel_sources WHERE source_url = ?')
|
||||||
|
.run(url);
|
||||||
this.deleteOrphanChannelsForSourceStmt.run(url);
|
this.deleteOrphanChannelsForSourceStmt.run(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { epgLogger } from '../util/epg-logger';
|
||||||
import type BetterSqlite3 from 'better-sqlite3';
|
import type BetterSqlite3 from 'better-sqlite3';
|
||||||
import {
|
import {
|
||||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||||
@@ -102,7 +103,7 @@ async function fetchAndParseEpgStreaming(
|
|||||||
url: string,
|
url: string,
|
||||||
options: ElectronBridgeTrustOptions = {}
|
options: ElectronBridgeTrustOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(loggerLabel, `Fetching EPG from ${url}`);
|
epgLogger.log(loggerLabel, 'Fetching EPG source');
|
||||||
|
|
||||||
// Create database connection in worker
|
// Create database connection in worker
|
||||||
const epgDb = new EpgDatabase(Database);
|
const epgDb = new EpgDatabase(Database);
|
||||||
@@ -141,18 +142,15 @@ async function fetchAndParseEpgStreaming(
|
|||||||
const contentEncoding = getEpgResponseContentEncoding(response.headers);
|
const contentEncoding = getEpgResponseContentEncoding(response.headers);
|
||||||
|
|
||||||
if (responseUrl && responseUrl !== url) {
|
if (responseUrl && responseUrl !== url) {
|
||||||
console.log(
|
epgLogger.log(loggerLabel, 'Resolved EPG redirect');
|
||||||
loggerLabel,
|
|
||||||
`Resolved EPG redirect: ${url} -> ${responseUrl}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
epgLogger.log(
|
||||||
loggerLabel,
|
loggerLabel,
|
||||||
`EPG response detected as gzipped: ${isGzipped}`
|
`EPG response detected as gzipped: ${isGzipped}`
|
||||||
);
|
);
|
||||||
if (contentEncoding) {
|
if (contentEncoding) {
|
||||||
console.log(
|
epgLogger.log(
|
||||||
loggerLabel,
|
loggerLabel,
|
||||||
`EPG response content-encoding: ${contentEncoding}`
|
`EPG response content-encoding: ${contentEncoding}`
|
||||||
);
|
);
|
||||||
@@ -200,7 +198,7 @@ async function fetchAndParseEpgStreaming(
|
|||||||
try {
|
try {
|
||||||
parser.write(chunk.toString('utf-8'));
|
parser.write(chunk.toString('utf-8'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(loggerLabel, 'Parse error:', err);
|
epgLogger.error(loggerLabel, 'Parse error:', err);
|
||||||
epgDb.close();
|
epgDb.close();
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
@@ -209,7 +207,7 @@ async function fetchAndParseEpgStreaming(
|
|||||||
dataStream.on('end', () => {
|
dataStream.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const stats = parser.finish();
|
const stats = parser.finish();
|
||||||
console.log(
|
epgLogger.log(
|
||||||
loggerLabel,
|
loggerLabel,
|
||||||
`Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs`
|
`Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs`
|
||||||
);
|
);
|
||||||
@@ -223,7 +221,7 @@ async function fetchAndParseEpgStreaming(
|
|||||||
// real problem (unreachable feed, SAX parse failure, etc.).
|
// real problem (unreachable feed, SAX parse failure, etc.).
|
||||||
if (stats.totalChannels === 0) {
|
if (stats.totalChannels === 0) {
|
||||||
const errorMessage = `EPG parse produced 0 channels — feed may be unreachable or unsupported`;
|
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 = {
|
const response: WorkerResponse = {
|
||||||
type: 'EPG_ERROR',
|
type: 'EPG_ERROR',
|
||||||
url,
|
url,
|
||||||
@@ -251,7 +249,7 @@ async function fetchAndParseEpgStreaming(
|
|||||||
});
|
});
|
||||||
|
|
||||||
dataStream.on('error', (err) => {
|
dataStream.on('error', (err) => {
|
||||||
console.error(loggerLabel, 'Stream error:', err);
|
epgLogger.error(loggerLabel, 'Stream error:', err);
|
||||||
epgDb.close();
|
epgDb.close();
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
@@ -315,16 +313,16 @@ function clearAllEpgData(): void {
|
|||||||
const clearOperation = new EpgDatabaseClearOperation(Database);
|
const clearOperation = new EpgDatabaseClearOperation(Database);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(loggerLabel, 'Clearing all EPG data...');
|
epgLogger.log(loggerLabel, 'Clearing all EPG data...');
|
||||||
|
|
||||||
clearOperation.run();
|
clearOperation.run();
|
||||||
|
|
||||||
console.log(loggerLabel, 'All EPG data cleared');
|
epgLogger.log(loggerLabel, 'All EPG data cleared');
|
||||||
|
|
||||||
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
|
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
|
||||||
parentPort?.postMessage(response);
|
parentPort?.postMessage(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(loggerLabel, 'Error clearing EPG data:', error);
|
epgLogger.error(loggerLabel, 'Error clearing EPG data:', error);
|
||||||
const errorResponse: WorkerResponse = {
|
const errorResponse: WorkerResponse = {
|
||||||
type: 'EPG_ERROR',
|
type: 'EPG_ERROR',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -339,19 +337,16 @@ function clearEpgDataForSource(sourceUrl: string): void {
|
|||||||
const clearOperation = new EpgDatabaseSourceClearOperation(Database);
|
const clearOperation = new EpgDatabaseSourceClearOperation(Database);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
epgLogger.log(loggerLabel, 'Clearing EPG data for source...');
|
||||||
loggerLabel,
|
|
||||||
`Clearing EPG data for source ${sourceUrl}...`
|
|
||||||
);
|
|
||||||
|
|
||||||
clearOperation.run(sourceUrl);
|
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' };
|
const response: WorkerResponse = { type: 'CLEAR_COMPLETE' };
|
||||||
parentPort?.postMessage(response);
|
parentPort?.postMessage(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(loggerLabel, 'Error clearing EPG source data:', error);
|
epgLogger.error(loggerLabel, 'Error clearing EPG source data:', error);
|
||||||
const errorResponse: WorkerResponse = {
|
const errorResponse: WorkerResponse = {
|
||||||
type: 'EPG_ERROR',
|
type: 'EPG_ERROR',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -379,7 +374,7 @@ if (parentPort) {
|
|||||||
clearEpgDataForSource(message.sourceUrl ?? '');
|
clearEpgDataForSource(message.sourceUrl ?? '');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(loggerLabel, 'Worker error:', error);
|
epgLogger.error(loggerLabel, 'Worker error:', error);
|
||||||
const typedError = error as {
|
const typedError = error as {
|
||||||
code?: ElectronBridgeSecurityErrorCode;
|
code?: ElectronBridgeSecurityErrorCode;
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -398,5 +393,5 @@ if (parentPort) {
|
|||||||
// Notify parent that worker is ready
|
// Notify parent that worker is ready
|
||||||
parentPort.postMessage({ type: 'READY' });
|
parentPort.postMessage({ type: 'READY' });
|
||||||
} else {
|
} 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 });
|
||||||
|
}
|
||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
} from '@iptvnator/workspace/shell/util';
|
} from '@iptvnator/workspace/shell/util';
|
||||||
import { MockProvider } from 'ng-mocks';
|
import { MockProvider } from 'ng-mocks';
|
||||||
import { EMPTY, of } from 'rxjs';
|
import { EMPTY, of } from 'rxjs';
|
||||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
import {
|
||||||
|
DataService,
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
SettingsStore,
|
||||||
|
RuntimeCapabilitiesService,
|
||||||
|
} from '@iptvnator/services';
|
||||||
import {
|
import {
|
||||||
Language,
|
Language,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -235,6 +240,37 @@ describe('AppComponent', () => {
|
|||||||
expect(snackBar.open).not.toHaveBeenCalled();
|
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 () => {
|
it('does not fetch EPG settings when the EPG bridge cannot import EPG', async () => {
|
||||||
const settings: Settings = {
|
const settings: Settings = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DataService,
|
DataService,
|
||||||
RuntimeCapabilitiesService,
|
RuntimeCapabilitiesService,
|
||||||
SettingsStore,
|
SettingsStore,
|
||||||
|
EpgSourceSettingsService,
|
||||||
} from '@iptvnator/services';
|
} from '@iptvnator/services';
|
||||||
import {
|
import {
|
||||||
AUTO_UPDATE_PLAYLISTS,
|
AUTO_UPDATE_PLAYLISTS,
|
||||||
@@ -63,6 +64,7 @@ export class AppComponent implements OnInit {
|
|||||||
private translate = inject(TranslateService);
|
private translate = inject(TranslateService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private settingsStore = inject(SettingsStore);
|
private settingsStore = inject(SettingsStore);
|
||||||
|
private readonly epgSources = inject(EpgSourceSettingsService);
|
||||||
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
|
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
|
||||||
private playlistOpenRequests = inject(PlaylistOpenRequestService);
|
private playlistOpenRequests = inject(PlaylistOpenRequestService);
|
||||||
private runtime = inject(RuntimeCapabilitiesService);
|
private runtime = inject(RuntimeCapabilitiesService);
|
||||||
@@ -188,8 +190,16 @@ export class AppComponent implements OnInit {
|
|||||||
* Data is considered fresh if updated within the last 12 hours.
|
* Data is considered fresh if updated within the last 12 hours.
|
||||||
*/
|
*/
|
||||||
private async fetchStaleEpgData(urls: string[]): Promise<void> {
|
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) {
|
if (!this.epgBridge.supportsSourceFreshness) {
|
||||||
this.epgService.fetchEpg(urls);
|
await fetchCurrentSources(urls);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +207,7 @@ export class AppComponent implements OnInit {
|
|||||||
const result = await this.epgBridge.checkFreshness(urls, 12);
|
const result = await this.epgBridge.checkFreshness(urls, 12);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.epgService.fetchEpg(urls);
|
await fetchCurrentSources(urls);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,12 +229,12 @@ export class AppComponent implements OnInit {
|
|||||||
debugAppComponent(
|
debugAppComponent(
|
||||||
`EPG: Fetching ${result.staleUrls.length} stale source(s)`
|
`EPG: Fetching ${result.staleUrls.length} stale source(s)`
|
||||||
);
|
);
|
||||||
this.epgService.fetchEpg(result.staleUrls);
|
await fetchCurrentSources(result.staleUrls);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking EPG freshness, fetching all:', error);
|
console.error('Error checking EPG freshness, fetching all:', error);
|
||||||
// Fallback: fetch all URLs if freshness check fails
|
// 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FormArray, FormBuilder } from '@angular/forms';
|
import { FormArray, FormBuilder } from '@angular/forms';
|
||||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
import {
|
||||||
|
EpgSourceReconciliationError,
|
||||||
|
RuntimeCapabilitiesService,
|
||||||
|
} from '@iptvnator/services';
|
||||||
import {
|
import {
|
||||||
CoverSize,
|
CoverSize,
|
||||||
EpgViewMode,
|
EpgViewMode,
|
||||||
@@ -146,6 +149,7 @@ export class SettingsFormFacade {
|
|||||||
*/
|
*/
|
||||||
removeEpgSource(index: number): void {
|
removeEpgSource(index: number): void {
|
||||||
this.epgUrl.removeAt(index);
|
this.epgUrl.removeAt(index);
|
||||||
|
this.epgUrl.markAsDirty();
|
||||||
this.form.markAsDirty();
|
this.form.markAsDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,19 +165,28 @@ export class SettingsFormFacade {
|
|||||||
this.settingsStore.getSettings()
|
this.settingsStore.getSettings()
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.settingsStore.updateSettings(settings);
|
let cleanupError: EpgSourceReconciliationError | undefined;
|
||||||
onSaved();
|
try {
|
||||||
|
await this.settingsStore.updateSettings(settings, {
|
||||||
if (!window.electron) {
|
retryEpgCleanup: this.epgUrl?.dirty ?? false,
|
||||||
return;
|
});
|
||||||
|
} 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) {
|
if (this.runtime.supportsExternalPlayerPathSettings) {
|
||||||
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
|
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
|
||||||
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
|
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (cleanupError) throw cleanupError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies the saved language/theme and resets the dirty state */
|
/** Applies the saved language/theme and resets the dirty state */
|
||||||
|
|||||||
@@ -351,11 +351,14 @@ describe('SettingsComponent form', () => {
|
|||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
|
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
|
||||||
...component.settingsForm.value,
|
{
|
||||||
trustedPrivateNetworkEpgUrls: [],
|
...component.settingsForm.value,
|
||||||
trustedInsecureTlsHosts: [],
|
trustedPrivateNetworkEpgUrls: [],
|
||||||
});
|
trustedInsecureTlsHosts: [],
|
||||||
|
},
|
||||||
|
{ retryEpgCleanup: false }
|
||||||
|
);
|
||||||
expect(updateSettings).toHaveBeenCalledWith({
|
expect(updateSettings).toHaveBeenCalledWith({
|
||||||
...component.settingsForm.value,
|
...component.settingsForm.value,
|
||||||
trustedPrivateNetworkEpgUrls: [],
|
trustedPrivateNetworkEpgUrls: [],
|
||||||
@@ -375,7 +378,8 @@ describe('SettingsComponent form', () => {
|
|||||||
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
|
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
webPlayerSharedControls: false,
|
webPlayerSharedControls: false,
|
||||||
})
|
}),
|
||||||
|
{ retryEpgCleanup: false }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -421,7 +425,8 @@ describe('SettingsComponent form', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
mpvPlayerArguments: '--screen=1\n--geometry=1280x720',
|
mpvPlayerArguments: '--screen=1\n--geometry=1280x720',
|
||||||
vlcPlayerArguments: '--qt-fullscreen-screennumber=1',
|
vlcPlayerArguments: '--qt-fullscreen-screennumber=1',
|
||||||
})
|
}),
|
||||||
|
{ retryEpgCleanup: false }
|
||||||
);
|
);
|
||||||
expect(updateSettings).toHaveBeenCalledWith(
|
expect(updateSettings).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -456,7 +461,8 @@ describe('SettingsComponent form', () => {
|
|||||||
epgUrl: ['https://example.com/guide.xml'],
|
epgUrl: ['https://example.com/guide.xml'],
|
||||||
preferUploadedEpgOverXtream: true,
|
preferUploadedEpgOverXtream: true,
|
||||||
theme: Theme.DarkTheme,
|
theme: Theme.DarkTheme,
|
||||||
})
|
}),
|
||||||
|
{ retryEpgCleanup: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
webFixture.destroy();
|
webFixture.destroy();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
import { EpgSourceReconciliationError } from '@iptvnator/services';
|
||||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||||
import { SettingsStore } from '../services/settings-store.service';
|
import { SettingsStore } from '../services/settings-store.service';
|
||||||
import { SettingsComponent } from './settings.component';
|
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
|
// write must not mark the form pristine — that is what keeps the
|
||||||
// retry path on screen.
|
// retry path on screen.
|
||||||
expect(component.settingsForm.dirty).toBe(true);
|
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 () => {
|
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);
|
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 () => {
|
it('keeps the user in settings when save-and-leave cannot persist', async () => {
|
||||||
settingsStore.updateSettings.mockRejectedValue(
|
settingsStore.updateSettings.mockRejectedValue(
|
||||||
new Error('storage unavailable')
|
new Error('storage unavailable')
|
||||||
@@ -124,5 +173,4 @@ describe('SettingsComponent storage failures', () => {
|
|||||||
);
|
);
|
||||||
expect(component.settingsForm.dirty).toBe(true);
|
expect(component.settingsForm.dirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -16,7 +16,10 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
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 { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
||||||
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
|
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
|
||||||
import { firstValueFrom, map } from 'rxjs';
|
import { firstValueFrom, map } from 'rxjs';
|
||||||
@@ -307,7 +310,13 @@ export class SettingsComponent
|
|||||||
try {
|
try {
|
||||||
await this.form.save(() => this.applyChangedSettings());
|
await this.form.save(() => this.applyChangedSettings());
|
||||||
return true;
|
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
|
// The store already applied the change in memory, so without
|
||||||
// this the save looks successful until the next restart. The
|
// this the save looks successful until the next restart. The
|
||||||
// unsaved-changes bar stays visible so it can be retried.
|
// unsaved-changes bar stays visible so it can be retried.
|
||||||
|
|||||||
@@ -1382,3 +1382,84 @@ Routes live in `libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts
|
|||||||
3. Dispatch `FavoritesActions.hydrateFavorites` only when copying values that
|
3. Dispatch `FavoritesActions.hydrateFavorites` only when copying values that
|
||||||
were already read from persistence into NgRx
|
were already read from persistence into NgRx
|
||||||
4. Effects persist the two user-mutation actions; hydration is reducer-only
|
4. Effects persist the two user-mutation actions; hydration is reducer-only
|
||||||
|
|
||||||
|
### XMLTV source lifecycle
|
||||||
|
|
||||||
|
Electron treats `Settings.epgUrl` as the committed global source list. Removing
|
||||||
|
an input is a draft edit; only a successful IndexedDB write authorizes source
|
||||||
|
reconciliation. A storage write failure restores the previous in-memory EPG
|
||||||
|
URLs. If subsequent cache cleanup fails, the saved URLs remain authoritative,
|
||||||
|
the settings form remains retryable and shows the existing EPG cleanup failure
|
||||||
|
message rather than claiming settings storage failed. Electron settings and
|
||||||
|
external-player paths still mirror the committed values after a cleanup failure;
|
||||||
|
a failed storage write never mirrors them. Ordinary settings saves
|
||||||
|
compare normalized source sets and skip reconciliation when they are unchanged,
|
||||||
|
so unrelated preferences do not depend on cache cleanup. An explicitly edited
|
||||||
|
EPG array requests reconciliation even when the committed URLs already match
|
||||||
|
(for example retrying a failed cleanup); removing a row marks that array dirty,
|
||||||
|
and only successful saving clears it. Startup reconciliation always runs.
|
||||||
|
|
||||||
|
`EpgSourceSettingsService` waits for `PlaylistsService.getAllPlaylists()` (which
|
||||||
|
performs the legacy playlist migration) before invoking `EPG_RECONCILE_SOURCES`.
|
||||||
|
Startup includes an empty global list and does not prune after a failed settings
|
||||||
|
read. Main verifies the completed migration flag, reads every enabled M3U
|
||||||
|
`epg_urls` list and unions them with the saved globals. Invalid ownership metadata
|
||||||
|
aborts pruning. Detected-but-disabled sources and Xtream/Stalker provider EPG are
|
||||||
|
not global XMLTV owners. No source-discovery or provider matching policy changes.
|
||||||
|
|
||||||
|
Reconciliation finds old sources in XMLTV channel, programme and per-source
|
||||||
|
metadata tables, plus queued imports. It
|
||||||
|
retires their generations before waiting for workers to exit, then uses the
|
||||||
|
`EpgWorkerRuntime` source-clear protocol. The runtime owns worker bootstrap and
|
||||||
|
shutdown; `EpgWorkerService` owns source generations and serialization, while
|
||||||
|
`runEpgFetch` owns each import’s message/timeout/exit lifecycle. Successfully cleared request candidates are forgotten
|
||||||
|
without resetting their generation fences; failed cleanups remain retryable.
|
||||||
|
Main-process and parser-worker diagnostics use `epgLogger`, applying shared
|
||||||
|
secret redaction and omitting complete URLs from error messages, nested request
|
||||||
|
data and redirect details. Error logs allowlist name/message/code/status/cause
|
||||||
|
and discard transport objects (which can contain relative request paths and raw
|
||||||
|
HTTP headers). XMLTV providers can put credentials in arbitrary path
|
||||||
|
segments or query keys; progress IPC and caller errors retain their original
|
||||||
|
values, while logs keep operation labels, counts and error codes.
|
||||||
|
Same-URL clears are serialized and replacement imports await the outstanding
|
||||||
|
clear, so an older cleanup cannot erase a newly re-added source. Source cleanup
|
||||||
|
also awaits the in-flight fetch promise when an error/timeout has already removed
|
||||||
|
the worker from the lookup but its termination is still pending.
|
||||||
|
Every removed source emits generation-scoped cancellation before cleanup starts,
|
||||||
|
including retained actionable errors whose workers have already finished. Retired
|
||||||
|
queued/running imports also cancel their own generation. Retry waits for source
|
||||||
|
reconciliation and rechecks the same error row; trust-setting continuations and
|
||||||
|
old dismissal timers cannot affect a removed or replacement row. Progress rows disappear without reporting routine worker termination as an import failure. Programmes are deleted by source; a globally keyed
|
||||||
|
channel is retained while another source still has programmes or channel metadata.
|
||||||
|
The additive `epg_channel_sources` table is created during database initialization
|
||||||
|
and records each imported source's name, logo, URL and timestamp. A per-channel
|
||||||
|
`write_order` advances inside the import transaction (including upserts and
|
||||||
|
refreshes), so restoration picks the latest surviving writer even when timestamps
|
||||||
|
collide or the clock moves backwards. Freshness still uses wall-clock timestamps.
|
||||||
|
Existing ledgers gain the column with zero for unknowable historical order. Before removing
|
||||||
|
source provenance, cleanup restores affected global channels from a surviving
|
||||||
|
snapshot, including when the legacy channel owner differs from the removed
|
||||||
|
metadata writer. Metadata-only owners survive another source's refresh or removal.
|
||||||
|
Refresh restores affected metadata before discarding that source's old snapshots;
|
||||||
|
clear-all deletes the snapshots too. Freshness reads source-specific snapshot
|
||||||
|
timestamps, so legacy sources without snapshots are refreshed on their next import
|
||||||
|
check. No legacy snapshot is guessed from global
|
||||||
|
channel metadata: its last writer may differ from its recorded owner. Affected
|
||||||
|
legacy channels without a surviving snapshot retain programmes with a neutral
|
||||||
|
XMLTV ID label, no logo/URL and no freshness timestamp until reimport. Historical
|
||||||
|
metadata with no remaining source provenance cannot be selectively reconstructed. Manual mappings are preserved and can
|
||||||
|
resolve another retained source sharing that channel ID. Legacy programmes with
|
||||||
|
unknown (`NULL`) ownership are conservatively left alone; the existing database
|
||||||
|
initialization backfill handles rows whose channel still identifies their owner.
|
||||||
|
|
||||||
|
|
||||||
|
Renderer reconciliation fences lookups before its first asynchronous step.
|
||||||
|
Imports wait for serialized reconciliation (including playlist migration), then
|
||||||
|
filter against its committed owner set. Completion increments the data revision again
|
||||||
|
and cancels earlier lookup
|
||||||
|
subscriptions, clears program caches and the selected M3U guide, and refreshes
|
||||||
|
Xtream selection and visible channel previews, plus Stalker manual mapping
|
||||||
|
overrides and bulk guides. A delayed startup import is
|
||||||
|
started only if its source still belongs to the reconciled configuration; its
|
||||||
|
completion observer is installed after settings initialization. Provider EPG
|
||||||
|
continues through its existing APIs. Playlist refresh is not EPG cache cleanup.
|
||||||
@@ -24,13 +24,13 @@ Stalker now uses two EPG paths with different purposes:
|
|||||||
settled bulk guide cannot answer fall back to throttled per-channel
|
settled bulk guide cannot answer fall back to throttled per-channel
|
||||||
`get_short_epg` through `StalkerEpgPreviewQueue` (see "Channel row preview
|
`get_short_epg` through `StalkerEpgPreviewQueue` (see "Channel row preview
|
||||||
flow").
|
flow").
|
||||||
- Effect ordering matters: the eager-EPG effect is registered **after** the
|
- Effect ordering matters: the eager-EPG effect is registered **after** the
|
||||||
playlist-change effect that calls `clearBulkItvEpgCache()`. On a portal
|
playlist-change effect that calls `clearBulkItvEpgCache()`. On a portal
|
||||||
switch the cache is cleared first and then refilled; if the order is
|
switch the cache is cleared first and then refilled; if the order is
|
||||||
reversed the clear clobbers the just-loaded bulk EPG on initial render.
|
reversed the clear clobbers the just-loaded bulk EPG on initial render.
|
||||||
- `ensureBulkItvEpg` de-duplicates (via `isLoadingBulkItvEpg` /
|
- `ensureBulkItvEpg` de-duplicates (via `isLoadingBulkItvEpg` /
|
||||||
`bulkItvEpgLoaded` + matching playlist/period), so the eager trigger and the
|
`bulkItvEpgLoaded` + matching playlist/period), so the eager trigger and the
|
||||||
play-time `loadEpgForChannel` path never double-fetch.
|
play-time `loadEpgForChannel` path never double-fetch.
|
||||||
- If a portal does not return usable bulk data for the selected channel, the
|
- If a portal does not return usable bulk data for the selected channel, the
|
||||||
active panel falls back to `get_short_epg`.
|
active panel falls back to `get_short_epg`.
|
||||||
|
|
||||||
@@ -196,12 +196,12 @@ Normalization rules:
|
|||||||
|
|
||||||
### Key files
|
### Key files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling |
|
| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling |
|
||||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-timeline` wiring |
|
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-timeline` wiring |
|
||||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template |
|
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template |
|
||||||
| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts` | shared controlled EPG timeline with date navigator |
|
| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts` | shared controlled EPG timeline with date navigator |
|
||||||
|
|
||||||
### Store API
|
### Store API
|
||||||
|
|
||||||
@@ -283,6 +283,10 @@ rows stop consuming portal request capacity.
|
|||||||
- Bulk EPG is fetched once per playlist session
|
- Bulk EPG is fetched once per playlist session
|
||||||
- Channel switches only read from `bulkItvEpgByChannel`
|
- Channel switches only read from `bulkItvEpgByChannel`
|
||||||
- The cache is cleared when the Stalker playlist changes
|
- The cache is cleared when the Stalker playlist changes
|
||||||
|
- Committed XMLTV source reconciliation also clears mapping overrides, checked
|
||||||
|
IDs and bulk data, reloads the guide and selected mapping, and fences pending
|
||||||
|
mapping/bulk replies. Saved mappings remain authoritative even when removal
|
||||||
|
leaves their guide empty; portal EPG is not mixed into that empty override.
|
||||||
- This implementation does not add TTL-based refresh or background polling
|
- This implementation does not add TTL-based refresh or background polling
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import {
|
|||||||
EpgImportProgress,
|
EpgImportProgress,
|
||||||
EpgRuntimeBridgeService,
|
EpgRuntimeBridgeService,
|
||||||
} from './epg-runtime-bridge.service';
|
} from './epg-runtime-bridge.service';
|
||||||
import { SettingsStore } from '@iptvnator/services';
|
import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
|
||||||
|
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
|
||||||
import { EpgProgressService } from './epg-progress.service';
|
import { EpgProgressService } from './epg-progress.service';
|
||||||
|
|
||||||
describe('EpgProgressService', () => {
|
describe('EpgProgressService', () => {
|
||||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||||
|
let sources: { waitForReconciliation: jest.Mock };
|
||||||
let settingsStore: {
|
let settingsStore: {
|
||||||
getSettings: jest.Mock;
|
getSettings: jest.Mock;
|
||||||
getTrustOptions: jest.Mock;
|
getTrustOptions: jest.Mock;
|
||||||
@@ -15,11 +17,14 @@ describe('EpgProgressService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
sources = {
|
||||||
|
waitForReconciliation: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
epgBridge = {
|
epgBridge = {
|
||||||
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
||||||
onProgress: jest.fn(),
|
onProgress: jest.fn(),
|
||||||
supportsDataManagement: false,
|
supportsDataManagement: false,
|
||||||
supportsProgress: false,
|
supportsProgress: true,
|
||||||
};
|
};
|
||||||
settingsStore = {
|
settingsStore = {
|
||||||
getSettings: jest.fn(() => ({
|
getSettings: jest.fn(() => ({
|
||||||
@@ -35,6 +40,7 @@ describe('EpgProgressService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -43,6 +49,7 @@ describe('EpgProgressService', () => {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
EpgProgressService,
|
EpgProgressService,
|
||||||
|
{ provide: EpgSourceSettingsService, useValue: sources },
|
||||||
{
|
{
|
||||||
provide: EpgRuntimeBridgeService,
|
provide: EpgRuntimeBridgeService,
|
||||||
useValue: epgBridge,
|
useValue: epgBridge,
|
||||||
@@ -57,25 +64,132 @@ describe('EpgProgressService', () => {
|
|||||||
return TestBed.inject(EpgProgressService);
|
return TestBed.inject(EpgProgressService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = 'https://example.com/epg.xml';
|
||||||
|
const emit = (progress: EpgImportProgress) =>
|
||||||
|
(epgBridge.onProgress as jest.Mock).mock.calls[0][0](progress);
|
||||||
|
const errorRow = (source = url) =>
|
||||||
|
emit({
|
||||||
|
url: source,
|
||||||
|
status: 'error',
|
||||||
|
generation: 0,
|
||||||
|
errorCode:
|
||||||
|
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes retained actionable errors when their source is cancelled', async () => {
|
||||||
|
epgBridge.supportsDataManagement = true;
|
||||||
|
const service = configureService();
|
||||||
|
errorRow();
|
||||||
|
emit({ url, status: 'cancelled', generation: 0 });
|
||||||
|
expect(service.isVisible()).toBe(false);
|
||||||
|
await service.retry(url);
|
||||||
|
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
'waits for source reconciliation before retrying (removed=%s)',
|
||||||
|
async (removed) => {
|
||||||
|
epgBridge.supportsDataManagement = true;
|
||||||
|
let finish!: () => void;
|
||||||
|
sources.waitForReconciliation.mockReturnValue(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const service = configureService();
|
||||||
|
errorRow();
|
||||||
|
const retry = service.retry(url);
|
||||||
|
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||||
|
if (removed) emit({ url, status: 'cancelled', generation: 0 });
|
||||||
|
finish();
|
||||||
|
await retry;
|
||||||
|
expect(epgBridge.forceFetchEpg).toHaveBeenCalledTimes(
|
||||||
|
removed ? 0 : 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['private', 'tls'])(
|
||||||
|
'does not retry a removed/replaced row after a pending %s trust write',
|
||||||
|
async (kind) => {
|
||||||
|
epgBridge.supportsDataManagement = true;
|
||||||
|
let finish!: () => void;
|
||||||
|
settingsStore.updateSettings.mockReturnValue(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const service = configureService();
|
||||||
|
errorRow();
|
||||||
|
const retry =
|
||||||
|
kind === 'private'
|
||||||
|
? service.trustPrivateNetworkSourceAndRetry(url)
|
||||||
|
: service.trustInsecureTlsHostAndRetry(url);
|
||||||
|
emit({ url, status: 'cancelled', generation: 0 });
|
||||||
|
emit({ url, status: 'loading', generation: 2 });
|
||||||
|
finish();
|
||||||
|
await retry;
|
||||||
|
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||||
|
expect(service.activeCount()).toBe(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not let an old dismissal timer remove a replacement import', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const service = configureService();
|
||||||
|
emit({ url, status: 'complete', generation: 0 });
|
||||||
|
emit({ url, status: 'cancelled', generation: 0 });
|
||||||
|
emit({ url, status: 'loading', generation: 2 });
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
expect(service.activeCount()).toBe(1);
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not subscribe to progress events when EPG progress support is disabled', () => {
|
it('does not subscribe to progress events when EPG progress support is disabled', () => {
|
||||||
|
epgBridge.supportsProgress = false;
|
||||||
configureService();
|
configureService();
|
||||||
|
|
||||||
expect(epgBridge.onProgress).not.toHaveBeenCalled();
|
expect(epgBridge.onProgress).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not force retry when EPG data management is disabled', () => {
|
it('removes a retired queued import immediately on cancellation', () => {
|
||||||
|
epgBridge.supportsProgress = true;
|
||||||
|
const service = configureService();
|
||||||
|
const listener = (epgBridge.onProgress as jest.Mock).mock.calls[0][0];
|
||||||
|
const url = 'https://removed.example/guide.xml';
|
||||||
|
listener({ url, status: 'queued' });
|
||||||
|
expect(service.queuedCount()).toBe(1);
|
||||||
|
listener({ url, status: 'cancelled' });
|
||||||
|
expect(service.queuedCount()).toBe(0);
|
||||||
|
expect(service.isVisible()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a replacement import when an older queued batch finally cancels', () => {
|
||||||
|
epgBridge.supportsProgress = true;
|
||||||
|
const service = configureService();
|
||||||
|
const listener = (epgBridge.onProgress as jest.Mock).mock.calls[0][0];
|
||||||
|
const url = 'https://readded.example/guide.xml';
|
||||||
|
listener({ url, status: 'queued', generation: 0 });
|
||||||
|
listener({ url, status: 'loading', generation: 2 });
|
||||||
|
listener({ url, status: 'cancelled', generation: 0 });
|
||||||
|
expect(service.activeCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not force retry when EPG data management is disabled', async () => {
|
||||||
const service = configureService();
|
const service = configureService();
|
||||||
|
|
||||||
service.retry('https://example.com/epg.xml');
|
errorRow();
|
||||||
|
await service.retry('https://example.com/epg.xml');
|
||||||
|
|
||||||
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces retry through the EPG runtime bridge when data management is enabled', () => {
|
it('forces retry through the EPG runtime bridge when data management is enabled', async () => {
|
||||||
epgBridge.supportsDataManagement = true;
|
epgBridge.supportsDataManagement = true;
|
||||||
const service = configureService();
|
const service = configureService();
|
||||||
|
|
||||||
service.retry('https://example.com/epg.xml');
|
errorRow();
|
||||||
|
await service.retry('https://example.com/epg.xml');
|
||||||
|
|
||||||
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
|
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
|
||||||
'https://example.com/epg.xml',
|
'https://example.com/epg.xml',
|
||||||
@@ -90,6 +204,7 @@ describe('EpgProgressService', () => {
|
|||||||
epgBridge.supportsDataManagement = true;
|
epgBridge.supportsDataManagement = true;
|
||||||
const service = configureService();
|
const service = configureService();
|
||||||
|
|
||||||
|
errorRow('http://192.168.1.30/guide.xml');
|
||||||
await service.trustPrivateNetworkSourceAndRetry(
|
await service.trustPrivateNetworkSourceAndRetry(
|
||||||
'http://192.168.1.30/guide.xml'
|
'http://192.168.1.30/guide.xml'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||||
normalizeHost,
|
normalizeHost,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { SettingsStore } from '@iptvnator/services';
|
import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
|
||||||
import {
|
import {
|
||||||
EpgImportProgress,
|
EpgImportProgress,
|
||||||
EpgRuntimeBridgeService,
|
EpgRuntimeBridgeService,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
export class EpgProgressService {
|
export class EpgProgressService {
|
||||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||||
private readonly settingsStore = inject(SettingsStore);
|
private readonly settingsStore = inject(SettingsStore);
|
||||||
|
private readonly sources = inject(EpgSourceSettingsService);
|
||||||
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
|
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
|
||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
@@ -45,20 +46,27 @@ export class EpgProgressService {
|
|||||||
this.importsMap.set(new Map());
|
this.importsMap.set(new Map());
|
||||||
}
|
}
|
||||||
|
|
||||||
retry(url: string): void {
|
async retry(url: string): Promise<void> {
|
||||||
// Clear the errored row so the backend's subsequent 'queued' event
|
const progress = this.importsMap().get(url);
|
||||||
// reappears cleanly rather than updating an existing error row.
|
if (
|
||||||
this.removeImport(url);
|
progress?.status !== 'error' ||
|
||||||
if (!this.epgBridge.supportsDataManagement) {
|
!this.epgBridge.supportsDataManagement
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
}
|
// A settings save may already be retiring this row. Never bypass its
|
||||||
void this.epgBridge.forceFetchEpg(
|
// ownership reconciliation by starting a new force-fetch generation.
|
||||||
|
await this.sources.waitForReconciliation();
|
||||||
|
if (this.importsMap().get(url) !== progress) return;
|
||||||
|
this.removeImport(url);
|
||||||
|
await this.epgBridge.forceFetchEpg(
|
||||||
url,
|
url,
|
||||||
this.settingsStore.getTrustOptions()
|
this.settingsStore.getTrustOptions()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async trustPrivateNetworkSourceAndRetry(url: string): Promise<void> {
|
async trustPrivateNetworkSourceAndRetry(url: string): Promise<void> {
|
||||||
|
const progress = this.importsMap().get(url);
|
||||||
|
if (progress?.status !== 'error') return;
|
||||||
const settings = this.settingsStore.getSettings();
|
const settings = this.settingsStore.getSettings();
|
||||||
const trustedUrls = new Set(
|
const trustedUrls = new Set(
|
||||||
settings.trustedPrivateNetworkEpgUrls ?? []
|
settings.trustedPrivateNetworkEpgUrls ?? []
|
||||||
@@ -68,13 +76,15 @@ export class EpgProgressService {
|
|||||||
await this.settingsStore.updateSettings({
|
await this.settingsStore.updateSettings({
|
||||||
trustedPrivateNetworkEpgUrls: Array.from(trustedUrls),
|
trustedPrivateNetworkEpgUrls: Array.from(trustedUrls),
|
||||||
});
|
});
|
||||||
this.retry(url);
|
if (this.importsMap().get(url) === progress) await this.retry(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async trustInsecureTlsHostAndRetry(
|
async trustInsecureTlsHostAndRetry(
|
||||||
url: string,
|
url: string,
|
||||||
host?: string
|
host?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const progress = this.importsMap().get(url);
|
||||||
|
if (progress?.status !== 'error') return;
|
||||||
const trustedHost = host ?? this.getHostname(url);
|
const trustedHost = host ?? this.getHostname(url);
|
||||||
if (!trustedHost) {
|
if (!trustedHost) {
|
||||||
return;
|
return;
|
||||||
@@ -91,7 +101,7 @@ export class EpgProgressService {
|
|||||||
await this.settingsStore.updateSettings({
|
await this.settingsStore.updateSettings({
|
||||||
trustedInsecureTlsHosts: Array.from(trustedHosts),
|
trustedInsecureTlsHosts: Array.from(trustedHosts),
|
||||||
});
|
});
|
||||||
this.retry(url);
|
if (this.importsMap().get(url) === progress) await this.retry(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeListener(): void {
|
private initializeListener(): void {
|
||||||
@@ -108,6 +118,12 @@ export class EpgProgressService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateProgress(progress: EpgImportProgress): void {
|
private updateProgress(progress: EpgImportProgress): void {
|
||||||
|
const current = this.importsMap().get(progress.url);
|
||||||
|
if ((progress.generation ?? 0) < (current?.generation ?? 0)) return;
|
||||||
|
if (progress.status === 'cancelled') {
|
||||||
|
this.removeImport(progress.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.importsMap.update((current) => {
|
this.importsMap.update((current) => {
|
||||||
const updated = new Map(current);
|
const updated = new Map(current);
|
||||||
updated.set(progress.url, progress);
|
updated.set(progress.url, progress);
|
||||||
@@ -118,7 +134,10 @@ export class EpgProgressService {
|
|||||||
progress.status === 'complete' ||
|
progress.status === 'complete' ||
|
||||||
(progress.status === 'error' && !this.isActionableError(progress))
|
(progress.status === 'error' && !this.isActionableError(progress))
|
||||||
) {
|
) {
|
||||||
setTimeout(() => this.removeImport(progress.url), 5000);
|
setTimeout(() => {
|
||||||
|
if (this.importsMap().get(progress.url) === progress)
|
||||||
|
this.removeImport(progress.url);
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { firstValueFrom, skip } from 'rxjs';
|
import { firstValueFrom, of, skip } from 'rxjs';
|
||||||
import { SettingsStore } from '@iptvnator/services';
|
import {
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
PlaylistsService,
|
||||||
|
SettingsStore,
|
||||||
|
} from '@iptvnator/services';
|
||||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||||
import { EpgService } from './epg.service';
|
import { EpgService } from './epg.service';
|
||||||
|
|
||||||
@@ -11,6 +15,7 @@ describe('EpgService', () => {
|
|||||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||||
let snackBar: { open: jest.Mock };
|
let snackBar: { open: jest.Mock };
|
||||||
let settingsStore: {
|
let settingsStore: {
|
||||||
|
loadSettings: jest.Mock;
|
||||||
getSettings: jest.Mock;
|
getSettings: jest.Mock;
|
||||||
getTrustOptions: jest.Mock;
|
getTrustOptions: jest.Mock;
|
||||||
resolvedEpgOffsetMinutes: jest.Mock;
|
resolvedEpgOffsetMinutes: jest.Mock;
|
||||||
@@ -29,6 +34,7 @@ describe('EpgService', () => {
|
|||||||
open: jest.fn(),
|
open: jest.fn(),
|
||||||
};
|
};
|
||||||
settingsStore = {
|
settingsStore = {
|
||||||
|
loadSettings: jest.fn().mockResolvedValue(undefined),
|
||||||
getSettings: jest.fn(() => ({
|
getSettings: jest.fn(() => ({
|
||||||
epgUrl: [],
|
epgUrl: [],
|
||||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||||
@@ -44,6 +50,19 @@ describe('EpgService', () => {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
EpgService,
|
EpgService,
|
||||||
|
{
|
||||||
|
provide: PlaylistsService,
|
||||||
|
useValue: {
|
||||||
|
getAllPlaylists: () =>
|
||||||
|
of([
|
||||||
|
{
|
||||||
|
epgUrls: [
|
||||||
|
'https://playlist.example/guide.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: EpgRuntimeBridgeService,
|
provide: EpgRuntimeBridgeService,
|
||||||
useValue: epgBridge,
|
useValue: epgBridge,
|
||||||
@@ -68,22 +87,126 @@ describe('EpgService', () => {
|
|||||||
service = TestBed.inject(EpgService);
|
service = TestBed.inject(EpgService);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('observes startup import completion after initial source reconciliation', async () => {
|
||||||
|
epgBridge.supportsImport = true;
|
||||||
|
let loaded!: () => void;
|
||||||
|
settingsStore.loadSettings.mockReturnValue(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
loaded = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||||
|
jest.spyOn(sources, 'retainCurrentSources').mockImplementation(
|
||||||
|
(urls) => urls
|
||||||
|
);
|
||||||
|
const pending = service.fetchEpg([
|
||||||
|
'https://configured.example/guide.xml',
|
||||||
|
]);
|
||||||
|
sources.revision.update((revision) => revision + 1);
|
||||||
|
sources.changed$.next();
|
||||||
|
const availability: boolean[] = [];
|
||||||
|
const subscription = service.epgAvailable$.subscribe((value) =>
|
||||||
|
availability.push(value)
|
||||||
|
);
|
||||||
|
loaded();
|
||||||
|
await pending;
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(epgBridge.fetchEpg).toHaveBeenCalledTimes(1);
|
||||||
|
expect(availability.filter(Boolean)).toHaveLength(2);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not launch an obsolete import deferred behind settings initialization', async () => {
|
||||||
|
epgBridge.supportsImport = true;
|
||||||
|
let loaded!: () => void;
|
||||||
|
settingsStore.loadSettings.mockReturnValue(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
loaded = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const pending = service.fetchEpg(['https://removed.example/guide.xml']);
|
||||||
|
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||||
|
sources.revision.update((revision) => revision + 1);
|
||||||
|
sources.changed$.next();
|
||||||
|
loaded();
|
||||||
|
await pending;
|
||||||
|
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents pending programmes from repopulating a cache after source deletion', async () => {
|
||||||
|
epgBridge.supportsProgramLookup = true;
|
||||||
|
let resolveOld!: (programs: unknown[]) => void;
|
||||||
|
(epgBridge.getChannelPrograms as jest.Mock).mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveOld = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const stale = jest.fn();
|
||||||
|
service.getCurrentProgramForChannel('deleted-channel').subscribe(stale);
|
||||||
|
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||||
|
sources.revision.update((revision) => revision + 1);
|
||||||
|
sources.changed$.next();
|
||||||
|
resolveOld([]);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(stale).not.toHaveBeenCalled();
|
||||||
|
await firstValueFrom(
|
||||||
|
service.getCurrentProgramForChannel('deleted-channel')
|
||||||
|
);
|
||||||
|
expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for ongoing reconciliation and filters imports against committed owners', async () => {
|
||||||
|
epgBridge.supportsImport = true;
|
||||||
|
const original = window.electron;
|
||||||
|
let complete!: (result: { success: boolean }) => void;
|
||||||
|
window.electron = {
|
||||||
|
reconcileEpgSources: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
complete = resolve;
|
||||||
|
}),
|
||||||
|
} as typeof window.electron;
|
||||||
|
try {
|
||||||
|
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||||
|
const reconciliation = sources.synchronize([
|
||||||
|
'https://kept.example/guide.xml',
|
||||||
|
]);
|
||||||
|
await Promise.resolve();
|
||||||
|
const pending = service.fetchEpg([
|
||||||
|
'https://removed.example/guide.xml',
|
||||||
|
'https://playlist.example/guide.xml',
|
||||||
|
]);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
|
||||||
|
complete({ success: true });
|
||||||
|
await reconciliation;
|
||||||
|
await pending;
|
||||||
|
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
|
||||||
|
['https://playlist.example/guide.xml'],
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
window.electron = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('does not fetch EPG when bridge import support is disabled', () => {
|
it('does not fetch EPG when bridge import support is disabled', () => {
|
||||||
service.fetchEpg(['https://example.com/epg.xml']);
|
service.fetchEpg(['https://example.com/epg.xml']);
|
||||||
|
|
||||||
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
|
expect(epgBridge.fetchEpg).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches EPG through the EPG runtime bridge when import support is enabled', () => {
|
it('fetches EPG through the EPG runtime bridge when import support is enabled', async () => {
|
||||||
epgBridge.supportsImport = true;
|
epgBridge.supportsImport = true;
|
||||||
|
|
||||||
service.fetchEpg([
|
await service.fetchEpg([
|
||||||
'https://example.com/epg.xml',
|
'https://example.com/epg.xml',
|
||||||
'',
|
'',
|
||||||
'https://example.com/other.xml',
|
'https://example.com/other.xml',
|
||||||
' https://example.com/epg.xml ',
|
' https://example.com/epg.xml ',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
|
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
|
||||||
['https://example.com/epg.xml', 'https://example.com/other.xml'],
|
['https://example.com/epg.xml', 'https://example.com/other.xml'],
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
EpgProgram,
|
EpgProgram,
|
||||||
epgProviderClockMs,
|
epgProviderClockMs,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { SettingsStore } from '@iptvnator/services';
|
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||||
import {
|
import {
|
||||||
EpgLookupOptions,
|
EpgLookupOptions,
|
||||||
EpgRuntimeBridgeService,
|
EpgRuntimeBridgeService,
|
||||||
@@ -42,6 +42,15 @@ export class EpgService {
|
|||||||
private translate = inject(TranslateService);
|
private translate = inject(TranslateService);
|
||||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||||
private readonly settingsStore = inject(SettingsStore);
|
private readonly settingsStore = inject(SettingsStore);
|
||||||
|
private readonly sourceSettings = inject(EpgSourceSettingsService);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.sourceSettings.changed$.subscribe(() => {
|
||||||
|
this.clearCache();
|
||||||
|
this.currentEpgPrograms.next([]);
|
||||||
|
this.epgAvailable.next(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private epgAvailable = new BehaviorSubject<boolean>(false);
|
private epgAvailable = new BehaviorSubject<boolean>(false);
|
||||||
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
|
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
|
||||||
@@ -79,11 +88,17 @@ export class EpgService {
|
|||||||
/**
|
/**
|
||||||
* Fetches EPG from the given URLs
|
* Fetches EPG from the given URLs
|
||||||
*/
|
*/
|
||||||
fetchEpg(urls: string[]): void {
|
async fetchEpg(urls: string[]): Promise<void> {
|
||||||
if (!this.epgBridge.supportsImport) return;
|
if (!this.epgBridge.supportsImport) return;
|
||||||
|
|
||||||
// Filter out empty and duplicate URLs and send all URLs at once.
|
// Filter out empty and duplicate URLs and send all URLs at once.
|
||||||
const validUrls = normalizeEpgUrls(urls);
|
const revision = this.sourceSettings.revision();
|
||||||
|
await this.settingsStore.loadSettings();
|
||||||
|
await this.sourceSettings.waitForReconciliation();
|
||||||
|
const validUrls = this.sourceSettings.retainCurrentSources(
|
||||||
|
normalizeEpgUrls(urls),
|
||||||
|
revision
|
||||||
|
);
|
||||||
if (validUrls.length === 0) return;
|
if (validUrls.length === 0) return;
|
||||||
|
|
||||||
from(
|
from(
|
||||||
@@ -93,6 +108,7 @@ export class EpgService {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
tap((result) => {
|
tap((result) => {
|
||||||
if (result === null) return;
|
if (result === null) return;
|
||||||
|
|
||||||
@@ -123,6 +139,7 @@ export class EpgService {
|
|||||||
|
|
||||||
from(this.epgBridge.getChannelPrograms(channelId))
|
from(this.epgBridge.getChannelPrograms(channelId))
|
||||||
.pipe(
|
.pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
timeout(3000),
|
timeout(3000),
|
||||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
@@ -187,6 +204,7 @@ export class EpgService {
|
|||||||
// Fetch from backend
|
// Fetch from backend
|
||||||
return this.getCachedOrFetchCurrentProgram(cacheKey, () =>
|
return this.getCachedOrFetchCurrentProgram(cacheKey, () =>
|
||||||
from(this.epgBridge.getChannelPrograms(channelId)).pipe(
|
from(this.epgBridge.getChannelPrograms(channelId)).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||||
map((programs: EpgProgram[]) =>
|
map((programs: EpgProgram[]) =>
|
||||||
this.findCurrentProgram(programs)
|
this.findCurrentProgram(programs)
|
||||||
@@ -287,6 +305,7 @@ export class EpgService {
|
|||||||
nowMs: this.epgClockMs(),
|
nowMs: this.epgClockMs(),
|
||||||
})
|
})
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
timeout(5000),
|
timeout(5000),
|
||||||
map((batchResult) => {
|
map((batchResult) => {
|
||||||
const cacheTimestamp = Date.now();
|
const cacheTimestamp = Date.now();
|
||||||
@@ -311,6 +330,7 @@ export class EpgService {
|
|||||||
// Fallback for older preload bundles without the batch endpoint.
|
// Fallback for older preload bundles without the batch endpoint.
|
||||||
const fetchObservables = channelsToFetch.map((channelId) =>
|
const fetchObservables = channelsToFetch.map((channelId) =>
|
||||||
this.getCurrentProgramForChannel(channelId).pipe(
|
this.getCurrentProgramForChannel(channelId).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
timeout(5000),
|
timeout(5000),
|
||||||
map((program) => ({ channelId, program })),
|
map((program) => ({ channelId, program })),
|
||||||
catchError(() => of({ channelId, program: null }))
|
catchError(() => of({ channelId, program: null }))
|
||||||
@@ -318,6 +338,7 @@ export class EpgService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return forkJoin(fetchObservables).pipe(
|
return forkJoin(fetchObservables).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
map((results) => {
|
map((results) => {
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
resultMap.set(result.channelId, result.program);
|
resultMap.set(result.channelId, result.program);
|
||||||
@@ -372,6 +393,7 @@ export class EpgService {
|
|||||||
sourceUrls,
|
sourceUrls,
|
||||||
fallbackSourceUrls
|
fallbackSourceUrls
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
tap((fetchedMap) => {
|
tap((fetchedMap) => {
|
||||||
const cacheTimestamp = Date.now();
|
const cacheTimestamp = Date.now();
|
||||||
channelsToFetch.forEach((channelId) => {
|
channelsToFetch.forEach((channelId) => {
|
||||||
@@ -386,7 +408,14 @@ export class EpgService {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
this.fetchingCurrentProgramBatches.delete(batchCacheKey);
|
if (
|
||||||
|
this.fetchingCurrentProgramBatches.get(
|
||||||
|
batchCacheKey
|
||||||
|
) === request$
|
||||||
|
)
|
||||||
|
this.fetchingCurrentProgramBatches.delete(
|
||||||
|
batchCacheKey
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
shareReplay({ bufferSize: 1, refCount: false })
|
shareReplay({ bufferSize: 1, refCount: false })
|
||||||
);
|
);
|
||||||
@@ -396,6 +425,7 @@ export class EpgService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return request$.pipe(
|
return request$.pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
map((fetchedMap) => {
|
map((fetchedMap) => {
|
||||||
const mergedResultMap = new Map(resultMap);
|
const mergedResultMap = new Map(resultMap);
|
||||||
channelsToFetch.forEach((channelId) => {
|
channelsToFetch.forEach((channelId) => {
|
||||||
@@ -421,6 +451,7 @@ export class EpgService {
|
|||||||
nowMs,
|
nowMs,
|
||||||
})
|
})
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
timeout(5000),
|
timeout(5000),
|
||||||
switchMap((scopedResult) => {
|
switchMap((scopedResult) => {
|
||||||
const resultMap = new Map<string, EpgProgram | null>();
|
const resultMap = new Map<string, EpgProgram | null>();
|
||||||
@@ -448,6 +479,7 @@ export class EpgService {
|
|||||||
nowMs,
|
nowMs,
|
||||||
})
|
})
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
timeout(5000),
|
timeout(5000),
|
||||||
map((globalResult) => {
|
map((globalResult) => {
|
||||||
fallbackChannelIds.forEach((channelId) => {
|
fallbackChannelIds.forEach((channelId) => {
|
||||||
@@ -500,6 +532,7 @@ export class EpgService {
|
|||||||
normalizedChannelIds,
|
normalizedChannelIds,
|
||||||
effectiveSourceUrls
|
effectiveSourceUrls
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
switchMap((metadataMap) => {
|
switchMap((metadataMap) => {
|
||||||
const fallbackChannelIds =
|
const fallbackChannelIds =
|
||||||
sourceUrls.length > 0 && globalSourceUrls.length > 0
|
sourceUrls.length > 0 && globalSourceUrls.length > 0
|
||||||
@@ -516,6 +549,7 @@ export class EpgService {
|
|||||||
fallbackChannelIds,
|
fallbackChannelIds,
|
||||||
globalSourceUrls
|
globalSourceUrls
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
map((globalMetadataMap) => {
|
map((globalMetadataMap) => {
|
||||||
fallbackChannelIds.forEach((channelId) => {
|
fallbackChannelIds.forEach((channelId) => {
|
||||||
metadataMap.set(
|
metadataMap.set(
|
||||||
@@ -561,6 +595,7 @@ export class EpgService {
|
|||||||
sourceUrls.length > 0 ? { sourceUrls } : undefined
|
sourceUrls.length > 0 ? { sourceUrls } : undefined
|
||||||
)
|
)
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
map((metadataByChannelId) => {
|
map((metadataByChannelId) => {
|
||||||
return new Map<string, EpgChannelMetadata | null>(
|
return new Map<string, EpgChannelMetadata | null>(
|
||||||
channelIds.map((channelId) => [
|
channelIds.map((channelId) => [
|
||||||
@@ -641,6 +676,7 @@ export class EpgService {
|
|||||||
|
|
||||||
const offsetMinutes = this.epgOffsetMinutes();
|
const offsetMinutes = this.epgOffsetMinutes();
|
||||||
const request$ = fetchProgram().pipe(
|
const request$ = fetchProgram().pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
tap((program) => {
|
tap((program) => {
|
||||||
this.programCache.set(cacheKey, {
|
this.programCache.set(cacheKey, {
|
||||||
program,
|
program,
|
||||||
@@ -649,7 +685,8 @@ export class EpgService {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
this.fetchingCurrentPrograms.delete(cacheKey);
|
if (this.fetchingCurrentPrograms.get(cacheKey) === request$)
|
||||||
|
this.fetchingCurrentPrograms.delete(cacheKey);
|
||||||
}),
|
}),
|
||||||
shareReplay({ bufferSize: 1, refCount: false })
|
shareReplay({ bufferSize: 1, refCount: false })
|
||||||
);
|
);
|
||||||
@@ -668,6 +705,7 @@ export class EpgService {
|
|||||||
from(
|
from(
|
||||||
this.epgBridge.getChannelPrograms(channelId, { sourceUrls })
|
this.epgBridge.getChannelPrograms(channelId, { sourceUrls })
|
||||||
).pipe(
|
).pipe(
|
||||||
|
this.sourceSettings.guard(),
|
||||||
timeout(3000),
|
timeout(3000),
|
||||||
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
map((programs) => normalizeEpgPrograms(programs ?? [])),
|
||||||
switchMap((programs) => {
|
switchMap((programs) => {
|
||||||
|
|||||||
+100
-1
@@ -1,6 +1,10 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { signalStore, withState } from '@ngrx/signals';
|
import { signalStore, withState } from '@ngrx/signals';
|
||||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
import {
|
||||||
|
DataService,
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
RuntimeCapabilitiesService,
|
||||||
|
} from '@iptvnator/services';
|
||||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||||
import { EpgItem, Playlist } from '@iptvnator/shared/interfaces';
|
import { EpgItem, Playlist } from '@iptvnator/shared/interfaces';
|
||||||
import { StalkerSessionService } from '../../stalker-session.service';
|
import { StalkerSessionService } from '../../stalker-session.service';
|
||||||
@@ -232,6 +236,44 @@ describe('withStalkerEpg', () => {
|
|||||||
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
|
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops removed-source overrides and reloads the selected mapping without a portal reset', async () => {
|
||||||
|
epgBridge.getEpgMappingsBatch.mockResolvedValue({
|
||||||
|
'stalker:playlist-1:10001': 'mapped.channel.id',
|
||||||
|
});
|
||||||
|
epgBridge.getChannelPrograms.mockResolvedValue([MAPPED_PROGRAM]);
|
||||||
|
await store.applyMappedItvEpg(['10001']);
|
||||||
|
epgBridge.getChannelPrograms.mockResolvedValue([]);
|
||||||
|
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||||
|
sources.revision.update((value) => value + 1);
|
||||||
|
sources.changed$.next();
|
||||||
|
expect(store.selectedItvEpgPrograms()).toEqual([]);
|
||||||
|
await store.applyMappedItvEpg(['10001']);
|
||||||
|
expect(store.selectedItvEpgPrograms()).toEqual([]);
|
||||||
|
// Saved mappings remain authoritative even when their source is gone.
|
||||||
|
expect(store.hasItvEpgMappingOverride('10001')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a mapped-program response that completes after source invalidation', async () => {
|
||||||
|
epgBridge.getEpgMappingsBatch.mockResolvedValue({
|
||||||
|
'stalker:playlist-1:10001': 'mapped.channel.id',
|
||||||
|
});
|
||||||
|
let resolvePrograms!: (value: unknown) => void;
|
||||||
|
epgBridge.getChannelPrograms.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvePrograms = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const pending = store.applyMappedItvEpg(['10001']);
|
||||||
|
await Promise.resolve();
|
||||||
|
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||||
|
sources.revision.update((value) => value + 1);
|
||||||
|
sources.changed$.next();
|
||||||
|
resolvePrograms([MAPPED_PROGRAM]);
|
||||||
|
await pending;
|
||||||
|
expect(store.selectedItvEpgPrograms()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps overrides when ensureBulkItvEpg replaces the bulk record', async () => {
|
it('keeps overrides when ensureBulkItvEpg replaces the bulk record', async () => {
|
||||||
epgBridge.getEpgMappingsBatch.mockResolvedValue({
|
epgBridge.getEpgMappingsBatch.mockResolvedValue({
|
||||||
'stalker:playlist-1:10001': 'mapped.channel.id',
|
'stalker:playlist-1:10001': 'mapped.channel.id',
|
||||||
@@ -259,6 +301,63 @@ describe('withStalkerEpg', () => {
|
|||||||
expect(record['10002']?.length).toBeGreaterThan(0);
|
expect(record['10002']?.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not mark later IDs checked after a stale mapped lookup rejects', async () => {
|
||||||
|
epgBridge.getEpgMappingsBatch.mockResolvedValue({
|
||||||
|
'stalker:playlist-1:10001': 'mapped.channel.id',
|
||||||
|
});
|
||||||
|
let rejectPrograms!: (error: Error) => void;
|
||||||
|
epgBridge.getChannelPrograms.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
rejectPrograms = reject;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const pending = store.applyMappedItvEpg(['10001', '10002']);
|
||||||
|
await Promise.resolve();
|
||||||
|
store.clearBulkItvEpgCache();
|
||||||
|
await store.applyMappedItvEpg(['10003']);
|
||||||
|
rejectPrograms(new Error('old request failed'));
|
||||||
|
await pending;
|
||||||
|
epgBridge.getEpgMappingsBatch.mockResolvedValue({
|
||||||
|
'stalker:playlist-1:10002': 'new.channel.id',
|
||||||
|
});
|
||||||
|
epgBridge.getChannelPrograms.mockResolvedValue([MAPPED_PROGRAM]);
|
||||||
|
await store.applyMappedItvEpg(['10002']);
|
||||||
|
expect(store.bulkItvEpgByChannel()['10002']).toEqual([
|
||||||
|
{ ...MAPPED_PROGRAM, channel: '10002' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows initial bulk loading and mapping lookup to finish concurrently', async () => {
|
||||||
|
let finishBulk!: (value: unknown) => void;
|
||||||
|
const response = new Promise((resolve) => {
|
||||||
|
finishBulk = resolve;
|
||||||
|
});
|
||||||
|
dataService.sendIpcEvent.mockReturnValueOnce(response);
|
||||||
|
const bulk = store.ensureBulkItvEpg();
|
||||||
|
epgBridge.getEpgMappingsBatch.mockResolvedValue({
|
||||||
|
'stalker:playlist-1:10001': 'mapped.channel.id',
|
||||||
|
});
|
||||||
|
epgBridge.getChannelPrograms.mockResolvedValue([]);
|
||||||
|
await store.applyMappedItvEpg(['10001']);
|
||||||
|
finishBulk({
|
||||||
|
js: {
|
||||||
|
'10001': [
|
||||||
|
buildEntry(
|
||||||
|
'10001',
|
||||||
|
'Portal Show',
|
||||||
|
1744365600,
|
||||||
|
1744367400
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await bulk;
|
||||||
|
expect(store.isLoadingBulkItvEpg()).toBe(false);
|
||||||
|
expect(store.bulkItvEpgLoaded()).toBe(true);
|
||||||
|
expect(store.selectedItvEpgPrograms()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does nothing when the mapping bridge is unsupported', async () => {
|
it('does nothing when the mapping bridge is unsupported', async () => {
|
||||||
epgBridge.supportsEpgMapping = false;
|
epgBridge.supportsEpgMapping = false;
|
||||||
|
|
||||||
|
|||||||
+40
-7
@@ -3,12 +3,17 @@ import {
|
|||||||
patchState,
|
patchState,
|
||||||
signalStoreFeature,
|
signalStoreFeature,
|
||||||
withComputed,
|
withComputed,
|
||||||
|
withHooks,
|
||||||
withMethods,
|
withMethods,
|
||||||
withState,
|
withState,
|
||||||
} from '@ngrx/signals';
|
} from '@ngrx/signals';
|
||||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
import {
|
||||||
|
DataService,
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
RuntimeCapabilitiesService,
|
||||||
|
} from '@iptvnator/services';
|
||||||
import {
|
import {
|
||||||
buildStalkerEpgMappingKey,
|
buildStalkerEpgMappingKey,
|
||||||
EpgItem,
|
EpgItem,
|
||||||
@@ -105,7 +110,8 @@ export function withStalkerEpg() {
|
|||||||
stalkerSession = inject(StalkerSessionService),
|
stalkerSession = inject(StalkerSessionService),
|
||||||
portalRepair = inject(StalkerPortalRepairService),
|
portalRepair = inject(StalkerPortalRepairService),
|
||||||
runtime = inject(RuntimeCapabilitiesService),
|
runtime = inject(RuntimeCapabilitiesService),
|
||||||
epgBridge = inject(EpgRuntimeBridgeService)
|
epgBridge = inject(EpgRuntimeBridgeService),
|
||||||
|
sources = inject(EpgSourceSettingsService)
|
||||||
) => {
|
) => {
|
||||||
const storeContext = store as typeof store &
|
const storeContext = store as typeof store &
|
||||||
StalkerEpgFeatureStoreContract;
|
StalkerEpgFeatureStoreContract;
|
||||||
@@ -127,6 +133,7 @@ export function withStalkerEpg() {
|
|||||||
// an empty mapped guide must still keep the portal EPG out.
|
// an empty mapped guide must still keep the portal EPG out.
|
||||||
const mappingOwnedIds = new Set<string>();
|
const mappingOwnedIds = new Set<string>();
|
||||||
let mappingPlaylistId: string | null = null;
|
let mappingPlaylistId: string | null = null;
|
||||||
|
let cacheGeneration = 0;
|
||||||
|
|
||||||
const resetMappingOverrides = (): void => {
|
const resetMappingOverrides = (): void => {
|
||||||
mappingOverridesById.clear();
|
mappingOverridesById.clear();
|
||||||
@@ -140,8 +147,8 @@ export function withStalkerEpg() {
|
|||||||
EpgProgram[]
|
EpgProgram[]
|
||||||
> => {
|
> => {
|
||||||
const record: Record<string, EpgProgram[]> = {};
|
const record: Record<string, EpgProgram[]> = {};
|
||||||
for (const [id, programs] of mappingOverridesById) {
|
for (const id of mappingOwnedIds) {
|
||||||
record[id] = programs;
|
record[id] = mappingOverridesById.get(id) ?? [];
|
||||||
}
|
}
|
||||||
return record;
|
return record;
|
||||||
};
|
};
|
||||||
@@ -222,6 +229,7 @@ export function withStalkerEpg() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playlistId = String(playlist._id);
|
const playlistId = String(playlist._id);
|
||||||
|
const generation = cacheGeneration;
|
||||||
if (!supportsEpg()) {
|
if (!supportsEpg()) {
|
||||||
patchState(store, {
|
patchState(store, {
|
||||||
bulkItvEpgByChannel: {},
|
bulkItvEpgByChannel: {},
|
||||||
@@ -258,6 +266,7 @@ export function withStalkerEpg() {
|
|||||||
type: 'itv',
|
type: 'itv',
|
||||||
period: String(periodHours),
|
period: String(periodHours),
|
||||||
});
|
});
|
||||||
|
if (generation !== cacheGeneration) return;
|
||||||
const selectedChannelId =
|
const selectedChannelId =
|
||||||
storeContext.selectedItvId() ?? null;
|
storeContext.selectedItvId() ?? null;
|
||||||
const bulkPrograms = extractBulkEpgByChannel(
|
const bulkPrograms = extractBulkEpgByChannel(
|
||||||
@@ -274,6 +283,7 @@ export function withStalkerEpg() {
|
|||||||
isLoadingBulkItvEpg: false,
|
isLoadingBulkItvEpg: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== cacheGeneration) return;
|
||||||
logger.warn('Bulk Stalker EPG unavailable', error);
|
logger.warn('Bulk Stalker EPG unavailable', error);
|
||||||
patchState(store, {
|
patchState(store, {
|
||||||
bulkItvEpgByChannel: mappingOverridesRecord(),
|
bulkItvEpgByChannel: mappingOverridesRecord(),
|
||||||
@@ -312,8 +322,7 @@ export function withStalkerEpg() {
|
|||||||
channelIds
|
channelIds
|
||||||
.map((id) => normalizeStalkerEntityId(id))
|
.map((id) => normalizeStalkerEntityId(id))
|
||||||
.filter(
|
.filter(
|
||||||
(id) =>
|
(id) => id && !mappingCheckedIds.has(id)
|
||||||
id && !mappingCheckedIds.has(id)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -325,7 +334,11 @@ export function withStalkerEpg() {
|
|||||||
// call can outlive a portal switch — bail out after
|
// call can outlive a portal switch — bail out after
|
||||||
// every await instead of writing portal A's data
|
// every await instead of writing portal A's data
|
||||||
// into portal B's state.
|
// into portal B's state.
|
||||||
|
const revision = sources.revision();
|
||||||
|
const generation = cacheGeneration;
|
||||||
const isStale = (): boolean =>
|
const isStale = (): boolean =>
|
||||||
|
revision !== sources.revision() ||
|
||||||
|
generation !== cacheGeneration ||
|
||||||
mappingPlaylistId !== playlistId ||
|
mappingPlaylistId !== playlistId ||
|
||||||
String(
|
String(
|
||||||
storeContext.currentPlaylist()?._id ?? ''
|
storeContext.currentPlaylist()?._id ?? ''
|
||||||
@@ -365,6 +378,7 @@ export function withStalkerEpg() {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
let ownershipChanged = false;
|
let ownershipChanged = false;
|
||||||
for (const [channelId, key] of keyById) {
|
for (const [channelId, key] of keyById) {
|
||||||
|
if (isStale()) return;
|
||||||
const mappedEpgId = mappings[key]?.trim();
|
const mappedEpgId = mappings[key]?.trim();
|
||||||
if (!mappedEpgId) {
|
if (!mappedEpgId) {
|
||||||
// No mapping for this channel — a stable
|
// No mapping for this channel — a stable
|
||||||
@@ -443,12 +457,31 @@ export function withStalkerEpg() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearBulkItvEpgCache(): void {
|
clearBulkItvEpgCache(): void {
|
||||||
|
cacheGeneration++;
|
||||||
resetMappingOverrides();
|
resetMappingOverrides();
|
||||||
patchState(store, initialEpgState);
|
patchState(store, initialEpgState);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
withHooks((store) => {
|
||||||
|
const sources = inject(EpgSourceSettingsService);
|
||||||
|
let subscription: { unsubscribe(): void } | undefined;
|
||||||
|
return {
|
||||||
|
onInit: () => {
|
||||||
|
subscription = sources.changed$.subscribe(() => {
|
||||||
|
const context = store as typeof store &
|
||||||
|
StalkerEpgFeatureStoreContract;
|
||||||
|
const selectedId = context.selectedItvId();
|
||||||
|
store.clearBulkItvEpgCache();
|
||||||
|
void store.ensureBulkItvEpg();
|
||||||
|
if (selectedId)
|
||||||
|
void store.applyMappedItvEpg([selectedId]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDestroy: () => subscription?.unsubscribe(),
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
EpgItem,
|
EpgItem,
|
||||||
windowEpgItemsAtProviderClock,
|
windowEpgItemsAtProviderClock,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { SettingsStore } from '@iptvnator/services';
|
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||||
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
||||||
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
|
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
|
||||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||||
@@ -50,6 +50,20 @@ export class EpgQueueService implements OnDestroy {
|
|||||||
private readonly apiService = inject(XtreamApiService);
|
private readonly apiService = inject(XtreamApiService);
|
||||||
private readonly fallbackService = inject(XtreamXmltvFallbackService);
|
private readonly fallbackService = inject(XtreamXmltvFallbackService);
|
||||||
private readonly settingsStore = inject(SettingsStore);
|
private readonly settingsStore = inject(SettingsStore);
|
||||||
|
private readonly sourceSubscription = inject(
|
||||||
|
EpgSourceSettingsService
|
||||||
|
).changed$.subscribe(() => {
|
||||||
|
this.enqueueGeneration++;
|
||||||
|
this.queue = [];
|
||||||
|
for (const id of new Set([
|
||||||
|
...this.cache.keys(),
|
||||||
|
...this.inFlight,
|
||||||
|
...this.visibleSet,
|
||||||
|
])) {
|
||||||
|
this.invalidate(id);
|
||||||
|
this.epgResult$.next({ streamId: id, items: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
private readonly logger = createLogger('EpgQueueService');
|
private readonly logger = createLogger('EpgQueueService');
|
||||||
private readonly previewLimit = 3;
|
private readonly previewLimit = 3;
|
||||||
|
|
||||||
@@ -494,6 +508,7 @@ export class EpgQueueService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
this.sourceSubscription.unsubscribe();
|
||||||
this.epgResult$.complete();
|
this.epgResult$.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
epgProviderClockMs,
|
epgProviderClockMs,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||||
import { SettingsStore } from '@iptvnator/services';
|
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||||
|
|
||||||
type ElectronEpgBridge = {
|
type ElectronEpgBridge = {
|
||||||
getChannelPrograms?: (channelId: string) => Promise<EpgProgram[]>;
|
getChannelPrograms?: (channelId: string) => Promise<EpgProgram[]>;
|
||||||
@@ -19,6 +19,7 @@ type ElectronEpgBridge = {
|
|||||||
export class XtreamXmltvFallbackService {
|
export class XtreamXmltvFallbackService {
|
||||||
private readonly logger = createLogger('XtreamXmltvFallback');
|
private readonly logger = createLogger('XtreamXmltvFallback');
|
||||||
private readonly settingsStore = inject(SettingsStore);
|
private readonly settingsStore = inject(SettingsStore);
|
||||||
|
private readonly sources = inject(EpgSourceSettingsService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `DataService.isElectron` is intentionally not consulted here: it
|
* `DataService.isElectron` is intentionally not consulted here: it
|
||||||
@@ -44,6 +45,7 @@ export class XtreamXmltvFallbackService {
|
|||||||
async getProgramsForChannel(
|
async getProgramsForChannel(
|
||||||
epgChannelId: string | null | undefined
|
epgChannelId: string | null | undefined
|
||||||
): Promise<EpgItem[]> {
|
): Promise<EpgItem[]> {
|
||||||
|
const revision = this.sources.revision();
|
||||||
const id = (epgChannelId ?? '').trim();
|
const id = (epgChannelId ?? '').trim();
|
||||||
if (!id) return [];
|
if (!id) return [];
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export class XtreamXmltvFallbackService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const programs = await fn.call(this.bridge, id);
|
const programs = await fn.call(this.bridge, id);
|
||||||
|
if (revision !== this.sources.revision()) return [];
|
||||||
return (programs ?? []).map((p) => mapEpgProgramToEpgItem(p, id));
|
return (programs ?? []).map((p) => mapEpgProgramToEpgItem(p, id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to load XMLTV programs for ${id}`, error);
|
this.logger.error(`Failed to load XMLTV programs for ${id}`, error);
|
||||||
@@ -62,6 +65,7 @@ export class XtreamXmltvFallbackService {
|
|||||||
async getCurrentProgramsBatch(
|
async getCurrentProgramsBatch(
|
||||||
epgChannelIds: ReadonlyArray<string | null | undefined>
|
epgChannelIds: ReadonlyArray<string | null | undefined>
|
||||||
): Promise<Record<string, EpgItem>> {
|
): Promise<Record<string, EpgItem>> {
|
||||||
|
const revision = this.sources.revision();
|
||||||
const fn = this.bridge?.getCurrentProgramsBatch;
|
const fn = this.bridge?.getCurrentProgramsBatch;
|
||||||
if (typeof fn !== 'function') return {};
|
if (typeof fn !== 'function') return {};
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ export class XtreamXmltvFallbackService {
|
|||||||
this.settingsStore.resolvedEpgOffsetMinutes()
|
this.settingsStore.resolvedEpgOffsetMinutes()
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
if (revision !== this.sources.revision()) return {};
|
||||||
const out: Record<string, EpgItem> = {};
|
const out: Record<string, EpgItem> = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const row = rows?.[id];
|
const row = rows?.[id];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
signalStoreFeature,
|
signalStoreFeature,
|
||||||
withComputed,
|
withComputed,
|
||||||
withMethods,
|
withMethods,
|
||||||
|
withHooks,
|
||||||
withState,
|
withState,
|
||||||
} from '@ngrx/signals';
|
} from '@ngrx/signals';
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +12,11 @@ import {
|
|||||||
EpgItem,
|
EpgItem,
|
||||||
epgProviderClockMs,
|
epgProviderClockMs,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
import {
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
RuntimeCapabilitiesService,
|
||||||
|
SettingsStore,
|
||||||
|
} from '@iptvnator/services';
|
||||||
import {
|
import {
|
||||||
XtreamApiService,
|
XtreamApiService,
|
||||||
XtreamCredentials,
|
XtreamCredentials,
|
||||||
@@ -104,6 +109,7 @@ export function withEpg() {
|
|||||||
const fallbackService = inject(XtreamXmltvFallbackService);
|
const fallbackService = inject(XtreamXmltvFallbackService);
|
||||||
const runtime = inject(RuntimeCapabilitiesService);
|
const runtime = inject(RuntimeCapabilitiesService);
|
||||||
const settingsStore = inject(SettingsStore);
|
const settingsStore = inject(SettingsStore);
|
||||||
|
const sources = inject(EpgSourceSettingsService);
|
||||||
|
|
||||||
const supportsEpg = (): boolean => runtime.supportsEpg;
|
const supportsEpg = (): boolean => runtime.supportsEpg;
|
||||||
|
|
||||||
@@ -146,6 +152,7 @@ export function withEpg() {
|
|||||||
* sets `preferUploadedEpgOverXtream`.
|
* sets `preferUploadedEpgOverXtream`.
|
||||||
*/
|
*/
|
||||||
async loadEpg(): Promise<EpgItem[]> {
|
async loadEpg(): Promise<EpgItem[]> {
|
||||||
|
const sourceRevision = sources.revision();
|
||||||
if (!supportsEpg()) {
|
if (!supportsEpg()) {
|
||||||
patchState(store, {
|
patchState(store, {
|
||||||
epgItems: [],
|
epgItems: [],
|
||||||
@@ -203,6 +210,7 @@ export function withEpg() {
|
|||||||
fetchFullProvider(credentials, xtreamId),
|
fetchFullProvider(credentials, xtreamId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (sourceRevision !== sources.revision()) return [];
|
||||||
patchState(store, {
|
patchState(store, {
|
||||||
epgItems,
|
epgItems,
|
||||||
isLoadingEpg: false,
|
isLoadingEpg: false,
|
||||||
@@ -210,6 +218,7 @@ export function withEpg() {
|
|||||||
|
|
||||||
return epgItems;
|
return epgItems;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (sourceRevision !== sources.revision()) return [];
|
||||||
logger.error('Error loading EPG', error);
|
logger.error('Error loading EPG', error);
|
||||||
patchState(store, {
|
patchState(store, {
|
||||||
epgItems: [],
|
epgItems: [],
|
||||||
@@ -255,6 +264,19 @@ export function withEpg() {
|
|||||||
patchState(store, initialEpgState);
|
patchState(store, initialEpgState);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}),
|
||||||
|
withHooks((store) => {
|
||||||
|
const sources = inject(EpgSourceSettingsService);
|
||||||
|
let subscription: { unsubscribe(): void } | undefined;
|
||||||
|
return {
|
||||||
|
onInit: () => {
|
||||||
|
subscription = sources.changed$.subscribe(() => {
|
||||||
|
store.clearEpg();
|
||||||
|
void store.loadEpg();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDestroy: () => subscription?.unsubscribe(),
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -1,3 +1,4 @@
|
|||||||
|
import { EpgSourceSettingsService } from '@iptvnator/services';
|
||||||
import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';
|
import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';
|
||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
@@ -170,6 +171,15 @@ describe('PortalChannelsListComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears visible cached XMLTV previews and re-enqueues after a source changes', () => {
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.epgPrograms.set(50, { title: 'Removed programme' } as never);
|
||||||
|
component.currentProgramsProgress.set(50, 20);
|
||||||
|
TestBed.inject(EpgSourceSettingsService).changed$.next();
|
||||||
|
expect(component.epgPrograms.size).toBe(0);
|
||||||
|
expect(component.currentProgramsProgress.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => {
|
it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -49,7 +49,11 @@ import { EpgQueueService } from '@iptvnator/portal/xtream/data-access';
|
|||||||
import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access';
|
import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access';
|
||||||
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
|
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
|
||||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
import {
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
RuntimeCapabilitiesService,
|
||||||
|
SettingsStore,
|
||||||
|
} from '@iptvnator/services';
|
||||||
import { XtreamFavoriteMarksService } from './xtream-favorite-marks.service';
|
import { XtreamFavoriteMarksService } from './xtream-favorite-marks.service';
|
||||||
|
|
||||||
export interface XtreamChannelListItem {
|
export interface XtreamChannelListItem {
|
||||||
@@ -164,6 +168,11 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
|||||||
private readonly settingsStore = inject(SettingsStore);
|
private readonly settingsStore = inject(SettingsStore);
|
||||||
|
|
||||||
constructor(private cdr: ChangeDetectorRef) {
|
constructor(private cdr: ChangeDetectorRef) {
|
||||||
|
this.subscriptions.add(
|
||||||
|
inject(EpgSourceSettingsService).changed$.subscribe(() => {
|
||||||
|
this.repickPreviewsForOffsetChange();
|
||||||
|
})
|
||||||
|
);
|
||||||
// A changed display offset moves "now" in the provider's clock, so
|
// A changed display offset moves "now" in the provider's clock, so
|
||||||
// the visible previews are re-picked from the cached EPG. The first
|
// the visible previews are re-picked from the cached EPG. The first
|
||||||
// run only records the initial value.
|
// run only records the initial value.
|
||||||
|
|||||||
@@ -21,3 +21,5 @@ export * from './lib/tmdb';
|
|||||||
export * from './lib/xtream-pending-restore.service';
|
export * from './lib/xtream-pending-restore.service';
|
||||||
export * from './lib/stream-probe.service';
|
export * from './lib/stream-probe.service';
|
||||||
export * from './lib/vod-source-pin.service';
|
export * from './lib/vod-source-pin.service';
|
||||||
|
|
||||||
|
export * from './lib/epg-source-settings.service';
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Injector } from '@angular/core';
|
||||||
|
import { firstValueFrom, of, Subject } from 'rxjs';
|
||||||
|
import { EpgSourceSettingsService } from './epg-source-settings.service';
|
||||||
|
import { PlaylistsService } from './playlists.service';
|
||||||
|
|
||||||
|
describe('EPG source settings synchronization', () => {
|
||||||
|
const original = window.electron;
|
||||||
|
afterEach(() => {
|
||||||
|
window.electron = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for playlist migration, normalizes URLs and invalidates pending lookups after success', async () => {
|
||||||
|
const playlists = new Subject<never[]>();
|
||||||
|
const reconcileEpgSources = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ success: true });
|
||||||
|
window.electron = {
|
||||||
|
reconcileEpgSources,
|
||||||
|
} as unknown as typeof window.electron;
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
{
|
||||||
|
provide: PlaylistsService,
|
||||||
|
useValue: { getAllPlaylists: () => playlists },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const service = injector.get(EpgSourceSettingsService);
|
||||||
|
const pending = new Subject<string>();
|
||||||
|
const observer = jest.fn();
|
||||||
|
const lookup = pending.pipe(service.guard());
|
||||||
|
lookup.subscribe(observer);
|
||||||
|
const synchronization = service.synchronize([' a ', '', 'a']);
|
||||||
|
expect(reconcileEpgSources).not.toHaveBeenCalled();
|
||||||
|
pending.next('response during playlist migration');
|
||||||
|
expect(observer).not.toHaveBeenCalled();
|
||||||
|
playlists.next([]);
|
||||||
|
await synchronization;
|
||||||
|
pending.next('old programme');
|
||||||
|
// Subscribing to a pre-deletion request later also cannot revive it.
|
||||||
|
lookup.subscribe(observer);
|
||||||
|
pending.next('late programme');
|
||||||
|
expect(observer).not.toHaveBeenCalled();
|
||||||
|
expect(service.revision()).toBe(2);
|
||||||
|
expect(reconcileEpgSources).toHaveBeenCalledWith(['a']);
|
||||||
|
expect(await firstValueFrom(of('new').pipe(service.guard()))).toBe(
|
||||||
|
'new'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates possibly partially deleted data and reports reconciliation failure', async () => {
|
||||||
|
window.electron = {
|
||||||
|
reconcileEpgSources: jest.fn().mockRejectedValue(new Error('disk')),
|
||||||
|
} as unknown as typeof window.electron;
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
{
|
||||||
|
provide: PlaylistsService,
|
||||||
|
useValue: { getAllPlaylists: () => of([]) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const service = injector.get(EpgSourceSettingsService);
|
||||||
|
await expect(service.synchronize(['current'])).rejects.toThrow(
|
||||||
|
'Failed to reconcile EPG sources'
|
||||||
|
);
|
||||||
|
expect(service.revision()).toBe(2);
|
||||||
|
expect(service.retainCurrentSources(['current', 'removed'], 0)).toEqual(
|
||||||
|
['current']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes overlapping saves and waits for the latest committed source set', async () => {
|
||||||
|
let finishFirst!: (result: { success: boolean }) => void;
|
||||||
|
const reconcileEpgSources = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishFirst = resolve;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValue({ success: true });
|
||||||
|
window.electron = {
|
||||||
|
reconcileEpgSources,
|
||||||
|
} as unknown as typeof window.electron;
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
{
|
||||||
|
provide: PlaylistsService,
|
||||||
|
useValue: { getAllPlaylists: () => of([]) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const service = injector.get(EpgSourceSettingsService);
|
||||||
|
const first = service.synchronize(['a']);
|
||||||
|
const second = service.synchronize(['b']);
|
||||||
|
const waiter = service.waitForReconciliation();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(reconcileEpgSources).toHaveBeenCalledTimes(1);
|
||||||
|
finishFirst({ success: true });
|
||||||
|
await Promise.all([first, second, waiter]);
|
||||||
|
expect(reconcileEpgSources.mock.calls.map(([urls]) => urls)).toEqual([
|
||||||
|
['a'],
|
||||||
|
['b'],
|
||||||
|
]);
|
||||||
|
expect(service.retainCurrentSources(['a', 'b'], 0)).toEqual(['b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { inject, Injectable, Injector, signal } from '@angular/core';
|
||||||
|
import {
|
||||||
|
firstValueFrom,
|
||||||
|
Subject,
|
||||||
|
Observable,
|
||||||
|
MonoTypeOperatorFunction,
|
||||||
|
filter,
|
||||||
|
takeUntil,
|
||||||
|
} from 'rxjs';
|
||||||
|
import { PlaylistsService } from './playlists.service';
|
||||||
|
|
||||||
|
export class EpgSourceReconciliationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Failed to reconcile EPG sources');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEpgSourceUrls(urls: string[] | string | undefined): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
(Array.isArray(urls) ? urls : [urls ?? ''])
|
||||||
|
.map((url) => url.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function epgSourceUrlsChanged(
|
||||||
|
previous: string[] | string | undefined,
|
||||||
|
next: string[] | string | undefined
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
next !== undefined &&
|
||||||
|
JSON.stringify(normalizeEpgSourceUrls(previous).sort()) !==
|
||||||
|
JSON.stringify(normalizeEpgSourceUrls(next).sort())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronizes committed global XMLTV settings, never unsaved form edits. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class EpgSourceSettingsService {
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
private activeUrls = new Set<string>();
|
||||||
|
private reconciliation: Promise<void> | undefined;
|
||||||
|
readonly revision = signal(0);
|
||||||
|
readonly changed$ = new Subject<void>();
|
||||||
|
|
||||||
|
retainCurrentSources(urls: string[], requestedRevision: number): string[] {
|
||||||
|
return requestedRevision === this.revision()
|
||||||
|
? urls
|
||||||
|
: urls.filter((url) => this.activeUrls.has(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
guard<T>(): MonoTypeOperatorFunction<T> {
|
||||||
|
const revision = this.revision();
|
||||||
|
return (source: Observable<T>) =>
|
||||||
|
source.pipe(
|
||||||
|
takeUntil(this.changed$),
|
||||||
|
filter(() => revision === this.revision())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForReconciliation(): Promise<void> {
|
||||||
|
while (this.reconciliation) {
|
||||||
|
await this.reconciliation.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synchronize(urls: string[] | string | undefined): Promise<void> {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!window.electron?.reconcileEpgSources
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// Fence existing lookups before playlist migration or IPC can yield.
|
||||||
|
this.revision.update((revision) => revision + 1);
|
||||||
|
const previous = this.reconciliation;
|
||||||
|
const operation = previous
|
||||||
|
? previous.catch(() => undefined).then(() => this.reconcile(urls))
|
||||||
|
: this.reconcile(urls);
|
||||||
|
const pending = operation.finally(() => {
|
||||||
|
if (this.reconciliation === pending)
|
||||||
|
this.reconciliation = undefined;
|
||||||
|
});
|
||||||
|
this.reconciliation = pending;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconcile(
|
||||||
|
urls: string[] | string | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = normalizeEpgSourceUrls(urls);
|
||||||
|
// These globals are committed even if playlist ownership or cleanup
|
||||||
|
// cannot be read. Never keep the previous global list on failure.
|
||||||
|
this.activeUrls = new Set(normalized);
|
||||||
|
try {
|
||||||
|
// This includes the legacy IndexedDB → SQLite playlist migration.
|
||||||
|
const playlists = await firstValueFrom(
|
||||||
|
this.injector.get(PlaylistsService).getAllPlaylists()
|
||||||
|
);
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
if (playlist.serverUrl || playlist.macAddress) continue;
|
||||||
|
for (const url of playlist.epgUrls ?? []) {
|
||||||
|
if (url.trim()) this.activeUrls.add(url.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result =
|
||||||
|
await window.electron.reconcileEpgSources(normalized);
|
||||||
|
if (!result.success)
|
||||||
|
throw new Error('EPG source reconciliation failed');
|
||||||
|
} catch {
|
||||||
|
throw new EpgSourceReconciliationError();
|
||||||
|
} finally {
|
||||||
|
this.revision.update((revision) => revision + 1);
|
||||||
|
this.changed$.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EpgSourceSettingsService } from './epg-source-settings.service';
|
||||||
import { Injector } from '@angular/core';
|
import { Injector } from '@angular/core';
|
||||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||||
import { of, Subject } from 'rxjs';
|
import { of, Subject } from 'rxjs';
|
||||||
@@ -46,6 +47,7 @@ describe('SettingsStore dashboard rail settings', () => {
|
|||||||
injector = Injector.create({
|
injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
SettingsStore,
|
SettingsStore,
|
||||||
|
EpgSourceSettingsService,
|
||||||
{
|
{
|
||||||
provide: StorageMap,
|
provide: StorageMap,
|
||||||
useValue: storage,
|
useValue: storage,
|
||||||
@@ -54,6 +56,118 @@ describe('SettingsStore dashboard rail settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reconciles only after persistence and restores the previous EPG list on a failed save', async () => {
|
||||||
|
storedSettings = { epgUrl: ['https://old.example/guide.xml'] };
|
||||||
|
const sources = injector.get(EpgSourceSettingsService);
|
||||||
|
const reconcile = jest
|
||||||
|
.spyOn(sources, 'synchronize')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const store = injector.get(SettingsStore);
|
||||||
|
await store.loadSettings();
|
||||||
|
reconcile.mockClear();
|
||||||
|
const write = new Subject<void>();
|
||||||
|
storage.set.mockReturnValue(write);
|
||||||
|
const saving = store.updateSettings({ epgUrl: [] });
|
||||||
|
expect(reconcile).not.toHaveBeenCalled();
|
||||||
|
write.error(new Error('storage unavailable'));
|
||||||
|
await expect(saving).rejects.toThrow('storage unavailable');
|
||||||
|
expect(reconcile).not.toHaveBeenCalled();
|
||||||
|
expect(store.epgUrl()).toEqual(['https://old.example/guide.xml']);
|
||||||
|
storage.set.mockReturnValue(of(undefined));
|
||||||
|
await store.updateSettings({ epgUrl: [] });
|
||||||
|
expect(reconcile).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reconciles empty defaults after settings storage fails to load', async () => {
|
||||||
|
const pending = new Subject<unknown>();
|
||||||
|
storage.get.mockReturnValue(pending);
|
||||||
|
const reconcile = jest.spyOn(
|
||||||
|
injector.get(EpgSourceSettingsService),
|
||||||
|
'synchronize'
|
||||||
|
);
|
||||||
|
const store = injector.get(SettingsStore);
|
||||||
|
pending.error(new Error('cannot read settings'));
|
||||||
|
await store.loadSettings();
|
||||||
|
expect(reconcile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps persisted URLs authoritative when subsequent EPG cleanup fails', async () => {
|
||||||
|
const sources = injector.get(EpgSourceSettingsService);
|
||||||
|
const reconcile = jest
|
||||||
|
.spyOn(sources, 'synchronize')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const store = injector.get(SettingsStore);
|
||||||
|
await store.loadSettings();
|
||||||
|
reconcile.mockRejectedValue(new Error('cleanup failed'));
|
||||||
|
await expect(
|
||||||
|
store.updateSettings({ epgUrl: ['new-source'] })
|
||||||
|
).rejects.toThrow('cleanup failed');
|
||||||
|
expect(store.epgUrl()).toEqual(['new-source']);
|
||||||
|
expect(store.storageFailure()).toBeNull();
|
||||||
|
expect(storage.set).toHaveBeenLastCalledWith(
|
||||||
|
STORE_KEY.Settings,
|
||||||
|
expect.objectContaining({ epgUrl: ['new-source'] })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ epgUrl: ['second', 'first'] },
|
||||||
|
{ epgUrl: [' first ', 'second', 'first', ''] },
|
||||||
|
])(
|
||||||
|
'persists unrelated settings without reconciling an unchanged normalized source set: %j',
|
||||||
|
async ({ epgUrl }) => {
|
||||||
|
storedSettings = { epgUrl: ['first', 'second'] };
|
||||||
|
const reconcile = jest
|
||||||
|
.spyOn(injector.get(EpgSourceSettingsService), 'synchronize')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const store = injector.get(SettingsStore);
|
||||||
|
await store.loadSettings();
|
||||||
|
reconcile
|
||||||
|
.mockClear()
|
||||||
|
.mockRejectedValue(new Error('migration unavailable'));
|
||||||
|
await expect(
|
||||||
|
store.updateSettings({
|
||||||
|
...store.getSettings(),
|
||||||
|
language: Language.FRENCH,
|
||||||
|
epgUrl,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(reconcile).not.toHaveBeenCalled();
|
||||||
|
expect(store.storageFailure()).toBeNull();
|
||||||
|
expect(storage.set).toHaveBeenLastCalledWith(
|
||||||
|
STORE_KEY.Settings,
|
||||||
|
expect.objectContaining({ language: Language.FRENCH })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('keeps failed cleanup retryable only when an EPG save explicitly requests it', async () => {
|
||||||
|
storedSettings = { epgUrl: ['removed'] };
|
||||||
|
const reconcile = jest
|
||||||
|
.spyOn(injector.get(EpgSourceSettingsService), 'synchronize')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const store = injector.get(SettingsStore);
|
||||||
|
await store.loadSettings();
|
||||||
|
reconcile.mockClear().mockRejectedValue(new Error('cleanup failed'));
|
||||||
|
await expect(store.updateSettings({ epgUrl: [] })).rejects.toThrow(
|
||||||
|
'cleanup failed'
|
||||||
|
);
|
||||||
|
reconcile.mockClear();
|
||||||
|
await expect(
|
||||||
|
store.updateSettings({
|
||||||
|
...store.getSettings(),
|
||||||
|
language: Language.FRENCH,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(reconcile).not.toHaveBeenCalled();
|
||||||
|
await expect(
|
||||||
|
store.updateSettings({ epgUrl: [] }, { retryEpgCleanup: true })
|
||||||
|
).rejects.toThrow('cleanup failed');
|
||||||
|
reconcile.mockResolvedValue(undefined);
|
||||||
|
await store.updateSettings({ epgUrl: [] }, { retryEpgCleanup: true });
|
||||||
|
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('defaults portal request pauses on and persists an explicit opt-out', async () => {
|
it('defaults portal request pauses on and persists an explicit opt-out', async () => {
|
||||||
const store = injector.get(SettingsStore);
|
const store = injector.get(SettingsStore);
|
||||||
expect(store.getSettings().portalConnectivityGuard).toBe(true);
|
expect(store.getSettings().portalConnectivityGuard).toBe(true);
|
||||||
@@ -448,6 +562,7 @@ describe('SettingsStore storage failure reporting', () => {
|
|||||||
injector = Injector.create({
|
injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
SettingsStore,
|
SettingsStore,
|
||||||
|
EpgSourceSettingsService,
|
||||||
{
|
{
|
||||||
provide: StorageMap,
|
provide: StorageMap,
|
||||||
useValue: storage,
|
useValue: storage,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
EpgSourceSettingsService,
|
||||||
|
epgSourceUrlsChanged,
|
||||||
|
} from './epg-source-settings.service';
|
||||||
import { computed, inject } from '@angular/core';
|
import { computed, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
patchState,
|
patchState,
|
||||||
@@ -143,6 +147,7 @@ export const SettingsStore = signalStore(
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
withMethods((store, storage = inject(StorageMap)) => {
|
withMethods((store, storage = inject(StorageMap)) => {
|
||||||
|
const epgSources = inject(EpgSourceSettingsService);
|
||||||
let settingsLoadPromise: Promise<void> | undefined;
|
let settingsLoadPromise: Promise<void> | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -190,6 +195,14 @@ export const SettingsStore = signalStore(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await epgSources
|
||||||
|
.synchronize(this.getSettings().epgUrl)
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(
|
||||||
|
'Could not reconcile cached EPG sources on startup.',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
settingsLoadPromise = undefined;
|
settingsLoadPromise = undefined;
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
@@ -202,7 +215,11 @@ export const SettingsStore = signalStore(
|
|||||||
return settingsLoadPromise;
|
return settingsLoadPromise;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateSettings(settings: Partial<Settings>) {
|
async updateSettings(
|
||||||
|
settings: Partial<Settings>,
|
||||||
|
options: { retryEpgCleanup?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const previousEpgUrls = store.epgUrl();
|
||||||
patchState(store, {
|
patchState(store, {
|
||||||
...settings,
|
...settings,
|
||||||
...(settings.webPlayerSharedControls !== undefined
|
...(settings.webPlayerSharedControls !== undefined
|
||||||
@@ -252,9 +269,18 @@ export const SettingsStore = signalStore(
|
|||||||
console.error('Failed to save settings:', error);
|
console.error('Failed to save settings:', error);
|
||||||
// The in-memory patch above already applied, so without
|
// The in-memory patch above already applied, so without
|
||||||
// this flag the change looks saved until the next restart.
|
// this flag the change looks saved until the next restart.
|
||||||
patchState(store, { storageFailure: 'save' });
|
patchState(store, {
|
||||||
|
storageFailure: 'save',
|
||||||
|
epgUrl: previousEpgUrls,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
epgSourceUrlsChanged(previousEpgUrls, settings.epgUrl) ||
|
||||||
|
options.retryEpgCleanup
|
||||||
|
) {
|
||||||
|
await epgSources.synchronize(completeSettings.epgUrl);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getSettings() {
|
getSettings() {
|
||||||
|
|||||||
@@ -287,6 +287,18 @@ const CREATE_TABLE_STATEMENTS = [
|
|||||||
source_url TEXT NOT NULL,
|
source_url TEXT NOT NULL,
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS epg_channel_sources (
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
icon_url TEXT,
|
||||||
|
url TEXT,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
write_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (channel_id, source_url),
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_epg_channel_sources_source ON epg_channel_sources(source_url)`,
|
||||||
`CREATE TABLE IF NOT EXISTS epg_programs (
|
`CREATE TABLE IF NOT EXISTS epg_programs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
channel_id TEXT NOT NULL,
|
channel_id TEXT NOT NULL,
|
||||||
@@ -419,6 +431,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
|
|||||||
`ALTER TABLE content ADD COLUMN original_title TEXT`,
|
`ALTER TABLE content ADD COLUMN original_title TEXT`,
|
||||||
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
|
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
|
||||||
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
|
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
|
||||||
|
// Preserve writer order independently of wall-clock precision or changes.
|
||||||
|
`ALTER TABLE epg_channel_sources ADD COLUMN write_order INTEGER NOT NULL DEFAULT 0`,
|
||||||
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
|
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
|
||||||
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
|
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
|
||||||
// Offline details: provider-neutral display metadata captured at download time
|
// Offline details: provider-neutral display metadata captured at download time
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { sql } from 'drizzle-orm';
|
|||||||
import {
|
import {
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
|
primaryKey,
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
@@ -204,6 +205,26 @@ export const epgChannels = sqliteTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A global XMLTV ID can be shared by sources with different channel metadata.
|
||||||
|
export const epgChannelSources = sqliteTable(
|
||||||
|
'epg_channel_sources',
|
||||||
|
{
|
||||||
|
channelId: text('channel_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => epgChannels.id, { onDelete: 'cascade' }),
|
||||||
|
sourceUrl: text('source_url').notNull(),
|
||||||
|
displayName: text('display_name').notNull(),
|
||||||
|
iconUrl: text('icon_url'),
|
||||||
|
url: text('url'),
|
||||||
|
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
writeOrder: integer('write_order').notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.channelId, table.sourceUrl] }),
|
||||||
|
index('idx_epg_channel_sources_source').on(table.sourceUrl),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// EPG Programs table
|
// EPG Programs table
|
||||||
export const epgPrograms = sqliteTable(
|
export const epgPrograms = sqliteTable(
|
||||||
'epg_programs',
|
'epg_programs',
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export type ElectronBridgePlaylistType =
|
|||||||
(typeof ELECTRON_BRIDGE_PLAYLIST_TYPES)[keyof typeof ELECTRON_BRIDGE_PLAYLIST_TYPES];
|
(typeof ELECTRON_BRIDGE_PLAYLIST_TYPES)[keyof typeof ELECTRON_BRIDGE_PLAYLIST_TYPES];
|
||||||
|
|
||||||
export const ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES = {
|
export const ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES = {
|
||||||
|
Cancelled: 'cancelled',
|
||||||
Complete: 'complete',
|
Complete: 'complete',
|
||||||
Error: 'error',
|
Error: 'error',
|
||||||
Loading: 'loading',
|
Loading: 'loading',
|
||||||
@@ -340,8 +341,7 @@ export interface ElectronBridgeTrustOptions {
|
|||||||
trustedInsecureTlsHosts?: string[];
|
trustedInsecureTlsHosts?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElectronBridgePlaylistFetchOptions
|
export interface ElectronBridgePlaylistFetchOptions extends ElectronBridgeTrustOptions {
|
||||||
extends ElectronBridgeTrustOptions {
|
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,8 +375,7 @@ export interface ElectronBridgeEpgLookupOptions {
|
|||||||
* renderer will also present as "now". Absent, the main process uses its own
|
* renderer will also present as "now". Absent, the main process uses its own
|
||||||
* clock.
|
* clock.
|
||||||
*/
|
*/
|
||||||
export interface ElectronBridgeCurrentProgramsOptions
|
export interface ElectronBridgeCurrentProgramsOptions extends ElectronBridgeEpgLookupOptions {
|
||||||
extends ElectronBridgeEpgLookupOptions {
|
|
||||||
nowMs?: number;
|
nowMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +386,7 @@ export interface ElectronBridgeEpgProgressStats {
|
|||||||
|
|
||||||
export interface ElectronBridgeEpgProgress {
|
export interface ElectronBridgeEpgProgress {
|
||||||
url: string;
|
url: string;
|
||||||
|
generation?: number;
|
||||||
status: ElectronBridgeEpgProgressStatus;
|
status: ElectronBridgeEpgProgressStatus;
|
||||||
stats?: ElectronBridgeEpgProgressStats;
|
stats?: ElectronBridgeEpgProgressStats;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -812,6 +812,7 @@ export interface ElectronBridgeApi {
|
|||||||
options?: ElectronBridgeTrustOptions
|
options?: ElectronBridgeTrustOptions
|
||||||
) => Promise<ElectronBridgeEpgFetchResult>;
|
) => Promise<ElectronBridgeEpgFetchResult>;
|
||||||
clearEpgData: () => Promise<ElectronBridgeResult>;
|
clearEpgData: () => Promise<ElectronBridgeResult>;
|
||||||
|
reconcileEpgSources: (urls: string[]) => Promise<ElectronBridgeResult>;
|
||||||
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
|
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
|
||||||
checkEpgFreshness: (
|
checkEpgFreshness: (
|
||||||
urls: string[],
|
urls: string[],
|
||||||
@@ -1291,7 +1292,9 @@ export interface ElectronBridgeApi {
|
|||||||
recordingsGet?: (
|
recordingsGet?: (
|
||||||
recordingId: number
|
recordingId: number
|
||||||
) => Promise<ElectronRecordingItem | null>;
|
) => Promise<ElectronRecordingItem | null>;
|
||||||
recordingsStop?: (recordingId: number) => Promise<ElectronBridgeErrorResult>;
|
recordingsStop?: (
|
||||||
|
recordingId: number
|
||||||
|
) => Promise<ElectronBridgeErrorResult>;
|
||||||
recordingsRemove?: (
|
recordingsRemove?: (
|
||||||
recordingId: number
|
recordingId: number
|
||||||
) => Promise<ElectronBridgeErrorResult>;
|
) => Promise<ElectronBridgeErrorResult>;
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export class EpgProgressPanelComponent {
|
|||||||
return 'check_circle';
|
return 'check_circle';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'error';
|
return 'error';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'cancel';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const maxLinesBaseline = [
|
|||||||
'apps/electron-backend/src/app/database/operations/content.operations.ts',
|
'apps/electron-backend/src/app/database/operations/content.operations.ts',
|
||||||
'apps/electron-backend/src/app/database/operations/playlist.operations.ts',
|
'apps/electron-backend/src/app/database/operations/playlist.operations.ts',
|
||||||
'apps/electron-backend/src/app/events/epg-query.service.ts',
|
'apps/electron-backend/src/app/events/epg-query.service.ts',
|
||||||
'apps/electron-backend/src/app/events/epg-worker.service.ts',
|
|
||||||
'apps/electron-backend/src/app/events/mpv-session.service.ts',
|
'apps/electron-backend/src/app/events/mpv-session.service.ts',
|
||||||
'apps/electron-backend/src/app/events/vlc-session.service.ts',
|
'apps/electron-backend/src/app/events/vlc-session.service.ts',
|
||||||
'apps/electron-backend/src/app/services/app-update.service.ts',
|
'apps/electron-backend/src/app/services/app-update.service.ts',
|
||||||
|
|||||||
Reference in new issue
Block a user