From 2e8441acebad0438eb5e19149e549c4493a02d86 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 00:35:41 +0200 Subject: [PATCH] fix(epg): redact diagnostics and mirror committed settings after cleanup errors --- AGENTS.md | 2 + CLAUDE.md | 2 + apps/electron-backend-e2e/src/epg.e2e.ts | 117 ++++++++++++++++++ .../src/app/events/epg-fetch-operation.ts | 17 +-- .../src/app/events/epg-fetch.service.ts | 15 +-- .../src/app/events/epg-query.service.ts | 11 +- .../src/app/events/epg-worker-runtime.ts | 13 +- .../src/app/events/epg-worker.service.spec.ts | 56 +++++++++ .../src/app/events/epg-worker.service.ts | 16 ++- .../src/app/util/epg-logger.spec.ts | 69 +++++++++++ .../src/app/util/epg-logger.ts | 61 +++++++++ .../src/app/workers/epg-parser.worker.ts | 39 +++--- .../src/app/settings/settings-form.facade.ts | 34 +++-- ...settings.component.storage-failure.spec.ts | 22 ++++ docs/architecture/m3u-playlist-module.md | 11 +- 15 files changed, 413 insertions(+), 72 deletions(-) create mode 100644 apps/electron-backend/src/app/util/epg-logger.spec.ts create mode 100644 apps/electron-backend/src/app/util/epg-logger.ts diff --git a/AGENTS.md b/AGENTS.md index fbcfa535b..f5824dff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,8 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index fc9f1545f..7ca7bbf56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1665,6 +1665,8 @@ 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 diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index 3cf4eaa4f..a5cf6930a 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, @@ -102,6 +104,121 @@ test.describe('Electron EPG', () => { } }); + 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 { + await app.mainWindow.evaluate(async (url) => { + await window.electron.forceFetchEpg(url); + await window.electron.clearEpgDataForSource(url); + }, sourceUrl); + 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'); + expect(diagnostics).toContain('[EPG Worker]'); + expect(diagnostics).toContain('Parsing complete:'); + 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, }) => { diff --git a/apps/electron-backend/src/app/events/epg-fetch-operation.ts b/apps/electron-backend/src/app/events/epg-fetch-operation.ts index a9809dc7a..90f792c8c 100644 --- a/apps/electron-backend/src/app/events/epg-fetch-operation.ts +++ b/apps/electron-backend/src/app/events/epg-fetch-operation.ts @@ -1,3 +1,4 @@ +import { epgLogger } from '../util/epg-logger'; import type { Worker } from 'worker_threads'; import type { ElectronBridgeSecurityErrorCode, @@ -37,7 +38,7 @@ export function runEpgFetch( try { worker = context.runtime.createEpgWorker(); } catch (error) { - console.error( + epgLogger.error( context.loggerLabel, 'Failed to create worker:', error @@ -124,7 +125,7 @@ export function runEpgFetch( const errorMessage = `EPG fetch timed out after ${ context.fetchTimeoutMs / 1000 }s without progress`; - console.error(context.loggerLabel, `${errorMessage}: ${url}`); + epgLogger.error(context.loggerLabel, errorMessage); context.sendProgressToRenderer( url, 'error', @@ -176,9 +177,9 @@ export function runEpgFetch( break; case 'EPG_COMPLETE': - console.log( + epgLogger.log( context.loggerLabel, - `EPG parsing complete for ${url}:`, + 'EPG parsing complete:', message.stats ); context.sendProgressToRenderer( @@ -196,7 +197,7 @@ export function runEpgFetch( break; case 'EPG_ERROR': - console.error( + epgLogger.error( context.loggerLabel, 'Worker error:', message.error @@ -226,7 +227,7 @@ export function runEpgFetch( } } catch (err) { if (settled || cancelIfRetired()) return; - console.error( + epgLogger.error( context.loggerLabel, 'Error handling message:', err @@ -248,7 +249,7 @@ export function runEpgFetch( worker.on('error', (error) => { if (settled || cancelIfRetired()) return; - console.error(context.loggerLabel, 'Worker error event:', error); + epgLogger.error(context.loggerLabel, 'Worker error event:', error); context.sendProgressToRenderer( url, 'error', @@ -266,7 +267,7 @@ export function runEpgFetch( worker.on('exit', (code) => { if (settled || cancelIfRetired(true)) return; const errorMessage = `Worker exited unexpectedly (code ${code})`; - console.error(context.loggerLabel, `${errorMessage}: ${url}`); + epgLogger.error(context.loggerLabel, errorMessage); context.sendProgressToRenderer( url, 'error', 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 e7dfd66fa..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,4 @@ +import { epgLogger } from '../util/epg-logger'; import { epgSourceGeneration, requestEpgSource } from './epg-source-generation'; import { eq } from 'drizzle-orm'; import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces'; @@ -69,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)` ); @@ -132,7 +133,7 @@ export async function handleFetchEpg( ); if (urlsToFetch.length === 0) { - console.log( + epgLogger.log( loggerLabel, `All ${staleUrls.length} stale URL(s) already fetched this session; skipping` ); @@ -167,11 +168,7 @@ export async function handleFetchEpg( } await epgWorkerService.fetchEpgFromUrl(url, options); } catch (error) { - console.error( - loggerLabel, - `Error fetching EPG from ${url}:`, - 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-worker-runtime.ts b/apps/electron-backend/src/app/events/epg-worker-runtime.ts index 45c444af2..11e9d69b5 100644 --- a/apps/electron-backend/src/app/events/epg-worker-runtime.ts +++ b/apps/electron-backend/src/app/events/epg-worker-runtime.ts @@ -1,3 +1,4 @@ +import { epgLogger } from '../util/epg-logger'; import { app } from 'electron'; import * as path from 'path'; import { pathToFileURL } from 'url'; @@ -30,7 +31,7 @@ export class EpgWorkerRuntime { try { worker = this.createEpgWorker(); } catch (error) { - console.error( + epgLogger.error( this.loggerLabel, 'Failed to create worker for clear:', error @@ -51,7 +52,7 @@ export class EpgWorkerRuntime { const errorMessage = `${options.timeoutLabel} timed out after ${ this.fetchTimeoutMs / 1000 }s`; - console.error(this.loggerLabel, errorMessage); + epgLogger.error(this.loggerLabel, errorMessage); settle(() => { void this.terminateWorker( worker, @@ -72,7 +73,7 @@ export class EpgWorkerRuntime { .then(() => resolve(), reject); }); } else if (message.type === 'EPG_ERROR') { - console.error( + epgLogger.error( this.loggerLabel, 'Worker clear error:', message.error @@ -92,7 +93,7 @@ export class EpgWorkerRuntime { ); worker.on('error', (error) => { - console.error( + epgLogger.error( this.loggerLabel, 'Worker error during clear:', error @@ -108,7 +109,7 @@ export class EpgWorkerRuntime { worker.on('exit', (code) => { if (settled) return; const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`; - console.error(this.loggerLabel, errorMessage); + epgLogger.error(this.loggerLabel, errorMessage); settle(() => reject(new Error(errorMessage))); }); }); @@ -123,7 +124,7 @@ export class EpgWorkerRuntime { try { await worker.terminate(); } catch (error) { - console.error( + epgLogger.error( this.loggerLabel, `Failed to terminate ${context} worker:`, error 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 647c592be..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,3 +1,4 @@ +import { epgLogger } from '../util/epg-logger'; import { epgSourceGeneration, requestEpgSource, @@ -105,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; } @@ -150,7 +148,7 @@ 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 @@ -215,9 +213,9 @@ 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.runtime.terminateWorker( 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-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/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index be578f85c..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, @@ -162,21 +165,28 @@ export class SettingsFormFacade { this.settingsStore.getSettings() ); - await this.settingsStore.updateSettings(settings, { - retryEpgCleanup: this.epgUrl?.dirty ?? false, - }); - 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.storage-failure.spec.ts b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts index 4b7ef8782..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 @@ -87,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 () => { @@ -109,12 +112,31 @@ describe('SettingsComponent storage failures', () => { 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 } diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index e5f6259c7..197f3a6ae 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1319,7 +1319,9 @@ 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. Ordinary settings saves +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 @@ -1341,6 +1343,13 @@ retires their generations before waiting for workers to exit, then uses the 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