diff --git a/.changes/epg-removed-source-cache.md b/.changes/epg-removed-source-cache.md new file mode 100644 index 000000000..b2f7bd281 --- /dev/null +++ b/.changes/epg-removed-source-cache.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 9e438c0fe..4af88313b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 - Half-open trial slots follow the complete request lifetime with no elapsed-time diff --git a/CLAUDE.md b/CLAUDE.md index 700c1eb7c..22a023963 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -758,7 +758,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - `database.events.ts` - Database CRUD operations - `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` - - `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 - `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 `, 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 +## 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 - Half-open trial slots follow the complete request lifetime with no elapsed-time diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index 4e5f55835..9b96075a1 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { Page } from '@playwright/test'; import { buildM3uContent, @@ -5,6 +7,7 @@ import { closeElectronApp, createMutableTextServer, expect, + goToDashboard, importM3uPlaylistFromUrl, launchElectronApp, openWorkspaceSection, @@ -33,7 +36,8 @@ const epgFixtureXml = ` function createCurrentXmltvFixture( channelId: string, channelName: string, - programTitle: string + programTitle: string, + iconUrl?: string ): string { const start = new Date(Date.now() - 15 * 60 * 1000); const stop = new Date(Date.now() + 45 * 60 * 1000); @@ -42,6 +46,7 @@ function createCurrentXmltvFixture( ${channelName} + ${iconUrl ? `` : ''} ${programTitle} @@ -65,7 +70,159 @@ function formatXmltvDate(date: Date): string { } test.describe('Electron EPG', () => { - test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({ + test('@epg @electron saves unrelated settings when EPG reconciliation is unavailable', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'general'); + // After startup, make any unexpected reconciliation fail. + await app.electronApp.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('EPG_RECONCILE_SOURCES'); + ipcMain.handle('EPG_RECONCILE_SOURCES', () => { + throw new Error('Synthetic migration failure'); + }); + }); + const guard = app.mainWindow + .getByTestId('portal-connectivity-toggle') + .locator('input'); + await guard.uncheck(); + await expect( + app.mainWindow.getByTestId('settings-unsaved-bar') + ).toBeVisible(); + await saveSettings(app.mainWindow); + await expect( + app.mainWindow.getByTestId('settings-unsaved-bar') + ).not.toBeVisible(); + await goToDashboard(app.mainWindow); + await expect( + app.mainWindow.getByTestId('unsaved-dialog-save') + ).not.toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + + test('@epg @electron mirrors saved desktop settings when source cleanup fails', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + try { + await openSettings(app.mainWindow); + await app.electronApp.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('EPG_RECONCILE_SOURCES'); + ipcMain.handle('EPG_RECONCILE_SOURCES', () => { + throw new Error('Synthetic cleanup failure'); + }); + }); + await openSettingsSection(app.mainWindow, 'general'); + await app.mainWindow + .getByTestId('portal-connectivity-toggle') + .locator('input') + .uncheck(); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + await app.mainWindow + .locator('.epg-source-row input') + .fill('https://example.invalid/guide.xml'); + await app.mainWindow + .getByTestId('save-settings') + .click({ noWaitAfter: true }); + await expect( + app.mainWindow.getByText( + 'Failed to clear EPG data. Please try again.', + { exact: true } + ) + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId('settings-unsaved-bar') + ).toBeVisible(); + await expect + .poll(async () => { + const config = JSON.parse( + await readFile( + join(dataDir, 'config', 'config.json'), + 'utf8' + ) + ); + return config.PORTAL_CONNECTIVITY_GUARD; + }) + .toBe(false); + // Restore cleanup so the explicit retry can finish and close normally. + await app.electronApp.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('EPG_RECONCILE_SOURCES'); + ipcMain.handle('EPG_RECONCILE_SOURCES', () => ({ + success: true, + removedUrls: [], + })); + }); + await app.mainWindow + .locator('.epg-source-row button') + .nth(1) + .click(); + await saveSettings(app.mainWindow); + await expect( + app.mainWindow.getByTestId('settings-unsaved-bar') + ).not.toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + + test('@epg @electron omits source secrets from main and parser-worker diagnostics', async ({ + dataDir, + }) => { + const server = await createMutableTextServer(epgFixtureXml, { + contentType: 'application/xml; charset=utf-8', + resourcePath: '/epg-path-secret/guide.xml', + }); + const app = await launchElectronApp(dataDir); + const sourceUrl = `${server.resourceUrl}?custom=epg-query-secret`; + let diagnostics = ''; + const capture = (chunk: Buffer) => { + diagnostics += chunk.toString(); + }; + const process = app.electronApp.process(); + process.stdout?.on('data', capture); + process.stderr?.on('data', capture); + try { + const imported = await app.mainWindow.evaluate(async (url) => { + const result = await window.electron.forceFetchEpg(url); + await window.electron.clearEpgDataForSource(url); + return result; + }, sourceUrl); + expect(imported.success).toBe(true); + await expect + .poll(() => diagnostics) + .toContain('EPG data cleared for source via worker'); + const failed = await app.mainWindow.evaluate(async (url) => { + const missing = new URL(url); + missing.pathname += '/missing'; + return window.electron.forceFetchEpg(missing.toString()); + }, sourceUrl); + expect(failed.success).toBe(false); + await expect + .poll(() => diagnostics) + .toContain('Request failed with status code 404'); + // Worker stdout can lose its final buffered line on termination; + // the awaited import result and main-process completion are authoritative. + expect(diagnostics).toContain('[EPG Worker] Fetching EPG source'); + expect(diagnostics).toContain('EPG parsing complete:'); + expect(diagnostics).not.toContain('epg-path-secret'); + expect(diagnostics).not.toContain('epg-query-secret'); + expect(await getEpgChannelCount(app.mainWindow)).toBe(0); + } finally { + process.stdout?.off('data', capture); + process.stderr?.off('data', capture); + await closeElectronApp(app); + await server.close(); + } + }); + + test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({ dataDir, }) => { const epgServer = await createMutableTextServer(epgFixtureXml, { @@ -103,6 +260,8 @@ test.describe('Electron EPG', () => { .first() ).toBeVisible(); + await saveSettings(app.mainWindow); + await app.mainWindow .locator('.epg-source-row button') .nth(1) @@ -116,18 +275,6 @@ test.describe('Electron EPG', () => { // would block the close and leak the Electron process. await saveSettings(app.mainWindow); - await app.mainWindow - .getByRole('button', { name: 'Clear EPG data' }) - .click(); - const dialog = app.mainWindow.locator('mat-dialog-container'); - await expect(dialog).toBeVisible(); - await dialog - .getByRole('button', { name: 'Yes', exact: true }) - .click(); - await app.mainWindow.waitForSelector('mat-dialog-container', { - state: 'detached', - }); - await expect .poll(() => getEpgChannelCount(app.mainWindow), { timeout: 20000, @@ -139,9 +286,249 @@ test.describe('Electron EPG', () => { } }); + test('@epg @electron removes a retained error row after its import worker has finished', async ({ + dataDir, + }) => { + const server = await createMutableTextServer(epgFixtureXml, { + contentType: 'application/xml', + }); + const app = await launchElectronApp(dataDir); + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + await app.mainWindow + .locator('.epg-source-row input') + .fill(server.resourceUrl); + await saveSettings(app.mainWindow); + await expect( + app.mainWindow.locator( + '.epg-progress-panel .import-item.status-complete' + ) + ).toHaveCount(1, { timeout: 30000 }); + // Capture the actual generation; a force-fetch also awaits termination. + const generation = await app.mainWindow.evaluate(async (url) => { + await window.electron.clearEpgDataForSource(url); + const completed = new Promise((resolve) => { + window.electron.onEpgProgress?.((progress) => { + if ( + progress.url === url && + progress.status === 'complete' + ) + resolve(progress.generation ?? 0); + }); + }); + await window.electron.forceFetchEpg(url); + return completed; + }, server.resourceUrl); + await app.electronApp.evaluate( + ({ BrowserWindow }, { url, generation }) => { + BrowserWindow.getAllWindows()[0].webContents.send( + 'EPG_PROGRESS_UPDATE', + { + url, + status: 'error', + generation, + errorCode: 'invalid-tls-certificate', + error: 'Synthetic retained TLS error', + } + ); + }, + { url: server.resourceUrl, generation } + ); + await expect( + app.mainWindow.locator( + '.epg-progress-panel .import-item.status-error' + ) + ).toHaveCount(1); + await app.mainWindow + .locator('.epg-source-row button') + .nth(1) + .click(); + await saveSettings(app.mainWindow); + await expect( + app.mainWindow.locator('.epg-progress-panel .import-item') + ).toHaveCount(0); + await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0); + } finally { + await closeElectronApp(app); + await server.close(); + } + }); + + test('@epg @electron removes only the saved source, preserves shared IDs and mappings, and repairs old orphan data on restart', async ({ + dataDir, + }) => { + test.setTimeout(120000); + const first = await createMutableTextServer( + createCurrentXmltvFixture( + 'shared-news', + 'Shared News', + 'Removed Bulletin' + ), + { contentType: 'application/xml', resourcePath: '/first.xml' } + ); + const second = await createMutableTextServer( + createCurrentXmltvFixture( + 'shared-news', + 'Shared News', + 'Retained Bulletin' + ), + { contentType: 'application/xml', resourcePath: '/second.xml' } + ); + let app = await launchElectronApp(dataDir); + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + for (const [index, url] of [ + first.resourceUrl, + second.resourceUrl, + ].entries()) { + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + const inputs = app.mainWindow.locator('.epg-source-row input'); + await expect(inputs).toHaveCount(index + 1); + await inputs.nth(index).fill(url); + } + await saveSettings(app.mainWindow); + const programs = () => + app.mainWindow.evaluate(async () => + ( + await window.electron.getChannelPrograms('mapped-news') + ).map((p) => p.title) + ); + await app.mainWindow.evaluate(() => + window.electron.setEpgMapping('mapped-news', 'shared-news') + ); + await expect + .poll(programs, { timeout: 30000 }) + .toEqual(['Removed Bulletin', 'Retained Bulletin']); + await app.mainWindow + .locator('.epg-source-row') + .first() + .locator('button') + .nth(1) + .click(); + // A staged removal must not delete data before Save. + expect(await programs()).toContain('Removed Bulletin'); + await saveSettings(app.mainWindow); + await expect.poll(programs).toEqual(['Retained Bulletin']); + await closeElectronApp(app); + app = await launchElectronApp(dataDir); + await expect.poll(programs).toEqual(['Retained Bulletin']); + // Simulate a cache left by 0.23: import a source absent from settings. + await app.mainWindow.evaluate( + (url) => window.electron.forceFetchEpg(url), + first.resourceUrl + ); + await expect.poll(programs).toContain('Removed Bulletin'); + await closeElectronApp(app); + app = await launchElectronApp(dataDir); + await expect.poll(programs).toEqual(['Retained Bulletin']); + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow + .locator('.epg-source-row button') + .nth(1) + .click(); + await saveSettings(app.mainWindow); + await expect.poll(programs).toEqual([]); + await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0); + expect( + await app.mainWindow.evaluate(() => + window.electron.getEpgMapping('mapped-news') + ) + ).toMatchObject({ epgChannelId: 'shared-news' }); + } finally { + await closeElectronApp(app); + await first.close(); + await second.close(); + } + }); + + test('@epg @electron restores the first source name and logo after removing the last metadata writer', async ({ + dataDir, + }) => { + const first = await createMutableTextServer( + createCurrentXmltvFixture( + 'shared-logo', + 'First News', + 'First Bulletin', + 'https://example.com/first.png' + ), + { contentType: 'application/xml', resourcePath: '/first.xml' } + ); + const second = await createMutableTextServer( + createCurrentXmltvFixture( + 'shared-logo', + 'Second News', + 'Second Bulletin', + 'https://example.com/second.png' + ), + { contentType: 'application/xml', resourcePath: '/second.xml' } + ); + let app = await launchElectronApp(dataDir); + const metadata = () => + app.mainWindow.evaluate( + async () => + ( + await window.electron.getEpgChannelMetadata([ + 'shared-logo', + ]) + )['shared-logo'] + ); + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + for (const [index, source] of [first, second].entries()) { + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + const inputs = app.mainWindow.locator('.epg-source-row input'); + await expect(inputs).toHaveCount(index + 1); + await inputs.nth(index).fill(source.resourceUrl); + await saveSettings(app.mainWindow); + await expect.poll(metadata, { timeout: 30000 }).toMatchObject({ + displayName: index === 0 ? 'First News' : 'Second News', + iconUrl: `https://example.com/${index === 0 ? 'first' : 'second'}.png`, + }); + } + await app.mainWindow + .locator('.epg-source-row') + .nth(1) + .locator('button') + .nth(1) + .click(); + await saveSettings(app.mainWindow); + const firstMetadata = { + displayName: 'First News', + iconUrl: 'https://example.com/first.png', + }; + await expect.poll(metadata).toMatchObject(firstMetadata); + await closeElectronApp(app); + app = await launchElectronApp(dataDir); + await expect.poll(metadata).toMatchObject(firstMetadata); + expect( + await app.mainWindow.evaluate(async () => + ( + await window.electron.getChannelPrograms('shared-logo') + ).map((p) => p.title) + ) + ).toEqual(['First Bulletin']); + } finally { + await closeElectronApp(app); + await first.close(); + await second.close(); + } + }); + test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({ dataDir, }) => { + test.setTimeout(90000); const epgServer = await createMutableTextServer( createCurrentXmltvFixture( 'playlist-guide-news', @@ -212,6 +599,27 @@ test.describe('Electron EPG', () => { 'Playlist Scoped Bulletin', { timeout: 30000 } ); + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + await app.mainWindow + .locator('.epg-source-row input') + .fill(epgServer.resourceUrl); + await saveSettings(app.mainWindow); + await app.mainWindow + .locator('.epg-source-row button') + .nth(1) + .click(); + await saveSettings(app.mainWindow); + // The same URL still belongs to the saved M3U playlist. + const retained = await app.mainWindow.evaluate(async () => + window.electron.getChannelPrograms('playlist-guide-news') + ); + expect(retained.map((program) => program.title)).toContain( + 'Playlist Scoped Bulletin' + ); } finally { await closeElectronApp(app); await playlistServer.close(); diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index bb83cfc21..02eeb28e3 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -5,6 +5,7 @@ import { channelItemByTitle, clickCategoryByNameExact, closeElectronApp, + createMutableTextServer, expect, goToDashboard, launchElectronApp, @@ -28,6 +29,189 @@ const epgCredentials = { password: 'epg', }; +test('@epg @xtream @electron removes uploaded guide data and restores provider EPG without restarting', async ({ + dataDir, + request, +}) => { + test.setTimeout(120000); + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const id = fixture.stream.epg_channel_id; + expect(id).toBeTruthy(); + const stamp = (date: Date) => + date.toISOString().replace(/[-:T]/g, '').slice(0, 14); + const xml = `Uploaded Guide + Temporary XMLTV Bulletin`; + 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( + `Mapped Guide + Retired Stalker Bulletin`, + { + contentType: 'application/xml', + resourcePath: '/stalker.xml', + } + ); + const app = await launchElectronApp(dataDir); + try { + await app.mainWindow.route('https://test-streams.mux.dev/**', () => { + // Keep external media pending while the local guide is exercised. + }); + await addStalkerPortal(app.mainWindow, { + name: 'Stalker XMLTV Removal', + }); + await waitForStalkerCatalog(app.mainWindow); + const playlistId = new URL(app.mainWindow.url()).pathname.match( + /\/stalker\/([^/]+)/ + )?.[1]; + expect(playlistId).toBeTruthy(); + const key = `stalker:${decodeURIComponent(playlistId!)}:${String(item.id).trim()}`; + await app.mainWindow.evaluate( + (mappingKey) => + window.electron.setEpgMapping(mappingKey, 'stalker-mapped'), + key + ); + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + await app.mainWindow + .locator('.epg-source-row input') + .fill(source.resourceUrl); + await saveSettings(app.mainWindow); + await expect + .poll( + () => + app.mainWindow.evaluate(async () => + ( + await window.electron.getChannelPrograms( + 'stalker-mapped' + ) + ).map((p) => p.title) + ), + { timeout: 30000 } + ) + .toContain('Retired Stalker Bulletin'); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + const row = channelItemByTitle( + app.mainWindow, + item.o_name || item.name || '' + ).first(); + await expect(row.locator('.epg-title')).toHaveText( + 'Retired Stalker Bulletin' + ); + await row.click(); + await expect + .poll(() => timelineBlockTitles(app.mainWindow)) + .toContain('Retired Stalker Bulletin'); + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow.locator('.epg-source-row button').nth(1).click(); + await saveSettings(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await expect(row).toBeVisible(); + await row.click(); + await expect + .poll(() => timelineBlockTitles(app.mainWindow)) + .not.toContain('Retired Stalker Bulletin'); + await expect(row.locator('.epg-title')).toHaveCount(0); + expect( + await app.mainWindow.evaluate( + (mappingKey) => window.electron.getEpgMapping(mappingKey), + key + ) + ).toMatchObject({ epgChannelId: 'stalker-mapped' }); + } finally { + await closeElectronApp(app); + await source.close(); + } +}); + for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({ dataDir, @@ -66,9 +250,7 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { // Provider-declared catch-up (tv_archive=1 in the fixture) is // surfaced as a badge on the sidebar row (#1128). - await expect( - channelRow.getByTestId('catchup-badge') - ).toBeVisible(); + await expect(channelRow.getByTestId('catchup-badge')).toBeVisible(); // Sidebar channel list shows the per-channel "now" programme line. await expect @@ -263,10 +445,7 @@ test('@radio @stalker @electron keeps radio rows compact at narrow widths', asyn await expect(categoryButton).toBeVisible(); await categoryButton.click(); - const radioRow = channelItemByTitle( - app.mainWindow, - firstTitle - ).first(); + const radioRow = channelItemByTitle(app.mainWindow, firstTitle).first(); await expect(radioRow).toBeVisible({ timeout: 20000 }); await expect(radioRow).toHaveClass(/compact/); await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0); @@ -301,7 +480,8 @@ test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" b // The guide runs an hour ahead of the real schedule, so the programme // actually on air is the one the provider files under "an hour ago" — // and every surface must agree on that same programme. - const providerNowSeconds = Math.floor(Date.now() / 1000) - offsetMinutes * 60; + const providerNowSeconds = + Math.floor(Date.now() / 1000) - offsetMinutes * 60; const expectedProgram = fixture.fullEpg.find( (listing) => listing.startTimestamp <= providerNowSeconds && @@ -341,8 +521,7 @@ test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" b await expect .poll(async () => ( - (await channelRow.locator('.epg-title').textContent()) ?? - '' + (await channelRow.locator('.epg-title').textContent()) ?? '' ).trim() ) .toBe(expectedProgram.title); diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 1f2db2db2..ff3b75bc3 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -536,6 +536,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [ channel: 'EPG_CLEAR_ALL', forwardedArgs: [], }, + { + method: 'reconcileEpgSources', + args: [epgUrls], + channel: 'EPG_RECONCILE_SOURCES', + forwardedArgs: [{ urls: epgUrls }], + }, { method: 'clearEpgDataForSource', args: ['https://example.com/guide.xml'], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 4e59298e3..b3a60ee3a 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -615,11 +615,7 @@ const electronApi: ElectronBridgeApi = { sessionId: string, style: EmbeddedMpvSubtitleStyle ): Promise => - ipcRenderer.invoke( - 'EMBEDDED_MPV_SET_SUBTITLE_STYLE', - sessionId, - style - ), + ipcRenderer.invoke('EMBEDDED_MPV_SET_SUBTITLE_STYLE', sessionId, style), selectEmbeddedMpvSubtitleFile: (): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'), setEmbeddedMpvSpeed: ( @@ -681,6 +677,8 @@ const electronApi: ElectronBridgeApi = { forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) => ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }), clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'), + reconcileEpgSources: (urls: string[]) => + ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }), clearEpgDataForSource: (sourceUrl: string) => ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }), checkEpgFreshness: (urls: string[], maxAgeHours?: number) => @@ -1118,8 +1116,7 @@ const electronApi: ElectronBridgeApi = { recordingsUpdatePrograms: ( targetPath: string, programs: RecordingProgramSnapshot[] - ) => - ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs), + ) => ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs), recordingsRevealFile: (filePath: string) => ipcRenderer.invoke('RECORDINGS_REVEAL_FILE', filePath), recordingsPlayFile: (filePath: string) => diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index ff07c4aaa..1147d34f3 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -4,40 +4,41 @@ */ export { - // Tables - playlists, - categories, - content, - recentlyViewed, - favorites, - epgChannels, - epgChannelMappings, - epgPrograms, - playbackPositions, - downloads, - recordings, - appState, - // Types - type Playlist, - type NewPlaylist, - type AppState, - type NewAppState, - type Category, - type NewCategory, - type Content, - type NewContent, - type RecentlyViewed, - type NewRecentlyViewed, - type Favorite, - type NewFavorite, - type EpgChannel, - type NewEpgChannel, - type EpgProgramDb, - type NewEpgProgramDb, - type PlaybackPosition, - type NewPlaybackPosition, - type Download, - type NewDownload, - type Recording, - type NewRecording, + // Tables + playlists, + categories, + content, + recentlyViewed, + favorites, + epgChannels, + epgChannelSources, + epgChannelMappings, + epgPrograms, + playbackPositions, + downloads, + recordings, + appState, + // Types + type Playlist, + type NewPlaylist, + type AppState, + type NewAppState, + type Category, + type NewCategory, + type Content, + type NewContent, + type RecentlyViewed, + type NewRecentlyViewed, + type Favorite, + type NewFavorite, + type EpgChannel, + type NewEpgChannel, + type EpgProgramDb, + type NewEpgProgramDb, + type PlaybackPosition, + type NewPlaybackPosition, + type Download, + type NewDownload, + type Recording, + type NewRecording, } from '@iptvnator/shared/database'; diff --git a/apps/electron-backend/src/app/events/epg-fetch-operation.ts b/apps/electron-backend/src/app/events/epg-fetch-operation.ts new file mode 100644 index 000000000..90f792c8c --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-fetch-operation.ts @@ -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; + fetchedUrls: Set; + 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 { + 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 | 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))); + }); + }); +} diff --git a/apps/electron-backend/src/app/events/epg-fetch.service.ts b/apps/electron-backend/src/app/events/epg-fetch.service.ts index 10b79f8a7..fe9bae906 100644 --- a/apps/electron-backend/src/app/events/epg-fetch.service.ts +++ b/apps/electron-backend/src/app/events/epg-fetch.service.ts @@ -1,3 +1,5 @@ +import { epgLogger } from '../util/epg-logger'; +import { epgSourceGeneration, requestEpgSource } from './epg-source-generation'; import { eq } from 'drizzle-orm'; import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; @@ -45,12 +47,13 @@ export async function checkEpgFreshness( const db = await getDatabase(); for (const url of urls) { + const generation = epgSourceGeneration(url); if (!url?.trim()) continue; const result = await db - .select({ updatedAt: schema.epgChannels.updatedAt }) - .from(schema.epgChannels) - .where(eq(schema.epgChannels.sourceUrl, url)) + .select({ updatedAt: schema.epgChannelSources.updatedAt }) + .from(schema.epgChannelSources) + .where(eq(schema.epgChannelSources.sourceUrl, url)) .limit(1); const isFresh = @@ -58,6 +61,7 @@ export async function checkEpgFreshness( result[0].updatedAt && result[0].updatedAt >= cutoffTime; + if (generation !== epgSourceGeneration(url)) continue; if (isFresh) { freshUrls.push(url); epgWorkerService.markFetchedUrl(url); @@ -66,18 +70,18 @@ export async function checkEpgFreshness( } } } catch (error) { - console.error(loggerLabel, 'Error checking EPG freshness:', error); + epgLogger.error(loggerLabel, 'Error checking EPG freshness:', error); return { staleUrls: urls, freshUrls: [] }; } if (freshUrls.length > 0) { - console.log( + epgLogger.log( loggerLabel, `EPG fresh (skipping): ${freshUrls.length} source(s)` ); } if (staleUrls.length > 0) { - console.log( + epgLogger.log( loggerLabel, `EPG stale (will fetch): ${staleUrls.length} source(s)` ); @@ -95,7 +99,12 @@ export async function handleFetchEpg( urls: string[], options: ElectronBridgeTrustOptions = {} ): Promise { - const validUrls = urls.filter((url) => url?.trim()); + const validUrls = urls + .filter((url) => url?.trim()) + .map((url) => url.trim()); + const generations = new Map( + validUrls.map((url) => [url, requestEpgSource(url)]) + ); if (validUrls.length === 0) { return { success: false, message: 'No valid URLs provided' }; @@ -118,11 +127,13 @@ export async function handleFetchEpg( // a 'queued' status, then fetchEpgFromUrl silently skips the URL and no // completion update ever arrives, leaving the UI stuck at "queued". const urlsToFetch = staleUrls.filter( - (url) => !epgWorkerService.hasFetchedUrl(url) + (url) => + generations.get(url) === epgSourceGeneration(url) && + !epgWorkerService.hasFetchedUrl(url) ); if (urlsToFetch.length === 0) { - console.log( + epgLogger.log( loggerLabel, `All ${staleUrls.length} stale URL(s) already fetched this session; skipping` ); @@ -142,16 +153,23 @@ export async function handleFetchEpg( const errors: string[] = []; for (const url of urlsToFetch) { try { + if (generations.get(url) !== epgSourceGeneration(url)) { + epgWorkerService.sendProgressToRenderer( + url, + 'cancelled', + undefined, + undefined, + undefined, + undefined, + undefined, + generations.get(url) + ); + continue; + } await epgWorkerService.fetchEpgFromUrl(url, options); } catch (error) { - console.error( - loggerLabel, - `Error fetching EPG from ${url}:`, - error - ); - errors.push( - error instanceof Error ? error.message : String(error) - ); + epgLogger.error(loggerLabel, 'Error fetching EPG source:', error); + errors.push(error instanceof Error ? error.message : String(error)); } } diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index d064188bb..c23764644 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -1,3 +1,4 @@ +import { epgLogger } from '../util/epg-logger'; import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; import { EpgChannelMetadata, @@ -154,7 +155,7 @@ export class EpgQueryService { return []; } catch (error) { - console.error( + epgLogger.error( this.loggerLabel, 'Error getting channel programs:', error @@ -310,7 +311,7 @@ export class EpgQueryService { } return result; } catch (error) { - console.error( + epgLogger.error( this.loggerLabel, 'Error getting batch current programs:', error @@ -335,7 +336,7 @@ export class EpgQueryService { return { channels, programs: [] }; } catch (error) { - console.error( + epgLogger.error( this.loggerLabel, 'Error getting all channels:', error @@ -391,7 +392,7 @@ export class EpgQueryService { ]) ); } catch (error) { - console.error( + epgLogger.error( this.loggerLabel, 'Error getting channel metadata:', error @@ -439,7 +440,7 @@ export class EpgQueryService { }) ); } catch (error) { - console.error( + epgLogger.error( this.loggerLabel, 'Error getting channels by range:', error diff --git a/apps/electron-backend/src/app/events/epg-source-generation.ts b/apps/electron-backend/src/app/events/epg-source-generation.ts new file mode 100644 index 000000000..be9aa620f --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-source-generation.ts @@ -0,0 +1,23 @@ +/** Retires queued imports as well as workers already parsing a removed URL. */ +const generations = new Map(); +const requests = new Map(); +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 { + 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()); +} diff --git a/apps/electron-backend/src/app/events/epg-source-settings.service.spec.ts b/apps/electron-backend/src/app/events/epg-source-settings.service.spec.ts new file mode 100644 index 000000000..23349ef07 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-source-settings.service.spec.ts @@ -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; + beforeEach(() => { + jest.clearAllMocks(); + rows = new Map([ + [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(); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-source-settings.service.ts b/apps/electron-backend/src/app/events/epg-source-settings.service.ts new file mode 100644 index 000000000..9e52ddb9e --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-source-settings.service.ts @@ -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 { + 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; +} diff --git a/apps/electron-backend/src/app/events/epg-worker-runtime.ts b/apps/electron-backend/src/app/events/epg-worker-runtime.ts new file mode 100644 index 000000000..11e9d69b5 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-worker-runtime.ts @@ -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; + }): Promise { + 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 { + 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, + }, + }); + } +} diff --git a/apps/electron-backend/src/app/events/epg-worker.service.spec.ts b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts index b86bfe67a..34f501a66 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts @@ -67,6 +67,62 @@ describe('EpgWorkerService worker lifecycle', () => { consoleErrorSpy.mockRestore(); }); + it('keeps source credentials out of completion, reuse, cache and cleanup logs', async () => { + const secretUrl = + 'https://secret-user:secret-password@example.com/private-key/guide.xml?custom=secret-query#secret-fragment'; + const first = service.fetchEpgFromUrl(secretUrl); + const reused = service.fetchEpgFromUrl(secretUrl); + mockWorkerInstances[0].emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 2 }, + }); + await Promise.all([first, reused]); + await service.fetchEpgFromUrl(secretUrl); + const clear = service.clearEpgDataForSource(secretUrl); + mockWorkerInstances[1].emit('message', { type: 'CLEAR_COMPLETE' }); + await clear; + const output = JSON.stringify(consoleLogSpy.mock.calls); + for (const secret of [ + 'secret-user', + 'secret-password', + 'private-key', + 'secret-query', + 'secret-fragment', + ]) { + expect(output).not.toContain(secret); + } + expect(output).toContain('EPG parsing complete'); + }); + + it('redacts worker errors without changing the rejected error', async () => { + const secretUrl = + 'https://user:password@example.com/path-token/guide.xml?custom=query-token'; + const error = Object.assign(new Error(`Request failed: ${secretUrl}`), { + config: { + url: secretUrl, + headers: { Authorization: 'Bearer header-secret' }, + }, + cause: new Error( + 'Redirect failed: https://example.org/redirect-token?custom=redirect-query' + ), + }); + const pending = service.fetchEpgFromUrl(secretUrl); + mockWorkerInstances[0].emit('error', error); + await expect(pending).rejects.toBe(error); + const output = JSON.stringify(consoleErrorSpy.mock.calls); + for (const secret of [ + 'password', + 'path-token', + 'query-token', + 'header-secret', + 'redirect-token', + 'redirect-query', + ]) { + expect(output).not.toContain(secret); + } + expect(output).toContain('Request failed'); + }); + describe('worker lifecycle', () => { it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => { const options = { manuallyTrustedHosts: ['example.com'] } as any; diff --git a/apps/electron-backend/src/app/events/epg-worker.service.ts b/apps/electron-backend/src/app/events/epg-worker.service.ts index 1b278ed97..0df02d09e 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -1,43 +1,40 @@ -import { app, BrowserWindow } from 'electron'; -import * as path from 'path'; -import { pathToFileURL } from 'url'; +import { epgLogger } from '../util/epg-logger'; +import { + epgSourceGeneration, + requestEpgSource, + retireEpgSource, +} from './epg-source-generation'; +import { BrowserWindow } from 'electron'; +import { EpgWorkerRuntime } from './epg-worker-runtime'; +import { runEpgFetch } from './epg-fetch-operation'; import { Worker } from 'worker_threads'; import { ElectronBridgeSecurityErrorCode, ElectronBridgeTrustOptions, } from '@iptvnator/shared/interfaces'; -import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths'; -export type EpgProgressStatus = 'queued' | 'loading' | 'complete' | 'error'; +export type EpgProgressStatus = + 'queued' | 'loading' | 'complete' | 'error' | 'cancelled'; export interface EpgProgressStats { totalChannels: number; totalPrograms: number; } -interface EpgWorkerMessage { - type: string; - error?: string; - errorCode?: ElectronBridgeSecurityErrorCode; - errorHost?: string; - url?: string; - stats?: EpgProgressStats; -} - -interface ClearWorkerMessage { - type: 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE'; - sourceUrl?: string; -} - export class EpgWorkerService { private readonly fetchedUrls = new Set(); private readonly workers = new Map(); private readonly inFlightFetches = new Map>(); + private readonly inFlightSourceClears = new Map>(); + + private readonly runtime: EpgWorkerRuntime; constructor( private readonly loggerLabel = '[EPG Events]', private readonly fetchTimeoutMs = 5 * 60 * 1000 - ) {} + ) { + this.runtime = new EpgWorkerRuntime(loggerLabel, fetchTimeoutMs); + } hasFetchedUrl(url: string): boolean { return this.fetchedUrls.has(url); @@ -58,12 +55,14 @@ export class EpgWorkerService { error?: string, queuePosition?: number, errorCode?: ElectronBridgeSecurityErrorCode, - errorHost?: string + errorHost?: string, + generation = epgSourceGeneration(url) ): void { const windows = BrowserWindow.getAllWindows(); windows.forEach((win) => { win.webContents.send('EPG_PROGRESS_UPDATE', { url, + generation, status, stats, error, @@ -78,6 +77,26 @@ export class EpgWorkerService { url: string, options: ElectronBridgeTrustOptions = {} ): Promise { + url = url.trim(); + const generation = requestEpgSource(url); + const clear = this.inFlightSourceClears.get(url); + if (clear) { + await clear.catch(() => undefined); + if (generation !== epgSourceGeneration(url)) { + this.sendProgressToRenderer( + url, + 'cancelled', + undefined, + undefined, + undefined, + undefined, + undefined, + generation + ); + return; + } + return this.fetchEpgFromUrl(url, options); + } // A second request for an URL that is already being fetched must not // spawn a competing worker: both would parse and write the same EPG // data, and the late one would overwrite the early one's entry in @@ -87,17 +106,14 @@ export class EpgWorkerService { // must keep awaiting that termination window. const inFlight = this.inFlightFetches.get(url); if (inFlight) { - console.log( - this.loggerLabel, - `Reusing in-flight EPG fetch: ${url}` - ); + epgLogger.log(this.loggerLabel, 'Reusing in-flight EPG fetch'); return inFlight; } if (this.fetchedUrls.has(url)) { - console.log( + epgLogger.log( this.loggerLabel, - `Skipping already fetched URL: ${url}` + 'Skipping already fetched EPG source' ); return; } @@ -113,230 +129,18 @@ export class EpgWorkerService { url: string, options: ElectronBridgeTrustOptions ): Promise { - return new Promise((resolve, reject) => { - let worker: Worker; - try { - worker = this.createEpgWorker(); - } catch (error) { - console.error( - this.loggerLabel, - 'Failed to create worker:', - error - ); - reject(error); - return; - } - - this.workers.set(url, worker); - - // Guards against double-settling and keeps the outer loop moving - // when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR. - let settled = false; - let timeoutId: ReturnType | undefined; - let lastProgressStats: EpgProgressStats = { - totalChannels: 0, - totalPrograms: 0, - }; - - const clearFetchTimeout = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = undefined; - } - }; - - const settle = (fn: () => void) => { - if (settled) return; - settled = true; - clearFetchTimeout(); - fn(); - }; - - const scheduleFetchTimeout = () => { - clearFetchTimeout(); - timeoutId = setTimeout(() => { - handleFetchTimeout(); - }, this.fetchTimeoutMs); - }; - - const hasProgressMoved = (stats: EpgProgressStats): boolean => - stats.totalChannels > lastProgressStats.totalChannels || - stats.totalPrograms > lastProgressStats.totalPrograms; - - const recordProgress = (stats: EpgProgressStats): void => { - lastProgressStats = { - totalChannels: Math.max( - lastProgressStats.totalChannels, - stats.totalChannels - ), - totalPrograms: Math.max( - lastProgressStats.totalPrograms, - stats.totalPrograms - ), - }; - }; - - const handleFetchTimeout = () => { - const errorMessage = `EPG fetch timed out after ${ - this.fetchTimeoutMs / 1000 - }s without progress`; - console.error(this.loggerLabel, `${errorMessage}: ${url}`); - this.sendProgressToRenderer( - url, - 'error', - undefined, - errorMessage - ); - this.workers.delete(url); - // Settle only after the worker thread is really gone: a - // terminated-but-still-running worker can keep holding the - // SQLite lock and block the next EPG fetch. - settle(() => { - void this.terminateWorker(worker, 'timed out fetch').then( - () => reject(new Error(errorMessage)) - ); - }); - }; - - scheduleFetchTimeout(); - - worker.on('message', async (message: EpgWorkerMessage) => { - try { - switch (message.type) { - case 'READY': - scheduleFetchTimeout(); - this.sendProgressToRenderer(url, 'loading', { - totalChannels: 0, - totalPrograms: 0, - }); - worker.postMessage({ - type: 'FETCH_EPG', - url, - options, - }); - break; - - case 'EPG_PROGRESS': - if (message.stats) { - if (hasProgressMoved(message.stats)) { - recordProgress(message.stats); - scheduleFetchTimeout(); - } - this.sendProgressToRenderer( - url, - 'loading', - message.stats - ); - } - break; - - case 'EPG_COMPLETE': - console.log( - this.loggerLabel, - `EPG parsing complete for ${url}:`, - message.stats - ); - this.sendProgressToRenderer( - url, - 'complete', - message.stats - ); - this.fetchedUrls.add(url); - this.workers.delete(url); - settle(() => { - void this.terminateWorker( - worker, - 'completed fetch' - ).then(() => resolve()); - }); - break; - - case 'EPG_ERROR': - console.error( - this.loggerLabel, - 'Worker error:', - message.error - ); - this.sendProgressToRenderer( - url, - 'error', - undefined, - message.error, - undefined, - message.errorCode, - message.errorHost - ); - this.workers.delete(url); - settle(() => { - void this.terminateWorker( - worker, - 'failed fetch' - ).then(() => - reject( - new Error( - message.error || 'Unknown error' - ) - ) - ); - }); - break; - } - } catch (err) { - console.error( - this.loggerLabel, - 'Error handling message:', - err - ); - this.sendProgressToRenderer( - url, - 'error', - undefined, - err instanceof Error ? err.message : String(err) - ); - this.workers.delete(url); - settle(() => { - void this.terminateWorker( - worker, - 'failed message handling' - ).then(() => reject(err)); - }); - } - }); - - worker.on('error', (error) => { - console.error(this.loggerLabel, 'Worker error event:', error); - this.sendProgressToRenderer( - url, - 'error', - undefined, - error.message - ); - this.workers.delete(url); - settle(() => { - void this.terminateWorker(worker, 'errored fetch').then( - () => reject(error) - ); - }); - }); - - worker.on('exit', (code) => { - if (settled) return; - const errorMessage = `Worker exited unexpectedly (code ${code})`; - console.error(this.loggerLabel, `${errorMessage}: ${url}`); - this.sendProgressToRenderer( - url, - 'error', - undefined, - errorMessage - ); - this.workers.delete(url); - settle(() => reject(new Error(errorMessage))); - }); + return runEpgFetch(url, options, { + runtime: this.runtime, + workers: this.workers, + fetchedUrls: this.fetchedUrls, + loggerLabel: this.loggerLabel, + fetchTimeoutMs: this.fetchTimeoutMs, + sendProgressToRenderer: this.sendProgressToRenderer.bind(this), }); } async clearEpgData(): Promise { - return this.runClearWorker({ + return this.runtime.runClearWorker({ timeoutLabel: 'EPG clear', exitLabel: 'Clear worker', readyMessage: { type: 'CLEAR_EPG' }, @@ -344,21 +148,21 @@ export class EpgWorkerService { failedWorkerLabel: 'failed clear', erroredWorkerLabel: 'errored clear', onComplete: async (worker) => { - console.log(this.loggerLabel, 'EPG data cleared via worker'); + epgLogger.log(this.loggerLabel, 'EPG data cleared via worker'); this.fetchedUrls.clear(); // Resolve only after every interrupted fetch worker has exited // too — they may still hold the SQLite lock the caller expects // to be free. const terminations = [...this.workers.values()].map( (runningWorker) => - this.terminateWorker( + this.runtime.terminateWorker( runningWorker, 'fetch during clear' ) ); this.workers.clear(); terminations.push( - this.terminateWorker(worker, 'completed clear') + this.runtime.terminateWorker(worker, 'completed clear') ); await Promise.all(terminations); }, @@ -371,13 +175,34 @@ export class EpgWorkerService { return; } + retireEpgSource(normalizedSourceUrl); + this.fetchedUrls.delete(normalizedSourceUrl); + const previous = this.inFlightSourceClears.get(normalizedSourceUrl); + const operation = previous + ? previous + .catch(() => undefined) + .then(() => this.startSourceClear(normalizedSourceUrl)) + : this.startSourceClear(normalizedSourceUrl); + const clear = operation.finally(() => { + if (this.inFlightSourceClears.get(normalizedSourceUrl) === clear) + this.inFlightSourceClears.delete(normalizedSourceUrl); + }); + this.inFlightSourceClears.set(normalizedSourceUrl, clear); + return clear; + } + + private async startSourceClear(normalizedSourceUrl: string): Promise { + const inFlight = this.inFlightFetches.get(normalizedSourceUrl); const runningWorker = this.workers.get(normalizedSourceUrl); if (runningWorker) { this.workers.delete(normalizedSourceUrl); - await this.terminateWorker(runningWorker, 'source clear'); + await this.runtime.terminateWorker(runningWorker, 'source clear'); } + // Error/timeout handlers may already have removed the worker from the + // lookup, but its fetch promise still owns asynchronous termination. + if (inFlight) await inFlight.catch(() => undefined); - return this.runClearWorker({ + return this.runtime.runClearWorker({ timeoutLabel: 'EPG source clear', exitLabel: 'Source clear worker', readyMessage: { @@ -388,153 +213,15 @@ export class EpgWorkerService { failedWorkerLabel: 'failed source clear', erroredWorkerLabel: 'errored source clear', onComplete: async (worker) => { - console.log( + epgLogger.log( this.loggerLabel, - `EPG data cleared for source via worker: ${normalizedSourceUrl}` + 'EPG data cleared for source via worker' ); this.fetchedUrls.delete(normalizedSourceUrl); - await this.terminateWorker(worker, 'completed source clear'); - }, - }); - } - - private runClearWorker(options: { - timeoutLabel: string; - exitLabel: string; - readyMessage: ClearWorkerMessage; - completeWorkerLabel: string; - failedWorkerLabel: string; - erroredWorkerLabel: string; - onComplete: (worker: Worker) => Promise; - }): Promise { - return new Promise((resolve, reject) => { - let worker: Worker; - try { - worker = this.createEpgWorker(); - } catch (error) { - console.error( - this.loggerLabel, - 'Failed to create worker for clear:', - error + await this.runtime.terminateWorker( + worker, + 'completed source clear' ); - reject(error); - return; - } - - let settled = false; - const settle = (fn: () => void) => { - if (settled) return; - settled = true; - clearTimeout(timeoutId); - fn(); - }; - - const timeoutId = setTimeout(() => { - const errorMessage = `${options.timeoutLabel} timed out after ${ - this.fetchTimeoutMs / 1000 - }s`; - console.error(this.loggerLabel, errorMessage); - settle(() => { - void this.terminateWorker( - worker, - `timed out ${options.timeoutLabel}` - ).then(() => reject(new Error(errorMessage))); - }); - }, this.fetchTimeoutMs); - - worker.on( - 'message', - (message: { type: string; error?: string }) => { - if (message.type === 'READY') { - worker.postMessage(options.readyMessage); - } else if (message.type === 'CLEAR_COMPLETE') { - settle(() => { - void options - .onComplete(worker) - .then(() => resolve(), reject); - }); - } else if (message.type === 'EPG_ERROR') { - console.error( - this.loggerLabel, - 'Worker clear error:', - message.error - ); - settle(() => { - void this.terminateWorker( - worker, - options.failedWorkerLabel - ).then(() => - reject( - new Error(message.error || 'Clear failed') - ) - ); - }); - } - } - ); - - worker.on('error', (error) => { - console.error( - this.loggerLabel, - 'Worker error during clear:', - error - ); - settle(() => { - void this.terminateWorker( - worker, - options.erroredWorkerLabel - ).then(() => reject(error)); - }); - }); - - worker.on('exit', (code) => { - if (settled) return; - const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`; - console.error(this.loggerLabel, errorMessage); - settle(() => reject(new Error(errorMessage))); - }); - }); - } - - /** - * Awaits worker shutdown so callers can sequence work (e.g. the next DB - * access) after the thread has really exited. Termination failures are - * logged and swallowed — there is nothing actionable left to do. - */ - private async terminateWorker( - worker: Worker, - context: string - ): Promise { - 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, }, }); } diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index 1f2977a66..c1c2cc226 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -78,6 +78,37 @@ describe('EpgEvents', () => { await new Promise((resolve) => setImmediate(resolve)); } + it.each(['missing', 'stale', 'fresh'])( + 'uses source-owned freshness for %s metadata', + async (state) => { + const { epgChannelSources } = await import('../database/schema'); + const { checkEpgFreshness } = await import('./epg-fetch.service'); + const fresh = [{ updatedAt: new Date().toISOString() }]; + const snapshots = + state === 'missing' + ? [] + : state === 'fresh' + ? fresh + : [{ updatedAt: '2000-01-01T00:00:00Z' }]; + getDatabase.mockResolvedValue({ + select: () => ({ + from: (table: unknown) => ({ + where: () => ({ + limit: async () => + table === epgChannelSources ? snapshots : fresh, + }), + }), + }), + }); + const result = await checkEpgFreshness(['shared-source'], 12); + expect(result).toEqual( + state === 'fresh' + ? { freshUrls: ['shared-source'], staleUrls: [] } + : { freshUrls: [], staleUrls: ['shared-source'] } + ); + } + ); + it('uses the shared worker bootstrap and passes native module search paths to the EPG worker', async () => { const fetchPromise = (EpgEvents as unknown as Record)[ 'fetchEpgFromUrl' @@ -287,6 +318,120 @@ describe('EpgEvents', () => { await flushPromises(); }); + it.each([ + ['exit', 1], + ['error', new Error('terminated worker')], + ] as const)( + 'cancels a retired source on %s without reviving it from late READY or COMPLETE messages', + async (event, payload) => { + const service = new EpgWorkerService('[Test EPG]', 1000); + const url = 'https://removed.example/guide.xml'; + const progress = jest.spyOn(service, 'sendProgressToRenderer'); + const fetch = service.fetchEpgFromUrl(url).then( + () => true, + () => false + ); + const worker = mockWorkerInstances[0]; + let finishTermination!: () => void; + worker.terminate.mockReturnValue( + new Promise((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((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((resolve) => { + finishTermination = resolve; + }) + ); + if (alreadyRetired) { + const { retireEpgSource } = + await import('./epg-source-generation'); + retireEpgSource(url); + } + worker.emit( + 'error', + new Error('worker failed while another source clears') + ); + const clear = service.clearEpgDataForSource(url); + const workersBeforeTermination = mockWorkerInstances.length; + finishTermination(); + await fetch; + await flushPromises(); + const clearWorker = mockWorkerInstances[1]; + clearWorker.emit('message', { type: 'READY' }); + clearWorker.emit('message', { type: 'CLEAR_COMPLETE' }); + await clear; + expect(workersBeforeTermination).toBe(1); + } + ); + it('clears one EPG source through a worker and allows it to be fetched again', async () => { const workerService = new EpgWorkerService('[Test EPG]', 1000); const sourceUrl = 'https://playlist.example.com/guide.xml'; @@ -336,6 +481,66 @@ describe('EpgEvents', () => { expect(terminated).toBe(true); }); + it('waits for source cleanup before starting a replacement import of the same normalized URL', async () => { + const service = new EpgWorkerService('[Test EPG]', 1000); + const url = 'https://replacement.example/guide.xml'; + const clear = service.clearEpgDataForSource(url); + const clearWorker = mockWorkerInstances[0]; + const replacement = service.fetchEpgFromUrl(` ${url} `); + const workersBeforeCleanup = mockWorkerInstances.length; + clearWorker.emit('message', { type: 'READY' }); + clearWorker.emit('message', { type: 'CLEAR_COMPLETE' }); + await clear; + await flushPromises(); + const fetchWorker = mockWorkerInstances[1]; + fetchWorker.emit('message', { type: 'READY' }); + fetchWorker.emit('message', { type: 'EPG_COMPLETE' }); + await replacement; + expect(workersBeforeCleanup).toBe(1); + expect(service.hasFetchedUrl(url)).toBe(true); + }); + + it('serializes repeated clears and retires a replacement waiting for the earlier clear', async () => { + const service = new EpgWorkerService('[Test EPG]', 1000); + const url = 'https://twice-removed.example/guide.xml'; + const firstClear = service.clearEpgDataForSource(url); + const waitingFetch = service.fetchEpgFromUrl(url); + const secondClear = service.clearEpgDataForSource(url); + expect(mockWorkerInstances).toHaveLength(1); + mockWorkerInstances[0].emit('message', { type: 'CLEAR_COMPLETE' }); + await firstClear; + await waitingFetch; + await flushPromises(); + expect(mockWorkerInstances).toHaveLength(2); + mockWorkerInstances[1].emit('message', { type: 'READY' }); + expect(mockWorkerInstances[1].postMessage).toHaveBeenCalledWith({ + type: 'CLEAR_EPG_SOURCE', + sourceUrl: url, + }); + mockWorkerInstances[1].emit('message', { type: 'CLEAR_COMPLETE' }); + await secondClear; + expect(service.hasFetchedUrl(url)).toBe(false); + }); + + it('allows a replacement import after a failed source cleanup has terminated', async () => { + const service = new EpgWorkerService('[Test EPG]', 1000); + const url = 'https://retry-clear.example/guide.xml'; + const clear = service.clearEpgDataForSource(url); + const outcome = clear.catch((error: Error) => error.message); + const replacement = service.fetchEpgFromUrl(url); + mockWorkerInstances[0].emit('message', { + type: 'EPG_ERROR', + error: 'clear failed', + }); + expect(await outcome).toBe('clear failed'); + await flushPromises(); + expect(mockWorkerInstances[0].terminate).toHaveBeenCalled(); + mockWorkerInstances[1].emit('message', { type: 'READY' }); + mockWorkerInstances[1].emit('message', { type: 'EPG_COMPLETE' }); + await replacement; + expect(service.hasFetchedUrl(url)).toBe(true); + }); + it('keeps an active EPG fetch alive when worker progress keeps moving', async () => { jest.useFakeTimers(); @@ -381,7 +586,10 @@ describe('EpgEvents', () => { * inserts extra queries that the original test didn't anticipate. */ function queryChain(data: T) { - const chain: Record = {} as Record; + const chain: Record = {} as Record< + string, + jest.Mock + >; chain.where = jest.fn().mockReturnValue(chain); chain.innerJoin = jest.fn().mockReturnValue(chain); chain.groupBy = jest.fn().mockReturnValue(chain); @@ -393,25 +601,30 @@ describe('EpgEvents', () => { // getMapping queries (must come first — they return empty) const from = jest .fn() - .mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings - .mockReturnValueOnce(queryChain([])) // 2. getMapping: content table + .mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings + .mockReturnValueOnce(queryChain([])) // 2. getMapping: content table // Test expectations below - .mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty) - .mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found - .mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs) - { - id: 1, - channelId: 'BBC.ONE.UK', - start: '2026-04-14T10:00:00Z', - stop: '2026-04-14T11:00:00Z', - title: 'News', - description: null, - category: null, - iconUrl: null, - rating: null, - episodeNum: null, - }, - ])); + .mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty) + .mockReturnValueOnce( + queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]) + ) // 4. selectChannelById → channel found + .mockReturnValueOnce( + queryChain([ + // 5. selectChannelPrograms (BBC.ONE.UK → programs) + { + id: 1, + channelId: 'BBC.ONE.UK', + start: '2026-04-14T10:00:00Z', + stop: '2026-04-14T11:00:00Z', + title: 'News', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }, + ]) + ); select.mockImplementation(() => ({ from })); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index c7149728c..fb40baa91 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,3 +1,4 @@ +import { reconcileEpgSources } from './epg-source-settings.service'; import { ipcMain } from 'electron'; import { ElectronBridgeCurrentProgramsOptions, @@ -29,6 +30,14 @@ export default class EpgEvents { * Bootstrap EPG events */ static bootstrapEpgEvents(): Electron.IpcMain { + ipcMain.handle( + 'EPG_RECONCILE_SOURCES', + async (_event, args: { urls: string[] }) => { + await reconcileEpgSources(args.urls); + return { success: true }; + } + ); + ipcMain.handle( 'FETCH_EPG', async ( @@ -179,10 +188,7 @@ export default class EpgEvents { ipcMain.handle( 'EPG_CHANNEL_SEARCH', - async ( - _event, - args: { searchTerm: string; limit?: number } - ) => { + async (_event, args: { searchTerm: string; limit?: number }) => { return handleSearchEpgChannels(args.searchTerm, args.limit); } ); diff --git a/apps/electron-backend/src/app/util/epg-logger.spec.ts b/apps/electron-backend/src/app/util/epg-logger.spec.ts new file mode 100644 index 000000000..d118a56d6 --- /dev/null +++ b/apps/electron-backend/src/app/util/epg-logger.spec.ts @@ -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); + }); +}); diff --git a/apps/electron-backend/src/app/util/epg-logger.ts b/apps/electron-backend/src/app/util/epg-logger.ts new file mode 100644 index 000000000..95c47f1ae --- /dev/null +++ b/apps/electron-backend/src/app/util/epg-logger.ts @@ -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))) + ) + ); + }, +}; diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index d8c5519c2..9e4627eda 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -93,6 +93,10 @@ describe('EpgDatabase', () => { FROM epg_programs WHERE epg_programs.channel_id = epg_channels.id ) + AND NOT EXISTS ( + SELECT 1 FROM epg_channel_sources + WHERE epg_channel_sources.channel_id = epg_channels.id + ) `); expect(preparedSql).toContain(deleteTodayAndFutureSql); @@ -230,6 +234,7 @@ describe('EpgDatabaseClearOperation', () => { expect(exec.mock.calls.map(([statement]) => statement)).toEqual([ 'BEGIN', 'DELETE FROM epg_programs', + 'DELETE FROM epg_channel_sources', 'DELETE FROM epg_channels', 'COMMIT', ]); @@ -250,6 +255,7 @@ describe('EpgDatabaseClearOperation', () => { expect(exec.mock.calls.map(([statement]) => statement)).toEqual([ 'BEGIN', 'DELETE FROM epg_programs', + 'DELETE FROM epg_channel_sources', 'DELETE FROM epg_channels', 'ROLLBACK', ]); @@ -276,6 +282,10 @@ describe('EpgDatabaseSourceClearOperation', () => { FROM epg_programs WHERE epg_programs.channel_id = epg_channels.id ) + AND NOT EXISTS ( + SELECT 1 FROM epg_channel_sources + WHERE epg_channel_sources.channel_id = epg_channels.id + ) `); expect(preparedSql).toContain(deleteProgramsSql); diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index 3865b4bad..213aa389f 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -1,6 +1,7 @@ import type BetterSqlite3 from 'better-sqlite3'; import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils'; import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser'; +import { restoreSurvivingChannelMetadata } from './epg-source-metadata'; /** * Database helper for worker-owned EPG operations. @@ -10,6 +11,8 @@ export class EpgDatabase { private readonly db: BetterSqlite3.Database; private readonly knownChannelIds = new Set(); private readonly insertChannelStmt: BetterSqlite3.Statement; + private readonly insertChannelSourceStmt: BetterSqlite3.Statement; + private readonly deleteChannelSourcesStmt: BetterSqlite3.Statement; private readonly insertProgramStmt: BetterSqlite3.Statement; private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement; private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement; @@ -30,6 +33,25 @@ export class EpgDatabase { updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') `); + // Allocated under the same SQLite write transaction as the channel + // upsert: ties and clock rollbacks cannot reorder surviving writers. + this.insertChannelSourceStmt = this.db.prepare(` + INSERT INTO epg_channel_sources + (channel_id, display_name, icon_url, url, source_url, updated_at, write_order) + VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + (SELECT COALESCE(MAX(write_order), 0) + 1 + FROM epg_channel_sources WHERE channel_id = ?)) + ON CONFLICT(channel_id, source_url) DO UPDATE SET + display_name = excluded.display_name, + icon_url = excluded.icon_url, + url = excluded.url, + updated_at = excluded.updated_at, + write_order = excluded.write_order + `); + this.deleteChannelSourcesStmt = this.db.prepare( + 'DELETE FROM epg_channel_sources WHERE source_url = ?' + ); + // Guard against duplicate entries when the clearFirst logic misses old // rows. The same channel + start + title + source is treated as the // same programme — a later import with a corrected stop time simply @@ -66,6 +88,10 @@ export class EpgDatabase { FROM epg_programs WHERE epg_programs.channel_id = epg_channels.id ) + AND NOT EXISTS ( + SELECT 1 FROM epg_channel_sources + WHERE epg_channel_sources.channel_id = epg_channels.id + ) `); this.deleteTodayAndFutureStmt = this.db.prepare(` @@ -88,7 +114,9 @@ export class EpgDatabase { ): void { const insertMany = this.db.transaction((channels: ParsedChannel[]) => { if (clearTodayAndFuture) { + restoreSurvivingChannelMetadata(this.db, sourceUrl); this.deleteTodayAndFutureStmt.run(sourceUrl); + this.deleteChannelSourcesStmt.run(sourceUrl); this.deleteOrphanChannelsForSourceStmt.run(sourceUrl); this.knownChannelIds.clear(); } @@ -106,6 +134,14 @@ export class EpgDatabase { url, sourceUrl ); + this.insertChannelSourceStmt.run( + channel.id, + displayName, + iconUrl, + url, + sourceUrl, + channel.id + ); this.knownChannelIds.add(channel.id); } }); @@ -222,6 +258,7 @@ export class EpgDatabaseClearOperation { this.db.exec('BEGIN'); try { this.db.exec('DELETE FROM epg_programs'); + this.db.exec('DELETE FROM epg_channel_sources'); this.db.exec('DELETE FROM epg_channels'); this.db.exec('COMMIT'); } catch (error) { @@ -256,6 +293,10 @@ export class EpgDatabaseSourceClearOperation { FROM epg_programs WHERE epg_programs.channel_id = epg_channels.id ) + AND NOT EXISTS ( + SELECT 1 FROM epg_channel_sources + WHERE epg_channel_sources.channel_id = epg_channels.id + ) `); } @@ -266,7 +307,12 @@ export class EpgDatabaseSourceClearOperation { } const clearSource = this.db.transaction((url: string) => { + // Capture affected IDs while removed-source provenance still exists. + restoreSurvivingChannelMetadata(this.db, url); this.deleteProgramsForSourceStmt.run(url); + this.db + .prepare('DELETE FROM epg_channel_sources WHERE source_url = ?') + .run(url); this.deleteOrphanChannelsForSourceStmt.run(url); }); diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index 3ac510546..c2567f652 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,3 +1,4 @@ +import { epgLogger } from '../util/epg-logger'; import type BetterSqlite3 from 'better-sqlite3'; import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES, @@ -102,7 +103,7 @@ async function fetchAndParseEpgStreaming( url: string, options: ElectronBridgeTrustOptions = {} ): Promise { - console.log(loggerLabel, `Fetching EPG from ${url}`); + epgLogger.log(loggerLabel, 'Fetching EPG source'); // Create database connection in worker const epgDb = new EpgDatabase(Database); @@ -141,18 +142,15 @@ async function fetchAndParseEpgStreaming( const contentEncoding = getEpgResponseContentEncoding(response.headers); if (responseUrl && responseUrl !== url) { - console.log( - loggerLabel, - `Resolved EPG redirect: ${url} -> ${responseUrl}` - ); + epgLogger.log(loggerLabel, 'Resolved EPG redirect'); } - console.log( + epgLogger.log( loggerLabel, `EPG response detected as gzipped: ${isGzipped}` ); if (contentEncoding) { - console.log( + epgLogger.log( loggerLabel, `EPG response content-encoding: ${contentEncoding}` ); @@ -200,7 +198,7 @@ async function fetchAndParseEpgStreaming( try { parser.write(chunk.toString('utf-8')); } catch (err) { - console.error(loggerLabel, 'Parse error:', err); + epgLogger.error(loggerLabel, 'Parse error:', err); epgDb.close(); reject(err); } @@ -209,7 +207,7 @@ async function fetchAndParseEpgStreaming( dataStream.on('end', () => { try { const stats = parser.finish(); - console.log( + epgLogger.log( loggerLabel, `Parsing complete: ${stats.totalChannels} channels, ${stats.totalPrograms} programs` ); @@ -223,7 +221,7 @@ async function fetchAndParseEpgStreaming( // real problem (unreachable feed, SAX parse failure, etc.). if (stats.totalChannels === 0) { const errorMessage = `EPG parse produced 0 channels — feed may be unreachable or unsupported`; - console.error(loggerLabel, `${errorMessage}: ${url}`); + epgLogger.error(loggerLabel, errorMessage); const response: WorkerResponse = { type: 'EPG_ERROR', url, @@ -251,7 +249,7 @@ async function fetchAndParseEpgStreaming( }); dataStream.on('error', (err) => { - console.error(loggerLabel, 'Stream error:', err); + epgLogger.error(loggerLabel, 'Stream error:', err); epgDb.close(); reject(err); }); @@ -315,16 +313,16 @@ function clearAllEpgData(): void { const clearOperation = new EpgDatabaseClearOperation(Database); try { - console.log(loggerLabel, 'Clearing all EPG data...'); + epgLogger.log(loggerLabel, 'Clearing all EPG data...'); clearOperation.run(); - console.log(loggerLabel, 'All EPG data cleared'); + epgLogger.log(loggerLabel, 'All EPG data cleared'); const response: WorkerResponse = { type: 'CLEAR_COMPLETE' }; parentPort?.postMessage(response); } catch (error) { - console.error(loggerLabel, 'Error clearing EPG data:', error); + epgLogger.error(loggerLabel, 'Error clearing EPG data:', error); const errorResponse: WorkerResponse = { type: 'EPG_ERROR', error: error instanceof Error ? error.message : String(error), @@ -339,19 +337,16 @@ function clearEpgDataForSource(sourceUrl: string): void { const clearOperation = new EpgDatabaseSourceClearOperation(Database); try { - console.log( - loggerLabel, - `Clearing EPG data for source ${sourceUrl}...` - ); + epgLogger.log(loggerLabel, 'Clearing EPG data for source...'); clearOperation.run(sourceUrl); - console.log(loggerLabel, `EPG data cleared for source ${sourceUrl}`); + epgLogger.log(loggerLabel, 'EPG data cleared for source'); const response: WorkerResponse = { type: 'CLEAR_COMPLETE' }; parentPort?.postMessage(response); } catch (error) { - console.error(loggerLabel, 'Error clearing EPG source data:', error); + epgLogger.error(loggerLabel, 'Error clearing EPG source data:', error); const errorResponse: WorkerResponse = { type: 'EPG_ERROR', error: error instanceof Error ? error.message : String(error), @@ -379,7 +374,7 @@ if (parentPort) { clearEpgDataForSource(message.sourceUrl ?? ''); } } catch (error) { - console.error(loggerLabel, 'Worker error:', error); + epgLogger.error(loggerLabel, 'Worker error:', error); const typedError = error as { code?: ElectronBridgeSecurityErrorCode; host?: string; @@ -398,5 +393,5 @@ if (parentPort) { // Notify parent that worker is ready parentPort.postMessage({ type: 'READY' }); } else { - console.error(loggerLabel, 'parentPort is not available!'); + epgLogger.error(loggerLabel, 'parentPort is not available!'); } diff --git a/apps/electron-backend/src/app/workers/epg-source-metadata.spec.ts b/apps/electron-backend/src/app/workers/epg-source-metadata.spec.ts new file mode 100644 index 000000000..bce4a0eea --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-source-metadata.spec.ts @@ -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 }); + }); +}); diff --git a/apps/electron-backend/src/app/workers/epg-source-metadata.ts b/apps/electron-backend/src/app/workers/epg-source-metadata.ts new file mode 100644 index 000000000..fdf078ab9 --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-source-metadata.ts @@ -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 }); +} diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 3bb625036..8a38ba1a1 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -14,7 +14,12 @@ import { } from '@iptvnator/workspace/shell/util'; import { MockProvider } from 'ng-mocks'; import { EMPTY, of } from 'rxjs'; -import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + DataService, + EpgSourceSettingsService, + SettingsStore, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { Language, Settings, @@ -235,6 +240,37 @@ describe('AppComponent', () => { expect(snackBar.open).not.toHaveBeenCalled(); }); + it('does not reimport a deleted source from a late startup freshness response', async () => { + await TestBed.inject(SettingsStore).loadSettings(); + let finishFreshness!: (value: { + freshUrls: string[]; + staleUrls: string[]; + }) => void; + epgBridge.checkFreshness = jest.fn( + () => + new Promise((resolve) => { + finishFreshness = resolve; + }) + ); + const pending = ( + component as unknown as { + fetchStaleEpgData(urls: string[]): Promise; + } + ).fetchStaleEpgData(['https://removed.example/guide.xml']); + await Promise.resolve(); + const sources = TestBed.inject(EpgSourceSettingsService); + sources.revision.update((value) => value + 1); + sources.changed$.next(); + finishFreshness({ + freshUrls: [], + staleUrls: ['https://removed.example/guide.xml'], + }); + await pending; + expect(epgService.fetchEpg).not.toHaveBeenCalledWith([ + 'https://removed.example/guide.xml', + ]); + }); + it('does not fetch EPG settings when the EPG bridge cannot import EPG', async () => { const settings: Settings = { ...DEFAULT_SETTINGS, diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index a6e67951a..a80a56979 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -20,6 +20,7 @@ import { DataService, RuntimeCapabilitiesService, SettingsStore, + EpgSourceSettingsService, } from '@iptvnator/services'; import { AUTO_UPDATE_PLAYLISTS, @@ -63,6 +64,7 @@ export class AppComponent implements OnInit { private translate = inject(TranslateService); private settingsService = inject(SettingsService); private settingsStore = inject(SettingsStore); + private readonly epgSources = inject(EpgSourceSettingsService); private playbackKeepAwake = inject(PlaybackKeepAwakeService); private playlistOpenRequests = inject(PlaylistOpenRequestService); private runtime = inject(RuntimeCapabilitiesService); @@ -188,8 +190,16 @@ export class AppComponent implements OnInit { * Data is considered fresh if updated within the last 12 hours. */ private async fetchStaleEpgData(urls: string[]): Promise { + await this.settingsStore.loadSettings(); + const revision = this.epgSources.revision(); + const fetchCurrentSources = async (sources: string[]) => { + await this.epgSources.waitForReconciliation(); + this.epgService.fetchEpg( + this.epgSources.retainCurrentSources(sources, revision) + ); + }; if (!this.epgBridge.supportsSourceFreshness) { - this.epgService.fetchEpg(urls); + await fetchCurrentSources(urls); return; } @@ -197,7 +207,7 @@ export class AppComponent implements OnInit { const result = await this.epgBridge.checkFreshness(urls, 12); if (!result) { - this.epgService.fetchEpg(urls); + await fetchCurrentSources(urls); return; } @@ -219,12 +229,12 @@ export class AppComponent implements OnInit { debugAppComponent( `EPG: Fetching ${result.staleUrls.length} stale source(s)` ); - this.epgService.fetchEpg(result.staleUrls); + await fetchCurrentSources(result.staleUrls); } } catch (error) { console.error('Error checking EPG freshness, fetching all:', error); // Fallback: fetch all URLs if freshness check fails - this.epgService.fetchEpg(urls); + await fetchCurrentSources(urls); } } diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index c64e46e3b..5713b7e28 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -2,7 +2,10 @@ import { DestroyRef, inject, Injectable } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormArray, FormBuilder } from '@angular/forms'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + EpgSourceReconciliationError, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { CoverSize, EpgViewMode, @@ -146,6 +149,7 @@ export class SettingsFormFacade { */ removeEpgSource(index: number): void { this.epgUrl.removeAt(index); + this.epgUrl.markAsDirty(); this.form.markAsDirty(); } @@ -161,19 +165,28 @@ export class SettingsFormFacade { this.settingsStore.getSettings() ); - await this.settingsStore.updateSettings(settings); - onSaved(); - - if (!window.electron) { - return; + let cleanupError: EpgSourceReconciliationError | undefined; + try { + await this.settingsStore.updateSettings(settings, { + retryEpgCleanup: this.epgUrl?.dirty ?? false, + }); + } catch (error) { + if (!(error instanceof EpgSourceReconciliationError)) throw error; + // This error follows a successful settings write. Mirror the + // committed values, but retain the dirty form for cleanup retry. + cleanupError = error; } + if (!cleanupError) onSaved(); - window.electron.updateSettings(settings); + if (window.electron) { + window.electron.updateSettings(settings); - if (this.runtime.supportsExternalPlayerPathSettings) { - window.electron.setMpvPlayerPath(settings.mpvPlayerPath); - window.electron.setVlcPlayerPath(settings.vlcPlayerPath); + if (this.runtime.supportsExternalPlayerPathSettings) { + window.electron.setMpvPlayerPath(settings.mpvPlayerPath); + window.electron.setVlcPlayerPath(settings.vlcPlayerPath); + } } + if (cleanupError) throw cleanupError; } /** Applies the saved language/theme and resets the dirty state */ diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index fed7a1a91..1e0eb7478 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -351,11 +351,14 @@ describe('SettingsComponent form', () => { component.onSubmit(); await fixture.whenStable(); - expect(settingsStore.updateSettings).toHaveBeenCalledWith({ - ...component.settingsForm.value, - trustedPrivateNetworkEpgUrls: [], - trustedInsecureTlsHosts: [], - }); + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + { + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }, + { retryEpgCleanup: false } + ); expect(updateSettings).toHaveBeenCalledWith({ ...component.settingsForm.value, trustedPrivateNetworkEpgUrls: [], @@ -375,7 +378,8 @@ describe('SettingsComponent form', () => { expect(settingsStore.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ webPlayerSharedControls: false, - }) + }), + { retryEpgCleanup: false } ); }); @@ -421,7 +425,8 @@ describe('SettingsComponent form', () => { expect.objectContaining({ mpvPlayerArguments: '--screen=1\n--geometry=1280x720', vlcPlayerArguments: '--qt-fullscreen-screennumber=1', - }) + }), + { retryEpgCleanup: false } ); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ @@ -456,7 +461,8 @@ describe('SettingsComponent form', () => { epgUrl: ['https://example.com/guide.xml'], preferUploadedEpgOverXtream: true, theme: Theme.DarkTheme, - }) + }), + { retryEpgCleanup: false } ); webFixture.destroy(); diff --git a/apps/web/src/app/settings/settings.component.storage-failure.spec.ts b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts index 9213f1a97..90a25bdca 100644 --- a/apps/web/src/app/settings/settings.component.storage-failure.spec.ts +++ b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { of } from 'rxjs'; +import { EpgSourceReconciliationError } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsComponent } from './settings.component'; @@ -86,6 +87,9 @@ describe('SettingsComponent storage failures', () => { // write must not mark the form pristine — that is what keeps the // retry path on screen. expect(component.settingsForm.dirty).toBe(true); + expect(window.electron.updateSettings).not.toHaveBeenCalled(); + expect(window.electron.setMpvPlayerPath).not.toHaveBeenCalled(); + expect(window.electron.setVlcPlayerPath).not.toHaveBeenCalled(); }); it('marks the form pristine only after the settings write succeeded', async () => { @@ -103,6 +107,51 @@ describe('SettingsComponent storage failures', () => { expect(component.settingsForm.pristine).toBe(true); }); + it('retries failed source cleanup on an explicit EPG save and clears dirty state only on success', async () => { + await fixture.whenStable(); + jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation(); + component.form.setEpgUrls(['removed-source']); + component.form.removeEpgSource(0); + component.settingsForm.patchValue({ + remoteControl: true, + mpvPlayerPath: '/saved/mpv', + vlcPlayerPath: '/saved/vlc', + }); + settingsStore.updateSettings + .mockRejectedValueOnce(new EpgSourceReconciliationError()) + .mockResolvedValue(undefined); + component.onSubmit(); + await fixture.whenStable(); + expect(component.form.epgUrl.dirty).toBe(true); + expect(window.electron.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + remoteControl: true, + mpvPlayerPath: '/saved/mpv', + vlcPlayerPath: '/saved/vlc', + epgUrl: [], + }) + ); + expect(window.electron.setMpvPlayerPath).toHaveBeenCalledWith( + '/saved/mpv' + ); + expect(window.electron.setVlcPlayerPath).toHaveBeenCalledWith( + '/saved/vlc' + ); + expect(settingsStore.updateSettings).toHaveBeenLastCalledWith( + expect.objectContaining({ epgUrl: [] }), + { retryEpgCleanup: true } + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.EPG_DATA_CLEAR_FAILED', + undefined, + expect.any(Object) + ); + component.onSubmit(); + await fixture.whenStable(); + expect(settingsStore.updateSettings).toHaveBeenCalledTimes(2); + expect(component.form.epgUrl.pristine).toBe(true); + }); + it('keeps the user in settings when save-and-leave cannot persist', async () => { settingsStore.updateSettings.mockRejectedValue( new Error('storage unavailable') @@ -124,5 +173,4 @@ describe('SettingsComponent storage failures', () => { ); expect(component.settingsForm.dirty).toBe(true); }); - }); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 8e1891377..e5b6f4868 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -16,7 +16,10 @@ import { MatIconModule } from '@angular/material/icon'; import { ActivatedRoute, Router } from '@angular/router'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + EpgSourceReconciliationError, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; import { Language, StreamFormat } from '@iptvnator/shared/interfaces'; import { firstValueFrom, map } from 'rxjs'; @@ -307,7 +310,13 @@ export class SettingsComponent try { await this.form.save(() => this.applyChangedSettings()); return true; - } catch { + } catch (error) { + if (error instanceof EpgSourceReconciliationError) { + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') + ); + return false; + } // The store already applied the change in memory, so without // this the save looks successful until the next restart. The // unsaved-changes bar stays visible so it can be retried. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index c6b46ae24..a8aecd194 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 were already read from persistence into NgRx 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. diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index 32f321bfc..73f069877 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -24,13 +24,13 @@ Stalker now uses two EPG paths with different purposes: settled bulk guide cannot answer fall back to throttled per-channel `get_short_epg` through `StalkerEpgPreviewQueue` (see "Channel row preview flow"). - - Effect ordering matters: the eager-EPG effect is registered **after** the - playlist-change effect that calls `clearBulkItvEpgCache()`. On a portal - 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. - - `ensureBulkItvEpg` de-duplicates (via `isLoadingBulkItvEpg` / - `bulkItvEpgLoaded` + matching playlist/period), so the eager trigger and the - play-time `loadEpgForChannel` path never double-fetch. + - Effect ordering matters: the eager-EPG effect is registered **after** the + playlist-change effect that calls `clearBulkItvEpgCache()`. On a portal + 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. + - `ensureBulkItvEpg` de-duplicates (via `isLoadingBulkItvEpg` / + `bulkItvEpgLoaded` + matching playlist/period), so the eager trigger and the + play-time `loadEpgForChannel` path never double-fetch. - If a portal does not return usable bulk data for the selected channel, the active panel falls back to `get_short_epg`. @@ -196,12 +196,12 @@ Normalization rules: ### Key files -| File | Purpose | -| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `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.html` | active panel template | -| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts` | shared controlled EPG timeline with date navigator | +| File | Purpose | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `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.html` | active panel template | +| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts` | shared controlled EPG timeline with date navigator | ### Store API @@ -283,6 +283,10 @@ rows stop consuming portal request capacity. - Bulk EPG is fetched once per playlist session - Channel switches only read from `bulkItvEpgByChannel` - 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 ## Authentication diff --git a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts index 434194761..e48f1a98a 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts @@ -3,11 +3,13 @@ import { EpgImportProgress, EpgRuntimeBridgeService, } 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'; describe('EpgProgressService', () => { let epgBridge: Partial; + let sources: { waitForReconciliation: jest.Mock }; let settingsStore: { getSettings: jest.Mock; getTrustOptions: jest.Mock; @@ -15,11 +17,14 @@ describe('EpgProgressService', () => { }; beforeEach(() => { + sources = { + waitForReconciliation: jest.fn().mockResolvedValue(undefined), + }; epgBridge = { forceFetchEpg: jest.fn().mockResolvedValue({ success: true }), onProgress: jest.fn(), supportsDataManagement: false, - supportsProgress: false, + supportsProgress: true, }; settingsStore = { getSettings: jest.fn(() => ({ @@ -35,6 +40,7 @@ describe('EpgProgressService', () => { }); afterEach(() => { + jest.useRealTimers(); TestBed.resetTestingModule(); jest.restoreAllMocks(); }); @@ -43,6 +49,7 @@ describe('EpgProgressService', () => { TestBed.configureTestingModule({ providers: [ EpgProgressService, + { provide: EpgSourceSettingsService, useValue: sources }, { provide: EpgRuntimeBridgeService, useValue: epgBridge, @@ -57,25 +64,132 @@ describe('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((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((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', () => { + epgBridge.supportsProgress = false; configureService(); 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(); - service.retry('https://example.com/epg.xml'); + errorRow(); + await service.retry('https://example.com/epg.xml'); 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; const service = configureService(); - service.retry('https://example.com/epg.xml'); + errorRow(); + await service.retry('https://example.com/epg.xml'); expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( 'https://example.com/epg.xml', @@ -90,6 +204,7 @@ describe('EpgProgressService', () => { epgBridge.supportsDataManagement = true; const service = configureService(); + errorRow('http://192.168.1.30/guide.xml'); await service.trustPrivateNetworkSourceAndRetry( 'http://192.168.1.30/guide.xml' ); diff --git a/libs/epg/data-access/src/lib/epg-progress.service.ts b/libs/epg/data-access/src/lib/epg-progress.service.ts index 77292f314..73ca330ab 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.ts @@ -3,7 +3,7 @@ import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES, normalizeHost, } from '@iptvnator/shared/interfaces'; -import { SettingsStore } from '@iptvnator/services'; +import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services'; import { EpgImportProgress, EpgRuntimeBridgeService, @@ -13,6 +13,7 @@ import { export class EpgProgressService { private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly settingsStore = inject(SettingsStore); + private readonly sources = inject(EpgSourceSettingsService); private readonly importsMap = signal>( new Map() ); @@ -45,20 +46,27 @@ export class EpgProgressService { this.importsMap.set(new Map()); } - retry(url: string): void { - // Clear the errored row so the backend's subsequent 'queued' event - // reappears cleanly rather than updating an existing error row. - this.removeImport(url); - if (!this.epgBridge.supportsDataManagement) { + async retry(url: string): Promise { + const progress = this.importsMap().get(url); + if ( + progress?.status !== 'error' || + !this.epgBridge.supportsDataManagement + ) return; - } - void this.epgBridge.forceFetchEpg( + // A settings save may already be retiring this row. Never bypass its + // 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, this.settingsStore.getTrustOptions() ); } async trustPrivateNetworkSourceAndRetry(url: string): Promise { + const progress = this.importsMap().get(url); + if (progress?.status !== 'error') return; const settings = this.settingsStore.getSettings(); const trustedUrls = new Set( settings.trustedPrivateNetworkEpgUrls ?? [] @@ -68,13 +76,15 @@ export class EpgProgressService { await this.settingsStore.updateSettings({ trustedPrivateNetworkEpgUrls: Array.from(trustedUrls), }); - this.retry(url); + if (this.importsMap().get(url) === progress) await this.retry(url); } async trustInsecureTlsHostAndRetry( url: string, host?: string ): Promise { + const progress = this.importsMap().get(url); + if (progress?.status !== 'error') return; const trustedHost = host ?? this.getHostname(url); if (!trustedHost) { return; @@ -91,7 +101,7 @@ export class EpgProgressService { await this.settingsStore.updateSettings({ trustedInsecureTlsHosts: Array.from(trustedHosts), }); - this.retry(url); + if (this.importsMap().get(url) === progress) await this.retry(url); } private initializeListener(): void { @@ -108,6 +118,12 @@ export class EpgProgressService { } 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) => { const updated = new Map(current); updated.set(progress.url, progress); @@ -118,7 +134,10 @@ export class EpgProgressService { progress.status === 'complete' || (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); } } diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index b26412205..69d4b29e0 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -1,8 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom, skip } from 'rxjs'; -import { SettingsStore } from '@iptvnator/services'; +import { firstValueFrom, of, skip } from 'rxjs'; +import { + EpgSourceSettingsService, + PlaylistsService, + SettingsStore, +} from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { EpgService } from './epg.service'; @@ -11,6 +15,7 @@ describe('EpgService', () => { let epgBridge: Partial; let snackBar: { open: jest.Mock }; let settingsStore: { + loadSettings: jest.Mock; getSettings: jest.Mock; getTrustOptions: jest.Mock; resolvedEpgOffsetMinutes: jest.Mock; @@ -29,6 +34,7 @@ describe('EpgService', () => { open: jest.fn(), }; settingsStore = { + loadSettings: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn(() => ({ epgUrl: [], trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], @@ -44,6 +50,19 @@ describe('EpgService', () => { TestBed.configureTestingModule({ providers: [ EpgService, + { + provide: PlaylistsService, + useValue: { + getAllPlaylists: () => + of([ + { + epgUrls: [ + 'https://playlist.example/guide.xml', + ], + }, + ]), + }, + }, { provide: EpgRuntimeBridgeService, useValue: epgBridge, @@ -68,22 +87,126 @@ describe('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((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((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', () => { service.fetchEpg(['https://example.com/epg.xml']); 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; - service.fetchEpg([ + await service.fetchEpg([ 'https://example.com/epg.xml', '', 'https://example.com/other.xml', ' https://example.com/epg.xml ', ]); + await Promise.resolve(); expect(epgBridge.fetchEpg).toHaveBeenCalledWith( ['https://example.com/epg.xml', 'https://example.com/other.xml'], { diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index b53502d99..d76ae9bf6 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -17,7 +17,7 @@ import { EpgProgram, epgProviderClockMs, } from '@iptvnator/shared/interfaces'; -import { SettingsStore } from '@iptvnator/services'; +import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services'; import { EpgLookupOptions, EpgRuntimeBridgeService, @@ -42,6 +42,15 @@ export class EpgService { private translate = inject(TranslateService); private readonly epgBridge = inject(EpgRuntimeBridgeService); 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(false); private currentEpgPrograms = new BehaviorSubject([]); @@ -79,11 +88,17 @@ export class EpgService { /** * Fetches EPG from the given URLs */ - fetchEpg(urls: string[]): void { + async fetchEpg(urls: string[]): Promise { if (!this.epgBridge.supportsImport) return; // 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; from( @@ -93,6 +108,7 @@ export class EpgService { ) ) .pipe( + this.sourceSettings.guard(), tap((result) => { if (result === null) return; @@ -123,6 +139,7 @@ export class EpgService { from(this.epgBridge.getChannelPrograms(channelId)) .pipe( + this.sourceSettings.guard(), timeout(3000), map((programs) => normalizeEpgPrograms(programs ?? [])), catchError((err) => { @@ -187,6 +204,7 @@ export class EpgService { // Fetch from backend return this.getCachedOrFetchCurrentProgram(cacheKey, () => from(this.epgBridge.getChannelPrograms(channelId)).pipe( + this.sourceSettings.guard(), map((programs) => normalizeEpgPrograms(programs ?? [])), map((programs: EpgProgram[]) => this.findCurrentProgram(programs) @@ -287,6 +305,7 @@ export class EpgService { nowMs: this.epgClockMs(), }) ).pipe( + this.sourceSettings.guard(), timeout(5000), map((batchResult) => { const cacheTimestamp = Date.now(); @@ -311,6 +330,7 @@ export class EpgService { // Fallback for older preload bundles without the batch endpoint. const fetchObservables = channelsToFetch.map((channelId) => this.getCurrentProgramForChannel(channelId).pipe( + this.sourceSettings.guard(), timeout(5000), map((program) => ({ channelId, program })), catchError(() => of({ channelId, program: null })) @@ -318,6 +338,7 @@ export class EpgService { ); return forkJoin(fetchObservables).pipe( + this.sourceSettings.guard(), map((results) => { results.forEach((result) => { resultMap.set(result.channelId, result.program); @@ -372,6 +393,7 @@ export class EpgService { sourceUrls, fallbackSourceUrls ).pipe( + this.sourceSettings.guard(), tap((fetchedMap) => { const cacheTimestamp = Date.now(); channelsToFetch.forEach((channelId) => { @@ -386,7 +408,14 @@ export class EpgService { }); }), finalize(() => { - this.fetchingCurrentProgramBatches.delete(batchCacheKey); + if ( + this.fetchingCurrentProgramBatches.get( + batchCacheKey + ) === request$ + ) + this.fetchingCurrentProgramBatches.delete( + batchCacheKey + ); }), shareReplay({ bufferSize: 1, refCount: false }) ); @@ -396,6 +425,7 @@ export class EpgService { } return request$.pipe( + this.sourceSettings.guard(), map((fetchedMap) => { const mergedResultMap = new Map(resultMap); channelsToFetch.forEach((channelId) => { @@ -421,6 +451,7 @@ export class EpgService { nowMs, }) ).pipe( + this.sourceSettings.guard(), timeout(5000), switchMap((scopedResult) => { const resultMap = new Map(); @@ -448,6 +479,7 @@ export class EpgService { nowMs, }) ).pipe( + this.sourceSettings.guard(), timeout(5000), map((globalResult) => { fallbackChannelIds.forEach((channelId) => { @@ -500,6 +532,7 @@ export class EpgService { normalizedChannelIds, effectiveSourceUrls ).pipe( + this.sourceSettings.guard(), switchMap((metadataMap) => { const fallbackChannelIds = sourceUrls.length > 0 && globalSourceUrls.length > 0 @@ -516,6 +549,7 @@ export class EpgService { fallbackChannelIds, globalSourceUrls ).pipe( + this.sourceSettings.guard(), map((globalMetadataMap) => { fallbackChannelIds.forEach((channelId) => { metadataMap.set( @@ -561,6 +595,7 @@ export class EpgService { sourceUrls.length > 0 ? { sourceUrls } : undefined ) ).pipe( + this.sourceSettings.guard(), map((metadataByChannelId) => { return new Map( channelIds.map((channelId) => [ @@ -641,6 +676,7 @@ export class EpgService { const offsetMinutes = this.epgOffsetMinutes(); const request$ = fetchProgram().pipe( + this.sourceSettings.guard(), tap((program) => { this.programCache.set(cacheKey, { program, @@ -649,7 +685,8 @@ export class EpgService { }); }), finalize(() => { - this.fetchingCurrentPrograms.delete(cacheKey); + if (this.fetchingCurrentPrograms.get(cacheKey) === request$) + this.fetchingCurrentPrograms.delete(cacheKey); }), shareReplay({ bufferSize: 1, refCount: false }) ); @@ -668,6 +705,7 @@ export class EpgService { from( this.epgBridge.getChannelPrograms(channelId, { sourceUrls }) ).pipe( + this.sourceSettings.guard(), timeout(3000), map((programs) => normalizeEpgPrograms(programs ?? [])), switchMap((programs) => { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts index 4573c561c..7b84bbd54 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts @@ -1,6 +1,10 @@ import { TestBed } from '@angular/core/testing'; 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 { EpgItem, Playlist } from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from '../../stalker-session.service'; @@ -232,6 +236,44 @@ describe('withStalkerEpg', () => { 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 () => { epgBridge.getEpgMappingsBatch.mockResolvedValue({ 'stalker:playlist-1:10001': 'mapped.channel.id', @@ -259,6 +301,63 @@ describe('withStalkerEpg', () => { 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 () => { epgBridge.supportsEpgMapping = false; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index 1fb216a31..6ed086652 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -3,12 +3,17 @@ import { patchState, signalStoreFeature, withComputed, + withHooks, withMethods, withState, } from '@ngrx/signals'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + DataService, + EpgSourceSettingsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { buildStalkerEpgMappingKey, EpgItem, @@ -105,7 +110,8 @@ export function withStalkerEpg() { stalkerSession = inject(StalkerSessionService), portalRepair = inject(StalkerPortalRepairService), runtime = inject(RuntimeCapabilitiesService), - epgBridge = inject(EpgRuntimeBridgeService) + epgBridge = inject(EpgRuntimeBridgeService), + sources = inject(EpgSourceSettingsService) ) => { const storeContext = store as typeof store & StalkerEpgFeatureStoreContract; @@ -127,6 +133,7 @@ export function withStalkerEpg() { // an empty mapped guide must still keep the portal EPG out. const mappingOwnedIds = new Set(); let mappingPlaylistId: string | null = null; + let cacheGeneration = 0; const resetMappingOverrides = (): void => { mappingOverridesById.clear(); @@ -140,8 +147,8 @@ export function withStalkerEpg() { EpgProgram[] > => { const record: Record = {}; - for (const [id, programs] of mappingOverridesById) { - record[id] = programs; + for (const id of mappingOwnedIds) { + record[id] = mappingOverridesById.get(id) ?? []; } return record; }; @@ -222,6 +229,7 @@ export function withStalkerEpg() { } const playlistId = String(playlist._id); + const generation = cacheGeneration; if (!supportsEpg()) { patchState(store, { bulkItvEpgByChannel: {}, @@ -258,6 +266,7 @@ export function withStalkerEpg() { type: 'itv', period: String(periodHours), }); + if (generation !== cacheGeneration) return; const selectedChannelId = storeContext.selectedItvId() ?? null; const bulkPrograms = extractBulkEpgByChannel( @@ -274,6 +283,7 @@ export function withStalkerEpg() { isLoadingBulkItvEpg: false, }); } catch (error) { + if (generation !== cacheGeneration) return; logger.warn('Bulk Stalker EPG unavailable', error); patchState(store, { bulkItvEpgByChannel: mappingOverridesRecord(), @@ -312,8 +322,7 @@ export function withStalkerEpg() { channelIds .map((id) => normalizeStalkerEntityId(id)) .filter( - (id) => - id && !mappingCheckedIds.has(id) + (id) => id && !mappingCheckedIds.has(id) ) ), ]; @@ -325,7 +334,11 @@ export function withStalkerEpg() { // call can outlive a portal switch — bail out after // every await instead of writing portal A's data // into portal B's state. + const revision = sources.revision(); + const generation = cacheGeneration; const isStale = (): boolean => + revision !== sources.revision() || + generation !== cacheGeneration || mappingPlaylistId !== playlistId || String( storeContext.currentPlaylist()?._id ?? '' @@ -365,6 +378,7 @@ export function withStalkerEpg() { let changed = false; let ownershipChanged = false; for (const [channelId, key] of keyById) { + if (isStale()) return; const mappedEpgId = mappings[key]?.trim(); if (!mappedEpgId) { // No mapping for this channel — a stable @@ -443,12 +457,31 @@ export function withStalkerEpg() { }, clearBulkItvEpgCache(): void { + cacheGeneration++; resetMappingOverrides(); 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(), + }; + }) ); } diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts index 75a48d856..eccc81f8b 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts @@ -5,7 +5,7 @@ import { EpgItem, windowEpgItemsAtProviderClock, } from '@iptvnator/shared/interfaces'; -import { SettingsStore } from '@iptvnator/services'; +import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services'; import { XtreamApiService, XtreamCredentials } from './xtream-api.service'; import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service'; import { createLogger } from '@iptvnator/portal/shared/util'; @@ -50,6 +50,20 @@ export class EpgQueueService implements OnDestroy { private readonly apiService = inject(XtreamApiService); private readonly fallbackService = inject(XtreamXmltvFallbackService); 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 previewLimit = 3; @@ -494,6 +508,7 @@ export class EpgQueueService implements OnDestroy { } ngOnDestroy(): void { + this.sourceSubscription.unsubscribe(); this.epgResult$.complete(); } } diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts index a527d0335..de0b42676 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts @@ -5,7 +5,7 @@ import { epgProviderClockMs, } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { SettingsStore } from '@iptvnator/services'; +import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services'; type ElectronEpgBridge = { getChannelPrograms?: (channelId: string) => Promise; @@ -19,6 +19,7 @@ type ElectronEpgBridge = { export class XtreamXmltvFallbackService { private readonly logger = createLogger('XtreamXmltvFallback'); private readonly settingsStore = inject(SettingsStore); + private readonly sources = inject(EpgSourceSettingsService); /** * `DataService.isElectron` is intentionally not consulted here: it @@ -44,6 +45,7 @@ export class XtreamXmltvFallbackService { async getProgramsForChannel( epgChannelId: string | null | undefined ): Promise { + const revision = this.sources.revision(); const id = (epgChannelId ?? '').trim(); if (!id) return []; @@ -52,6 +54,7 @@ export class XtreamXmltvFallbackService { try { const programs = await fn.call(this.bridge, id); + if (revision !== this.sources.revision()) return []; return (programs ?? []).map((p) => mapEpgProgramToEpgItem(p, id)); } catch (error) { this.logger.error(`Failed to load XMLTV programs for ${id}`, error); @@ -62,6 +65,7 @@ export class XtreamXmltvFallbackService { async getCurrentProgramsBatch( epgChannelIds: ReadonlyArray ): Promise> { + const revision = this.sources.revision(); const fn = this.bridge?.getCurrentProgramsBatch; if (typeof fn !== 'function') return {}; @@ -83,6 +87,7 @@ export class XtreamXmltvFallbackService { this.settingsStore.resolvedEpgOffsetMinutes() ), }); + if (revision !== this.sources.revision()) return {}; const out: Record = {}; for (const id of ids) { const row = rows?.[id]; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 7d3c44e44..2bb834d4a 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -4,6 +4,7 @@ import { signalStoreFeature, withComputed, withMethods, + withHooks, withState, } from '@ngrx/signals'; import { @@ -11,7 +12,11 @@ import { EpgItem, epgProviderClockMs, } from '@iptvnator/shared/interfaces'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + EpgSourceSettingsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { XtreamApiService, XtreamCredentials, @@ -104,6 +109,7 @@ export function withEpg() { const fallbackService = inject(XtreamXmltvFallbackService); const runtime = inject(RuntimeCapabilitiesService); const settingsStore = inject(SettingsStore); + const sources = inject(EpgSourceSettingsService); const supportsEpg = (): boolean => runtime.supportsEpg; @@ -146,6 +152,7 @@ export function withEpg() { * sets `preferUploadedEpgOverXtream`. */ async loadEpg(): Promise { + const sourceRevision = sources.revision(); if (!supportsEpg()) { patchState(store, { epgItems: [], @@ -203,6 +210,7 @@ export function withEpg() { fetchFullProvider(credentials, xtreamId), }); + if (sourceRevision !== sources.revision()) return []; patchState(store, { epgItems, isLoadingEpg: false, @@ -210,6 +218,7 @@ export function withEpg() { return epgItems; } catch (error) { + if (sourceRevision !== sources.revision()) return []; logger.error('Error loading EPG', error); patchState(store, { epgItems: [], @@ -255,6 +264,19 @@ export function withEpg() { 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(), + }; }) ); } diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 6a17eb1d7..f13d16726 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -1,3 +1,4 @@ +import { EpgSourceSettingsService } from '@iptvnator/services'; import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling'; import { signal } from '@angular/core'; 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', () => { fixture.detectChanges(); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 66d86590b..74ff3ef04 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -49,7 +49,11 @@ import { EpgQueueService } from '@iptvnator/portal/xtream/data-access'; import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access'; import { FavoritesService } 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'; export interface XtreamChannelListItem { @@ -164,6 +168,11 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { private readonly settingsStore = inject(SettingsStore); 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 // the visible previews are re-picked from the cached EPG. The first // run only records the initial value. diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 8126c9c86..301cb4b04 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -21,3 +21,5 @@ export * from './lib/tmdb'; export * from './lib/xtream-pending-restore.service'; export * from './lib/stream-probe.service'; export * from './lib/vod-source-pin.service'; + +export * from './lib/epg-source-settings.service'; diff --git a/libs/services/src/lib/epg-source-settings.service.spec.ts b/libs/services/src/lib/epg-source-settings.service.spec.ts new file mode 100644 index 000000000..0bd81c73c --- /dev/null +++ b/libs/services/src/lib/epg-source-settings.service.spec.ts @@ -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(); + 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(); + 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']); + }); +}); diff --git a/libs/services/src/lib/epg-source-settings.service.ts b/libs/services/src/lib/epg-source-settings.service.ts new file mode 100644 index 000000000..129d84789 --- /dev/null +++ b/libs/services/src/lib/epg-source-settings.service.ts @@ -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(); + private reconciliation: Promise | undefined; + readonly revision = signal(0); + readonly changed$ = new Subject(); + + retainCurrentSources(urls: string[], requestedRevision: number): string[] { + return requestedRevision === this.revision() + ? urls + : urls.filter((url) => this.activeUrls.has(url)); + } + + guard(): MonoTypeOperatorFunction { + const revision = this.revision(); + return (source: Observable) => + source.pipe( + takeUntil(this.changed$), + filter(() => revision === this.revision()) + ); + } + + async waitForReconciliation(): Promise { + while (this.reconciliation) { + await this.reconciliation.catch(() => undefined); + } + } + + async synchronize(urls: string[] | string | undefined): Promise { + 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 { + 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(); + } + } +} diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index 10cb3d9e3..b2ff5c20e 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -1,3 +1,4 @@ +import { EpgSourceSettingsService } from './epg-source-settings.service'; import { Injector } from '@angular/core'; import { StorageMap } from '@ngx-pwa/local-storage'; import { of, Subject } from 'rxjs'; @@ -46,6 +47,7 @@ describe('SettingsStore dashboard rail settings', () => { injector = Injector.create({ providers: [ SettingsStore, + EpgSourceSettingsService, { provide: StorageMap, 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(); + 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(); + 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 () => { const store = injector.get(SettingsStore); expect(store.getSettings().portalConnectivityGuard).toBe(true); @@ -448,6 +562,7 @@ describe('SettingsStore storage failure reporting', () => { injector = Injector.create({ providers: [ SettingsStore, + EpgSourceSettingsService, { provide: StorageMap, useValue: storage, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 2afabe1f9..d230cfc91 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -1,3 +1,7 @@ +import { + EpgSourceSettingsService, + epgSourceUrlsChanged, +} from './epg-source-settings.service'; import { computed, inject } from '@angular/core'; import { patchState, @@ -143,6 +147,7 @@ export const SettingsStore = signalStore( ), })), withMethods((store, storage = inject(StorageMap)) => { + const epgSources = inject(EpgSourceSettingsService); let settingsLoadPromise: Promise | undefined; 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) => { settingsLoadPromise = undefined; console.error('Failed to load settings:', error); @@ -202,7 +215,11 @@ export const SettingsStore = signalStore( return settingsLoadPromise; }, - async updateSettings(settings: Partial) { + async updateSettings( + settings: Partial, + options: { retryEpgCleanup?: boolean } = {} + ) { + const previousEpgUrls = store.epgUrl(); patchState(store, { ...settings, ...(settings.webPlayerSharedControls !== undefined @@ -252,9 +269,18 @@ export const SettingsStore = signalStore( console.error('Failed to save settings:', error); // The in-memory patch above already applied, so without // this flag the change looks saved until the next restart. - patchState(store, { storageFailure: 'save' }); + patchState(store, { + storageFailure: 'save', + epgUrl: previousEpgUrls, + }); throw error; } + if ( + epgSourceUrlsChanged(previousEpgUrls, settings.epgUrl) || + options.retryEpgCleanup + ) { + await epgSources.synchronize(completeSettings.epgUrl); + } }, getSettings() { diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 304f47588..dfffd1e81 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -287,6 +287,18 @@ const CREATE_TABLE_STATEMENTS = [ source_url TEXT NOT NULL, 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, @@ -419,6 +431,8 @@ const COLUMN_MIGRATION_STATEMENTS = [ `ALTER TABLE content ADD COLUMN original_title TEXT`, // v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup `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 `ALTER TABLE downloads ADD COLUMN resume_validator TEXT`, // Offline details: provider-neutral display metadata captured at download time diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 93771f49d..242ccd843 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -11,6 +11,7 @@ import { sql } from 'drizzle-orm'; import { index, integer, + primaryKey, sqliteTable, text, 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 export const epgPrograms = sqliteTable( 'epg_programs', diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 4229bf3ef..0706bbb2c 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -85,6 +85,7 @@ export type ElectronBridgePlaylistType = (typeof ELECTRON_BRIDGE_PLAYLIST_TYPES)[keyof typeof ELECTRON_BRIDGE_PLAYLIST_TYPES]; export const ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES = { + Cancelled: 'cancelled', Complete: 'complete', Error: 'error', Loading: 'loading', @@ -340,8 +341,7 @@ export interface ElectronBridgeTrustOptions { trustedInsecureTlsHosts?: string[]; } -export interface ElectronBridgePlaylistFetchOptions - extends ElectronBridgeTrustOptions { +export interface ElectronBridgePlaylistFetchOptions extends ElectronBridgeTrustOptions { userAgent?: string; } @@ -375,8 +375,7 @@ export interface ElectronBridgeEpgLookupOptions { * renderer will also present as "now". Absent, the main process uses its own * clock. */ -export interface ElectronBridgeCurrentProgramsOptions - extends ElectronBridgeEpgLookupOptions { +export interface ElectronBridgeCurrentProgramsOptions extends ElectronBridgeEpgLookupOptions { nowMs?: number; } @@ -387,6 +386,7 @@ export interface ElectronBridgeEpgProgressStats { export interface ElectronBridgeEpgProgress { url: string; + generation?: number; status: ElectronBridgeEpgProgressStatus; stats?: ElectronBridgeEpgProgressStats; error?: string; @@ -812,6 +812,7 @@ export interface ElectronBridgeApi { options?: ElectronBridgeTrustOptions ) => Promise; clearEpgData: () => Promise; + reconcileEpgSources: (urls: string[]) => Promise; clearEpgDataForSource: (sourceUrl: string) => Promise; checkEpgFreshness: ( urls: string[], @@ -1291,7 +1292,9 @@ export interface ElectronBridgeApi { recordingsGet?: ( recordingId: number ) => Promise; - recordingsStop?: (recordingId: number) => Promise; + recordingsStop?: ( + recordingId: number + ) => Promise; recordingsRemove?: ( recordingId: number ) => Promise; diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts index ce8cb2a8d..b83619288 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts @@ -96,6 +96,8 @@ export class EpgProgressPanelComponent { return 'check_circle'; case 'error': return 'error'; + case 'cancelled': + return 'cancel'; } } diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 416cdcbfa..c609d81dc 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -13,7 +13,6 @@ export const maxLinesBaseline = [ '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/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/vlc-session.service.ts', 'apps/electron-backend/src/app/services/app-update.service.ts',