From 8e0abe6feb72a479e07f0e3873bbdf0477abda19 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:27 +0200 Subject: [PATCH 1/8] feat(ui): custom title bar with window controls for Windows and Linux (#1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add custom title bar window controls for Windows and Linux Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame untouched so native resize borders and snapping keep working) and render minimize / maximize-restore / close buttons in the renderer, mirroring the existing macOS traffic-light setup. - New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state) handled in window.events.ts, resolved from the sender WebContents; close goes through win.close() so window-bounds persistence still runs. - WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the maximize/restore glyph stays correct for OS-triggered changes; controls hide while fullscreen. - WindowControlsComponent mounts once in app-root as a manual popover so it stays in the browser top layer above CDK overlays (dialogs, multi-EPG) - same behavior as macOS traffic lights. - Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay); Windows-red close hover. Drag regions get right padding through a body-level frameless-platform class. - Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and macOS never mount the controls. Includes unit specs for the component and IPC handlers, an Electron E2E suite (window-controls.e2e.ts), and a window-chrome section in docs/architecture/workspace-shell.md. Co-Authored-By: Claude Fable 5 * feat(linux): upgrade Electron to 41 for frameless window decorations on Wayland With the native title bar hidden, Linux windows lost the WM-drawn shadow and rounded corners. Electron draws client-side decorations only on native Wayland, and frameless-window CSD (GTK drop shadow + extended resize boundaries) landed in Electron 41 - before that, frameless windows render as plain rectangles. - electron ^39.8.5 -> ^41.7.2 (Wayland auto-detected since 38.2; X11 sessions remain undecorated, matching other frameless Electron apps; Windows keeps its DWM shadow and rounded corners). - better-sqlite3 pinned to exactly 12.9.0: the last release shipping prebuilt binaries for both Node 20 (ABI 115, Jest) and Electron 41 (ABI 145, runtime). 12.10.0 dropped the Node 20 prebuilds, forcing a from-source build that fails without a C++ toolchain. - pnpm override node-abi 3.85.0 -> 3.92.0 so electron-builder install-app-deps can map Electron 41 to ABI 145. Reviewed Electron 40/41 breaking changes: only the renderer clipboard deprecation, which this app does not use. Co-Authored-By: Claude Fable 5 * fix(e2e): address review feedback and window-managerless Linux CI - Skip the three window-manager-dependent E2E assertions (maximize toggle, main-process state sync, minimize) on Linux CI: GitHub's ubuntu runners drive Electron under xvfb without a window manager, so maximize/minimize state never materializes there. Windows CI and local Linux/macOS runs keep the coverage. - WINDOW:TOGGLE_MAXIMIZE now returns the requested state instead of re-reading isMaximized() right after the call, which races on Linux window managers where maximize()/unmaximize() complete asynchronously; the WINDOW:STATE_CHANGED push stays authoritative. - Skip attaching window-state push listeners on macOS, where the custom controls never mount and the IPC traffic had no subscriber. Co-Authored-By: Claude Fable 5 * fix(ui): gate custom window controls on the full bridge surface Include getWindowState and onWindowStateChange in the usesCustomWindowControls capability check — the controls rely on both for initial state and for keeping the maximize/restore glyph in sync with OS-triggered changes, so a partial bridge should not mount them. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../src/window-controls.e2e.ts | 128 ++++++++++ .../src/app/api/main.preload.ts | 20 ++ apps/electron-backend/src/app/app.ts | 54 +++- .../src/app/events/window.events.spec.ts | 140 +++++++++++ .../src/app/events/window.events.ts | 78 ++++++ apps/electron-backend/src/main.ts | 2 + apps/web/src/app/app.component.html | 4 + apps/web/src/app/app.component.ts | 17 +- apps/web/src/styles.scss | 11 + docs/architecture/workspace-shell.md | 117 +++++++-- .../src/lib/runtime-capabilities.service.ts | 26 ++ .../src/lib/electron-api.interface.ts | 12 + .../shared/interfaces/src/lib/ipc-commands.ts | 7 + libs/ui/components/src/index.ts | 1 + .../window-controls.component.html | 55 +++++ .../window-controls.component.scss | 88 +++++++ .../window-controls.component.spec.ts | 119 +++++++++ .../window-controls.component.ts | 126 ++++++++++ package.json | 5 +- pnpm-lock.yaml | 231 +++++------------- 20 files changed, 1041 insertions(+), 200 deletions(-) create mode 100644 apps/electron-backend-e2e/src/window-controls.e2e.ts create mode 100644 apps/electron-backend/src/app/events/window.events.spec.ts create mode 100644 apps/electron-backend/src/app/events/window.events.ts create mode 100644 libs/ui/components/src/lib/window-controls/window-controls.component.html create mode 100644 libs/ui/components/src/lib/window-controls/window-controls.component.scss create mode 100644 libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts create mode 100644 libs/ui/components/src/lib/window-controls/window-controls.component.ts diff --git a/apps/electron-backend-e2e/src/window-controls.e2e.ts b/apps/electron-backend-e2e/src/window-controls.e2e.ts new file mode 100644 index 000000000..5d4f36c99 --- /dev/null +++ b/apps/electron-backend-e2e/src/window-controls.e2e.ts @@ -0,0 +1,128 @@ +import { + closeElectronApp, + expect, + launchElectronApp, + test, +} from './electron-test-fixtures'; + +// Custom window controls are only rendered on Windows/Linux; macOS keeps +// the native traffic lights. +// +// Linux CI runs Electron under xvfb, which has no window manager, so +// maximize/minimize never take effect there — those tests are skipped and +// rely on Windows CI plus local Linux/macOS runs for coverage. +const isWindowManagerlessCi = + process.platform === 'linux' && !!process.env['CI']; + +test.describe('Custom window controls', () => { + test.skip( + process.platform === 'darwin', + 'macOS uses native traffic lights instead of custom controls' + ); + + test('@electron renders the window control buttons', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await expect( + app.mainWindow.getByTestId('window-minimize') + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId('window-maximize') + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId('window-close') + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId('window-maximize-glyph') + ).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + + test('@electron maximize button toggles the native window state', async ({ + dataDir, + }) => { + test.skip( + isWindowManagerlessCi, + 'xvfb on Linux CI has no window manager' + ); + const app = await launchElectronApp(dataDir); + + try { + const isMaximized = () => + app.electronApp.evaluate(({ BrowserWindow }) => { + const mainWindow = BrowserWindow.getAllWindows()[0]; + return mainWindow ? mainWindow.isMaximized() : false; + }); + + await app.mainWindow.getByTestId('window-maximize').click(); + await expect.poll(isMaximized, { timeout: 10_000 }).toBe(true); + await expect( + app.mainWindow.getByTestId('window-restore-glyph') + ).toBeVisible(); + + await app.mainWindow.getByTestId('window-maximize').click(); + await expect.poll(isMaximized, { timeout: 10_000 }).toBe(false); + await expect( + app.mainWindow.getByTestId('window-maximize-glyph') + ).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + + test('@electron reflects window state changes triggered from the main process', async ({ + dataDir, + }) => { + test.skip( + isWindowManagerlessCi, + 'xvfb on Linux CI has no window manager' + ); + const app = await launchElectronApp(dataDir); + + try { + await app.electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.maximize(); + }); + await expect( + app.mainWindow.getByTestId('window-restore-glyph') + ).toBeVisible({ timeout: 10_000 }); + + await app.electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.unmaximize(); + }); + await expect( + app.mainWindow.getByTestId('window-maximize-glyph') + ).toBeVisible({ timeout: 10_000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('@electron minimize button minimizes the window', async ({ + dataDir, + }) => { + test.skip( + isWindowManagerlessCi, + 'xvfb on Linux CI has no window manager' + ); + const app = await launchElectronApp(dataDir); + + try { + const isMinimized = () => + app.electronApp.evaluate(({ BrowserWindow }) => { + const mainWindow = BrowserWindow.getAllWindows()[0]; + return mainWindow ? mainWindow.isMinimized() : false; + }); + + await app.mainWindow.getByTestId('window-minimize').click(); + await expect.poll(isMinimized, { timeout: 10_000 }).toBe(true); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 1ea1d604f..27b9ece70 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -13,6 +13,7 @@ import type { ElectronBridgePlaylistUpsertInput, ElectronBridgeRemoteControlCommand, ElectronBridgeRemoteControlStatus, + ElectronBridgeWindowState, ElectronBridgeXtreamContentStream, ExternalPlayerSession, PlaybackPositionData, @@ -37,6 +38,11 @@ const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE'; const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE'; const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT'; const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT'; +const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE'; +const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE'; +const WINDOW_CLOSE = 'WINDOW:CLOSE'; +const WINDOW_GET_STATE = 'WINDOW:GET_STATE'; +const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED'; const dbSaveContentProgressListeners = new Set< ( @@ -282,6 +288,20 @@ const electronApi: ElectronBridgeApi = { }, getAppVersion: () => ipcRenderer.invoke('get-app-version'), platform: process.platform, + minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE), + toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE), + closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE), + getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE), + onWindowStateChange: ( + callback: (state: ElectronBridgeWindowState) => void + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + state: ElectronBridgeWindowState + ) => callback(state); + ipcRenderer.on(WINDOW_STATE_CHANGED, handler); + return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler); + }, fetchPlaylistByUrl: (url: string, title?: string) => ipcRenderer.invoke('fetch-playlist-by-url', url, title), updatePlaylistFromFilePath: (filePath: string, title: string) => diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index db4d12e35..884e8a54f 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow, Menu, screen, shell } from 'electron'; +import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces'; import { join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { rendererAppName, rendererAppPort } from './constants'; @@ -237,6 +238,50 @@ export default class App { } } + /** + * Hide the native title bar on every desktop platform. macOS keeps the + * system traffic lights (overlay), while Windows/Linux rely on the + * renderer-drawn window controls (`app-window-controls`) wired up via the + * WINDOW:* IPC channels. `frame` stays untouched so native resize borders + * and snapping keep working. + */ + private static getPlatformTitleBarOptions(): Electron.BrowserWindowConstructorOptions { + if (process.platform === 'darwin') { + return { + titleBarStyle: 'hidden', + titleBarOverlay: true, + trafficLightPosition: { x: 16, y: 20 }, + }; + } + + return { titleBarStyle: 'hidden' }; + } + + private static attachWindowStateEvents(win: Electron.BrowserWindow): void { + // Only Windows/Linux render custom window controls that subscribe + // to these pushes; macOS keeps the native traffic lights, so + // sending state updates there would be dead IPC traffic. + if (process.platform === 'darwin') { + return; + } + + const sendWindowState = () => { + if (win.isDestroyed()) { + return; + } + + win.webContents.send(WINDOW_STATE_CHANGED, { + isMaximized: win.isMaximized(), + isFullScreen: win.isFullScreen(), + }); + }; + + win.on('maximize', sendWindowState); + win.on('unmaximize', sendWindowState); + win.on('enter-full-screen', sendWindowState); + win.on('leave-full-screen', sendWindowState); + } + private static initMainWindow() { const workAreaSize = screen.getPrimaryDisplay().workAreaSize; const width = Math.min(1280, workAreaSize.width || 1280); @@ -254,16 +299,11 @@ export default class App { ...savedWindowBounds, minHeight: 600, minWidth: 900, - ...(process.platform === 'darwin' - ? { - titleBarStyle: 'hidden', - titleBarOverlay: true, - trafficLightPosition: { x: 16, y: 20 }, - } - : {}), + ...App.getPlatformTitleBarOptions(), }); App.mainWindow.setMenu(null); attachWindowTrace(App.mainWindow); + App.attachWindowStateEvents(App.mainWindow); if (!savedWindowBounds) { App.mainWindow.center(); } diff --git a/apps/electron-backend/src/app/events/window.events.spec.ts b/apps/electron-backend/src/app/events/window.events.spec.ts new file mode 100644 index 000000000..c5195ef51 --- /dev/null +++ b/apps/electron-backend/src/app/events/window.events.spec.ts @@ -0,0 +1,140 @@ +const mockHandlers = new Map< + string, + (event: unknown, ...args: unknown[]) => unknown +>(); +const mockFromWebContents = jest.fn(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn( + ( + channel: string, + handler: (event: unknown, ...args: unknown[]) => unknown + ) => { + mockHandlers.set(channel, handler); + } + ), + }, + BrowserWindow: { + fromWebContents: (...args: unknown[]) => mockFromWebContents(...args), + }, +})); + +function createFakeWindow( + initial: { maximized?: boolean; fullScreen?: boolean } = {} +) { + const state = { + maximized: initial.maximized ?? false, + fullScreen: initial.fullScreen ?? false, + }; + + return { + isDestroyed: jest.fn(() => false), + isMaximized: jest.fn(() => state.maximized), + isFullScreen: jest.fn(() => state.fullScreen), + minimize: jest.fn(), + maximize: jest.fn(() => { + state.maximized = true; + }), + unmaximize: jest.fn(() => { + state.maximized = false; + }), + close: jest.fn(), + }; +} + +const fakeEvent = { sender: {} }; + +describe('WindowEvents', () => { + beforeAll(async () => { + await import('./window.events'); + }); + + beforeEach(() => { + mockFromWebContents.mockReset(); + }); + + it('registers handlers for all window control channels', () => { + expect([...mockHandlers.keys()].sort()).toEqual([ + 'WINDOW:CLOSE', + 'WINDOW:GET_STATE', + 'WINDOW:MINIMIZE', + 'WINDOW:TOGGLE_MAXIMIZE', + ]); + }); + + it('minimizes the sender window', () => { + const win = createFakeWindow(); + mockFromWebContents.mockReturnValue(win); + + mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent); + + expect(win.minimize).toHaveBeenCalledTimes(1); + }); + + it('maximizes an unmaximized window and returns the new state', () => { + const win = createFakeWindow({ maximized: false }); + mockFromWebContents.mockReturnValue(win); + + const result = mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent); + + expect(win.maximize).toHaveBeenCalledTimes(1); + expect(win.unmaximize).not.toHaveBeenCalled(); + expect(result).toEqual({ isMaximized: true, isFullScreen: false }); + }); + + it('unmaximizes a maximized window and returns the new state', () => { + const win = createFakeWindow({ maximized: true }); + mockFromWebContents.mockReturnValue(win); + + const result = mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent); + + expect(win.unmaximize).toHaveBeenCalledTimes(1); + expect(win.maximize).not.toHaveBeenCalled(); + expect(result).toEqual({ isMaximized: false, isFullScreen: false }); + }); + + it('closes the sender window', () => { + const win = createFakeWindow(); + mockFromWebContents.mockReturnValue(win); + + mockHandlers.get('WINDOW:CLOSE')!(fakeEvent); + + expect(win.close).toHaveBeenCalledTimes(1); + }); + + it('returns the current window state', () => { + const win = createFakeWindow({ maximized: true, fullScreen: true }); + mockFromWebContents.mockReturnValue(win); + + const result = mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent); + + expect(result).toEqual({ isMaximized: true, isFullScreen: true }); + }); + + it('is a safe no-op when the sender has no window', () => { + mockFromWebContents.mockReturnValue(null); + + expect(() => + mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent) + ).not.toThrow(); + expect(mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent)).toEqual({ + isMaximized: false, + isFullScreen: false, + }); + expect(mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent)).toEqual({ + isMaximized: false, + isFullScreen: false, + }); + }); + + it('treats a destroyed window like a missing window', () => { + const win = createFakeWindow(); + win.isDestroyed.mockReturnValue(true); + mockFromWebContents.mockReturnValue(win); + + mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent); + + expect(win.minimize).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/window.events.ts b/apps/electron-backend/src/app/events/window.events.ts new file mode 100644 index 000000000..a5e01ae2a --- /dev/null +++ b/apps/electron-backend/src/app/events/window.events.ts @@ -0,0 +1,78 @@ +/** + * IPC handlers for the renderer-drawn window controls used with the custom + * title bar on Windows/Linux (`titleBarStyle: 'hidden'`). Each handler + * resolves the window from the calling WebContents so it works without + * coupling to the static main-window reference. + */ + +import { BrowserWindow, ipcMain } from 'electron'; +import { + WINDOW_CLOSE, + WINDOW_GET_STATE, + WINDOW_MINIMIZE, + WINDOW_TOGGLE_MAXIMIZE, +} from '@iptvnator/shared/interfaces'; + +interface WindowState { + isMaximized: boolean; + isFullScreen: boolean; +} + +function getSenderWindow( + event: Electron.IpcMainInvokeEvent +): Electron.BrowserWindow | null { + const win = BrowserWindow.fromWebContents(event.sender); + return win && !win.isDestroyed() ? win : null; +} + +function getWindowState(win: Electron.BrowserWindow | null): WindowState { + return { + isMaximized: !!win?.isMaximized(), + isFullScreen: !!win?.isFullScreen(), + }; +} + +export default class WindowEvents { + static bootstrapWindowEvents(): Electron.IpcMain { + return ipcMain; + } +} + +ipcMain.handle(WINDOW_MINIMIZE, (event) => { + getSenderWindow(event)?.minimize(); +}); + +ipcMain.handle(WINDOW_TOGGLE_MAXIMIZE, (event): WindowState => { + const win = getSenderWindow(event); + + if (!win) { + return getWindowState(win); + } + + // maximize()/unmaximize() complete asynchronously on Linux window + // managers, so re-reading isMaximized() here would race. Report the + // requested state instead; the WINDOW:STATE_CHANGED push stays the + // authoritative update once the window manager has acted. + const shouldMaximize = !win.isMaximized(); + + if (shouldMaximize) { + win.maximize(); + } else { + win.unmaximize(); + } + + return { + isMaximized: shouldMaximize, + isFullScreen: win.isFullScreen(), + }; +}); + +// win.close() (instead of app.quit()) so the window's 'close' handler still +// persists the window bounds before shutdown. +ipcMain.handle(WINDOW_CLOSE, (event) => { + getSenderWindow(event)?.close(); +}); + +ipcMain.handle(WINDOW_GET_STATE, (event): WindowState => { + return getWindowState(getSenderWindow(event)); +}); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 4d616cbde..9ecc6b0d2 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -24,6 +24,7 @@ import SquirrelEvents from './app/events/squirrel.events'; import StalkerEvents from './app/events/stalker.events'; import { isStartupTraceEnabled, trace } from './app/services/debug-trace'; import { databaseWorkerClient } from './app/services/database-worker-client'; +import WindowEvents from './app/events/window.events'; import XtreamEvents from './app/events/xtream.events'; app.setName('iptvnator'); @@ -90,6 +91,7 @@ export default class Main { } ElectronEvents.bootstrapElectronEvents(); + WindowEvents.bootstrapWindowEvents(); EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); PlaylistEvents.bootstrapPlaylistEvents(); SharedEvents.bootstrapSharedEvents(); diff --git a/apps/web/src/app/app.component.html b/apps/web/src/app/app.component.html index b11815628..da17c53a0 100644 --- a/apps/web/src/app/app.component.html +++ b/apps/web/src/app/app.component.html @@ -1,3 +1,7 @@ + +@if (usesCustomWindowControls) { + +} diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index a26f63e50..22a41a3f6 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -4,9 +4,13 @@ import { Router, RouterOutlet } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; +import { WindowControlsComponent } from '@iptvnator/ui/components'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { filter, take } from 'rxjs'; import { @@ -30,12 +34,15 @@ const debugAppComponent = createDevLogger('AppComponent'); @Component({ selector: 'app-root', templateUrl: './app.component.html', - imports: [EpgProgressPanelComponent, RouterOutlet], + imports: [EpgProgressPanelComponent, RouterOutlet, WindowControlsComponent], }) export class AppComponent implements OnInit { @HostBinding('class.macos-platform') get isMacOS() { return this.runtime.isMacOS; } + get usesCustomWindowControls() { + return this.runtime.usesCustomWindowControls; + } private actions$ = inject(Actions); private dataService = inject(DataService); private epgBridge = inject(EpgRuntimeBridgeService); @@ -53,6 +60,12 @@ export class AppComponent implements OnInit { private readonly DEFAULT_LANG = Language.ENGLISH; constructor() { + // Body-level class (like 'dark-theme') so layout adjustments also + // reach content rendered outside app-root, e.g. cdk-overlay content. + if (this.runtime.usesCustomWindowControls) { + document.body.classList.add('frameless-platform'); + } + const electronProcess = this.dataService.remote?.process; if ( this.dataService.isElectron && diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 6470250f7..799b67185 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -100,6 +100,17 @@ app-root.macos-platform app-navigation .portal-status-container { padding-top: 30px; } +// Windows/Linux frameless title bar: reserve room in top-aligned drag +// regions for the renderer-drawn window controls (3 × 46px buttons). +// Body-level class so it also reaches cdk-overlay content (multi-EPG). +body.frameless-platform .workspace-header { + padding-right: 150px; +} + +body.frameless-platform #epg-navigation { + padding-right: 150px; +} + // ─── Base ───────────────────────────────────────────────────────────────────── body, html { diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 05a7642e6..991582789 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -74,20 +74,20 @@ Provider route integration: The shell is intentionally split into four persistent regions: 1. Left rail: - 1. Static workspace links for dashboard, sources, global favorites, and recently viewed. - 2. Provider-aware context links derived from the active or current playlist. - 3. Settings remains a persistent footer shortcut in the rail. + 1. Static workspace links for dashboard, sources, global favorites, and recently viewed. + 2. Provider-aware context links derived from the active or current playlist. + 3. Settings remains a persistent footer shortcut in the rail. 2. Top header: - 1. Playlist switcher. - 2. Route-aware search input and command palette trigger. - 3. Add source action. - 4. Optional playlist refresh and route-specific shortcut actions. - 5. Downloads shortcut in Electron. + 1. Playlist switcher. + 2. Route-aware search input and command palette trigger. + 3. Add source action. + 4. Optional playlist refresh and route-specific shortcut actions. + 5. Downloads shortcut in Electron. 3. Main body: - 1. Optional left context panel. - 2. Main router outlet content. + 1. Optional left context panel. + 2. Main router outlet content. 4. Optional footer: - 1. External playback session bar when a docked session is visible. + 1. External playback session bar when a docked session is visible. `WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is kept as a thin template-facing API and delegates ownership to component-scoped @@ -116,15 +116,15 @@ for the template unless the template contract itself intentionally changes. The shell decides which secondary panel to show from the current route: 1. `/workspace/sources` - 1. `WorkspaceSourcesFiltersPanelComponent` + 1. `WorkspaceSourcesFiltersPanelComponent` 2. Xtream category sections (`live`, `vod`, `series`) - 1. `WorkspaceContextPanelComponent` + 1. `WorkspaceContextPanelComponent` 3. Stalker category sections (`itv`, `radio`, `vod`, `series`) - 1. `WorkspaceContextPanelComponent` + 1. `WorkspaceContextPanelComponent` 4. `/workspace/settings` - 1. `WorkspaceSettingsContextPanelComponent` + 1. `WorkspaceSettingsContextPanelComponent` 5. Downloads sections - 1. `WorkspaceCollectionContextPanelComponent` + 1. `WorkspaceCollectionContextPanelComponent` The context panel is part of the shell contract. New workspace-level routes should explicitly decide whether they need one rather than adding local @@ -197,6 +197,91 @@ Keyboard shortcut help is shell-owned: added. Do not include native browser/editor behavior such as `Tab` or platform text editing shortcuts. +## Window Chrome And Custom Title Bar + +The Electron window hides the native title bar on all desktop platforms +(`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`): + +1. macOS keeps the native traffic lights (`titleBarOverlay: true`, + `trafficLightPosition`); the renderer draws no window buttons. +2. Windows and Linux use renderer-drawn window controls + (`app-window-controls`, `libs/ui/components/src/lib/window-controls/`). + `frame` is intentionally left untouched so native resize borders and + window snapping keep working. + +The controls are mounted once in `app-root` (not inside the workspace +header) as a `position: fixed` top-right overlay so they stay clickable +above full-window content such as the multi-EPG cdk overlay and Material +dialog backdrops — the same behavior as the macOS traffic lights. Because +CDK overlays render as popovers in the browser top layer (above any +z-index), the component host is itself a `popover="manual"` element: it +enters the top layer on init and re-enters it (hide + show) whenever +another popover opens, so the controls always paint last. The +`z-index: 10000` remains only as a fallback when the popover API is +unavailable. They render only when +`RuntimeCapabilitiesService.usesCustomWindowControls` is true (Windows/Linux +Electron with the window-control bridge methods available); the PWA and +macOS never mount them. + +IPC contract (constants in `libs/shared/interfaces/src/lib/ipc-commands.ts`, +handlers in `apps/electron-backend/src/app/events/window.events.ts`): + +1. `WINDOW:MINIMIZE`, `WINDOW:TOGGLE_MAXIMIZE`, `WINDOW:CLOSE`, + `WINDOW:GET_STATE` are `ipcMain.handle` channels resolved from the sender + WebContents. Close goes through `win.close()` so the existing + window-bounds persistence in `app.ts` still runs. +2. `WINDOW:STATE_CHANGED` is pushed main → renderer on + maximize/unmaximize/enter-full-screen/leave-full-screen so the + maximize/restore glyph stays correct for externally triggered changes + (double-click on a drag region, OS snap, F11). The controls hide + themselves while the window is fullscreen. + +Layout integration: + +1. `document.body` gets a `frameless-platform` class (set in + `AppComponent`, same mechanism as `dark-theme`) — body-level so rules + also reach cdk-overlay content rendered outside `app-root`. +2. `apps/web/src/styles.scss` reserves `padding-right: 150px` in + top-aligned drag regions (`.workspace-header`, multi-EPG + `#epg-navigation`) for the 3 × 46px button strip. +3. Button colors follow the theme via CSS variables (`--app-on-surface`, + `--app-hover-overlay`); the close button uses the Windows-style red + hover (`#e81123`). No theme IPC is involved. + +Window decorations on Linux (shadows, corners): + +1. Hiding the title bar removes the window manager's decorations, so the + shadow/rounded corners must come from client-side decorations (CSD). + Electron only draws CSD on native Wayland, and frameless-window CSD + (GTK drop shadow + extended resize boundaries, `hasShadow: true` by + default) requires **Electron >= 41** — the reason the dependency was + bumped from 39. Electron picks Wayland automatically on Wayland + sessions since 38.2. +2. On X11 sessions frameless windows stay undecorated (square, no + shadow) — an upstream platform limitation shared by e.g. VS Code. +3. Rounded corners for frameless Linux windows are not yet supported by + Electron (tracked upstream as planned work); Windows 11 keeps its DWM + rounded corners and shadow because the standard frame is retained. + +Toolchain notes for the Electron 41 upgrade: + +1. `better-sqlite3` is pinned to exactly `12.9.0` — the last release that + ships prebuilt binaries for BOTH Node 20 (ABI 115, used by Jest) and + Electron 41 (ABI 145, used at runtime). `12.10.0` dropped the Node 20 + prebuilds, which forces a from-source build that fails on machines + without a C++ toolchain. +2. The pnpm override `node-abi@3.85.0 -> 3.92.0` is required so + `@electron/rebuild` (via `electron-builder install-app-deps`) can map + Electron 41 to its ABI. + +Known caveats: + +1. DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native + caption buttons or the Window Controls Overlay get that). +2. Double-click-to-maximize on drag regions is handled natively by + Electron/Chromium; on Linux the exact behavior depends on the window + manager. + ## Maintenance Guidance Use this document as the source of truth when changing workspace shell behavior. diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index d2f262938..f56123f2d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -38,6 +38,32 @@ export class RuntimeCapabilitiesService { return this.platform === 'darwin'; } + get isWindows(): boolean { + return this.platform === 'win32'; + } + + get isLinux(): boolean { + return this.platform === 'linux'; + } + + /** + * True when the window has no native title bar and the renderer must + * draw its own window-management buttons (Windows/Linux). macOS keeps + * the native traffic lights instead. + */ + get usesCustomWindowControls(): boolean { + return ( + (this.isWindows || this.isLinux) && + [ + 'minimizeWindow', + 'toggleMaximizeWindow', + 'closeWindow', + 'getWindowState', + 'onWindowStateChange', + ].every((methodName) => this.hasElectronMethod(methodName)) + ); + } + get supportsEpg(): boolean { return ( this.supportsEpgImport && diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index afabf5477..ac21d369a 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -135,6 +135,11 @@ export interface ElectronBridgeDialogFilter { extensions: string[]; } +export interface ElectronBridgeWindowState { + isMaximized: boolean; + isFullScreen: boolean; +} + export interface ElectronBridgeAiSettings { aiProvider: string; aiModelName: string; @@ -425,6 +430,13 @@ export interface ElectronBridgeApi { ) => () => void; getAppVersion: () => Promise; platform: string; + minimizeWindow: () => Promise; + toggleMaximizeWindow: () => Promise; + closeWindow: () => Promise; + getWindowState: () => Promise; + onWindowStateChange: ( + callback: (state: ElectronBridgeWindowState) => void + ) => () => void; fetchPlaylistByUrl: (url: string, title?: string) => Promise; updatePlaylistFromFilePath: ( filePath: string, diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 4c5ae551f..d309675b7 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -75,3 +75,10 @@ export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS'; // Remote Control export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL'; + +// Window controls (custom title bar on Windows/Linux) +export const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE'; +export const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE'; +export const WINDOW_CLOSE = 'WINDOW:CLOSE'; +export const WINDOW_GET_STATE = 'WINDOW:GET_STATE'; +export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED'; diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 773f06bb8..96a626368 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -12,3 +12,4 @@ export * from './lib/progress-capsule/progress-capsule.component'; export * from './lib/resizable/resizable.directive'; export * from './lib/season-container/season-container.component'; export * from './lib/watched-badge/watched-badge.component'; +export * from './lib/window-controls/window-controls.component'; diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.html b/libs/ui/components/src/lib/window-controls/window-controls.component.html new file mode 100644 index 000000000..a98c9279d --- /dev/null +++ b/libs/ui/components/src/lib/window-controls/window-controls.component.html @@ -0,0 +1,55 @@ + + + + + diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.scss b/libs/ui/components/src/lib/window-controls/window-controls.component.scss new file mode 100644 index 000000000..22c72625a --- /dev/null +++ b/libs/ui/components/src/lib/window-controls/window-controls.component.scss @@ -0,0 +1,88 @@ +// Floats above all app content so the window stays controllable while +// dialogs or the multi-EPG overlay are open — same behavior as the macOS +// traffic lights. The host is a manual popover (see component) because CDK +// overlays live in the browser top layer where z-index cannot compete; the +// declarations below also neutralize the UA popover styles (inset, margin, +// border, background) and keep the strip visible/positioned when the +// popover API is unavailable. +:host { + position: fixed; + inset: 0 0 auto auto; + z-index: 10000; + display: flex; + width: fit-content; + height: fit-content; + margin: 0; + padding: 0; + border: none; + background: transparent; + overflow: visible; + color: inherit; + app-region: no-drag; + -webkit-app-region: no-drag; +} + +:host(.is-hidden) { + display: none; +} + +.window-control { + display: flex; + align-items: center; + justify-content: center; + width: 46px; + height: 32px; + margin: 0; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--app-on-surface, #1d1b1e); + opacity: 0.85; + // Native caption buttons use the arrow cursor, not the hand. + cursor: default; + + svg { + display: block; + width: 10px; + height: 10px; + } + + svg.crisp { + shape-rendering: crispEdges; + } + + path { + fill: none; + stroke: currentColor; + stroke-width: 1; + } + + &:hover { + background: var(--app-hover-overlay, rgba(0, 0, 0, 0.06)); + opacity: 1; + } + + &:active { + background: color-mix( + in srgb, + var(--app-on-surface, #1d1b1e) 14%, + transparent + ); + } + + &:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: -2px; + } + + &.close:hover { + background: #e81123; + color: #fff; + } + + &.close:active { + background: #c50f1f; + color: #fff; + } +} diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts b/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts new file mode 100644 index 000000000..c9f152259 --- /dev/null +++ b/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts @@ -0,0 +1,119 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces'; +import { WindowControlsComponent } from './window-controls.component'; + +describe('WindowControlsComponent', () => { + let fixture: ComponentFixture; + let stateChangeCallback: + | ((state: ElectronBridgeWindowState) => void) + | undefined; + + const unsubscribe = jest.fn(); + const electronMock = { + getWindowState: jest.fn(), + onWindowStateChange: jest.fn(), + minimizeWindow: jest.fn(), + toggleMaximizeWindow: jest.fn(), + closeWindow: jest.fn(), + }; + + const query = (testId: string): HTMLElement | null => + fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`); + + beforeEach(async () => { + jest.clearAllMocks(); + stateChangeCallback = undefined; + electronMock.getWindowState.mockResolvedValue({ + isMaximized: false, + isFullScreen: false, + }); + electronMock.toggleMaximizeWindow.mockResolvedValue({ + isMaximized: true, + isFullScreen: false, + }); + electronMock.minimizeWindow.mockResolvedValue(undefined); + electronMock.closeWindow.mockResolvedValue(undefined); + electronMock.onWindowStateChange.mockImplementation( + (callback: (state: ElectronBridgeWindowState) => void) => { + stateChangeCallback = callback; + return unsubscribe; + } + ); + (window as { electron?: unknown }).electron = electronMock; + + await TestBed.configureTestingModule({ + imports: [WindowControlsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(WindowControlsComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + delete (window as { electron?: unknown }).electron; + }); + + it('renders minimize, maximize and close buttons with the maximize glyph', () => { + expect(query('window-minimize')).not.toBeNull(); + expect(query('window-maximize')).not.toBeNull(); + expect(query('window-close')).not.toBeNull(); + expect(query('window-maximize-glyph')).not.toBeNull(); + expect(query('window-restore-glyph')).toBeNull(); + expect(electronMock.getWindowState).toHaveBeenCalled(); + }); + + it('calls the bridge methods when the buttons are clicked', () => { + query('window-minimize')?.click(); + expect(electronMock.minimizeWindow).toHaveBeenCalledTimes(1); + + query('window-maximize')?.click(); + expect(electronMock.toggleMaximizeWindow).toHaveBeenCalledTimes(1); + + query('window-close')?.click(); + expect(electronMock.closeWindow).toHaveBeenCalledTimes(1); + }); + + it('swaps to the restore glyph when the window state reports maximized', () => { + stateChangeCallback?.({ isMaximized: true, isFullScreen: false }); + fixture.detectChanges(); + + expect(query('window-restore-glyph')).not.toBeNull(); + expect(query('window-maximize-glyph')).toBeNull(); + expect(query('window-maximize')?.getAttribute('aria-label')).toBe( + 'Restore' + ); + }); + + it('applies the restore state returned by toggleMaximizeWindow', async () => { + query('window-maximize')?.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(query('window-restore-glyph')).not.toBeNull(); + }); + + it('hides the host while the window is in fullscreen', () => { + stateChangeCallback?.({ isMaximized: false, isFullScreen: true }); + fixture.detectChanges(); + + expect( + (fixture.nativeElement as HTMLElement).classList.contains( + 'is-hidden' + ) + ).toBe(true); + + stateChangeCallback?.({ isMaximized: false, isFullScreen: false }); + fixture.detectChanges(); + + expect( + (fixture.nativeElement as HTMLElement).classList.contains( + 'is-hidden' + ) + ).toBe(false); + }); + + it('unsubscribes from window state changes on destroy', () => { + fixture.destroy(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.ts b/libs/ui/components/src/lib/window-controls/window-controls.component.ts new file mode 100644 index 000000000..861c06ef1 --- /dev/null +++ b/libs/ui/components/src/lib/window-controls/window-controls.component.ts @@ -0,0 +1,126 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + inject, + signal, +} from '@angular/core'; +import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces'; + +/** + * Renderer-drawn window-management buttons (minimize / maximize-restore / + * close) for the frameless title bar on Windows and Linux. macOS keeps the + * native traffic lights instead. + * + * Rendered once in the app root so the buttons stay reachable above + * full-window content such as the multi-EPG overlay and dialog backdrops — + * mirroring how the macOS traffic lights float above everything. CDK + * overlays render as popovers in the browser top layer (above any + * z-index), so the host is itself a manual popover and re-enters the top + * layer whenever another popover opens, keeping the controls topmost. + * Hidden while the window is in fullscreen, matching native title-bar + * behavior. + */ +@Component({ + selector: 'app-window-controls', + templateUrl: './window-controls.component.html', + styleUrl: './window-controls.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + popover: 'manual', + '[class.is-hidden]': 'isFullScreen()', + }, +}) +export class WindowControlsComponent implements AfterViewInit { + readonly isMaximized = signal(false); + readonly isFullScreen = signal(false); + + private readonly host = + inject>(ElementRef).nativeElement; + + constructor() { + const bridge = window.electron; + + void bridge + ?.getWindowState?.() + .then((state) => this.applyState(state)) + .catch(() => undefined); + + const unsubscribe = bridge?.onWindowStateChange?.((state) => + this.applyState(state) + ); + + document.addEventListener('toggle', this.onDocumentToggle, true); + + inject(DestroyRef).onDestroy(() => { + unsubscribe?.(); + document.removeEventListener('toggle', this.onDocumentToggle, true); + }); + } + + ngAfterViewInit(): void { + this.enterTopLayer(); + } + + onMinimize(): void { + void window.electron?.minimizeWindow?.(); + } + + onToggleMaximize(): void { + void window.electron + ?.toggleMaximizeWindow?.() + .then((state) => this.applyState(state)) + .catch(() => undefined); + } + + onClose(): void { + void window.electron?.closeWindow?.(); + } + + private applyState(state: ElectronBridgeWindowState | undefined): void { + if (!state) { + return; + } + + this.isMaximized.set(state.isMaximized); + this.isFullScreen.set(state.isFullScreen); + } + + /** + * Re-enter the top layer after any other popover opens: top-layer + * elements paint in insertion order, so hiding and re-showing puts the + * controls back above freshly opened CDK overlays (dialogs, multi-EPG). + */ + private readonly onDocumentToggle = (event: Event): void => { + const newState = (event as { newState?: string }).newState; + if (event.target === this.host || newState !== 'open') { + return; + } + + requestAnimationFrame(() => this.enterTopLayer(true)); + }; + + private enterTopLayer(reassert = false): void { + if ( + typeof this.host.showPopover !== 'function' || + !this.host.isConnected + ) { + return; + } + + try { + if (this.host.matches(':popover-open')) { + if (!reassert) { + return; + } + this.host.hidePopover(); + } + this.host.showPopover(); + } catch { + // Stays a regular fixed element when the popover API is + // unavailable — still above non-top-layer content via z-index. + } + } +} diff --git a/package.json b/package.json index 9f52486b2..2c7475334 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "angularx-qrcode": "21.0.4", "artplayer": "5.3.0", "axios": "1.15.2", - "better-sqlite3": "^12.5.0", + "better-sqlite3": "12.9.0", "date-fns": "4.1.0", "drizzle-orm": "0.45.2", "electron-conf": "1.3.0", @@ -181,7 +181,7 @@ "conventional-changelog-cli": "5.0.0", "cors": "2.8.6", "drizzle-kit": "0.31.5", - "electron": "^39.8.5", + "electron": "^41.7.2", "electron-builder": "^26.0.12", "electron-playwright-helpers": "1.8.2", "esbuild": "0.27.0", @@ -229,6 +229,7 @@ "hono@4.12.0": "4.12.14", "immutable@5.1.4": "5.1.5", "lodash-es@4.17.22": "4.18.1", + "node-abi@3.85.0": "3.92.0", "node-forge@1.3.3": "1.4.0", "path-to-regexp@0.1.12": "0.1.13", "picomatch@2.3.1": "2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc4d35bca..76d074c4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,7 @@ overrides: hono@4.12.0: 4.12.14 immutable@5.1.4: 5.1.5 lodash-es@4.17.22: 4.18.1 + node-abi@3.85.0: 3.92.0 node-forge@1.3.3: 1.4.0 path-to-regexp@0.1.12: 0.1.13 picomatch@2.3.1: 2.3.2 @@ -104,17 +105,17 @@ importers: specifier: 1.15.2 version: 1.15.2 better-sqlite3: - specifier: ^12.5.0 - version: 12.5.0 + specifier: 12.9.0 + version: 12.9.0 date-fns: specifier: 4.1.0 version: 4.1.0 drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0) + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.9.0) electron-conf: specifier: 1.3.0 - version: 1.3.0(electron@39.8.8) + version: 1.3.0(electron@41.7.2) electron-dl: specifier: 4.0.0 version: 4.0.0 @@ -354,8 +355,8 @@ importers: specifier: 0.31.5 version: 0.31.5 electron: - specifier: ^39.8.5 - version: 39.8.8 + specifier: ^41.7.2 + version: 41.7.2 electron-builder: specifier: ^26.0.12 version: 26.0.12(electron-builder-squirrel-windows@26.0.12) @@ -421,7 +422,7 @@ importers: version: 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) nx-electron: specifier: 22.0.0 - version: 22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))))(@nx/workspace@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(@swc/core@1.15.8(@swc/helpers@0.5.18))(electron-builder-squirrel-windows@26.0.12)(electron@39.8.8)(esbuild@0.27.0)(rxjs@7.8.2)(typescript@5.9.3) + version: 22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))))(@nx/workspace@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(@swc/core@1.15.8(@swc/helpers@0.5.18))(electron-builder-squirrel-windows@26.0.12)(electron@41.7.2)(esbuild@0.27.0)(rxjs@7.8.2)(typescript@5.9.3) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -1550,6 +1551,10 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electron-internal/extract-zip@1.0.2': + resolution: {integrity: sha512-VJuNETNPEhrmQEZezeTZO5TZMV+dobBRyJ7zHjGJWIhMS7m7W1UeClt69u4hkUxv9ZZVxuli/E9Yvc4gDNHGsg==} + engines: {node: '>=22.12.0'} + '@electron/asar@3.2.18': resolution: {integrity: sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg==} engines: {node: '>=10.12.0'} @@ -1564,9 +1569,9 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2': resolution: {tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2} @@ -4647,8 +4652,8 @@ packages: '@types/node@20.19.9': resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} - '@types/node@22.19.3': - resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + '@types/node@24.13.1': + resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -4728,9 +4733,6 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.51.0': resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5412,8 +5414,8 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - better-sqlite3@12.5.0: - resolution: {integrity: sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==} + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} big.js@5.2.2: @@ -5443,10 +5445,6 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - boolean@3.2.0: - resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -5493,9 +5491,6 @@ packages: buffer-builder@0.2.0: resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6529,9 +6524,9 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@39.8.8: - resolution: {integrity: sha512-24MMLdwCg8xwlWzDxC1XZU2MqW0Xx2UPxt9EU15vMDoRSMHPqmi/BgRCEINXZaBLfFSeXEKGpg+QlBRdL5uwaw==} - engines: {node: '>= 12.20.55'} + electron@41.7.2: + resolution: {integrity: sha512-oYbZNimoMlAy6Fp/o5x2vTggptuPsIbnPKCb6jGf1PJStXH7Gkw0MUQrBliHmDqKLW7yeE+SPsvFNILVN0ORMQ==} + engines: {node: '>= 22.12.0'} hasBin: true emittery@0.13.1: @@ -6588,6 +6583,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -6642,9 +6641,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - es6-error@4.1.1: - resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} - es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} @@ -6931,11 +6927,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - extsprintf@1.4.1: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} @@ -6969,9 +6960,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -7303,10 +7291,6 @@ packages: engines: {node: '>=12'} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - global-agent@3.0.0: - resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} - engines: {node: '>=10.0'} - global-modules@1.0.0: resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} engines: {node: '>=0.10.0'} @@ -8259,9 +8243,6 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -8546,10 +8527,6 @@ packages: engines: {node: '>= 18'} hasBin: true - matcher@3.0.0: - resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} - engines: {node: '>=10'} - material-design-icons-iconfont@6.7.0: resolution: {integrity: sha512-lSj71DgVv20kO0kGbs42icDzbRot61gEDBLQACzkUuznRQBUYmbxzEkGU6dNBb5fRWHMaScYlAXX96HQ4/cJWA==} @@ -9028,8 +9005,8 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} - node-abi@3.85.0: - resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} node-abort-controller@3.1.1: @@ -9586,9 +9563,6 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -10347,10 +10321,6 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - roarr@2.15.4: - resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} - engines: {node: '>=8.0'} - rolldown@1.0.0-rc.4: resolution: {integrity: sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10602,9 +10572,6 @@ packages: engines: {node: '>=20.8.1'} hasBin: true - semver-compare@1.0.0: - resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - semver-diff@5.0.0: resolution: {integrity: sha512-0HbGtOm+S7T6NGQ/pxJSJipJvc4DK3FcRVMRkhsIwJDJ4Jcz5DQC1cPPzB5GhzyHjwttW878HaWQq46CkL3cqg==} engines: {node: '>=12'} @@ -10645,10 +10612,6 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-error@7.0.1: - resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} - engines: {node: '>=10'} - serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -10884,9 +10847,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - ssri@13.0.1: resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -11368,10 +11328,6 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} - type-fest@0.13.1: - resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} - engines: {node: '>=10'} - type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -11452,6 +11408,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.24.4: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} @@ -12137,9 +12096,6 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -13737,6 +13693,8 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@electron-internal/extract-zip@1.0.2': {} + '@electron/asar@3.2.18': dependencies: commander: 5.1.0 @@ -13755,17 +13713,16 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@5.0.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.7.3 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 7.24.7 transitivePeerDependencies: - supports-color @@ -13813,7 +13770,7 @@ snapshots: detect-libc: 2.1.2 fs-extra: 10.1.0 got: 11.8.6 - node-abi: 3.85.0 + node-abi: 3.92.0 node-api-version: 0.2.1 ora: 5.4.1 read-binary-file-arch: 1.0.6 @@ -16967,9 +16924,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.19.3': + '@types/node@24.13.1': dependencies: - undici-types: 6.21.0 + undici-types: 7.18.2 '@types/normalize-package-data@2.4.4': {} @@ -17052,11 +17009,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 20.19.9 - optional: true - '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -18008,7 +17960,7 @@ snapshots: before-after-hook@4.0.0: {} - better-sqlite3@12.5.0: + better-sqlite3@12.9.0: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 @@ -18065,9 +18017,6 @@ snapshots: boolbase@1.0.0: {} - boolean@3.2.0: - optional: true - bottleneck@2.19.5: {} boxen@8.0.1: @@ -18126,8 +18075,6 @@ snapshots: buffer-builder@0.2.0: {} - buffer-crc32@0.2.13: {} - buffer-from@1.1.2: {} buffer@5.7.1: @@ -19051,10 +18998,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0): + drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.9.0): optionalDependencies: '@types/better-sqlite3': 7.6.13 - better-sqlite3: 12.5.0 + better-sqlite3: 12.9.0 dset@3.1.4: {} @@ -19105,10 +19052,10 @@ snapshots: - electron-builder-squirrel-windows - supports-color - electron-conf@1.3.0(electron@39.8.8): + electron-conf@1.3.0(electron@41.7.2): dependencies: ajv: 8.17.1 - electron: 39.8.8 + electron: 41.7.2 electron-dl@4.0.0: dependencies: @@ -19149,11 +19096,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron@39.8.8: + electron@41.7.2: dependencies: - '@electron/get': 2.0.3 - '@types/node': 22.19.3 - extract-zip: 2.0.1 + '@electron-internal/extract-zip': 1.0.2 + '@electron/get': 5.0.0 + '@types/node': 24.13.1 transitivePeerDependencies: - supports-color @@ -19199,6 +19146,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} epg-parser@0.1.6: @@ -19306,9 +19255,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es6-error@4.1.1: - optional: true - es6-promise@4.2.8: {} esast-util-from-estree@2.0.0: @@ -19835,16 +19781,6 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - extsprintf@1.4.1: optional: true @@ -19878,10 +19814,6 @@ snapshots: dependencies: bser: 2.1.1 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -20256,16 +20188,6 @@ snapshots: minimatch: 5.1.9 once: 1.4.0 - global-agent@3.0.0: - dependencies: - boolean: 3.2.0 - es6-error: 4.1.1 - matcher: 3.0.0 - roarr: 2.15.4 - semver: 7.7.3 - serialize-error: 7.0.1 - optional: true - global-modules@1.0.0: dependencies: global-prefix: 1.0.2 @@ -21522,9 +21444,6 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - json-stringify-safe@5.0.1: - optional: true - json5@1.0.2: dependencies: minimist: 1.2.8 @@ -21877,11 +21796,6 @@ snapshots: marked@15.0.12: {} - matcher@3.0.0: - dependencies: - escape-string-regexp: 4.0.0 - optional: true - material-design-icons-iconfont@6.7.0: {} math-intrinsics@1.1.0: {} @@ -22604,7 +22518,7 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 - node-abi@3.85.0: + node-abi@3.92.0: dependencies: semver: 7.7.3 @@ -22757,12 +22671,12 @@ snapshots: nwsapi@2.2.23: {} - nx-electron@22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))))(@nx/workspace@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(@swc/core@1.15.8(@swc/helpers@0.5.18))(electron-builder-squirrel-windows@26.0.12)(electron@39.8.8)(esbuild@0.27.0)(rxjs@7.8.2)(typescript@5.9.3): + nx-electron@22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))))(@nx/workspace@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(@swc/core@1.15.8(@swc/helpers@0.5.18))(electron-builder-squirrel-windows@26.0.12)(electron@41.7.2)(esbuild@0.27.0)(rxjs@7.8.2)(typescript@5.9.3): dependencies: '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) '@nx/workspace': 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) copy-webpack-plugin: 12.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.27.0)) - electron: 39.8.8 + electron: 41.7.2 electron-builder: 26.0.12(electron-builder-squirrel-windows@26.0.12) fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.27.0)) license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.27.0)) @@ -23298,8 +23212,6 @@ snapshots: pe-library@0.4.1: {} - pend@1.2.0: {} - piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -23646,7 +23558,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.85.0 + node-abi: 3.92.0 pump: 3.0.3 rc: 1.2.8 simple-get: 4.0.1 @@ -24087,16 +23999,6 @@ snapshots: dependencies: glob: 7.2.3 - roarr@2.15.4: - dependencies: - boolean: 3.2.0 - detect-node: 2.1.0 - globalthis: 1.0.4 - json-stringify-safe: 5.0.1 - semver-compare: 1.0.0 - sprintf-js: 1.1.3 - optional: true - rolldown@1.0.0-rc.4: dependencies: '@oxc-project/types': 0.113.0 @@ -24397,9 +24299,6 @@ snapshots: - supports-color - typescript - semver-compare@1.0.0: - optional: true - semver-diff@5.0.0: dependencies: semver: 7.7.3 @@ -24450,11 +24349,6 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-error@7.0.1: - dependencies: - type-fest: 0.13.1 - optional: true - serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -24791,9 +24685,6 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.3: - optional: true - ssri@13.0.1: dependencies: minipass: 7.1.3 @@ -25330,9 +25221,6 @@ snapshots: type-detect@4.0.8: {} - type-fest@0.13.1: - optional: true - type-fest@0.20.2: {} type-fest@0.21.3: {} @@ -25420,6 +25308,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.18.2: {} + undici@7.24.4: {} undici@7.24.7: {} @@ -26097,11 +25987,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yn@3.1.1: {} yocto-queue@0.1.0: {} From e2a7b5364f221576eeb787ccad5303ba4f501401 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:23:36 +0200 Subject: [PATCH 2/8] test(electron): allow local EPG mocks in E2E Co-authored-by: 4gray --- .github/workflows/e2e-tests.yaml | 1 + apps/electron-backend-e2e/src/electron-test-fixtures.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index b3ef0012c..7190a00bd 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -13,6 +13,7 @@ jobs: name: Electron E2E on ${{ matrix.os }} runs-on: ${{ matrix.os }} env: + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1' NX_SKIP_NX_CACHE: true strategy: fail-fast: false diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 9589992ad..62f365b24 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -141,6 +141,9 @@ export async function launchElectronApp( args, env: { ...process.env, + // Electron E2E uses local mock HTTP servers for playlists, portals, and EPG sources. + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: + process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1', ...options.env, ELECTRON_IS_DEV: '0', IPTVNATOR_E2E_DATA_DIR: dataDir, From 7775553a6b0945eb0e8a3bc909676349160625fd Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:02:42 +0200 Subject: [PATCH 3/8] ci: skip draft release for fork PRs Co-authored-by: 4gray --- .github/workflows/build-and-make.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 36ace9e00..e9e0c173a 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -522,6 +522,7 @@ jobs: create-release: name: Create Draft Release needs: build + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest permissions: contents: write From 2c032cd3c8bef968bbce4be2449891f13baf7950 Mon Sep 17 00:00:00 2001 From: Salem <40477317+SalemOurabi@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:24:29 +0200 Subject: [PATCH 4/8] fix(security): complete Electron hardening and review follow-ups * fix(security): harden Electron IPC against MITM, SSRF, path and injection risks S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts. S2: write-file IPC restricted to save-dialog-authorized paths. S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests). S4: EPG titles rendered via interpolation, not [innerHTML]. S5: downloads reveal/play limited to recorded download paths. S6: Stalker cmd encoded (slash-preserving) to block query injection. EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed. * perf(player): lazy-load web video players via @defer Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js, artplayer and mpegts.js split into a deferred chunk loaded on first playback instead of eagerly on the player route. Embedded MPV (native) stays eager. Spec uses DeferBlockBehavior.Playthrough. * fix(player): remove leaked HTML video listeners on destroy volumechange used a mismatched removeEventListener reference, while loadedmetadata and timeupdate were never removed at all. Bind all three to stable handler fields used for both add and remove, and add a teardown regression test asserting each listener is detached on destroy. * refactor(dashboard): extract pure navigation helpers from DashboardDataService Move the 8 stateless link/navigation-state/type-kind helpers into a new dashboard-navigation.util.ts so the routing logic is independently testable and the 1260-line god-service shrinks. DashboardDataService keeps the public methods as thin delegators (facade) so the public API and the single consumer (workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService decomposition; verified by the existing service spec (33/33) and the app typecheck. * fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder) - url-safety: broaden IPv6 link-local detection to the full fe80::/10 range (fe80:: through febf::), not just the fe80:: prefix (+ regression tests). - playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save dialog opened without a following write cannot accumulate entries until restart. - web-player-view: add a @placeholder to each @defer (on immediate) player block to avoid the one-frame blank/layout-shift before the chunk resolves. * fix(security): close Electron network and download gaps * test(downloads): cover cancellation and restart cleanup * fix(downloads): address Greptile review gaps * test(security): reproduce remaining Greptile findings * fix(security): close remaining Greptile findings * test(downloads): reproduce early database queue stall * fix(downloads): release queue after setup failures * test(downloads): reproduce completion queue stall * fix(downloads): release queue after completion failures --- README.md | 9 + .../download-directory-authorization.spec.ts | 76 ++ .../download-directory-authorization.ts | 80 +++ .../database/download-file-path.spec.ts | 90 +++ .../app/events/database/download-file-path.ts | 57 ++ .../events/database/download-recovery.spec.ts | 48 ++ .../app/events/database/download-recovery.ts | 45 ++ .../app/events/database/download-requests.ts | 206 ++++++ .../events/database/download-runtime.spec.ts | 326 +++++++++ .../app/events/database/download-runtime.ts | 295 ++++++++ .../src/app/events/database/download-task.ts | 27 + .../app/events/database/downloads.events.ts | 673 ++++-------------- .../events/database/stale-download-files.ts | 29 + .../src/app/events/playlist-source.ts | 63 ++ .../events/playlist-write-authorization.ts | 36 + .../src/app/events/playlist.events.spec.ts | 81 ++- .../src/app/events/playlist.events.ts | 237 +++--- .../src/app/events/stalker.events.ts | 37 +- .../src/app/events/url-safety.spec.ts | 124 ++++ .../src/app/events/url-safety.ts | 272 +++++++ .../src/app/events/xtream.events.spec.ts | 118 +-- .../src/app/events/xtream.events.ts | 56 +- .../src/app/util/secure-https.spec.ts | 71 ++ .../src/app/util/secure-https.ts | 36 + .../src/app/util/validated-axios.spec.ts | 384 ++++++++++ .../src/app/util/validated-axios.ts | 215 ++++++ .../src/app/workers/epg-database.spec.ts | 48 ++ .../src/app/workers/epg-database.ts | 146 ++++ .../src/app/workers/epg-parser.worker.ts | 206 +----- .../app/workers/epg-response-utils.spec.ts | 16 +- .../src/app/workers/epg-response-utils.ts | 26 +- .../app/workers/playlist-refresh.worker.ts | 102 +-- docs/architecture/download-manager.md | 29 +- docs/architecture/electron-security.md | 46 ++ .../src/lib/downloads.service.spec.ts | 24 + libs/services/src/lib/downloads.service.ts | 11 +- .../src/lib/epg-list/epg-list.component.html | 5 +- .../html-video-player.component.spec.ts | 25 + .../html-video-player.component.ts | 50 +- .../web-player-view.component.html | 76 +- .../web-player-view.component.scss | 9 + .../web-player-view.component.spec.ts | 37 +- .../src/lib/dashboard-data.service.ts | 95 +-- .../src/lib/dashboard-navigation.util.ts | 128 ++++ 44 files changed, 3646 insertions(+), 1124 deletions(-) create mode 100644 apps/electron-backend/src/app/events/database/download-directory-authorization.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-directory-authorization.ts create mode 100644 apps/electron-backend/src/app/events/database/download-file-path.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-file-path.ts create mode 100644 apps/electron-backend/src/app/events/database/download-recovery.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-recovery.ts create mode 100644 apps/electron-backend/src/app/events/database/download-requests.ts create mode 100644 apps/electron-backend/src/app/events/database/download-runtime.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-runtime.ts create mode 100644 apps/electron-backend/src/app/events/database/download-task.ts create mode 100644 apps/electron-backend/src/app/events/database/stale-download-files.ts create mode 100644 apps/electron-backend/src/app/events/playlist-source.ts create mode 100644 apps/electron-backend/src/app/events/playlist-write-authorization.ts create mode 100644 apps/electron-backend/src/app/events/url-safety.spec.ts create mode 100644 apps/electron-backend/src/app/events/url-safety.ts create mode 100644 apps/electron-backend/src/app/util/secure-https.spec.ts create mode 100644 apps/electron-backend/src/app/util/secure-https.ts create mode 100644 apps/electron-backend/src/app/util/validated-axios.spec.ts create mode 100644 apps/electron-backend/src/app/util/validated-axios.ts create mode 100644 apps/electron-backend/src/app/workers/epg-database.spec.ts create mode 100644 apps/electron-backend/src/app/workers/epg-database.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts diff --git a/README.md b/README.md index 2c1d2f2df..e5f638b03 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,15 @@ Useful narrower flags: - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console messages into the Electron terminal output +Security-sensitive network compatibility flags are opt-in: + +- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits EPG URLs that resolve to + localhost, LAN, or other private addresses. Leave this unset for playlists + you do not fully trust. +- `IPTVNATOR_ALLOW_INSECURE_TLS=1` disables certificate validation for remote + playlist imports and refreshes. Use it only for a trusted provider with a + self-signed or otherwise invalid certificate. + If the local Nx daemon gets into a bad state before rerunning Electron, reset it: ``` diff --git a/apps/electron-backend/src/app/events/database/download-directory-authorization.spec.ts b/apps/electron-backend/src/app/events/database/download-directory-authorization.spec.ts new file mode 100644 index 000000000..e5bf0d819 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-directory-authorization.spec.ts @@ -0,0 +1,76 @@ +import { resolve } from 'node:path'; +import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; + +describe('DownloadDirectoryAuthorizer', () => { + it('returns a previously authorized native-dialog selection', async () => { + const authorizer = new DownloadDirectoryAuthorizer({ + getDefaultDirectory: () => '/downloads', + loadSelectedDirectory: async () => '/media/iptv', + saveSelectedDirectory: jest.fn(), + }); + + await expect(authorizer.getPreferredDirectory()).resolves.toBe( + resolve('/media/iptv') + ); + await expect(authorizer.requireAuthorized('/media/iptv')).resolves.toBe( + resolve('/media/iptv') + ); + }); + + it('rejects a renderer-supplied directory that was never authorized', async () => { + const authorizer = new DownloadDirectoryAuthorizer({ + getDefaultDirectory: () => '/downloads', + loadSelectedDirectory: async () => null, + saveSelectedDirectory: jest.fn(), + }); + + await expect( + authorizer.requireAuthorized('/tmp/renderer-controlled') + ).rejects.toThrow(/not authorized/i); + }); + + it('persists and authorizes a directory selected by the native dialog', async () => { + const saveSelectedDirectory = jest.fn().mockResolvedValue(undefined); + const authorizer = new DownloadDirectoryAuthorizer({ + getDefaultDirectory: () => '/downloads', + loadSelectedDirectory: async () => null, + saveSelectedDirectory, + }); + + await expect( + authorizer.authorizeSelectedDirectory('/media/iptv') + ).resolves.toBe(resolve('/media/iptv')); + expect(saveSelectedDirectory).toHaveBeenCalledWith( + resolve('/media/iptv') + ); + await expect(authorizer.requireAuthorized('/media/iptv')).resolves.toBe( + resolve('/media/iptv') + ); + }); + + it('matches authorized paths case-insensitively on Windows', async () => { + const authorizer = new DownloadDirectoryAuthorizer({ + getDefaultDirectory: () => 'C:\\Downloads', + loadSelectedDirectory: async () => 'C:\\Media\\IPTV', + saveSelectedDirectory: jest.fn(), + platform: 'win32', + }); + + await expect( + authorizer.requireAuthorized('c:\\media\\iptv') + ).resolves.toBe(resolve('c:\\media\\iptv')); + }); + + it('keeps authorized path matching case-sensitive on POSIX', async () => { + const authorizer = new DownloadDirectoryAuthorizer({ + getDefaultDirectory: () => '/downloads', + loadSelectedDirectory: async () => '/media/IPTV', + saveSelectedDirectory: jest.fn(), + platform: 'linux', + }); + + await expect( + authorizer.requireAuthorized('/media/iptv') + ).rejects.toThrow(/not authorized/i); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-directory-authorization.ts b/apps/electron-backend/src/app/events/database/download-directory-authorization.ts new file mode 100644 index 000000000..11413911a --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-directory-authorization.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path'; + +export interface DownloadDirectoryAuthorizerOptions { + getDefaultDirectory: () => string; + loadSelectedDirectory: () => Promise; + saveSelectedDirectory: (directory: string) => Promise; + platform?: NodeJS.Platform; +} + +function normalizeDirectory(directory: string): string { + const value = String(directory ?? '').trim(); + if (!value) { + throw new Error('Download directory is required'); + } + return resolve(value); +} + +function directoryKey(directory: string, platform: NodeJS.Platform): string { + const normalized = normalizeDirectory(directory); + return platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +/** + * Keeps download-directory authorization in the Electron main process. + * Only the OS default or a directory persisted after a native dialog may be + * used by renderer-triggered download IPC calls. + */ +export class DownloadDirectoryAuthorizer { + private selectedDirectory: string | null | undefined; + + constructor(private readonly options: DownloadDirectoryAuthorizerOptions) {} + + private async loadSelectedDirectory(): Promise { + if (this.selectedDirectory !== undefined) { + return this.selectedDirectory; + } + + const storedDirectory = await this.options.loadSelectedDirectory(); + this.selectedDirectory = storedDirectory + ? normalizeDirectory(storedDirectory) + : null; + return this.selectedDirectory; + } + + async getPreferredDirectory(): Promise { + return ( + (await this.loadSelectedDirectory()) ?? + normalizeDirectory(this.options.getDefaultDirectory()) + ); + } + + async authorizeSelectedDirectory(directory: string): Promise { + const normalized = normalizeDirectory(directory); + await this.options.saveSelectedDirectory(normalized); + this.selectedDirectory = normalized; + return normalized; + } + + async requireAuthorized(directory: string): Promise { + const normalized = normalizeDirectory(directory); + const defaultDirectory = normalizeDirectory( + this.options.getDefaultDirectory() + ); + const selectedDirectory = await this.loadSelectedDirectory(); + const platform = this.options.platform ?? process.platform; + const requestedKey = directoryKey(normalized, platform); + + if ( + requestedKey !== directoryKey(defaultDirectory, platform) && + (!selectedDirectory || + requestedKey !== directoryKey(selectedDirectory, platform)) + ) { + throw new Error( + 'Download directory was not authorized by a native folder dialog' + ); + } + + return normalized; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-file-path.spec.ts b/apps/electron-backend/src/app/events/database/download-file-path.spec.ts new file mode 100644 index 000000000..56ef927b2 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-file-path.spec.ts @@ -0,0 +1,90 @@ +import { join } from 'node:path'; +import { + removePartialDownload, + reserveAvailableDownloadFile, +} from './download-file-path'; + +describe('reserveAvailableDownloadFile', () => { + it('atomically reserves the requested filename when it is unused', () => { + const reserveFile = jest.fn(); + + expect( + reserveAvailableDownloadFile('/downloads', 'movie.mp4', reserveFile) + ).toEqual({ + filename: 'movie.mp4', + path: join('/downloads', 'movie.mp4'), + }); + expect(reserveFile).toHaveBeenCalledWith( + join('/downloads', 'movie.mp4') + ); + }); + + it('retries with a numbered filename after exclusive-create collisions', () => { + const reserveFile = jest.fn((filePath: string) => { + if (!filePath.endsWith('movie (2).mp4')) { + const error = new Error( + 'already exists' + ) as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + }); + + expect( + reserveAvailableDownloadFile('/downloads', 'movie.mp4', reserveFile) + ).toEqual({ + filename: 'movie (2).mp4', + path: join('/downloads', 'movie (2).mp4'), + }); + expect(reserveFile).toHaveBeenCalledTimes(3); + }); + + it('does not hide non-collision filesystem errors', () => { + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + + expect(() => + reserveAvailableDownloadFile('/downloads', 'movie.mp4', () => { + throw error; + }) + ).toThrow(error); + }); +}); + +describe('removePartialDownload', () => { + it('removes only the actual partial save path', () => { + const requestedPath = join('/downloads', 'movie.mp4'); + const partialPath = join('/downloads', 'movie (1).mp4'); + const removeFile = jest.fn(); + + expect( + removePartialDownload( + { getSavePath: () => partialPath }, + (filePath) => filePath === partialPath, + removeFile + ) + ).toBe(true); + expect(removeFile).toHaveBeenCalledWith(partialPath); + expect(removeFile).not.toHaveBeenCalledWith(requestedPath); + }); + + it('does not remove a missing or unavailable partial path', () => { + const removeFile = jest.fn(); + + expect( + removePartialDownload( + { getSavePath: () => '' }, + () => true, + removeFile + ) + ).toBe(false); + expect( + removePartialDownload( + { getSavePath: () => '/downloads/missing.mp4' }, + () => false, + removeFile + ) + ).toBe(false); + expect(removeFile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-file-path.ts b/apps/electron-backend/src/app/events/database/download-file-path.ts new file mode 100644 index 000000000..6bab7324f --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-file-path.ts @@ -0,0 +1,57 @@ +import { closeSync, existsSync, openSync, unlinkSync } from 'node:fs'; +import { extname, join } from 'node:path'; + +export interface ReservedDownloadFile { + filename: string; + path: string; +} + +function createExclusiveFile(filePath: string): void { + const descriptor = openSync(filePath, 'wx'); + closeSync(descriptor); +} + +export function reserveAvailableDownloadFile( + directory: string, + requestedFilename: string, + reserveFile: (filePath: string) => void = createExclusiveFile +): ReservedDownloadFile { + const extension = extname(requestedFilename); + const stem = extension + ? requestedFilename.slice(0, -extension.length) + : requestedFilename; + let candidate = requestedFilename; + let suffix = 1; + + for (;;) { + const filePath = join(directory, candidate); + try { + reserveFile(filePath); + return { filename: candidate, path: filePath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + candidate = `${stem} (${suffix})${extension}`; + suffix += 1; + } + } +} + +export interface DownloadSavePathItem { + getSavePath(): string; +} + +export function removePartialDownload( + downloadItem: DownloadSavePathItem | undefined, + pathExists: (filePath: string) => boolean = existsSync, + removeFile: (filePath: string) => void = unlinkSync +): boolean { + const savePath = downloadItem?.getSavePath(); + if (!savePath || !pathExists(savePath)) { + return false; + } + + removeFile(savePath); + return true; +} diff --git a/apps/electron-backend/src/app/events/database/download-recovery.spec.ts b/apps/electron-backend/src/app/events/database/download-recovery.spec.ts new file mode 100644 index 000000000..4456b0585 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-recovery.spec.ts @@ -0,0 +1,48 @@ +import { cleanupStaleDownloadFiles } from './stale-download-files'; + +describe('cleanupStaleDownloadFiles', () => { + it('removes every persisted partial path and ignores empty paths', () => { + const removeFile = jest.fn(); + + cleanupStaleDownloadFiles( + [ + { filePath: '/downloads/one.mp4' }, + { filePath: null }, + { filePath: '/downloads/two.mp4' }, + ], + removeFile + ); + + expect(removeFile).toHaveBeenCalledTimes(2); + expect(removeFile).toHaveBeenNthCalledWith(1, '/downloads/one.mp4'); + expect(removeFile).toHaveBeenNthCalledWith(2, '/downloads/two.mp4'); + }); + + it('continues cleaning other stale files after one removal fails', () => { + const removeFile = jest.fn((filePath: string) => { + if (filePath.endsWith('one.mp4')) { + throw new Error('locked'); + } + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + cleanupStaleDownloadFiles( + [ + { filePath: '/downloads/one.mp4' }, + { filePath: '/downloads/two.mp4' }, + ], + removeFile + ); + + expect(removeFile).toHaveBeenCalledTimes(2); + expect(consoleError).toHaveBeenCalledWith( + '[Downloads] Failed to delete stale partial file:', + '/downloads/one.mp4', + expect.any(Error) + ); + + consoleError.mockRestore(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-recovery.ts b/apps/electron-backend/src/app/events/database/download-recovery.ts new file mode 100644 index 000000000..09f3fc8f3 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-recovery.ts @@ -0,0 +1,45 @@ +import { inArray, sql } from 'drizzle-orm'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { cleanupStaleDownloadFiles } from './stale-download-files'; + +export async function resetStaleDownloads(): Promise { + try { + const db = await getDatabase(); + const staleDownloads = await db + .select({ + filePath: schema.downloads.filePath, + id: schema.downloads.id, + }) + .from(schema.downloads) + .where(inArray(schema.downloads.status, ['queued', 'downloading'])); + const ownedReservations = staleDownloads.filter( + (item) => item.filePath + ); + + cleanupStaleDownloadFiles(ownedReservations); + await db + .update(schema.downloads) + .set({ + errorMessage: 'Download interrupted by application restart', + status: 'failed', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(inArray(schema.downloads.status, ['queued', 'downloading'])); + + if (ownedReservations.length > 0) { + await db + .update(schema.downloads) + .set({ filePath: null }) + .where( + inArray( + schema.downloads.id, + ownedReservations.map((item) => item.id) + ) + ); + } + console.log('[Downloads] Reset stale downloads'); + } catch (error) { + console.error('[Downloads] Error resetting stale downloads:', error); + } +} diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts new file mode 100644 index 000000000..deb643ac7 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -0,0 +1,206 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { extname } from 'node:path'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { assertRemoteUrlAllowed } from '../url-safety'; +import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; +import { enqueueDownload } from './download-runtime'; + +export interface StartDownloadRequest { + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + title: string; + url: string; + posterUrl?: string; + downloadFolder: string; + headers?: { userAgent?: string; referer?: string; origin?: string }; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + playlistName?: string; + playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; + serverUrl?: string; + portalUrl?: string; + macAddress?: string; +} + +function getExtensionFromUrl(url: string): string { + try { + return extname(new URL(url).pathname) || '.mp4'; + } catch { + return '.mp4'; + } +} + +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*]/g, '_').trim(); +} + +function createFileName(title: string, url: string): string { + return sanitizeFilename(title) + getExtensionFromUrl(url); +} + +function createHeaders( + headers: StartDownloadRequest['headers'] +): Record | undefined { + return headers + ? { + 'User-Agent': headers.userAgent || '', + Origin: headers.origin || '', + Referer: headers.referer || '', + } + : undefined; +} + +export async function startDownloadRequest( + data: StartDownloadRequest, + authorizer: DownloadDirectoryAuthorizer +): Promise<{ success: boolean; error?: string; id?: number }> { + console.log('[Downloads] Enqueue download:', data.title); + const directory = await authorizer.requireAuthorized(data.downloadFolder); + await assertRemoteUrlAllowed(data.url, { allowPrivateNetworks: true }); + const db = await getDatabase(); + + if (!data.playlistId) { + throw new Error('playlistId is required for downloads'); + } + + const existingPlaylist = await db + .select() + .from(schema.playlists) + .where(eq(schema.playlists.id, data.playlistId)) + .limit(1); + if (existingPlaylist.length === 0) { + console.log( + '[Downloads] Creating playlist entry for:', + data.playlistId + ); + await db.insert(schema.playlists).values({ + id: data.playlistId, + macAddress: data.macAddress, + name: data.playlistName || 'Unknown Playlist', + serverUrl: data.serverUrl, + type: data.playlistType || 'stalker', + url: data.portalUrl, + }); + } + + const existing = await db + .select() + .from(schema.downloads) + .where( + and( + eq(schema.downloads.playlistId, data.playlistId), + eq(schema.downloads.xtreamId, data.xtreamId), + eq(schema.downloads.contentType, data.contentType) + ) + ) + .limit(1); + const fileName = createFileName(data.title, data.url); + + if (existing.length > 0) { + const item = existing[0]; + if (!['completed', 'failed', 'canceled'].includes(item.status)) { + return { + error: 'Download already in progress', + id: item.id, + success: false, + }; + } + + await db + .update(schema.downloads) + .set({ + bytesDownloaded: 0, + errorMessage: null, + fileName, + filePath: null, + status: 'queued', + totalBytes: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + url: data.url, + }) + .where(eq(schema.downloads.id, item.id)); + enqueueDownload({ + directory, + fileName, + headers: createHeaders(data.headers), + id: item.id, + url: data.url, + }); + return { id: item.id, success: true }; + } + + const result = await db.insert(schema.downloads).values({ + contentType: data.contentType, + episodeNumber: data.episodeNumber, + fileName, + playlistId: data.playlistId, + posterUrl: data.posterUrl, + seasonNumber: data.seasonNumber, + seriesXtreamId: data.seriesXtreamId, + status: 'queued', + title: data.title, + url: data.url, + xtreamId: data.xtreamId, + }); + const insertedId = Number(result.lastInsertRowid); + enqueueDownload({ + directory, + fileName, + headers: createHeaders(data.headers), + id: insertedId, + url: data.url, + }); + return { id: insertedId, success: true }; +} + +export async function retryDownloadRequest( + downloadId: number, + downloadFolder: string, + authorizer: DownloadDirectoryAuthorizer +): Promise<{ success: boolean; error?: string }> { + console.log('[Downloads] Retry download:', downloadId); + const directory = await authorizer.requireAuthorized(downloadFolder); + const db = await getDatabase(); + const existing = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + + if (existing.length === 0) { + return { error: 'Download not found', success: false }; + } + + const item = existing[0]; + await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + if (!['failed', 'canceled'].includes(item.status)) { + return { + error: 'Can only retry failed or canceled downloads', + success: false, + }; + } + + const fileName = createFileName(item.title, item.url); + await db + .update(schema.downloads) + .set({ + bytesDownloaded: 0, + errorMessage: null, + fileName, + filePath: null, + status: 'queued', + totalBytes: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, downloadId)); + enqueueDownload({ + directory, + fileName, + id: item.id, + url: item.url, + }); + return { success: true }; +} diff --git a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts new file mode 100644 index 000000000..64b7bfd2b --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts @@ -0,0 +1,326 @@ +import type { DownloadItem } from 'electron'; +import { + attachDownloadItem, + requestDownloadCancellation, + type DownloadTask, +} from './download-task'; + +function createTask(): DownloadTask { + return { + directory: '/downloads', + fileName: 'movie.mp4', + id: 42, + url: 'https://example.test/movie.mp4', + }; +} + +async function waitForCallCount( + mock: jest.Mock, + expectedCallCount: number +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (mock.mock.calls.length === expectedCallCount) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(mock).toHaveBeenCalledTimes(expectedCallCount); +} + +describe('download runtime cancellation', () => { + it('cancels the item when an earlier cancellation request reaches onStarted', () => { + const task = createTask(); + const cancel = jest.fn(); + + requestDownloadCancellation(task); + attachDownloadItem(task, { cancel } as unknown as DownloadItem); + + expect(task.cancelRequested).toBe(true); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('cancels an already-started item immediately', () => { + const cancel = jest.fn(); + const task = { + ...createTask(), + downloadItem: { cancel } as unknown as DownloadItem, + }; + + requestDownloadCancellation(task); + + expect(task.cancelRequested).toBe(true); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('continues the queue when cancellation persistence fails', async () => { + jest.resetModules(); + + class TestCancelError extends Error {} + + let cancellationCallback: Promise | undefined; + const download = jest + .fn() + .mockImplementationOnce( + async ( + _window: unknown, + _url: string, + options: { + onCancel: (item: DownloadItem) => Promise; + } + ) => { + cancellationCallback = options.onCancel({ + getSavePath: () => '/downloads/movie.mp4', + } as unknown as DownloadItem); + throw new TestCancelError(); + } + ) + .mockImplementationOnce( + async ( + _window: unknown, + _url: string, + options: { + onCompleted: (file: { + fileSize: number; + filename: string; + path: string; + }) => Promise; + } + ) => { + await options.onCompleted({ + fileSize: 1, + filename: 'second.mp4', + path: '/downloads/second.mp4', + }); + } + ); + + const where = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('database is busy')) + .mockResolvedValue(undefined); + const db = { + update: jest.fn(() => ({ + set: jest.fn(() => ({ where })), + })), + }; + + jest.doMock('electron-dl', () => ({ + CancelError: TestCancelError, + download, + })); + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + removePartialDownload: jest.fn(), + reserveAvailableDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + path: `${directory}/${filename}`, + }) + ), + })); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload(createTask()); + runtime.enqueueDownload({ + ...createTask(), + fileName: 'second.mp4', + id: 43, + }); + + await waitForCallCount(download, 2); + await expect(cancellationCallback).resolves.toBeUndefined(); + + consoleError.mockRestore(); + }); + + it('continues the queue when completion persistence fails', async () => { + jest.resetModules(); + + class TestCancelError extends Error {} + + let completionCallback: Promise | undefined; + const download = jest + .fn() + .mockImplementationOnce( + async ( + _window: unknown, + _url: string, + options: { + onCompleted: (file: { + fileSize: number; + filename: string; + path: string; + }) => Promise; + } + ) => { + completionCallback = options.onCompleted({ + fileSize: 1, + filename: 'movie.mp4', + path: '/downloads/movie.mp4', + }); + } + ) + .mockImplementationOnce( + async ( + _window: unknown, + _url: string, + options: { + onCompleted: (file: { + fileSize: number; + filename: string; + path: string; + }) => Promise; + } + ) => { + await options.onCompleted({ + fileSize: 1, + filename: 'second.mp4', + path: '/downloads/second.mp4', + }); + } + ); + + const where = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('database is busy')) + .mockResolvedValue(undefined); + const db = { + update: jest.fn(() => ({ + set: jest.fn(() => ({ where })), + })), + }; + + jest.doMock('electron-dl', () => ({ + CancelError: TestCancelError, + download, + })); + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + removePartialDownload: jest.fn(), + reserveAvailableDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + path: `${directory}/${filename}`, + }) + ), + })); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload(createTask()); + runtime.enqueueDownload({ + ...createTask(), + fileName: 'second.mp4', + id: 43, + }); + + await waitForCallCount(download, 2); + await expect(completionCallback).resolves.toBeUndefined(); + } finally { + consoleError.mockRestore(); + } + }); + + it('continues the queue when initial database access fails', async () => { + jest.resetModules(); + + class TestCancelError extends Error {} + + const download = jest.fn( + async ( + _window: unknown, + _url: string, + options: { + onCompleted: (file: { + fileSize: number; + filename: string; + path: string; + }) => Promise; + } + ) => { + await options.onCompleted({ + fileSize: 1, + filename: 'second.mp4', + path: '/downloads/second.mp4', + }); + } + ); + const where = jest.fn().mockResolvedValue(undefined); + const db = { + update: jest.fn(() => ({ + set: jest.fn(() => ({ where })), + })), + }; + const getDatabase = jest + .fn() + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValue(db); + + jest.doMock('electron-dl', () => ({ + CancelError: TestCancelError, + download, + })); + jest.doMock('../../database/connection', () => ({ getDatabase })); + jest.doMock('./download-file-path', () => ({ + removePartialDownload: jest.fn(), + reserveAvailableDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + path: `${directory}/${filename}`, + }) + ), + })); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload(createTask()); + runtime.enqueueDownload({ + ...createTask(), + fileName: 'second.mp4', + id: 43, + }); + + await waitForCallCount(download, 1); + expect(download).toHaveBeenCalledWith( + expect.anything(), + 'https://example.test/movie.mp4', + expect.objectContaining({ filename: 'second.mp4' }) + ); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-runtime.ts b/apps/electron-backend/src/app/events/database/download-runtime.ts new file mode 100644 index 000000000..c5837524c --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-runtime.ts @@ -0,0 +1,295 @@ +import { eq, sql } from 'drizzle-orm'; +import type { BrowserWindow } from 'electron'; +import { CancelError, download } from 'electron-dl'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { + removePartialDownload, + reserveAvailableDownloadFile, +} from './download-file-path'; +import { + attachDownloadItem, + requestDownloadCancellation, + type DownloadTask, +} from './download-task'; + +const downloadQueue: DownloadTask[] = []; +let activeDownload: DownloadTask | null = null; +let mainWindow: BrowserWindow | null = null; + +export function setMainWindow(win: BrowserWindow): void { + mainWindow = win; +} + +export function broadcastDownloadUpdate(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT'); + } +} + +export function enqueueDownload(task: DownloadTask): void { + downloadQueue.push(task); + broadcastDownloadUpdate(); + void processQueue(); +} + +export async function cancelDownload(downloadId: number): Promise { + if (activeDownload?.id === downloadId) { + requestDownloadCancellation(activeDownload); + return true; + } + + const queueIndex = downloadQueue.findIndex( + (task) => task.id === downloadId + ); + if (queueIndex === -1) { + return false; + } + + downloadQueue.splice(queueIndex, 1); + const db = await getDatabase(); + await db + .update(schema.downloads) + .set({ + errorMessage: null, + filePath: null, + status: 'canceled', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, downloadId)); + broadcastDownloadUpdate(); + return true; +} + +export function removeDownloadFromRuntime(downloadId: number): void { + if (activeDownload?.id === downloadId) { + requestDownloadCancellation(activeDownload); + } + + const queueIndex = downloadQueue.findIndex( + (task) => task.id === downloadId + ); + if (queueIndex !== -1) { + downloadQueue.splice(queueIndex, 1); + } +} + +async function processQueue(): Promise { + if (activeDownload || downloadQueue.length === 0) { + return; + } + + const task = downloadQueue.shift(); + if (!task) { + return; + } + + activeDownload = task; + try { + await startDownload(task); + } catch (error) { + console.error( + `[Downloads] Unhandled error for ${task.fileName}:`, + error + ); + finishTask(task); + } +} + +function finishTask(task: DownloadTask): void { + if (activeDownload === task) { + activeDownload = null; + } + broadcastDownloadUpdate(); + void processQueue(); +} + +function createCancellationHandler( + task: DownloadTask, + db: Awaited>, + reservation: ReturnType +): (item: Parameters[0]) => Promise { + let cancellationPromise: Promise | undefined; + + return (item) => { + cancellationPromise ??= (async () => { + console.log(`[Downloads] Canceled: ${reservation.filename}`); + removePartialFile(item); + try { + await db + .update(schema.downloads) + .set({ + errorMessage: null, + filePath: null, + status: 'canceled', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + } catch (error) { + console.error( + '[Downloads] Failed to persist cancellation:', + error + ); + } finally { + finishTask(task); + } + })(); + + return cancellationPromise; + }; +} + +async function startDownload(task: DownloadTask): Promise { + const db = await getDatabase(); + await db + .update(schema.downloads) + .set({ + errorMessage: null, + filePath: null, + status: 'downloading', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + broadcastDownloadUpdate(); + + if (!mainWindow || mainWindow.isDestroyed()) { + console.error('[Downloads] No main window available'); + await db + .update(schema.downloads) + .set({ + errorMessage: 'No window available for download', + status: 'failed', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + finishTask(task); + return; + } + + let lastProgressUpdate = 0; + const progressThrottleMs = 500; + let handleCancellation: + | ReturnType + | undefined; + + try { + const reservation = reserveAvailableDownloadFile( + task.directory, + task.fileName + ); + task.reservedPath = reservation.path; + handleCancellation = createCancellationHandler(task, db, reservation); + await db + .update(schema.downloads) + .set({ + errorMessage: null, + fileName: reservation.filename, + filePath: reservation.path, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + + const downloadOptions: Parameters[2] = { + directory: task.directory, + filename: reservation.filename, + onStarted: (item) => { + console.log(`[Downloads] Started: ${reservation.filename}`); + attachDownloadItem(task, item); + }, + onProgress: async (progress) => { + const now = Date.now(); + if (now - lastProgressUpdate < progressThrottleMs) { + return; + } + lastProgressUpdate = now; + await db + .update(schema.downloads) + .set({ + bytesDownloaded: progress.transferredBytes, + totalBytes: progress.totalBytes, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + broadcastDownloadUpdate(); + }, + onCompleted: async (file) => { + console.log(`[Downloads] Completed: ${file.filename}`); + try { + await db + .update(schema.downloads) + .set({ + bytesDownloaded: file.fileSize, + errorMessage: null, + fileName: file.filename, + filePath: file.path, + status: 'completed', + totalBytes: file.fileSize, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + } catch (error) { + console.error( + '[Downloads] Failed to persist completion:', + error + ); + } finally { + finishTask(task); + } + }, + onCancel: handleCancellation, + }; + + if (task.headers) { + ( + downloadOptions as typeof downloadOptions & { + headers: Record; + } + ).headers = task.headers; + } + + await download(mainWindow, task.url, downloadOptions); + } catch (error) { + if (error instanceof CancelError) { + const reservedPath = task.reservedPath; + if (handleCancellation) { + await handleCancellation( + task.downloadItem ?? + (reservedPath + ? { getSavePath: () => reservedPath } + : undefined) + ); + } else { + finishTask(task); + } + return; + } + + console.error(`[Downloads] Error downloading ${task.fileName}:`, error); + const reservedPath = task.reservedPath; + removePartialFile( + task.downloadItem ?? + (reservedPath ? { getSavePath: () => reservedPath } : undefined) + ); + await db + .update(schema.downloads) + .set({ + errorMessage: + error instanceof Error ? error.message : String(error), + filePath: null, + status: 'failed', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + finishTask(task); + } +} + +function removePartialFile( + item: Parameters[0] +): void { + try { + removePartialDownload(item); + } catch (error) { + console.error('[Downloads] Failed to delete partial file:', error); + } +} diff --git a/apps/electron-backend/src/app/events/database/download-task.ts b/apps/electron-backend/src/app/events/database/download-task.ts new file mode 100644 index 000000000..93c0f890e --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-task.ts @@ -0,0 +1,27 @@ +import type { DownloadItem } from 'electron'; + +export interface DownloadTask { + id: number; + url: string; + fileName: string; + directory: string; + headers?: Record; + cancelRequested?: boolean; + downloadItem?: DownloadItem; + reservedPath?: string; +} + +export function attachDownloadItem( + task: DownloadTask, + item: DownloadItem +): void { + task.downloadItem = item; + if (task.cancelRequested) { + item.cancel(); + } +} + +export function requestDownloadCancellation(task: DownloadTask): void { + task.cancelRequested = true; + task.downloadItem?.cancel(); +} diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts index c451f6bfd..2f41b83f0 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -1,373 +1,90 @@ -/** - * Downloads IPC event handlers - * Uses electron-dl for download management with queue control and progress tracking - */ - -import { and, eq, inArray, sql } from 'drizzle-orm'; -import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; -import { download, File as ElectronDlFile } from 'electron-dl'; -import { existsSync, unlinkSync } from 'fs'; -import { basename, extname, join } from 'path'; +import { and, eq, inArray } from 'drizzle-orm'; +import { app, dialog, ipcMain, shell } from 'electron'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; +import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; +import { + retryDownloadRequest, + startDownloadRequest, + type StartDownloadRequest, +} from './download-requests'; +import { resetStaleDownloads } from './download-recovery'; +import { + broadcastDownloadUpdate, + cancelDownload, + removeDownloadFromRuntime, + setMainWindow, +} from './download-runtime'; -type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled'; - -interface DownloadTask { - id: number; - url: string; - fileName: string; - directory: string; - headers?: Record; - downloadItem?: ElectronDlFile; +function getDownloadAuthorizationPath(): string { + return join( + app.getPath('userData'), + 'download-directory-authorization.json' + ); } -// Download queue management -const downloadQueue: DownloadTask[] = []; -let activeDownload: DownloadTask | null = null; -let mainWindow: BrowserWindow | null = null; - -/** - * Set the main window reference for sending updates - */ -export function setMainWindow(win: BrowserWindow) { - mainWindow = win; -} - -/** - * Broadcast download updates to renderer - */ -function broadcastUpdate() { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT'); - } -} - -/** - * Get file extension from URL - */ -function getExtensionFromUrl(url: string): string { - try { - const urlObj = new URL(url); - const pathname = urlObj.pathname; - const ext = extname(pathname); - return ext || '.mp4'; - } catch { - return '.mp4'; - } -} - -/** - * Sanitize filename for filesystem - */ -function sanitizeFilename(name: string): string { - return name.replace(/[<>:"/\\|?*]/g, '_').trim(); -} - -/** - * Process the download queue - starts next download if none active - */ -async function processQueue() { - if (activeDownload || downloadQueue.length === 0) { - return; - } - - const task = downloadQueue.shift(); - if (!task) return; - - activeDownload = task; - await startDownload(task); -} - -/** - * Start a download using electron-dl - */ -async function startDownload(task: DownloadTask) { - const db = await getDatabase(); - - // Update status to downloading - await db - .update(schema.downloads) - .set({ - status: 'downloading', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - - broadcastUpdate(); - - if (!mainWindow || mainWindow.isDestroyed()) { - console.error('[Downloads] No main window available'); - await db - .update(schema.downloads) - .set({ - status: 'failed', - errorMessage: 'No window available for download', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - activeDownload = null; - broadcastUpdate(); - processQueue(); - return; - } - - let lastProgressUpdate = 0; - const PROGRESS_THROTTLE_MS = 500; - - try { - const downloadOptions: Parameters[2] = { - directory: task.directory, - filename: task.fileName, - overwrite: true, - onStarted: (item) => { - console.log(`[Downloads] Started: ${task.fileName}`); - task.downloadItem = item as unknown as ElectronDlFile; - }, - onProgress: async (progress) => { - const now = Date.now(); - if (now - lastProgressUpdate < PROGRESS_THROTTLE_MS) { - return; - } - lastProgressUpdate = now; - - await db - .update(schema.downloads) - .set({ - bytesDownloaded: progress.transferredBytes, - totalBytes: progress.totalBytes, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - - broadcastUpdate(); - }, - onCompleted: async (file) => { - console.log(`[Downloads] Completed: ${task.fileName}`); - await db - .update(schema.downloads) - .set({ - status: 'completed', - filePath: file.path, - fileName: file.filename, - bytesDownloaded: file.fileSize, - totalBytes: file.fileSize, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - - activeDownload = null; - broadcastUpdate(); - processQueue(); - }, - onCancel: async () => { - console.log(`[Downloads] Canceled: ${task.fileName}`); - // Delete partial file if it exists - const partialPath = join(task.directory, task.fileName); - if (existsSync(partialPath)) { - try { - unlinkSync(partialPath); - } catch (e) { - console.error('[Downloads] Failed to delete partial file:', e); - } - } - - await db - .update(schema.downloads) - .set({ - status: 'canceled', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - - activeDownload = null; - broadcastUpdate(); - processQueue(); - }, - }; - - // Add headers if provided (user-agent, referer, origin) - if (task.headers) { - (downloadOptions as any).headers = task.headers; - } - - await download(mainWindow, task.url, downloadOptions); - } catch (error) { - console.error(`[Downloads] Error downloading ${task.fileName}:`, error); - - // Delete partial file if it exists - const partialPath = join(task.directory, task.fileName); - if (existsSync(partialPath)) { - try { - unlinkSync(partialPath); - } catch (e) { - console.error('[Downloads] Failed to delete partial file:', e); +const downloadDirectoryAuthorizer = new DownloadDirectoryAuthorizer({ + getDefaultDirectory: () => app.getPath('downloads'), + loadSelectedDirectory: async () => { + try { + const stored = JSON.parse( + await readFile(getDownloadAuthorizationPath(), 'utf-8') + ) as { directory?: unknown }; + return typeof stored.directory === 'string' + ? stored.directory + : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn( + '[Downloads] Ignoring invalid folder authorization:', + error + ); } + return null; } + }, + saveSelectedDirectory: async (directory) => { + const authorizationPath = getDownloadAuthorizationPath(); + const temporaryPath = `${authorizationPath}.${process.pid}.tmp`; + await mkdir(app.getPath('userData'), { recursive: true }); + await writeFile( + temporaryPath, + JSON.stringify({ directory, version: 1 }), + 'utf-8' + ); + await rename(temporaryPath, authorizationPath); + }, +}); - await db - .update(schema.downloads) - .set({ - status: 'failed', - errorMessage: error instanceof Error ? error.message : String(error), - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - - activeDownload = null; - broadcastUpdate(); - processQueue(); +async function isManagedDownloadFile(filePath: string): Promise { + if (!filePath) { + return false; + } + try { + const db = await getDatabase(); + const rows = await db + .select({ id: schema.downloads.id }) + .from(schema.downloads) + .where(eq(schema.downloads.filePath, filePath)) + .limit(1); + return rows.length > 0; + } catch (error) { + console.error('Error verifying managed download path:', error); + return false; } } -/** - * Enqueue a new download - */ ipcMain.handle( 'DOWNLOADS_START', - async ( - _event, - data: { - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - title: string; - url: string; - posterUrl?: string; - downloadFolder: string; - headers?: { userAgent?: string; referer?: string; origin?: string }; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - // Playlist info for auto-creation if needed - playlistName?: string; - playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; - serverUrl?: string; - portalUrl?: string; - macAddress?: string; - } - ) => { + async (_event, data: StartDownloadRequest) => { try { - console.log('[Downloads] Enqueue download:', data.title); - const db = await getDatabase(); - - // Ensure playlist exists in database (required for foreign key constraint) - if (data.playlistId) { - const existingPlaylist = await db - .select() - .from(schema.playlists) - .where(eq(schema.playlists.id, data.playlistId)) - .limit(1); - - if (existingPlaylist.length === 0) { - // Create playlist entry for downloads to work - console.log('[Downloads] Creating playlist entry for:', data.playlistId); - await db.insert(schema.playlists).values({ - id: data.playlistId, - name: data.playlistName || 'Unknown Playlist', - type: data.playlistType || 'stalker', - serverUrl: data.serverUrl, - macAddress: data.macAddress, - url: data.portalUrl, - }); - } - } else { - throw new Error('playlistId is required for downloads'); - } - - // Check if already exists - const existing = await db - .select() - .from(schema.downloads) - .where( - and( - eq(schema.downloads.playlistId, data.playlistId), - eq(schema.downloads.xtreamId, data.xtreamId), - eq(schema.downloads.contentType, data.contentType) - ) - ) - .limit(1); - - if (existing.length > 0) { - const item = existing[0]; - // If completed or failed/canceled, allow retry by updating - if (['completed', 'failed', 'canceled'].includes(item.status)) { - await db - .update(schema.downloads) - .set({ - status: 'queued', - url: data.url, - bytesDownloaded: 0, - totalBytes: null, - errorMessage: null, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, item.id)); - - const ext = getExtensionFromUrl(data.url); - const fileName = sanitizeFilename(data.title) + ext; - - downloadQueue.push({ - id: item.id, - url: data.url, - fileName, - directory: data.downloadFolder, - headers: data.headers - ? { - 'User-Agent': data.headers.userAgent || '', - Referer: data.headers.referer || '', - Origin: data.headers.origin || '', - } - : undefined, - }); - - broadcastUpdate(); - processQueue(); - return { success: true, id: item.id }; - } - - // Already queued or downloading - return { success: false, error: 'Download already in progress', id: item.id }; - } - - // Create new download entry - const ext = getExtensionFromUrl(data.url); - const fileName = sanitizeFilename(data.title) + ext; - - const result = await db.insert(schema.downloads).values({ - playlistId: data.playlistId, - xtreamId: data.xtreamId, - contentType: data.contentType, - title: data.title, - url: data.url, - posterUrl: data.posterUrl, - fileName, - status: 'queued', - seriesXtreamId: data.seriesXtreamId, - seasonNumber: data.seasonNumber, - episodeNumber: data.episodeNumber, - }); - - const insertedId = Number(result.lastInsertRowid); - - downloadQueue.push({ - id: insertedId, - url: data.url, - fileName, - directory: data.downloadFolder, - headers: data.headers - ? { - 'User-Agent': data.headers.userAgent || '', - Referer: data.headers.referer || '', - Origin: data.headers.origin || '', - } - : undefined, - }); - - broadcastUpdate(); - processQueue(); - - return { success: true, id: insertedId }; + return await startDownloadRequest( + data, + downloadDirectoryAuthorizer + ); } catch (error) { console.error('[Downloads] Error enqueuing download:', error); throw error; @@ -375,95 +92,27 @@ ipcMain.handle( } ); -/** - * Cancel a download - */ ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => { try { console.log('[Downloads] Cancel download:', downloadId); - const db = await getDatabase(); - - // Check if it's the active download - if (activeDownload && activeDownload.id === downloadId) { - if (activeDownload.downloadItem) { - (activeDownload.downloadItem as any).cancel?.(); - } - return { success: true }; - } - - // Remove from queue - const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId); - if (queueIndex !== -1) { - downloadQueue.splice(queueIndex, 1); - await db - .update(schema.downloads) - .set({ - status: 'canceled', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, downloadId)); - - broadcastUpdate(); - return { success: true }; - } - - return { success: false, error: 'Download not found in queue' }; + return (await cancelDownload(downloadId)) + ? { success: true } + : { error: 'Download not found in queue', success: false }; } catch (error) { console.error('[Downloads] Error canceling download:', error); throw error; } }); -/** - * Retry a failed/canceled download - */ ipcMain.handle( 'DOWNLOADS_RETRY', async (_event, downloadId: number, downloadFolder: string) => { try { - console.log('[Downloads] Retry download:', downloadId); - const db = await getDatabase(); - - const existing = await db - .select() - .from(schema.downloads) - .where(eq(schema.downloads.id, downloadId)) - .limit(1); - - if (existing.length === 0) { - return { success: false, error: 'Download not found' }; - } - - const item = existing[0]; - if (!['failed', 'canceled'].includes(item.status)) { - return { success: false, error: 'Can only retry failed or canceled downloads' }; - } - - await db - .update(schema.downloads) - .set({ - status: 'queued', - bytesDownloaded: 0, - totalBytes: null, - errorMessage: null, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, downloadId)); - - const ext = getExtensionFromUrl(item.url); - const fileName = sanitizeFilename(item.title) + ext; - - downloadQueue.push({ - id: item.id, - url: item.url, - fileName, - directory: downloadFolder, - }); - - broadcastUpdate(); - processQueue(); - - return { success: true }; + return await retryDownloadRequest( + downloadId, + downloadFolder, + downloadDirectoryAuthorizer + ); } catch (error) { console.error('[Downloads] Error retrying download:', error); throw error; @@ -471,31 +120,15 @@ ipcMain.handle( } ); -/** - * Remove a download from the list - */ ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { try { console.log('[Downloads] Remove download:', downloadId); + removeDownloadFromRuntime(downloadId); const db = await getDatabase(); - - // Cancel if active - if (activeDownload && activeDownload.id === downloadId) { - if (activeDownload.downloadItem) { - (activeDownload.downloadItem as any).cancel?.(); - } - } - - // Remove from queue - const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId); - if (queueIndex !== -1) { - downloadQueue.splice(queueIndex, 1); - } - - // Delete from database - await db.delete(schema.downloads).where(eq(schema.downloads.id, downloadId)); - - broadcastUpdate(); + await db + .delete(schema.downloads) + .where(eq(schema.downloads.id, downloadId)); + broadcastDownloadUpdate(); return { success: true }; } catch (error) { console.error('[Downloads] Error removing download:', error); @@ -503,36 +136,21 @@ ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { } }); -/** - * Get all downloads for a playlist - */ ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => { try { const db = await getDatabase(); - - if (playlistId) { - const result = await db - .select() - .from(schema.downloads) - .where(eq(schema.downloads.playlistId, playlistId)) - .orderBy(schema.downloads.createdAt); - return result; - } - - const result = await db - .select() - .from(schema.downloads) - .orderBy(schema.downloads.createdAt); - return result; + const query = db.select().from(schema.downloads); + return playlistId + ? query + .where(eq(schema.downloads.playlistId, playlistId)) + .orderBy(schema.downloads.createdAt) + : query.orderBy(schema.downloads.createdAt); } catch (error) { console.error('[Downloads] Error getting download list:', error); throw error; } }); -/** - * Get download by ID - */ ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => { try { const db = await getDatabase(); @@ -548,102 +166,67 @@ ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => { } }); -/** - * Get default download folder - */ ipcMain.handle('DOWNLOADS_GET_DEFAULT_FOLDER', async () => { - return app.getPath('downloads'); + return downloadDirectoryAuthorizer.getPreferredDirectory(); }); -/** - * Select download folder via dialog - */ ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => { const result = await dialog.showOpenDialog({ + defaultPath: app.getPath('downloads'), properties: ['openDirectory', 'createDirectory'], title: 'Select Download Folder', - defaultPath: app.getPath('downloads'), }); - if (result.canceled || result.filePaths.length === 0) { return null; } - - return result.filePaths[0]; + return downloadDirectoryAuthorizer.authorizeSelectedDirectory( + result.filePaths[0] + ); }); -/** - * Reveal file in system file manager - */ ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => { - if (existsSync(filePath)) { - shell.showItemInFolder(filePath); - return { success: true }; + if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) { + return { error: 'File not found', success: false }; } - return { success: false, error: 'File not found' }; + shell.showItemInFolder(filePath); + return { success: true }; }); -/** - * Play downloaded file - */ ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => { - if (existsSync(filePath)) { - await shell.openPath(filePath); - return { success: true }; + if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) { + return { error: 'File not found', success: false }; } - return { success: false, error: 'File not found' }; + await shell.openPath(filePath); + return { success: true }; }); -/** - * Clear all completed downloads - */ -ipcMain.handle('DOWNLOADS_CLEAR_COMPLETED', async (_event, playlistId?: string) => { - try { - const db = await getDatabase(); - - if (playlistId) { +ipcMain.handle( + 'DOWNLOADS_CLEAR_COMPLETED', + async (_event, playlistId?: string) => { + try { + const db = await getDatabase(); + const terminalStatus = inArray(schema.downloads.status, [ + 'completed', + 'failed', + 'canceled', + ]); await db .delete(schema.downloads) .where( - and( - eq(schema.downloads.playlistId, playlistId), - inArray(schema.downloads.status, ['completed', 'failed', 'canceled']) - ) - ); - } else { - await db - .delete(schema.downloads) - .where( - inArray(schema.downloads.status, ['completed', 'failed', 'canceled']) + playlistId + ? and( + eq(schema.downloads.playlistId, playlistId), + terminalStatus + ) + : terminalStatus ); + broadcastDownloadUpdate(); + return { success: true }; + } catch (error) { + console.error('[Downloads] Error clearing completed:', error); + throw error; } - - broadcastUpdate(); - return { success: true }; - } catch (error) { - console.error('[Downloads] Error clearing completed:', error); - throw error; } -}); +); -/** - * Reset stale downloads on startup (downloading -> failed) - */ -export async function resetStaleDownloads() { - try { - const db = await getDatabase(); - await db - .update(schema.downloads) - .set({ - status: 'failed', - errorMessage: 'Download interrupted by application restart', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where( - inArray(schema.downloads.status, ['queued', 'downloading']) - ); - console.log('[Downloads] Reset stale downloads'); - } catch (error) { - console.error('[Downloads] Error resetting stale downloads:', error); - } -} +export { resetStaleDownloads, setMainWindow }; diff --git a/apps/electron-backend/src/app/events/database/stale-download-files.ts b/apps/electron-backend/src/app/events/database/stale-download-files.ts new file mode 100644 index 000000000..71d1f159e --- /dev/null +++ b/apps/electron-backend/src/app/events/database/stale-download-files.ts @@ -0,0 +1,29 @@ +import { removePartialDownload } from './download-file-path'; + +interface StaleDownloadFile { + filePath: string | null; +} + +function removePersistedPartial(filePath: string): void { + removePartialDownload({ getSavePath: () => filePath }); +} + +export function cleanupStaleDownloadFiles( + downloads: readonly StaleDownloadFile[], + removeFile: (filePath: string) => void = removePersistedPartial +): void { + for (const download of downloads) { + if (!download.filePath) { + continue; + } + try { + removeFile(download.filePath); + } catch (error) { + console.error( + '[Downloads] Failed to delete stale partial file:', + download.filePath, + error + ); + } + } +} diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts new file mode 100644 index 000000000..907867be1 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -0,0 +1,63 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { + createPlaylistObject, + getFilenameFromUrl, +} from '@iptvnator/shared/m3u-utils'; +import { parse } from 'iptv-playlist-parser'; +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { createPlaylistAgentFactory } from '../util/secure-https'; +import { requestWithValidatedRedirects } from '../util/validated-axios'; + +export async function fetchPlaylistFromUrl( + url: string, + title?: string +): Promise { + const result = await requestWithValidatedRedirects( + url, + { + agentFactory: createPlaylistAgentFactory(), + method: 'GET', + }, + { allowPrivateNetworks: true } + ); + const parsedPlaylist = parse(result.data); + const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : ''; + const playlistName = + !extractedName || extractedName === 'Untitled playlist' + ? 'Imported from URL' + : extractedName; + + return createPlaylistObject( + title ?? playlistName, + parsedPlaylist, + url, + 'URL' + ); +} + +export async function fetchPlaylistFromFile( + filePath: string, + title: string +): Promise { + const fileContent = await readFile(filePath, 'utf-8'); + return createPlaylistObject(title, parse(fileContent), filePath, 'FILE'); +} + +export function derivePlaylistTitleFromFilePath(filePath: string): string { + const filename = basename(filePath); + return filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file'; +} + +export function preserveAutoUpdatedPlaylistFields( + playlistObject: Playlist, + playlist: Playlist +): Playlist { + return { + ...playlistObject, + _id: playlist._id, + autoRefresh: playlist.autoRefresh, + favorites: playlist.favorites || [], + userAgent: playlist.userAgent, + }; +} diff --git a/apps/electron-backend/src/app/events/playlist-write-authorization.ts b/apps/electron-backend/src/app/events/playlist-write-authorization.ts new file mode 100644 index 000000000..69e009f15 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-write-authorization.ts @@ -0,0 +1,36 @@ +import { resolve } from 'node:path'; + +const MAX_AUTHORIZED_WRITE_PATHS = 32; + +export class PlaylistWriteAuthorizer { + private readonly pathsBySender = new Map>(); + + authorize(senderId: number, filePath: string): void { + const senderPaths = + this.pathsBySender.get(senderId) ?? new Set(); + senderPaths.add(resolve(filePath)); + this.pathsBySender.set(senderId, senderPaths); + + while (senderPaths.size > MAX_AUTHORIZED_WRITE_PATHS) { + const oldest = senderPaths.values().next().value; + if (oldest === undefined) { + break; + } + senderPaths.delete(oldest); + } + } + + consume(senderId: number, filePath: string): string { + const normalizedPath = resolve(String(filePath ?? '')); + const senderPaths = this.pathsBySender.get(senderId); + if (!senderPaths?.delete(normalizedPath)) { + throw new Error( + 'Refusing to write to a path not authorized by a save dialog' + ); + } + if (senderPaths.size === 0) { + this.pathsBySender.delete(senderId); + } + return normalizedPath; + } +} diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index 2233bbb56..c0060c242 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -9,11 +9,13 @@ import { PLAYLIST_REFRESH, PLAYLIST_REFRESH_EVENT, } from '@iptvnator/shared/interfaces'; +import { resolve } from 'node:path'; type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise; type MockIpcEvent = { sender: { + id: number; isDestroyed: jest.Mock; send: jest.Mock; }; @@ -57,9 +59,7 @@ jest.mock('electron', () => ({ jest.mock('axios', () => ({ __esModule: true, - default: { - get: (...args: unknown[]) => mockAxiosGet(...args), - }, + default: (...args: unknown[]) => mockAxiosGet(...args), })); jest.mock('iptv-playlist-parser', () => ({ @@ -118,9 +118,10 @@ function createPlaylist(overrides: Partial = {}): Playlist { }; } -function createIpcEvent(): MockIpcEvent { +function createIpcEvent(senderId = 1): MockIpcEvent { return { sender: { + id: senderId, isDestroyed: jest.fn(() => false), send: jest.fn(), }, @@ -190,8 +191,12 @@ describe('playlist IPC events', () => { ); expect(mockAxiosGet).toHaveBeenCalledWith( - 'https://example.test/remote.m3u', - { httpsAgent: expect.any(Object) } + expect.objectContaining({ + httpsAgent: expect.any(Object), + maxRedirects: 0, + method: 'GET', + url: 'https://example.test/remote.m3u', + }) ); expect(mockParse).toHaveBeenCalledWith('#EXTM3U'); expect(mockCreatePlaylistObject).toHaveBeenCalledWith( @@ -323,8 +328,12 @@ describe('playlist IPC events', () => { }), ]); expect(mockAxiosGet).toHaveBeenCalledWith( - 'https://example.test/list.m3u', - { httpsAgent: expect.any(Object) } + expect.objectContaining({ + httpsAgent: expect.any(Object), + maxRedirects: 0, + method: 'GET', + url: 'https://example.test/list.m3u', + }) ); expect(mockReadFile).toHaveBeenCalledWith( '/playlists/local.m3u', @@ -521,9 +530,63 @@ describe('playlist IPC events', () => { ) ).resolves.toEqual({ success: true }); expect(mockWriteFile).toHaveBeenCalledWith( - '/exports/list.m3u', + resolve('/exports/list.m3u'), '#EXTM3U', 'utf-8' ); }); + + it('rejects write-file for a path not authorized by a save dialog', async () => { + mockWriteFile.mockClear(); + + await expect( + getHandler('write-file')( + createIpcEvent(), + '/unauthorized/evil.sh', + 'payload' + ) + ).rejects.toThrow(/not authorized/i); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('scopes save-dialog write authorization to the requesting renderer', async () => { + mockShowSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/exports/private.m3u', + }); + + await getHandler('save-file-dialog')( + createIpcEvent(1), + '/exports/private.m3u', + [] + ); + + await expect( + getHandler('write-file')( + createIpcEvent(2), + '/exports/private.m3u', + '#EXTM3U' + ) + ).rejects.toThrow(/not authorized/i); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('consumes write authorization even when the filesystem write fails', async () => { + mockShowSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/exports/failure.m3u', + }); + mockWriteFile.mockRejectedValueOnce(new Error('disk full')); + + const event = createIpcEvent(3); + await getHandler('save-file-dialog')(event, '/exports/failure.m3u', []); + + await expect( + getHandler('write-file')(event, '/exports/failure.m3u', '#EXTM3U') + ).rejects.toThrow('disk full'); + await expect( + getHandler('write-file')(event, '/exports/failure.m3u', '#EXTM3U') + ).rejects.toThrow(/not authorized/i); + expect(mockWriteFile).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index f9926ea5d..fc2ad44c5 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -3,12 +3,8 @@ * between the frontend to the electron backend. */ -import axios from 'axios'; import { app, dialog, ipcMain, WebContents } from 'electron'; -import { parse } from 'iptv-playlist-parser'; -import { createPlaylistObject, getFilenameFromUrl } from '@iptvnator/shared/m3u-utils'; -import { readFile, writeFile } from 'node:fs/promises'; -import { basename } from 'node:path'; +import { writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'url'; import { Worker } from 'worker_threads'; import { @@ -25,6 +21,13 @@ import type { PlaylistRefreshWorkerMessage, PlaylistRefreshWorkerResponseMessage, } from '../workers/playlist-refresh.worker.types'; +import { + derivePlaylistTitleFromFilePath, + fetchPlaylistFromFile, + fetchPlaylistFromUrl, + preserveAutoUpdatedPlaylistFields, +} from './playlist-source'; +import { PlaylistWriteAuthorizer } from './playlist-write-authorization'; export default class PlaylistEvents { static bootstrapPlaylistEvents(): Electron.IpcMain { @@ -32,7 +35,7 @@ export default class PlaylistEvents { } } -const https = require('https'); +const playlistWriteAuthorizer = new PlaylistWriteAuthorizer(); type ActivePlaylistRefresh = { reject: (reason?: unknown) => void; @@ -43,68 +46,13 @@ type ActivePlaylistRefresh = { const activePlaylistRefreshes = new Map(); -/** - * Fetches and parses a playlist from a URL - * @param url - The URL to fetch the playlist from - * @param title - Optional title for the playlist - * @returns Parsed playlist object - */ -async function fetchPlaylistFromUrl( - url: string, - title?: string -): Promise { - const agent = new https.Agent({ - rejectUnauthorized: false, - }); - const result = await axios.get(url, { httpsAgent: agent }); - const parsedPlaylist = parse(result.data); - - const extractedName = - url && url.length > 1 ? getFilenameFromUrl(url) : ''; - const playlistName = - !extractedName || extractedName === 'Untitled playlist' - ? 'Imported from URL' - : extractedName; - - const playlistObject = createPlaylistObject( - title ?? playlistName, - parsedPlaylist, - url, - 'URL' - ); - - return playlistObject; -} - -/** - * Reads and parses a playlist from a file path - * @param filePath - The path to the playlist file - * @param title - Title for the playlist - * @returns Parsed playlist object - */ -async function fetchPlaylistFromFile( - filePath: string, - title: string -): Promise { - const fileContent = await readFile(filePath, 'utf-8'); - const parsedPlaylist = parse(fileContent); - const playlistObject = createPlaylistObject( - title, - parsedPlaylist, - filePath, - 'FILE' - ); - return playlistObject; -} - function resolvePlaylistRefreshWorker(): Worker { const bootstrap = resolveWorkerRuntimeBootstrap({ isPackaged: app.isPackaged, workerFilename: 'playlist-refresh.worker.js', developmentWorkerDir: __dirname + '/workers', - resourcesPath: ( - process as NodeJS.Process & { resourcesPath?: string } - ).resourcesPath, + resourcesPath: (process as NodeJS.Process & { resourcesPath?: string }) + .resourcesPath, appPath: app.getAppPath(), }); @@ -137,24 +85,6 @@ function createPlaylistRefreshError(error: { return workerError; } -function derivePlaylistTitleFromFilePath(filePath: string): string { - const filename = basename(filePath); - return filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file'; -} - -function preserveAutoUpdatedPlaylistFields( - playlistObject: Playlist, - playlist: Playlist -): Playlist { - return { - ...playlistObject, - _id: playlist._id, - autoRefresh: playlist.autoRefresh, - favorites: playlist.favorites || [], - userAgent: playlist.userAgent, - }; -} - ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { try { return await fetchPlaylistFromUrl(url, title); @@ -254,75 +184,83 @@ ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { return updatedPlaylists; }); -ipcMain.handle(PLAYLIST_REFRESH, async (event, payload: PlaylistRefreshPayload) => { - const worker = resolvePlaylistRefreshWorker(); +ipcMain.handle( + PLAYLIST_REFRESH, + async (event, payload: PlaylistRefreshPayload) => { + const worker = resolvePlaylistRefreshWorker(); - return await new Promise((resolve, reject) => { - const cleanup = async (): Promise => { - activePlaylistRefreshes.delete(payload.operationId); - worker.removeAllListeners(); - await worker.terminate().catch(() => undefined); - }; + return await new Promise((resolve, reject) => { + const cleanup = async (): Promise => { + activePlaylistRefreshes.delete(payload.operationId); + worker.removeAllListeners(); + await worker.terminate().catch(() => undefined); + }; - activePlaylistRefreshes.set(payload.operationId, { - worker, - sender: event.sender, - resolve, - reject, - }); + activePlaylistRefreshes.set(payload.operationId, { + worker, + sender: event.sender, + resolve, + reject, + }); - worker.on('message', async (message: PlaylistRefreshWorkerMessage) => { - if (message.type === 'ready') { - worker.postMessage({ - type: 'request', - payload, - }); - return; - } - - if (message.type === 'event') { - emitPlaylistRefreshEvent(event.sender, message.event); - return; - } - - await cleanup(); - - const response = message as PlaylistRefreshWorkerResponseMessage; - if (response.success && response.result) { - resolve(response.result); - return; - } - - reject( - createPlaylistRefreshError( - response.error ?? { - message: 'Playlist refresh worker request failed', + worker.on( + 'message', + async (message: PlaylistRefreshWorkerMessage) => { + if (message.type === 'ready') { + worker.postMessage({ + type: 'request', + payload, + }); + return; } - ) + + if (message.type === 'event') { + emitPlaylistRefreshEvent(event.sender, message.event); + return; + } + + await cleanup(); + + const response = + message as PlaylistRefreshWorkerResponseMessage; + if (response.success && response.result) { + resolve(response.result); + return; + } + + reject( + createPlaylistRefreshError( + response.error ?? { + message: + 'Playlist refresh worker request failed', + } + ) + ); + } ); - }); - worker.on('error', async (error) => { - await cleanup(); - reject(error); - }); + worker.on('error', async (error) => { + await cleanup(); + reject(error); + }); - worker.on('exit', async (code) => { - if (!activePlaylistRefreshes.has(payload.operationId)) { - return; - } + worker.on('exit', async (code) => { + if (!activePlaylistRefreshes.has(payload.operationId)) { + return; + } - await cleanup(); - reject( - new Error( - code === 0 - ? 'Playlist refresh worker exited unexpectedly' - : `Playlist refresh worker stopped with exit code ${code}` - ) - ); + await cleanup(); + reject( + new Error( + code === 0 + ? 'Playlist refresh worker exited unexpectedly' + : `Playlist refresh worker stopped with exit code ${code}` + ) + ); + }); }); - }); -}); + } +); ipcMain.handle( PLAYLIST_CANCEL_REFRESH, @@ -345,15 +283,14 @@ ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => { try { const { canceled, filePath } = await dialog.showSaveDialog({ defaultPath, - filters: filters || [ - { name: 'All Files', extensions: ['*'] }, - ], + filters: filters || [{ name: 'All Files', extensions: ['*'] }], }); if (canceled || !filePath) { return null; } + playlistWriteAuthorizer.authorize(event.sender.id, filePath); return filePath; } catch (error) { console.error('Error showing save dialog:', error); @@ -362,8 +299,18 @@ ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => { }); ipcMain.handle('write-file', async (event, filePath, content) => { + let normalizedPath: string; try { - await writeFile(filePath, content, 'utf-8'); + normalizedPath = playlistWriteAuthorizer.consume( + event.sender.id, + filePath + ); + } catch (error) { + console.error('Blocked unauthorized write-file path:', filePath); + throw error; + } + try { + await writeFile(normalizedPath, content, 'utf-8'); return { success: true }; } catch (error) { console.error('Error writing file:', error); diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 81eaa5955..d16fd8cba 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -5,10 +5,15 @@ import axios, { AxiosRequestConfig } from 'axios'; import { ipcMain } from 'electron'; -import { PortalDebugEvent, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; +import { + PortalDebugEvent, + STALKER_REQUEST, +} from '@iptvnator/shared/interfaces'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; import { buildStalkerIdentityRequestContext } from './stalker-identity'; +import { assertRemoteUrlAllowed } from './url-safety'; +import { requestWithValidatedRedirects } from '../util/validated-axios'; export default class StalkerEvents { static bootstrapStalkerEvents(): Electron.IpcMain { @@ -16,6 +21,13 @@ export default class StalkerEvents { } } +type StalkerResponseData = { + js?: { + cmd?: unknown; + }; + [key: string]: unknown; +}; + /** * Handle Stalker API requests with MAC address cookie and optional Bearer token */ @@ -48,14 +60,24 @@ ipcMain.handle( // Build URL with query parameters // Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent) // to preserve forward slashes, matching stalker-to-m3u implementation + // SSRF/LFI guard: block non-http(s)/credentialed portal URLs. + // Private/LAN targets remain allowed (users run local Stalker servers). + await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true }); const urlObject = new URL(url); const queryParts: string[] = []; Object.entries(requestParams).forEach(([key, value]) => { if (key === 'cmd') { - // Don't encode cmd - it's already a path like /media/12345.mpg - // Encoding would break the path format expected by the server - queryParts.push(`${key}=${String(value)}`); + // Encode cmd but preserve forward slashes so the path format + // (e.g. /media/12345.mpg) the server expects still survives. + // Encoding the remaining characters prevents a malicious portal + // from injecting extra query parameters (&, =, #) into the URL. + queryParts.push( + `${key}=${encodeURIComponent(String(value)).replace( + /%2F/gi, + '/' + )}` + ); } else { // Use encodeURIComponent for other params queryParts.push( @@ -93,7 +115,12 @@ ipcMain.handle( params: requestParams, }; - const response = await axios(config); + const response = + await requestWithValidatedRedirects( + fullUrl, + config, + { allowPrivateNetworks: true } + ); // Check if response is successful if (response.status >= 400) { diff --git a/apps/electron-backend/src/app/events/url-safety.spec.ts b/apps/electron-backend/src/app/events/url-safety.spec.ts new file mode 100644 index 000000000..58592a5f1 --- /dev/null +++ b/apps/electron-backend/src/app/events/url-safety.spec.ts @@ -0,0 +1,124 @@ +import { + assertRemoteUrlAllowed, + isPrivateOrReservedIp, + isPrivateNetworkUrlAccessAllowed, + UnsafeUrlError, + validateRemoteUrl, +} from './url-safety'; + +describe('url-safety', () => { + describe('isPrivateOrReservedIp', () => { + it.each([ + '127.0.0.1', + '10.0.0.5', + '192.168.1.1', + '172.16.0.1', + '169.254.169.254', + '::1', + 'fd00::1', + 'fe80::1', + 'febf::1', + '::ffff:127.0.0.1', + '::ffff:7f00:1', + '::ffff:c0a8:101', + ])('flags %s as private/reserved', (ip) => { + expect(isPrivateOrReservedIp(ip)).toBe(true); + }); + + it.each(['93.184.216.34', '8.8.8.8', '1.1.1.1'])( + 'allows public address %s', + (ip) => { + expect(isPrivateOrReservedIp(ip)).toBe(false); + } + ); + }); + + describe('assertRemoteUrlAllowed', () => { + const publicResolver = async () => ['93.184.216.34']; + + it('rejects non-http(s) protocols', async () => { + await expect( + assertRemoteUrlAllowed('file:///etc/passwd') + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it('rejects embedded credentials', async () => { + await expect( + assertRemoteUrlAllowed('http://user:pass@example.com', { + resolveHostname: publicResolver, + }) + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it('rejects loopback literal', async () => { + await expect( + assertRemoteUrlAllowed('http://127.0.0.1/admin') + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it('rejects localhost hostname', async () => { + await expect( + assertRemoteUrlAllowed('http://localhost:8080') + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it('rejects the cloud metadata address', async () => { + await expect( + assertRemoteUrlAllowed( + 'http://169.254.169.254/latest/meta-data' + ) + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it('rejects hex-form IPv4-mapped IPv6 loopback literals', async () => { + await expect( + assertRemoteUrlAllowed('http://[::ffff:7f00:1]/admin') + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it('rejects a hostname that resolves to a private IP', async () => { + await expect( + assertRemoteUrlAllowed('http://rebind.example', { + resolveHostname: async () => ['10.0.0.1'], + }) + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it('allows a public URL', async () => { + const target = await validateRemoteUrl( + 'https://example.com/playlist.m3u', + { resolveHostname: publicResolver } + ); + expect(target.url.hostname).toBe('example.com'); + expect(target.addresses).toEqual(['93.184.216.34']); + }); + + it('honors the allowPrivateNetworks override', async () => { + const url = await assertRemoteUrlAllowed('http://127.0.0.1/probe', { + allowPrivateNetworks: true, + }); + expect(url.hostname).toBe('127.0.0.1'); + }); + }); + + describe('isPrivateNetworkUrlAccessAllowed', () => { + const originalValue = process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS; + + afterEach(() => { + if (originalValue === undefined) { + delete process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS; + } else { + process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS = + originalValue; + } + }); + + it('requires an explicit environment opt-in', () => { + delete process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS; + expect(isPrivateNetworkUrlAccessAllowed()).toBe(false); + + process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS = ' TRUE '; + expect(isPrivateNetworkUrlAccessAllowed()).toBe(true); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/url-safety.ts b/apps/electron-backend/src/app/events/url-safety.ts new file mode 100644 index 000000000..501f627fc --- /dev/null +++ b/apps/electron-backend/src/app/events/url-safety.ts @@ -0,0 +1,272 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + +const PRIVATE_NETWORK_URLS_ENV = 'IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'; + +/** + * Raised when a renderer-supplied remote URL is rejected by the SSRF guard. + */ +export class UnsafeUrlError extends Error { + readonly status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = 'UnsafeUrlError'; + this.status = status; + } +} + +export interface RemoteUrlPolicy { + /** When true, private/reserved targets are permitted (default false). */ + allowPrivateNetworks?: boolean; + /** Injectable DNS resolver. Defaults to `dns.lookup`; overridable in tests. */ + resolveHostname?: (hostname: string) => Promise; +} + +export interface ValidatedRemoteUrl { + /** Parsed URL, retaining the original hostname for TLS SNI and Host. */ + url: URL; + /** Validated addresses that socket-level DNS lookup must be pinned to. */ + addresses?: readonly string[]; +} + +function isTruthyEnvironmentOptIn(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === '1' || normalized === 'true'; +} + +export function isPrivateNetworkUrlAccessAllowed(): boolean { + return isTruthyEnvironmentOptIn(process.env[PRIVATE_NETWORK_URLS_ENV]); +} + +function normalizeHostname(hostname: string): string { + let host = hostname.trim().toLowerCase(); + if (host.startsWith('[') && host.endsWith(']')) { + host = host.slice(1, -1); + } + return host; +} + +export function isLocalHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname.endsWith('.localhost'); +} + +export function isPrivateOrReservedIpv4(address: string): boolean { + const parts = address.split('.').map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return true; + } + + const [first, second, third] = parts; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 192 && second === 0) || + (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224 + ); +} + +function parseIpv6Words(address: string): number[] | null { + const halves = address.split('::'); + if (halves.length > 2) { + return null; + } + + const parseHalf = (half: string): number[] | null => { + if (!half) { + return []; + } + + const words: number[] = []; + for (const part of half.split(':')) { + if (part.includes('.')) { + const octets = part.split('.').map((octet) => Number(octet)); + if ( + octets.length !== 4 || + octets.some( + (octet) => + !Number.isInteger(octet) || octet < 0 || octet > 255 + ) + ) { + return null; + } + words.push( + (octets[0] << 8) | octets[1], + (octets[2] << 8) | octets[3] + ); + continue; + } + + if (!/^[0-9a-f]{1,4}$/i.test(part)) { + return null; + } + words.push(Number.parseInt(part, 16)); + } + return words; + }; + + const left = parseHalf(halves[0]); + const right = parseHalf(halves[1] ?? ''); + if (!left || !right) { + return null; + } + + if (halves.length === 1) { + return left.length === 8 ? left : null; + } + + const omittedWordCount = 8 - left.length - right.length; + if (omittedWordCount < 1) { + return null; + } + + return [...left, ...Array(omittedWordCount).fill(0), ...right]; +} + +function getMappedIpv4Address(address: string): string | null { + const words = parseIpv6Words(address); + if ( + !words || + words.slice(0, 5).some((word) => word !== 0) || + words[5] !== 0xffff + ) { + return null; + } + + return [ + words[6] >> 8, + words[6] & 0xff, + words[7] >> 8, + words[7] & 0xff, + ].join('.'); +} + +export function isPrivateOrReservedIpv6(address: string): boolean { + const normalized = address.toLowerCase(); + if ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + // Link-local fe80::/10 spans fe80:: through febf:: (3rd nibble 8-b). + /^fe[89ab]/.test(normalized) + ) { + return true; + } + + const mappedIpv4 = getMappedIpv4Address(normalized); + return mappedIpv4 ? isPrivateOrReservedIpv4(mappedIpv4) : false; +} + +export function isPrivateOrReservedIp(address: string): boolean { + const version = isIP(address); + if (version === 4) { + return isPrivateOrReservedIpv4(address); + } + if (version === 6) { + return isPrivateOrReservedIpv6(address); + } + return false; +} + +async function defaultResolveHostname( + hostname: string +): Promise { + const records = await lookup(hostname, { all: true, verbatim: true }); + return records.map((record) => record.address); +} + +/** + * Validates a renderer-supplied remote URL before the Electron main process + * fetches it, preventing SSRF to loopback / private / reserved network + * targets (e.g. `http://127.0.0.1`, `http://169.254.169.254` metadata). + * + * Returns the parsed {@link URL} when allowed, otherwise throws + * {@link UnsafeUrlError}. + */ +export async function validateRemoteUrl( + rawUrl: string, + policy: RemoteUrlPolicy = {} +): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new UnsafeUrlError('Invalid URL'); + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new UnsafeUrlError('Only http and https URLs are supported'); + } + + if (url.username || url.password) { + throw new UnsafeUrlError('URL credentials are not supported'); + } + + if (policy.allowPrivateNetworks) { + return { url }; + } + + const hostname = normalizeHostname(url.hostname); + if (isLocalHostname(hostname) || isPrivateOrReservedIp(hostname)) { + throw new UnsafeUrlError( + 'URL points to a private or local network address' + ); + } + + if (isIP(hostname) !== 0) { + return { url, addresses: [hostname] }; + } + + { + const resolveHostname = + policy.resolveHostname ?? defaultResolveHostname; + let addresses: readonly string[]; + try { + addresses = await resolveHostname(hostname); + } catch { + throw new UnsafeUrlError('URL host could not be resolved'); + } + + if ( + addresses.length === 0 || + addresses.some((address) => { + const normalizedAddress = normalizeHostname(address); + return ( + isIP(normalizedAddress) === 0 || + isPrivateOrReservedIp(normalizedAddress) + ); + }) + ) { + throw new UnsafeUrlError( + 'URL points to a private or local network address' + ); + } + + return { + url, + addresses: addresses.map((address) => normalizeHostname(address)), + }; + } +} + +/** + * Validates a URL and returns its parsed representation. + */ +export async function assertRemoteUrlAllowed( + rawUrl: string, + policy: RemoteUrlPolicy = {} +): Promise { + return (await validateRemoteUrl(rawUrl, policy)).url; +} diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 94bce8b3b..f9f9fbb88 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -18,9 +18,11 @@ function createDeferred() { jest.mock('electron', () => ({ ipcMain: { - handle: jest.fn((channel: string, handler: (...args: unknown[]) => unknown) => { - registeredHandlers.set(channel, handler); - }), + handle: jest.fn( + (channel: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(channel, handler); + } + ), }, })); @@ -53,7 +55,10 @@ describe('XtreamEvents session cancellation', () => { it('aborts requests that were registered with only a session id', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION); - const pendingRequest = createDeferred<{ status: number; data: unknown }>(); + const pendingRequest = createDeferred<{ + status: number; + data: unknown; + }>(); const cancelError = Object.assign(new Error('cancelled'), { code: 'ERR_CANCELED', }); @@ -70,23 +75,27 @@ describe('XtreamEvents session cancellation', () => { (error: unknown) => error === cancelError ); - const requestPromise = requestHandler?.({}, { - url: 'http://localhost:3211', - params: { - action: 'get_live_categories', - password: 'secret', - username: 'user1', - }, - sessionId: 'session-1', - suppressErrorLog: true, - }) as Promise; + const requestPromise = requestHandler?.( + {}, + { + url: 'http://localhost:3211', + params: { + action: 'get_live_categories', + password: 'secret', + username: 'user1', + }, + sessionId: 'session-1', + suppressErrorLog: true, + } + ) as Promise; + await Promise.resolve(); expect(abortSignal?.aborted).toBe(false); - const cancelResult = (await cancelHandler?.( - {}, - 'session-1' - )) as { success: boolean; cancelled: number }; + const cancelResult = (await cancelHandler?.({}, 'session-1')) as { + success: boolean; + cancelled: number; + }; expect(cancelResult).toEqual({ success: true, cancelled: 1 }); expect(abortSignal?.aborted).toBe(true); @@ -102,8 +111,14 @@ describe('XtreamEvents session cancellation', () => { it('counts every matching in-flight request for the same session', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION); - const firstRequest = createDeferred<{ status: number; data: unknown }>(); - const secondRequest = createDeferred<{ status: number; data: unknown }>(); + const firstRequest = createDeferred<{ + status: number; + data: unknown; + }>(); + const secondRequest = createDeferred<{ + status: number; + data: unknown; + }>(); const cancelError = Object.assign(new Error('cancelled'), { code: 'ERR_CANCELED', }); @@ -129,31 +144,38 @@ describe('XtreamEvents session cancellation', () => { (error: unknown) => error === cancelError ); - const firstPromise = requestHandler?.({}, { - url: 'http://localhost:3211', - params: { - action: 'get_live_categories', - password: 'secret', - username: 'user1', - }, - sessionId: 'session-2', - suppressErrorLog: true, - }) as Promise; - const secondPromise = requestHandler?.({}, { - url: 'http://localhost:3211', - params: { - action: 'get_vod_streams', - password: 'secret', - username: 'user1', - }, - sessionId: 'session-2', - suppressErrorLog: true, - }) as Promise; - - const cancelResult = (await cancelHandler?.( + const firstPromise = requestHandler?.( {}, - 'session-2' - )) as { success: boolean; cancelled: number }; + { + url: 'http://localhost:3211', + params: { + action: 'get_live_categories', + password: 'secret', + username: 'user1', + }, + sessionId: 'session-2', + suppressErrorLog: true, + } + ) as Promise; + const secondPromise = requestHandler?.( + {}, + { + url: 'http://localhost:3211', + params: { + action: 'get_vod_streams', + password: 'secret', + username: 'user1', + }, + sessionId: 'session-2', + suppressErrorLog: true, + } + ) as Promise; + + await Promise.resolve(); + const cancelResult = (await cancelHandler?.({}, 'session-2')) as { + success: boolean; + cancelled: number; + }; expect(cancelResult).toEqual({ success: true, cancelled: 2 }); expect(abortSignals).toHaveLength(2); @@ -162,7 +184,11 @@ describe('XtreamEvents session cancellation', () => { firstRequest.reject(cancelError); secondRequest.reject(cancelError); - await expect(firstPromise).rejects.toMatchObject({ name: 'AbortError' }); - await expect(secondPromise).rejects.toMatchObject({ name: 'AbortError' }); + await expect(firstPromise).rejects.toMatchObject({ + name: 'AbortError', + }); + await expect(secondPromise).rejects.toMatchObject({ + name: 'AbortError', + }); }); }); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 2706fad3c..c4720dd5e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -5,8 +5,13 @@ import axios, { AxiosRequestConfig } from 'axios'; import { ipcMain } from 'electron'; -import { PortalDebugEvent, XTREAM_CANCEL_SESSION } from '@iptvnator/shared/interfaces'; +import { + PortalDebugEvent, + XTREAM_CANCEL_SESSION, +} from '@iptvnator/shared/interfaces'; import { emitPortalDebugEvent } from './portal-debug.events'; +import { assertRemoteUrlAllowed, UnsafeUrlError } from './url-safety'; +import { requestWithValidatedRedirects } from '../util/validated-axios'; export default class XtreamEvents { static bootstrapXtreamEvents(): Electron.IpcMain { @@ -14,7 +19,11 @@ export default class XtreamEvents { } } -function formatXtreamError(error: unknown, requestUrl: string, action?: string) { +function formatXtreamError( + error: unknown, + requestUrl: string, + action?: string +) { const parsedUrl = new URL(requestUrl); const base = { action, @@ -101,7 +110,11 @@ ipcMain.handle( signal: controller.signal, }; - const response = await axios(config); + const response = await requestWithValidatedRedirects( + apiUrl.toString(), + config, + { allowPrivateNetworks: true } + ); // Check if response is successful if (response.status >= 400) { @@ -172,7 +185,11 @@ ipcMain.handle( if (!payload.suppressErrorLog) { console.error( '[XTREAM_REQUEST] Failed', - formatXtreamError(error, payload.url, payload.params?.action) + formatXtreamError( + error, + payload.url, + payload.params?.action + ) ); } @@ -218,7 +235,10 @@ ipcMain.handle( ipcMain.handle( XTREAM_CANCEL_SESSION, - async (_event, sessionId: string): Promise<{ success: boolean; cancelled: number }> => { + async ( + _event, + sessionId: string + ): Promise<{ success: boolean; cancelled: number }> => { if (!sessionId) { return { success: false, cancelled: 0 }; } @@ -255,6 +275,26 @@ ipcMain.handle( method?: 'GET' | 'HEAD'; } ) => { + // Guard against SSRF: a malicious portal/playlist could ask the main + // process to probe loopback/private/metadata addresses. Only allow + // public http(s) targets. + try { + // Private/LAN targets are allowed (users probe self-hosted Xtream + // servers); maxRedirects:0 below blocks redirect-based SSRF. + await assertRemoteUrlAllowed(payload.url, { + allowPrivateNetworks: true, + }); + } catch (error) { + return { + status: 0, + url: payload.url, + error: + error instanceof UnsafeUrlError + ? error.message + : 'Invalid URL', + }; + } + const config: AxiosRequestConfig = { method: payload.method ?? 'HEAD', url: payload.url, @@ -263,7 +303,11 @@ ipcMain.handle( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', }, timeout: 10000, - maxRedirects: 5, + // SSRF hardening: do NOT follow redirects. assertRemoteUrlAllowed + // validated payload.url, but a validated public host could 3xx to an + // internal address that would never be re-checked. validateStatus + // below returns the 3xx as the probe result instead of following it. + maxRedirects: 0, validateStatus: () => true, }; diff --git a/apps/electron-backend/src/app/util/secure-https.spec.ts b/apps/electron-backend/src/app/util/secure-https.spec.ts new file mode 100644 index 000000000..d0b8421a8 --- /dev/null +++ b/apps/electron-backend/src/app/util/secure-https.spec.ts @@ -0,0 +1,71 @@ +import { Agent } from 'node:https'; +import type { LookupFunction } from 'node:net'; +import { + createPlaylistAgentFactory, + isInsecureTlsAllowed, +} from './secure-https'; + +jest.mock('node:https', () => ({ + Agent: jest.fn(() => ({ protocol: 'https:' })), +})); + +describe('secure-https', () => { + const originalValue = process.env.IPTVNATOR_ALLOW_INSECURE_TLS; + const agentConstructorMock = Agent as unknown as jest.Mock; + const lookup = jest.fn() as unknown as LookupFunction; + + beforeEach(() => { + agentConstructorMock.mockClear(); + }); + + afterEach(() => { + if (originalValue === undefined) { + delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; + } else { + process.env.IPTVNATOR_ALLOW_INSECURE_TLS = originalValue; + } + }); + + it('validates certificates by default', () => { + delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; + + expect(isInsecureTlsAllowed()).toBe(false); + createPlaylistAgentFactory().createHttpsAgent(lookup); + + expect(agentConstructorMock).toHaveBeenCalledWith({ + lookup, + rejectUnauthorized: true, + }); + }); + + it.each(['1', 'true', ' TRUE '])( + 'allows an explicit insecure TLS opt-in via %s', + (value) => { + process.env.IPTVNATOR_ALLOW_INSECURE_TLS = value; + + expect(isInsecureTlsAllowed()).toBe(true); + createPlaylistAgentFactory().createHttpsAgent(lookup); + + expect(agentConstructorMock).toHaveBeenCalledWith({ + lookup, + rejectUnauthorized: false, + }); + } + ); + + it('does not accept unrelated truthy values', () => { + process.env.IPTVNATOR_ALLOW_INSECURE_TLS = 'yes'; + + expect(isInsecureTlsAllowed()).toBe(false); + }); + + it('preserves TLS policy when no pinned lookup is required', () => { + delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; + + createPlaylistAgentFactory().createHttpsAgent(); + + expect(agentConstructorMock).toHaveBeenCalledWith({ + rejectUnauthorized: true, + }); + }); +}); diff --git a/apps/electron-backend/src/app/util/secure-https.ts b/apps/electron-backend/src/app/util/secure-https.ts new file mode 100644 index 000000000..5ef49a432 --- /dev/null +++ b/apps/electron-backend/src/app/util/secure-https.ts @@ -0,0 +1,36 @@ +import { Agent } from 'node:https'; +import type { LookupFunction } from 'node:net'; +import type { ValidatedRequestAgentFactory } from './validated-axios'; + +const INSECURE_TLS_ENV = 'IPTVNATOR_ALLOW_INSECURE_TLS'; + +/** + * Whether TLS certificate validation is disabled for remote playlist fetches. + * + * Secure by default. Validation is only disabled when the operator explicitly + * opts in via `IPTVNATOR_ALLOW_INSECURE_TLS=1` (or `=true`). Some IPTV + * providers serve self-signed or otherwise invalid certificates, so the + * opt-out exists — but it must never be the default, otherwise every playlist + * fetch is silently exposed to network MITM. + */ +export function isInsecureTlsAllowed(): boolean { + const value = process.env[INSECURE_TLS_ENV]?.trim().toLowerCase(); + return value === '1' || value === 'true'; +} + +/** + * Builds the agent factory used for remote playlist fetches. + * + * Certificates are validated unless insecure TLS has been explicitly opted in + * (see {@link isInsecureTlsAllowed}). The validated request layer supplies a + * DNS lookup pinned to the addresses approved for each redirect hop. + */ +export function createPlaylistAgentFactory(): ValidatedRequestAgentFactory & { + createHttpsAgent(lookup?: LookupFunction): Agent; +} { + const rejectUnauthorized = !isInsecureTlsAllowed(); + + return { + createHttpsAgent: (lookup) => new Agent({ lookup, rejectUnauthorized }), + }; +} diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts new file mode 100644 index 000000000..21bd3f8dd --- /dev/null +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -0,0 +1,384 @@ +import axios from 'axios'; +import type { LookupAddress, LookupOptions } from 'node:dns'; +import { Agent as HttpAgent } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; +import type { LookupFunction } from 'node:net'; +import { UnsafeUrlError } from '../events/url-safety'; +import { + requestWithValidatedRedirects, + type ValidatedRequestAgentFactory, +} from './validated-axios'; + +jest.mock('axios', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const axiosMock = axios as unknown as jest.Mock; + +describe('requestWithValidatedRedirects', () => { + const publicResolver = async () => ['93.184.216.34']; + + function createCapturingAgentFactory() { + const lookups: LookupFunction[] = []; + const factory: ValidatedRequestAgentFactory = { + createHttpsAgent: jest.fn((lookup) => { + if (!lookup) { + throw new Error('Expected a pinned lookup'); + } + lookups.push(lookup); + return new HttpsAgent({ lookup }); + }), + }; + + return { factory, lookups }; + } + + beforeEach(() => { + axiosMock.mockReset(); + }); + + it('rejects a redirect target that violates the URL policy', async () => { + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { + location: 'http://169.254.169.254/latest/meta-data', + }, + }); + + await expect( + requestWithValidatedRedirects( + 'https://example.com/player_api.php', + { method: 'GET' }, + { resolveHostname: publicResolver } + ) + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + it('pins the socket lookup to the address validated by the URL policy', async () => { + axiosMock.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: '#EXTM3U', + }); + const { factory, lookups } = createCapturingAgentFactory(); + + await requestWithValidatedRedirects( + 'https://epg.example/guide.xml', + { agentFactory: factory, method: 'GET' }, + { + resolveHostname: async () => ['93.184.216.34'], + } + ); + + const requestConfig = axiosMock.mock.calls[0][0]; + const resolvedAddress = await new Promise<{ + address: string | LookupAddress[]; + family?: number; + }>((resolve, reject) => { + lookups[0]( + 'epg.example', + { all: false, family: 0 } as LookupOptions, + (error, address, family) => { + if (error) { + reject(error); + return; + } + resolve({ address, family }); + } + ); + }); + + expect(factory.createHttpsAgent).toHaveBeenCalledWith(lookups[0]); + expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); + expect(requestConfig).not.toHaveProperty('agentFactory'); + expect(resolvedAddress).toEqual({ + address: '93.184.216.34', + family: 4, + }); + expect(requestConfig.proxy).toBe(false); + expect(requestConfig.url).toBe('https://epg.example/guide.xml'); + }); + + it('supplies the validated lookup to an explicit HTTP agent factory', async () => { + axiosMock.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: '#EXTM3U', + }); + let pinnedLookup: LookupFunction | undefined; + const factory: ValidatedRequestAgentFactory = { + createHttpAgent: jest.fn((lookup) => { + pinnedLookup = lookup; + return new HttpAgent({ lookup }); + }), + }; + + await requestWithValidatedRedirects( + 'http://playlist.example/list.m3u', + { agentFactory: factory, method: 'GET' }, + { resolveHostname: publicResolver } + ); + + const requestConfig = axiosMock.mock.calls[0][0]; + expect(factory.createHttpAgent).toHaveBeenCalledWith(pinnedLookup); + expect(requestConfig.httpAgent).toBeInstanceOf(HttpAgent); + expect(requestConfig).not.toHaveProperty('agentFactory'); + }); + + it('uses a custom HTTPS agent factory when private networks are allowed', async () => { + axiosMock.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: '#EXTM3U', + }); + const factory: ValidatedRequestAgentFactory = { + createHttpsAgent: jest.fn((lookup) => new HttpsAgent({ lookup })), + }; + + await requestWithValidatedRedirects( + 'https://192.168.1.10/list.m3u', + { agentFactory: factory, method: 'GET' }, + { allowPrivateNetworks: true } + ); + + const requestConfig = axiosMock.mock.calls[0][0]; + expect(factory.createHttpsAgent).toHaveBeenCalledWith(); + expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); + expect(requestConfig).not.toHaveProperty('agentFactory'); + }); + + it('revalidates and repins every redirect hop', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: 'https://cdn.example/guide.xml' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: '', + }); + const { factory, lookups } = createCapturingAgentFactory(); + const resolveHostname = jest.fn(async (hostname: string) => + hostname === 'epg.example' ? ['93.184.216.34'] : ['142.250.191.110'] + ); + + await requestWithValidatedRedirects( + 'https://epg.example/guide.xml', + { agentFactory: factory, method: 'GET' }, + { resolveHostname } + ); + + const resolvePinnedAddress = async (callIndex: number) => { + return new Promise((resolve, reject) => { + lookups[callIndex]( + 'ignored.example', + { all: false, family: 0 } as LookupOptions, + (error, address) => { + if (error) { + reject(error); + return; + } + resolve(address); + } + ); + }); + }; + + expect(resolveHostname).toHaveBeenNthCalledWith(1, 'epg.example'); + expect(resolveHostname).toHaveBeenNthCalledWith(2, 'cdn.example'); + expect(factory.createHttpsAgent).toHaveBeenCalledTimes(2); + await expect(resolvePinnedAddress(0)).resolves.toBe('93.184.216.34'); + await expect(resolvePinnedAddress(1)).resolves.toBe('142.250.191.110'); + }); + + it('removes sensitive headers when a redirect changes origin', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: 'https://cdn.example.net/playlist.m3u' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: '#EXTM3U', + }); + + await requestWithValidatedRedirects( + 'https://example.com/playlist.m3u', + { + headers: { + Authorization: 'Bearer secret', + Cookie: 'session=secret', + Accept: 'text/plain', + }, + method: 'GET', + }, + { resolveHostname: publicResolver } + ); + + const redirectedConfig = axiosMock.mock.calls[1][0]; + expect(redirectedConfig.headers).toMatchObject({ + Accept: 'text/plain', + }); + expect(redirectedConfig.headers).not.toHaveProperty('Authorization'); + expect(redirectedConfig.headers).not.toHaveProperty('Cookie'); + }); + + it('does not forward axios params to a cross-origin redirect', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: 'https://cdn.example.net/playlist.m3u' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: '#EXTM3U', + }); + + await requestWithValidatedRedirects( + 'https://example.com/playlist.m3u', + { + method: 'GET', + params: { token: 'secret' }, + }, + { resolveHostname: publicResolver } + ); + + expect(axiosMock.mock.calls[1][0].params).toBeUndefined(); + }); + + it('does not forward axios basic auth to a cross-origin redirect', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: 'https://cdn.example.net/playlist.m3u' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: '#EXTM3U', + }); + + await requestWithValidatedRedirects( + 'https://example.com/playlist.m3u', + { + auth: { password: 'secret', username: 'provider' }, + method: 'GET', + }, + { resolveHostname: publicResolver } + ); + + expect(axiosMock.mock.calls[1][0].auth).toBeUndefined(); + }); + + it('rejects a cross-origin redirect that would replay a request body', async () => { + axiosMock.mockResolvedValueOnce({ + status: 307, + headers: { location: 'https://other.example/submit' }, + }); + + await expect( + requestWithValidatedRedirects( + 'https://example.com/submit', + { data: { secret: true }, method: 'POST' }, + { resolveHostname: publicResolver } + ) + ).rejects.toThrow(/request bodies/i); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + it('rejects redirects without a location header', async () => { + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: {}, + }); + + await expect( + requestWithValidatedRedirects( + 'https://example.com/start', + { method: 'GET' }, + { resolveHostname: publicResolver } + ) + ).rejects.toMatchObject({ + message: expect.stringMatching(/location/i), + status: 502, + }); + }); + + it('stops after the configured redirect limit', async () => { + axiosMock.mockResolvedValue({ + status: 302, + headers: { location: '/again' }, + }); + + await expect( + requestWithValidatedRedirects( + 'https://example.com/start', + { method: 'GET' }, + { resolveHostname: publicResolver }, + 1 + ) + ).rejects.toMatchObject({ + message: expect.stringMatching(/too many redirects/i), + status: 502, + }); + expect(axiosMock).toHaveBeenCalledTimes(2); + }); + + it('converts a 303 redirect to GET and removes the request body', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 303, + headers: { location: '/result' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: 'ok', + }); + + await requestWithValidatedRedirects( + 'https://example.com/submit', + { data: { value: 1 }, method: 'POST' }, + { resolveHostname: publicResolver } + ); + + expect(axiosMock.mock.calls[1][0]).toMatchObject({ + data: undefined, + method: 'GET', + url: 'https://example.com/result', + }); + }); + + it.each([301, 302])( + 'converts a POST redirected with %i to GET and removes the request body', + async (status) => { + axiosMock + .mockResolvedValueOnce({ + status, + headers: { location: '/result' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: 'ok', + }); + + await requestWithValidatedRedirects( + 'https://example.com/submit', + { data: { value: 1 }, method: 'POST' }, + { resolveHostname: publicResolver } + ); + + expect(axiosMock.mock.calls[1][0]).toMatchObject({ + data: undefined, + method: 'GET', + url: 'https://example.com/result', + }); + } + ); +}); diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts new file mode 100644 index 000000000..ad6922198 --- /dev/null +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -0,0 +1,215 @@ +import axios, { + AxiosRequestConfig, + AxiosResponse, + RawAxiosRequestHeaders, +} from 'axios'; +import type { LookupAddress } from 'node:dns'; +import { Agent as HttpAgent } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; +import { isIP, LookupFunction } from 'node:net'; +import { + RemoteUrlPolicy, + UnsafeUrlError, + validateRemoteUrl, +} from '../events/url-safety'; + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const SENSITIVE_HEADERS = new Set([ + 'authorization', + 'cookie', + 'proxy-authorization', +]); + +export interface ValidatedRequestAgentFactory { + createHttpAgent?(lookup?: LookupFunction): HttpAgent; + createHttpsAgent?(lookup?: LookupFunction): HttpsAgent; +} + +export type ValidatedAxiosRequestConfig = Omit< + AxiosRequestConfig, + 'httpAgent' | 'httpsAgent' +> & { + agentFactory?: ValidatedRequestAgentFactory; +}; + +function copyHeadersWithoutSensitiveValues( + headers: AxiosRequestConfig['headers'] +): AxiosRequestConfig['headers'] { + if (!headers) { + return headers; + } + + const source = + typeof (headers as { toJSON?: () => RawAxiosRequestHeaders }).toJSON === + 'function' + ? ( + headers as { + toJSON: () => RawAxiosRequestHeaders; + } + ).toJSON() + : headers; + const sanitized: RawAxiosRequestHeaders = {}; + + for (const [name, value] of Object.entries(source)) { + if (!SENSITIVE_HEADERS.has(name.toLowerCase())) { + sanitized[name] = value; + } + } + + return sanitized; +} + +function createPinnedLookup(addresses: readonly string[]): LookupFunction { + const records: LookupAddress[] = addresses.map((address) => ({ + address, + family: isIP(address), + })); + + return (_hostname, options, callback) => { + const requestedFamily = options.family; + const eligibleRecords = requestedFamily + ? records.filter((record) => record.family === requestedFamily) + : records; + + if (eligibleRecords.length === 0) { + const error = new Error( + 'Validated URL has no connectable address' + ) as NodeJS.ErrnoException; + error.code = 'ENOTFOUND'; + callback(error, []); + return; + } + + if (options.all) { + callback(null, eligibleRecords); + return; + } + + const selected = eligibleRecords[0]; + callback(null, selected.address, selected.family); + }; +} + +function pinRequestToValidatedAddresses( + config: ValidatedAxiosRequestConfig, + url: URL, + addresses: readonly string[] | undefined +): AxiosRequestConfig { + const { agentFactory, ...axiosConfig } = config; + if (!addresses) { + if (url.protocol === 'https:' && agentFactory?.createHttpsAgent) { + return { + ...axiosConfig, + httpsAgent: agentFactory.createHttpsAgent(), + }; + } + if (url.protocol === 'http:' && agentFactory?.createHttpAgent) { + return { + ...axiosConfig, + httpAgent: agentFactory.createHttpAgent(), + }; + } + return axiosConfig; + } + + const lookup = createPinnedLookup(addresses); + if (url.protocol === 'https:') { + return { + ...axiosConfig, + httpsAgent: + agentFactory?.createHttpsAgent?.(lookup) ?? + new HttpsAgent({ lookup }), + proxy: false, + }; + } + + return { + ...axiosConfig, + httpAgent: + agentFactory?.createHttpAgent?.(lookup) ?? + new HttpAgent({ lookup }), + proxy: false, + }; +} + +/** + * Runs an Axios request while validating the initial URL and every redirect. + * Redirects are followed manually so each target passes through the same + * private-network and protocol policy. + */ +export async function requestWithValidatedRedirects( + rawUrl: string, + config: ValidatedAxiosRequestConfig = {}, + policy: RemoteUrlPolicy = {}, + maxRedirects = 5 +): Promise> { + const originalValidateStatus = + config.validateStatus ?? + ((status: number) => status >= 200 && status < 300); + let currentUrl = rawUrl; + let requestConfig = { ...config }; + + for (let redirectCount = 0; ; redirectCount += 1) { + const validatedTarget = await validateRemoteUrl(currentUrl, policy); + const validatedUrl = validatedTarget.url; + const pinnedConfig = pinRequestToValidatedAddresses( + requestConfig, + validatedUrl, + validatedTarget.addresses + ); + const response = await axios({ + ...pinnedConfig, + maxRedirects: 0, + url: validatedUrl.toString(), + validateStatus: (status) => + REDIRECT_STATUSES.has(status) || originalValidateStatus(status), + }); + + if (!REDIRECT_STATUSES.has(response.status)) { + return response; + } + + const location = response.headers?.location; + if (!location) { + throw new UnsafeUrlError( + 'Redirect response did not include a location', + 502 + ); + } + if (redirectCount >= maxRedirects) { + throw new UnsafeUrlError('Too many redirects', 502); + } + + const nextUrl = new URL(location, validatedUrl); + const method = requestConfig.method?.toUpperCase(); + const shouldRewriteToGet = + (response.status === 303 && method !== 'HEAD') || + ((response.status === 301 || response.status === 302) && + method === 'POST'); + if (shouldRewriteToGet) { + requestConfig = { + ...requestConfig, + data: undefined, + method: 'GET', + }; + } + if (nextUrl.origin !== validatedUrl.origin) { + if (requestConfig.data !== undefined) { + throw new UnsafeUrlError( + 'Cross-origin redirects with request bodies are not supported', + 502 + ); + } + requestConfig = { + ...requestConfig, + auth: undefined, + headers: copyHeadersWithoutSensitiveValues( + requestConfig.headers + ), + params: undefined, + }; + } + + currentUrl = nextUrl.toString(); + } +} diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts new file mode 100644 index 000000000..8e97951e6 --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -0,0 +1,48 @@ +import type BetterSqlite3 from 'better-sqlite3'; +import { EpgDatabaseClearOperation } from './epg-database'; + +function createDatabaseMock(exec: jest.Mock) { + const database = { + close: jest.fn(), + exec, + }; + const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3; + + return { Database, database }; +} + +describe('EpgDatabaseClearOperation', () => { + it('clears programs and channels in one transaction', () => { + const exec = jest.fn(); + const { Database } = createDatabaseMock(exec); + + new EpgDatabaseClearOperation(Database).run(); + + expect(exec.mock.calls.map(([statement]) => statement)).toEqual([ + 'BEGIN', + 'DELETE FROM epg_programs', + 'DELETE FROM epg_channels', + 'COMMIT', + ]); + }); + + it('rolls back when either delete fails', () => { + const failure = new Error('database is busy'); + const exec = jest.fn((statement: string) => { + if (statement === 'DELETE FROM epg_channels') { + throw failure; + } + }); + const { Database } = createDatabaseMock(exec); + + expect(() => new EpgDatabaseClearOperation(Database).run()).toThrow( + failure + ); + expect(exec.mock.calls.map(([statement]) => statement)).toEqual([ + 'BEGIN', + 'DELETE FROM epg_programs', + 'DELETE FROM epg_channels', + 'ROLLBACK', + ]); + }); +}); diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts new file mode 100644 index 000000000..bcf5d18e4 --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -0,0 +1,146 @@ +import type BetterSqlite3 from 'better-sqlite3'; +import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils'; +import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser'; + +/** + * Database helper for worker-owned EPG operations. + * Creates its own connection to avoid blocking the main thread. + */ +export class EpgDatabase { + private readonly db: BetterSqlite3.Database; + private readonly knownChannelIds = new Set(); + private readonly insertChannelStmt: BetterSqlite3.Statement; + private readonly insertProgramStmt: BetterSqlite3.Statement; + private readonly deleteChannelsStmt: BetterSqlite3.Statement; + + constructor(Database: typeof BetterSqlite3) { + this.db = new Database(getIptvnatorDatabasePath()); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('busy_timeout = 5000'); + + this.insertChannelStmt = this.db.prepare(` + INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at) + VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + icon_url = excluded.icon_url, + url = excluded.url, + source_url = excluded.source_url, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + `); + + this.insertProgramStmt = this.db.prepare(` + INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + this.deleteChannelsStmt = this.db.prepare(` + DELETE FROM epg_channels WHERE source_url = ? + `); + } + + /** + * Insert a batch of channels. When `clearFirst` is true, the existing rows + * for `sourceUrl` are deleted inside the same transaction as the insert so + * old data is preserved if the fetch/parse never produces any channels. + */ + insertChannels( + channels: ParsedChannel[], + sourceUrl: string, + clearFirst = false + ): void { + const insertMany = this.db.transaction((channels: ParsedChannel[]) => { + if (clearFirst) { + this.deleteChannelsStmt.run(sourceUrl); + this.knownChannelIds.clear(); + } + + for (const channel of channels) { + const displayName = + channel.displayName?.[0]?.value || channel.id; + const iconUrl = channel.icon?.[0]?.src || null; + const url = channel.url?.[0] || null; + + this.insertChannelStmt.run( + channel.id, + displayName, + iconUrl, + url, + sourceUrl + ); + this.knownChannelIds.add(channel.id); + } + }); + + insertMany(channels); + } + + /** + * Insert programs for channels already seen during the current parse. + */ + insertPrograms(programs: ParsedProgram[]): number { + let insertedCount = 0; + + const insertMany = this.db.transaction((programs: ParsedProgram[]) => { + for (const program of programs) { + if (!this.knownChannelIds.has(program.channel)) continue; + + const title = program.title?.[0]?.value || 'Unknown'; + const description = program.desc?.[0]?.value || null; + const category = program.category?.[0]?.value || null; + const iconUrl = program.icon?.[0]?.src || null; + const rating = program.rating?.[0]?.value || null; + const episodeNum = program.episodeNum?.[0]?.value || null; + + try { + this.insertProgramStmt.run( + program.channel, + program.start, + program.stop, + title, + description, + category, + iconUrl, + rating, + episodeNum + ); + insertedCount++; + } catch { + // Skip individual failures such as FK constraint errors. + } + } + }); + + insertMany(programs); + return insertedCount; + } + + close(): void { + this.db.close(); + } +} + +export class EpgDatabaseClearOperation { + private readonly db: BetterSqlite3.Database; + + constructor(Database: typeof BetterSqlite3) { + this.db = new Database(getIptvnatorDatabasePath()); + } + + run(): void { + this.db.exec('BEGIN'); + try { + this.db.exec('DELETE FROM epg_programs'); + this.db.exec('DELETE FROM epg_channels'); + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + + close(): void { + this.db.close(); + } +} 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 26d683b19..f65c95df8 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,15 +1,12 @@ import type BetterSqlite3 from 'better-sqlite3'; -import { existsSync, mkdirSync } from 'fs'; -import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils'; import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; import { createGunzip } from 'zlib'; -import { - ParsedChannel, - ParsedProgram, - StreamingEpgParser, -} from './epg-streaming-parser'; +import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database'; +import { StreamingEpgParser } from './epg-streaming-parser'; import { shouldGunzipEpgResponse } from './epg-response-utils'; +import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety'; +import { requestWithValidatedRedirects } from '../util/validated-axios'; import { getNativeModuleSearchPaths, getWorkerDataNativeModuleSearchPaths, @@ -17,14 +14,11 @@ import { registerNativeModuleSearchPaths, } from './worker-runtime-paths'; -let Database: typeof BetterSqlite3; - const nativeModuleSearchPaths = [ ...getWorkerDataNativeModuleSearchPaths(workerData), ...getNativeModuleSearchPaths({ - resourcesPath: ( - process as NodeJS.Process & { resourcesPath?: string } - ).resourcesPath, + resourcesPath: (process as NodeJS.Process & { resourcesPath?: string }) + .resourcesPath, }), ]; @@ -36,12 +30,11 @@ function loadBetterSqlite3(): typeof BetterSqlite3 { loggerLabel: '[EPG Worker]', searchPaths: nativeModuleSearchPaths, fallbackRequire: () => - // eslint-disable-next-line @typescript-eslint/no-require-imports require('better-sqlite3') as typeof BetterSqlite3, }); } -Database = loadBetterSqlite3(); +const Database = loadBetterSqlite3(); /** * Streaming EPG Parser Worker @@ -76,143 +69,6 @@ const loggerLabel = '[EPG Worker]'; const CHANNEL_BATCH_SIZE = 100; const PROGRAM_BATCH_SIZE = 1000; -/** - * Database helper class for EPG operations - * Creates its own connection to avoid blocking main thread - */ -class EpgDatabase { - private db: BetterSqlite3.Database; - private knownChannelIds: Set = new Set(); - - // Prepared statements for better performance - private insertChannelStmt: BetterSqlite3.Statement; - private insertProgramStmt: BetterSqlite3.Statement; - private deleteChannelsStmt: BetterSqlite3.Statement; - - constructor() { - const dbPath = getIptvnatorDatabasePath(); - this.db = new Database(dbPath); - this.db.pragma('foreign_keys = ON'); - this.db.pragma('journal_mode = WAL'); // Better concurrent write performance - this.db.pragma('busy_timeout = 5000'); - - // Prepare statements - // Use INSERT OR REPLACE to update existing channels and refresh updated_at - // Use strftime with ISO format for consistent date comparison - this.insertChannelStmt = this.db.prepare(` - INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at) - VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) - ON CONFLICT(id) DO UPDATE SET - display_name = excluded.display_name, - icon_url = excluded.icon_url, - url = excluded.url, - source_url = excluded.source_url, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - `); - - this.insertProgramStmt = this.db.prepare(` - INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - this.deleteChannelsStmt = this.db.prepare(` - DELETE FROM epg_channels WHERE source_url = ? - `); - } - - /** - * Clear existing EPG data for a source URL - */ - clearSourceData(sourceUrl: string): void { - this.deleteChannelsStmt.run(sourceUrl); - this.knownChannelIds.clear(); - } - - /** - * Insert a batch of channels. When `clearFirst` is true, the existing rows - * for `sourceUrl` are deleted inside the same transaction as the insert so - * old data is preserved if the fetch/parse never produces any channels. - */ - insertChannels( - channels: ParsedChannel[], - sourceUrl: string, - clearFirst = false - ): void { - const insertMany = this.db.transaction((channels: ParsedChannel[]) => { - if (clearFirst) { - this.deleteChannelsStmt.run(sourceUrl); - this.knownChannelIds.clear(); - } - for (const channel of channels) { - const displayName = - channel.displayName?.[0]?.value || channel.id; - const iconUrl = channel.icon?.[0]?.src || null; - const url = channel.url?.[0] || null; - - this.insertChannelStmt.run( - channel.id, - displayName, - iconUrl, - url, - sourceUrl - ); - this.knownChannelIds.add(channel.id); - } - }); - - insertMany(channels); - } - - /** - * Insert a batch of programs - * Only inserts programs for known channels to avoid FK constraint failures - */ - insertPrograms(programs: ParsedProgram[]): number { - let insertedCount = 0; - - const insertMany = this.db.transaction((programs: ParsedProgram[]) => { - for (const prog of programs) { - // Skip if channel not known - if (!this.knownChannelIds.has(prog.channel)) continue; - - const title = prog.title?.[0]?.value || 'Unknown'; - const description = prog.desc?.[0]?.value || null; - const category = prog.category?.[0]?.value || null; - const iconUrl = prog.icon?.[0]?.src || null; - const rating = prog.rating?.[0]?.value || null; - const episodeNum = prog.episodeNum?.[0]?.value || null; - - try { - this.insertProgramStmt.run( - prog.channel, - prog.start, - prog.stop, - title, - description, - category, - iconUrl, - rating, - episodeNum - ); - insertedCount++; - } catch (err) { - // Skip individual failures (e.g., FK constraint) - } - } - }); - - insertMany(programs); - return insertedCount; - } - - /** - * Close the database connection - */ - close(): void { - this.db.close(); - } -} - /** * Fetches and parses EPG data from URL using streaming * Inserts directly into SQLite to avoid blocking main thread @@ -221,7 +77,7 @@ async function fetchAndParseEpgStreaming(url: string): Promise { console.log(loggerLabel, `Fetching EPG from ${url}`); // Create database connection in worker - const epgDb = new EpgDatabase(); + const epgDb = new EpgDatabase(Database); // Old rows for this source are retained until the first successful insert // batch arrives — see `hasClearedSource` below. That way, a fetch or parse @@ -230,13 +86,30 @@ async function fetchAndParseEpgStreaming(url: string): Promise { let hasClearedSource = false; try { - const response = await fetch(url.trim()); - const isGzipped = shouldGunzipEpgResponse(url, response); + // EPG URLs can originate from an untrusted M3U `url-tvg` attribute. + // Validate every redirect and require an explicit operator opt-in for + // private/LAN sources. + const response = await requestWithValidatedRedirects( + url.trim(), + { + decompress: false, + method: 'GET', + responseType: 'stream', + }, + { + allowPrivateNetworks: isPrivateNetworkUrlAccessAllowed(), + } + ); + const responseUrl = response.config.url; + const isGzipped = shouldGunzipEpgResponse(url, { + headers: response.headers, + url: responseUrl, + }); - if (response.url && response.url !== url) { + if (responseUrl && responseUrl !== url) { console.log( loggerLabel, - `Resolved EPG redirect: ${url} -> ${response.url}` + `Resolved EPG redirect: ${url} -> ${responseUrl}` ); } @@ -245,11 +118,11 @@ async function fetchAndParseEpgStreaming(url: string): Promise { `EPG response detected as gzipped: ${isGzipped}` ); - if (!response.ok) { + if (response.status < 200 || response.status >= 300) { throw new Error(`HTTP error! status: ${response.status}`); } - if (!response.body) { + if (!response.data) { throw new Error('Response body is null'); } @@ -276,15 +149,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise { PROGRAM_BATCH_SIZE ); - // Convert web stream to Node.js stream - const nodeStream = Readable.fromWeb(response.body as any); - return new Promise((resolve, reject) => { - let dataStream: Readable = nodeStream; + let dataStream: Readable = response.data; if (isGzipped) { const gunzip = createGunzip(); - dataStream = nodeStream.pipe(gunzip); + dataStream = response.data.pipe(gunzip); gunzip.on('error', (err) => { console.error(loggerLabel, 'Gunzip error:', err); @@ -364,16 +234,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise { * Runs in worker thread to avoid blocking main thread */ function clearAllEpgData(): void { - const dbPath = getIptvnatorDatabasePath(); - const db = new Database(dbPath); + const clearOperation = new EpgDatabaseClearOperation(Database); try { console.log(loggerLabel, 'Clearing all EPG data...'); - // Delete programs first (foreign key constraint) - db.exec('DELETE FROM epg_programs'); - // Then delete channels - db.exec('DELETE FROM epg_channels'); + clearOperation.run(); console.log(loggerLabel, 'All EPG data cleared'); @@ -387,7 +253,7 @@ function clearAllEpgData(): void { }; parentPort?.postMessage(errorResponse); } finally { - db.close(); + clearOperation.close(); } } diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts index 99962c77a..314c10192 100644 --- a/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts @@ -37,11 +37,25 @@ describe('shouldGunzipEpgResponse', () => { ).toBe(true); }); + it('supports plain Axios response header objects', () => { + expect( + shouldGunzipEpgResponse('https://example.com/guide', { + headers: { + 'Content-Type': 'application/gzip', + }, + url: 'https://example.com/guide', + }) + ).toBe(true); + }); + it('returns true when content-disposition advertises a .gz filename', () => { expect( shouldGunzipEpgResponse('https://example.com/guide', { headers: new Headers([ - ['content-disposition', 'attachment; filename="guide.xml.gz"'], + [ + 'content-disposition', + 'attachment; filename="guide.xml.gz"', + ], ]), url: 'https://example.com/guide', }) diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.ts b/apps/electron-backend/src/app/workers/epg-response-utils.ts index 55db43d99..363dfd00a 100644 --- a/apps/electron-backend/src/app/workers/epg-response-utils.ts +++ b/apps/electron-backend/src/app/workers/epg-response-utils.ts @@ -1,3 +1,20 @@ +type HeaderReader = + | Record + | { + get(name: string): unknown; + }; + +function getHeaderValue(headers: HeaderReader, name: string): string | null { + const value = + 'get' in headers && typeof headers.get === 'function' + ? headers.get(name) + : Object.entries(headers).find( + ([headerName]) => + headerName.toLowerCase() === name.toLowerCase() + )?.[1]; + return value === null || value === undefined ? null : String(value); +} + function hasGzipPath(url: string | null | undefined): boolean { if (!url) { return false; @@ -16,13 +33,13 @@ function hasGzipPath(url: string | null | undefined): boolean { */ export function shouldGunzipEpgResponse( originalUrl: string, - response: { headers: Headers; url?: string } + response: { headers: HeaderReader; url?: string } ): boolean { if (hasGzipPath(originalUrl) || hasGzipPath(response.url)) { return true; } - const contentType = response.headers.get('content-type'); + const contentType = getHeaderValue(response.headers, 'content-type'); if ( contentType && /(application\/gzip|application\/x-gzip)/i.test(contentType) @@ -30,7 +47,10 @@ export function shouldGunzipEpgResponse( return true; } - const contentDisposition = response.headers.get('content-disposition'); + const contentDisposition = getHeaderValue( + response.headers, + 'content-disposition' + ); if (contentDisposition?.toLowerCase().includes('.gz')) { return true; } diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index b0eabc9db..fd78af623 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -1,8 +1,11 @@ -import axios from 'axios'; import { parentPort } from 'worker_threads'; import { parse } from 'iptv-playlist-parser'; import { readFile } from 'node:fs/promises'; -import { createPlaylistObject, getFilenameFromUrl } from '@iptvnator/shared/m3u-utils'; +import { + createPlaylistObject, + getFilenameFromUrl, +} from '@iptvnator/shared/m3u-utils'; +import { createPlaylistAgentFactory } from '../util/secure-https'; import type { Playlist, PlaylistRefreshEvent, @@ -12,8 +15,7 @@ import type { PlaylistRefreshWorkerIncomingMessage, PlaylistRefreshWorkerMessage, } from './playlist-refresh.worker.types'; - -const https = require('https'); +import { requestWithValidatedRedirects } from '../util/validated-axios'; type ActiveRefreshState = { cancelled: boolean; @@ -23,7 +25,9 @@ type ActiveRefreshState = { const activeRefreshes = new Map(); if (!parentPort) { - throw new Error('Playlist refresh worker must be started with a parent port'); + throw new Error( + 'Playlist refresh worker must be started with a parent port' + ); } function postMessage(message: PlaylistRefreshWorkerMessage): void { @@ -78,14 +82,16 @@ async function fetchPlaylistFromUrl( emitEvent(payload, { status: 'started', phase: 'fetching' }); checkpoint(payload); - const agent = new https.Agent({ - rejectUnauthorized: false, - }); - const result = await axios.get(payload.url!, { - httpsAgent: agent, - signal: controller.signal, - timeout: 30000, - }); + const result = await requestWithValidatedRedirects( + payload.url!, + { + agentFactory: createPlaylistAgentFactory(), + method: 'GET', + signal: controller.signal, + timeout: 30000, + }, + { allowPrivateNetworks: true } + ); checkpoint(payload); emitEvent(payload, { status: 'progress', phase: 'parsing' }); @@ -129,7 +135,9 @@ async function fetchPlaylistFromFile( ); } -async function executeRefresh(payload: PlaylistRefreshPayload): Promise { +async function executeRefresh( + payload: PlaylistRefreshPayload +): Promise { const controller = new AbortController(); activeRefreshes.set(payload.operationId, { cancelled: false, @@ -148,41 +156,45 @@ async function executeRefresh(payload: PlaylistRefreshPayload): Promise { - if (message.type === 'cancel') { - const active = activeRefreshes.get(message.operationId); - if (active) { - active.cancelled = true; - active.controller.abort(); +parentPort.on( + 'message', + async (message: PlaylistRefreshWorkerIncomingMessage) => { + if (message.type === 'cancel') { + const active = activeRefreshes.get(message.operationId); + if (active) { + active.cancelled = true; + active.controller.abort(); + } + return; } - return; - } - try { - const result = await executeRefresh(message.payload); - postMessage({ - type: 'response', - success: true, - result, - }); - } catch (error) { - const payload = message.payload; - if (error instanceof Error && error.name === 'AbortError') { - emitEvent(payload, { status: 'cancelled', phase: 'parsing' }); - } else { - emitEvent(payload, { - status: 'error', - phase: payload.url ? 'fetching' : 'reading-file', - error: error instanceof Error ? error.message : String(error), + try { + const result = await executeRefresh(message.payload); + postMessage({ + type: 'response', + success: true, + result, + }); + } catch (error) { + const payload = message.payload; + if (error instanceof Error && error.name === 'AbortError') { + emitEvent(payload, { status: 'cancelled', phase: 'parsing' }); + } else { + emitEvent(payload, { + status: 'error', + phase: payload.url ? 'fetching' : 'reading-file', + error: + error instanceof Error ? error.message : String(error), + }); + } + + postMessage({ + type: 'response', + success: false, + error: serializeError(error), }); } - - postMessage({ - type: 'response', - success: false, - error: serializeError(error), - }); } -}); +); postMessage({ type: 'ready' }); diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 6abcc7d7f..1db7fca8c 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -4,17 +4,25 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Backend responsibilities -- **Queue control (apps/electron-backend/src/app/events/downloads.events.ts)** - `DownloadTask` mirrors the shared `DownloadItem` table plus transient cancel/progress helpers. `enqueueDownload()` resolves a unique file path, persists a `queued` row in `downloads`, pushes the task onto `downloadQueue`, and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. -- **electron-dl integration** - `startDownload()` now calls `electron-dl`’s `download()` helper. Headers (user agent, referer, origin) are attached, and the `onStarted`, `onProgress`, `onCompleted`, and `onCancel` callbacks translate the helper’s payload into Drizzle updates. The handler throttles progress broadcast, saves `filePath`/`fileName` from `electron-dl`, and marks failures/cancellations cleanly. Errors and cancellations delete partial files. +- **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)** + `DownloadTask` mirrors the shared `DownloadItem` table plus transient cancel/progress helpers. Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. +- **electron-dl integration** + `startDownload()` calls `electron-dl`'s `download()` helper. Headers (user agent, referer, origin) are attached, and the `onStarted`, `onProgress`, `onCompleted`, and `onCancel` callbacks translate the helper's payload into Drizzle updates. A cancellation requested before `onStarted` is remembered and applied as soon as Electron supplies the `DownloadItem`, so the request cannot be lost in the startup race. +- **Destination collision policy** + Existing destination files are never overwritten. Before starting Electron's + download, the backend atomically reserves a free numbered filename with an + exclusive filesystem create. Electron may overwrite that empty reservation, + but cannot overwrite a file that existed before the reservation. The selected + `filePath` and `fileName` are persisted before transfer begins. Errors, + cancellations, and startup recovery remove that exact partial path and clear + it from the row; completed downloads replace it with Electron's final values. - **IPC surface** The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store. ## Renderer architecture -- **Downloads service** (`apps/web/src/app/services/downloads.service.ts`) - Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service resolves a download folder (stored in `SettingsStore` or fetched via `downloadsGetDefaultFolder`) and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. +- **Downloads service** (`libs/services/src/lib/downloads.service.ts`) + Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service asks the main process for the authorized folder and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. - **Downloads view** (`libs/portal/downloads/feature`) A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` now wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives a bold two-tone aesthetic inspired by the frontend-design mandate—gradient cards, floating avatars, and theme-aware variables triggered via `body.dark-theme`. Failed/canceled cards now show retry/delete controls, queued/downloading cards show a cancel icon, and completed cards render inline play/open buttons with `mat-icon` cues. The header also shows the resolved download folder and a `CHANGE FOLDER` action. @@ -33,9 +41,12 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Queuing, persistence, and UX notes -- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, and Xtream identifiers. Stale downloads reset to `failed` on startup. -- Queue cancellation removes the task or calls `downloadItem.cancel()` if the item is active; retries reuse the same database entry, preventing duplicate rows. -- Folder selection first checks stored preferences, falls back to the OS default downloads path, and finally prompts the user to pick a folder. The downloads service persists the chosen path via `SettingsStore`. +- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, and Xtream identifiers. On startup, `download-recovery.ts` deletes persisted partial reservations before stale queued/downloading rows become `failed`. +- Queue cancellation removes a queued task or records an active cancellation request and calls `downloadItem.cancel()` when the item is available; retries reuse the same database entry, preventing duplicate rows. +- The OS downloads path is always authorized. A custom folder becomes + authorized only after native folder selection, and the main process persists + that selection under Electron `userData`. Renderer settings may display the + path, but they are not trusted as authorization. - The new UI leverages CSS variables for theme-specific backgrounds/borders, ensures `.downloads__list` can scroll inside its panel, and brings consistent badge/typography treatments to each card. Keeping the backend queue, IPC handlers, shared schema, and renderer signals synchronized minimizes drift between platform rules and the UI. Future work might cover download list filters, cancel-all actions, or integration with upcoming playback analytics. diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 9c8b1f1de..3163c1bdf 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -103,3 +103,49 @@ Rules: When changing this flow, keep stale header cleanup covered. Switching from a channel or playlist with custom headers to one without custom headers must clear the previous override. + +## Main-Process Remote Requests + +Renderer-triggered HTTP requests must pass through the URL policy in +`apps/electron-backend/src/app/events/url-safety.ts`. The policy rejects +non-HTTP(S) URLs and embedded credentials, and strict callers also reject +loopback, private, reserved, and DNS-resolved private addresses. IPv4-mapped +IPv6 literals are decoded before classification, including hexadecimal forms +such as `::ffff:7f00:1`, so alternate IPv6 spelling cannot bypass IPv4 rules. + +Remote request callers must use the validated Axios redirect helper so every +redirect target is checked before the main process follows it. Under the strict +policy, the helper pins the socket lookup to the IP addresses that passed +validation while retaining the original hostname for TLS SNI, certificate +validation, and virtual hosting. This prevents DNS rebinding between validation +and connection. Callers with custom TLS policy provide a typed agent factory; +the validated request layer supplies the pinned lookup instead of copying +private Node `Agent.options` state. Cross-origin redirects must not forward `Authorization`, +`Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies. + +EPG URLs are strict by default because an M3U playlist can supply them through +`url-tvg`. Operators who intentionally use a LAN-hosted EPG source can opt in +for that run with `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1`. Directly configured +Xtream, Stalker, and playlist providers retain private-network support, but +still require HTTP(S), reject embedded credentials, and validate redirects. + +Remote playlist TLS certificates are validated by default. The +`IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch is only for explicitly trusted +providers with invalid or self-signed certificates. + +## Filesystem Capabilities + +Renderer IPC payloads are not filesystem authorization. + +- `write-file` accepts only a path returned to the same renderer by the native + save dialog. The capability is single-use and is consumed before the write, + including when the filesystem operation fails. +- Download folders are owned by the Electron main process. The OS downloads + directory is always allowed; a custom directory is accepted only after the + native folder dialog selects it. +- The selected download directory is persisted under Electron `userData` and + returned by `DOWNLOADS_GET_DEFAULT_FOLDER`, so renderer-managed settings + cannot substitute an arbitrary host path. +- Downloads do not overwrite an existing destination file. +- Reveal and playback handlers accept only file paths recorded in IPTVnator's + downloads database. diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index ee83d16a5..c26cb294e 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -16,16 +16,22 @@ import { SettingsStore } from './settings-store.service'; type TestDownloadsService = { downloads: WritableSignal; + downloadFolder: WritableSignal; isAvailable: () => boolean; isLoadingDownloads: Signal; hasLoadedDownloads: Signal; loadDownloads: DownloadsService['loadDownloads']; + loadDownloadFolder: DownloadsService['loadDownloadFolder']; _isLoadingDownloads: WritableSignal; _hasLoadedDownloads: WritableSignal; loadDownloadsRequestId: number; + settingsStore: { + getDownloadFolder: () => string; + }; }; type DownloadsElectronStub = { + downloadsGetDefaultFolder?: jest.Mock, []>; downloadsGetList: jest.Mock, [string?]>; }; @@ -67,18 +73,23 @@ describe('DownloadsService', () => { const downloads = signal(initialDownloads); const isLoadingDownloads = signal(false); const hasLoadedDownloads = signal(false); + const downloadFolder = signal(''); const service = Object.create( DownloadsService.prototype ) as TestDownloadsService; Object.assign(service, { downloads, + downloadFolder, isAvailable: () => true, _isLoadingDownloads: isLoadingDownloads, isLoadingDownloads: isLoadingDownloads.asReadonly(), _hasLoadedDownloads: hasLoadedDownloads, hasLoadedDownloads: hasLoadedDownloads.asReadonly(), loadDownloadsRequestId: 0, + settingsStore: { + getDownloadFolder: () => '/renderer-controlled', + }, }); return service; @@ -132,6 +143,19 @@ describe('DownloadsService', () => { expect(service.hasLoadedDownloads()).toBe(true); }); + it('uses the main-process authorized download folder instead of renderer storage', async () => { + const electron = { + downloadsGetDefaultFolder: jest.fn(async () => '/authorized'), + downloadsGetList: jest.fn(async () => []), + }; + testWindow.electron = electron; + const service = createService(); + + await expect(service.loadDownloadFolder()).resolves.toBe('/authorized'); + expect(service.downloadFolder()).toBe('/authorized'); + expect(electron.downloadsGetDefaultFolder).toHaveBeenCalledTimes(1); + }); + it('marks downloads as loaded after a failed request while preserving existing data', async () => { const existing = createDownload(1); const error = new Error('download query failed'); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 505c7fa53..fd550208a 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -137,14 +137,9 @@ export class DownloadsService implements OnDestroy { async loadDownloadFolder(): Promise { if (!this.isAvailable()) return ''; - // First check settings - const storedFolder = this.settingsStore.getDownloadFolder?.(); - if (storedFolder) { - this.downloadFolder.set(storedFolder); - return storedFolder; - } - - // Fall back to default + // The main process owns folder authorization. It returns either the OS + // default or a custom folder previously selected through a native + // dialog, rather than trusting renderer-managed settings. try { const defaultFolder = await window.electron.downloadsGetDefaultFolder(); diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index caf290ece..bb9fa320a 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -85,10 +85,7 @@ " /> -
+
{{ program.title }}
@if (isProgramPlaying(program)) {
diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index a4d2f5f78..b499917ae 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -67,6 +67,31 @@ describe('HtmlVideoPlayerComponent', () => { expect(component).toBeTruthy(); }); + it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => { + const el = component.videoPlayer.nativeElement; + const removeSpy = jest.spyOn(el, 'removeEventListener'); + const handlers = component as unknown as { + handleVolumeChange: EventListener; + handleLoadedMetadata: EventListener; + handleTimeUpdate: EventListener; + }; + + fixture.destroy(); + + expect(removeSpy).toHaveBeenCalledWith( + 'volumechange', + handlers.handleVolumeChange + ); + expect(removeSpy).toHaveBeenCalledWith( + 'loadedmetadata', + handlers.handleLoadedMetadata + ); + expect(removeSpy).toHaveBeenCalledWith( + 'timeupdate', + handlers.handleTimeUpdate + ); + }); + it('should call play channel function after input changes', () => { jest.spyOn(component, 'playChannel'); jest.spyOn(global.console, 'error').mockImplementation(() => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 10d298cee..f1819bd5f 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -79,26 +79,38 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.playbackIssue.emit(null); }; - ngOnInit() { - this.videoPlayer.nativeElement.addEventListener('volumechange', () => { - this.onVolumeChange(); + private readonly handleVolumeChange = (): void => { + this.onVolumeChange(); + }; + + private readonly handleLoadedMetadata = (): void => { + if (this.startTime > 0) { + this.videoPlayer.nativeElement.currentTime = this.startTime; + } + }; + + private readonly handleTimeUpdate = (): void => { + this.timeUpdate.emit({ + currentTime: this.videoPlayer.nativeElement.currentTime, + duration: this.videoPlayer.nativeElement.duration, }); + }; + + ngOnInit() { + this.videoPlayer.nativeElement.addEventListener( + 'volumechange', + this.handleVolumeChange + ); this.videoPlayer.nativeElement.addEventListener( 'loadedmetadata', - () => { - if (this.startTime > 0) { - this.videoPlayer.nativeElement.currentTime = this.startTime; - } - } + this.handleLoadedMetadata ); - this.videoPlayer.nativeElement.addEventListener('timeupdate', () => { - this.timeUpdate.emit({ - currentTime: this.videoPlayer.nativeElement.currentTime, - duration: this.videoPlayer.nativeElement.duration, - }); - }); + this.videoPlayer.nativeElement.addEventListener( + 'timeupdate', + this.handleTimeUpdate + ); this.videoPlayer.nativeElement.addEventListener( 'error', @@ -332,7 +344,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { ngOnDestroy(): void { this.videoPlayer.nativeElement.removeEventListener( 'volumechange', - this.onVolumeChange + this.handleVolumeChange + ); + this.videoPlayer.nativeElement.removeEventListener( + 'loadedmetadata', + this.handleLoadedMetadata + ); + this.videoPlayer.nativeElement.removeEventListener( + 'timeupdate', + this.handleTimeUpdate ); this.videoPlayer.nativeElement.removeEventListener( 'error', diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index c97b18ddb..cc4c21ce5 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -1,29 +1,41 @@ @if (selectedPlayer() === 'videojs') { - + @defer (on immediate) { + + } @placeholder { + + } } @else if (selectedPlayer() === 'html5') { - + @defer (on immediate) { + + } @placeholder { + + } } @else if (selectedPlayer() === 'artplayer') { - + @defer (on immediate) { + + } @placeholder { + + } } @else if (selectedPlayer() === 'embedded-mpv') { } @else { - + @defer (on immediate) { + + } @placeholder { + + } } @if (visiblePlaybackDiagnostic(); as issue) { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss index 996c6b19a..c8ecd4a7c 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss @@ -14,6 +14,15 @@ app-html-video-player { height: 100%; } +// Holds the player's space (black, no layout shift) for the one-frame gap +// before a @defer (on immediate) player chunk resolves. +.web-player-defer-placeholder { + display: block; + width: 100%; + height: 100%; + background: #000; +} + .web-player-diagnostic { position: absolute; inset: 0; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index e76177273..9d07c80f0 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -1,5 +1,9 @@ import { Component, input, output } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + DeferBlockBehavior, + TestBed, +} from '@angular/core/testing'; import { ClipboardModule } from '@angular/cdk/clipboard'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; @@ -95,6 +99,8 @@ describe('WebPlayerViewComponent', () => { runtimeCapabilities = { supportsManagedExternalPlayers: false }; await TestBed.configureTestingModule({ + // @defer blocks render their main content synchronously in tests. + deferBlockBehavior: DeferBlockBehavior.Playthrough, imports: [WebPlayerViewComponent, TranslateModule.forRoot()], providers: [ { provide: StorageMap, useValue: storageMap }, @@ -208,7 +214,7 @@ describe('WebPlayerViewComponent', () => { ]); }); - it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', () => { + it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => { const streamUrl = 'https://example.com/movie/123.ts'; fixture.componentRef.setInput('playback', { streamUrl, @@ -220,6 +226,8 @@ describe('WebPlayerViewComponent', () => { }, }); + fixture.detectChanges(); + await fixture.whenStable(); fixture.detectChanges(); const player = fixture.debugElement.query( @@ -238,7 +246,7 @@ describe('WebPlayerViewComponent', () => { ); }); - it('preserves playback HTTP metadata for channel-based players', () => { + it('preserves playback HTTP metadata for channel-based players', async () => { const streamUrl = 'https://example.com/live/channel.m3u8'; fixture.componentRef.setInput( 'playerOverride', @@ -257,6 +265,8 @@ describe('WebPlayerViewComponent', () => { }, }); + fixture.detectChanges(); + await fixture.whenStable(); fixture.detectChanges(); const player = fixture.debugElement.query( @@ -275,7 +285,7 @@ describe('WebPlayerViewComponent', () => { ); }); - it('falls back to playback headers when explicit HTTP metadata is absent', () => { + it('falls back to playback headers when explicit HTTP metadata is absent', async () => { const streamUrl = 'https://example.com/live/channel.m3u8'; fixture.componentRef.setInput( 'playerOverride', @@ -291,6 +301,8 @@ describe('WebPlayerViewComponent', () => { }, }); + fixture.detectChanges(); + await fixture.whenStable(); fixture.detectChanges(); const player = fixture.debugElement.query( @@ -376,18 +388,25 @@ describe('WebPlayerViewComponent', () => { canNext: false, autoplayEnabled: true, }; - fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv); + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.EmbeddedMpv + ); fixture.componentRef.setInput('seriesNavigation', seriesNavigation); ( component as unknown as { playbackEnded: { subscribe: (fn: () => void) => void }; - previousEpisodeRequested: { subscribe: (fn: () => void) => void }; + previousEpisodeRequested: { + subscribe: (fn: () => void) => void; + }; nextEpisodeRequested: { subscribe: (fn: () => void) => void }; } ).playbackEnded.subscribe(() => events.push('ended')); ( component as unknown as { - previousEpisodeRequested: { subscribe: (fn: () => void) => void }; + previousEpisodeRequested: { + subscribe: (fn: () => void) => void; + }; } ).previousEpisodeRequested.subscribe(() => events.push('previous')); ( @@ -494,7 +513,9 @@ describe('WebPlayerViewComponent', () => { ); }); - it('clears playback diagnostics when retrying inline playback', () => { + it('clears playback diagnostics when retrying inline playback', async () => { + fixture.detectChanges(); + await fixture.whenStable(); fixture.detectChanges(); const player = fixture.debugElement.query( By.directive(StubVjsPlayerComponent) diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index a2cc1ff70..5daccec16 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -47,17 +47,23 @@ import { toTimestamp, } from './dashboard-mappers'; import { - buildStalkerDetailNavigationTarget, - buildStalkerStateItem, - buildXtreamNavigationTarget, - getGlobalFavoriteNavigation, - getRecentItemNavigation, PORTAL_PLAYBACK_POSITIONS, WorkspaceNavigationTarget, } from '@iptvnator/portal/shared/util'; import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + getGlobalFavoriteLink as getGlobalFavoriteLinkUtil, + getGlobalFavoriteNavigationState as getGlobalFavoriteNavigationStateUtil, + getPlaylistLink as getPlaylistLinkUtil, + getRecentItemLink as getRecentItemLinkUtil, + getRecentItemNavigationState as getRecentItemNavigationStateUtil, + getRecentlyAddedLink as getRecentlyAddedLinkUtil, + getRecentlyAddedNavigationState as getRecentlyAddedNavigationStateUtil, + isTypeInKind as isTypeInKindUtil, + type DashboardContentKind, +} from './dashboard-navigation.util'; -export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series'; +export type { DashboardContentKind }; // Compound key for looking up a playback position by recent item — a single // playlist can contain the same xtream-id for a VOD and an episode (rare, @@ -799,28 +805,11 @@ export class DashboardDataService { type: PortalActivityType, kind: DashboardContentKind ): boolean { - if (kind === 'all') { - return true; - } - if (kind === 'channels') { - return type === 'live'; - } - if (kind === 'vod') { - return type === 'movie'; - } - return type === 'series'; + return isTypeInKindUtil(type, kind); } getPlaylistLink(playlist: PlaylistMeta): string[] { - if (playlist.serverUrl) { - return ['/workspace', 'xtreams', playlist._id, 'vod']; - } - - if (playlist.macAddress) { - return ['/workspace', 'stalker', playlist._id, 'vod']; - } - - return ['/workspace', 'playlists', playlist._id]; + return getPlaylistLinkUtil(playlist); } getPlaylistProvider(playlist: PlaylistMeta): string { @@ -858,13 +847,13 @@ export class DashboardDataService { } getRecentItemLink(item: GlobalRecentItem): string[] { - return getRecentItemNavigation(item).link; + return getRecentItemLinkUtil(item); } getRecentItemNavigationState( item: GlobalRecentItem ): WorkspaceNavigationTarget['state'] { - return getRecentItemNavigation(item).state; + return getRecentItemNavigationStateUtil(item); } async removeGlobalRecentItem(item: GlobalRecentItem): Promise { @@ -965,67 +954,23 @@ export class DashboardDataService { } getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] { - return getGlobalFavoriteNavigation(item).link; + return getGlobalFavoriteLinkUtil(item); } getGlobalFavoriteNavigationState( item: DashboardFavoriteItem ): WorkspaceNavigationTarget['state'] { - return getGlobalFavoriteNavigation(item).state; + return getGlobalFavoriteNavigationStateUtil(item); } getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] { - if (item.source === 'stalker' && item.type !== 'live') { - return buildStalkerDetailNavigationTarget({ - playlistId: item.playlist_id, - type: item.type, - categoryId: item.category_id, - item: buildStalkerStateItem(item.stalker_item, { - id: item.id, - title: item.title, - type: item.type, - category_id: item.category_id, - poster_url: item.poster_url, - }), - }).link; - } - - return buildXtreamNavigationTarget({ - playlistId: item.playlist_id, - type: item.type, - categoryId: item.category_id, - itemId: item.xtream_id, - title: item.title, - imageUrl: item.poster_url, - }).link; + return getRecentlyAddedLinkUtil(item); } getRecentlyAddedNavigationState( item: DashboardRecentlyAddedItem ): WorkspaceNavigationTarget['state'] { - if (item.source === 'stalker' && item.type !== 'live') { - return buildStalkerDetailNavigationTarget({ - playlistId: item.playlist_id, - type: item.type, - categoryId: item.category_id, - item: buildStalkerStateItem(item.stalker_item, { - id: item.id, - title: item.title, - type: item.type, - category_id: item.category_id, - poster_url: item.poster_url, - }), - }).state; - } - - return buildXtreamNavigationTarget({ - playlistId: item.playlist_id, - type: item.type, - categoryId: item.category_id, - itemId: item.xtream_id, - title: item.title, - imageUrl: item.poster_url, - }).state; + return getRecentlyAddedNavigationStateUtil(item); } async removeGlobalFavorite(item: DashboardFavoriteItem): Promise { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts new file mode 100644 index 000000000..6b8993d1b --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts @@ -0,0 +1,128 @@ +import { + PlaylistMeta, + PortalActivityType, + PortalAddedItem, + PortalFavoriteItem, + PortalRecentItem, +} from '@iptvnator/shared/interfaces'; +import { + buildStalkerDetailNavigationTarget, + buildStalkerStateItem, + buildXtreamNavigationTarget, + getGlobalFavoriteNavigation, + getRecentItemNavigation, + WorkspaceNavigationTarget, +} from '@iptvnator/portal/shared/util'; + +/** + * Pure navigation/link helpers for dashboard items. + * + * Extracted from `DashboardDataService` so the routing logic can be unit-tested + * in isolation and the service stays a thin facade. None of these functions + * touch component/service state — they map a dashboard item to a router link + * (and optional navigation state) using the shared portal navigation builders. + */ + +export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series'; + +export function isTypeInKind( + type: PortalActivityType, + kind: DashboardContentKind +): boolean { + if (kind === 'all') { + return true; + } + if (kind === 'channels') { + return type === 'live'; + } + if (kind === 'vod') { + return type === 'movie'; + } + return type === 'series'; +} + +export function getPlaylistLink(playlist: PlaylistMeta): string[] { + if (playlist.serverUrl) { + return ['/workspace', 'xtreams', playlist._id, 'vod']; + } + + if (playlist.macAddress) { + return ['/workspace', 'stalker', playlist._id, 'vod']; + } + + return ['/workspace', 'playlists', playlist._id]; +} + +export function getRecentItemLink(item: PortalRecentItem): string[] { + return getRecentItemNavigation(item).link; +} + +export function getRecentItemNavigationState( + item: PortalRecentItem +): WorkspaceNavigationTarget['state'] { + return getRecentItemNavigation(item).state; +} + +export function getGlobalFavoriteLink(item: PortalFavoriteItem): string[] { + return getGlobalFavoriteNavigation(item).link; +} + +export function getGlobalFavoriteNavigationState( + item: PortalFavoriteItem +): WorkspaceNavigationTarget['state'] { + return getGlobalFavoriteNavigation(item).state; +} + +export function getRecentlyAddedLink(item: PortalAddedItem): string[] { + if (item.source === 'stalker' && item.type !== 'live') { + return buildStalkerDetailNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + item: buildStalkerStateItem(item.stalker_item, { + id: item.id, + title: item.title, + type: item.type, + category_id: item.category_id, + poster_url: item.poster_url, + }), + }).link; + } + + return buildXtreamNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + itemId: item.xtream_id, + title: item.title, + imageUrl: item.poster_url, + }).link; +} + +export function getRecentlyAddedNavigationState( + item: PortalAddedItem +): WorkspaceNavigationTarget['state'] { + if (item.source === 'stalker' && item.type !== 'live') { + return buildStalkerDetailNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + item: buildStalkerStateItem(item.stalker_item, { + id: item.id, + title: item.title, + type: item.type, + category_id: item.category_id, + poster_url: item.poster_url, + }), + }).state; + } + + return buildXtreamNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + itemId: item.xtream_id, + title: item.title, + imageUrl: item.poster_url, + }).state; +} From 9043116ebd6e92f728c24da53ede78d8ab3048fb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:46:01 +0200 Subject: [PATCH 5/8] [codex] fix Electron hardening follow-ups (#1053) * fix: clean up Electron hardening follow-ups * fix: remove duplicate Xtream probe comment --------- Co-authored-by: 4gray --- .../src/app/events/xtream.events.ts | 8 +++---- .../src/app/workers/epg-database.spec.ts | 4 +++- .../src/app/workers/epg-database.ts | 1 + .../src/lib/downloads.service.spec.ts | 23 ++++++++++++------- libs/services/src/lib/downloads.service.ts | 4 ---- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index c4720dd5e..e8ef3d09e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -275,12 +275,10 @@ ipcMain.handle( method?: 'GET' | 'HEAD'; } ) => { - // Guard against SSRF: a malicious portal/playlist could ask the main - // process to probe loopback/private/metadata addresses. Only allow - // public http(s) targets. + // Probe URLs must be http(s), but may target private/LAN Xtream hosts + // for self-hosted setups. Redirects stay disabled below so a validated + // URL cannot bounce to a different private target. try { - // Private/LAN targets are allowed (users probe self-hosted Xtream - // servers); maxRedirects:0 below blocks redirect-based SSRF. await assertRemoteUrlAllowed(payload.url, { allowPrivateNetworks: true, }); 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 8e97951e6..b9684dbac 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -5,6 +5,7 @@ function createDatabaseMock(exec: jest.Mock) { const database = { close: jest.fn(), exec, + pragma: jest.fn(), }; const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3; @@ -14,10 +15,11 @@ function createDatabaseMock(exec: jest.Mock) { describe('EpgDatabaseClearOperation', () => { it('clears programs and channels in one transaction', () => { const exec = jest.fn(); - const { Database } = createDatabaseMock(exec); + const { Database, database } = createDatabaseMock(exec); new EpgDatabaseClearOperation(Database).run(); + expect(database.pragma).toHaveBeenCalledWith('busy_timeout = 5000'); expect(exec.mock.calls.map(([statement]) => statement)).toEqual([ 'BEGIN', 'DELETE FROM epg_programs', diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index bcf5d18e4..27391ac67 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -126,6 +126,7 @@ export class EpgDatabaseClearOperation { constructor(Database: typeof BetterSqlite3) { this.db = new Database(getIptvnatorDatabasePath()); + this.db.pragma('busy_timeout = 5000'); } run(): void { diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index c26cb294e..d970093cf 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -12,7 +12,6 @@ import { DownloadsService, } from './downloads.service'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; -import { SettingsStore } from './settings-store.service'; type TestDownloadsService = { downloads: WritableSignal; @@ -22,16 +21,15 @@ type TestDownloadsService = { hasLoadedDownloads: Signal; loadDownloads: DownloadsService['loadDownloads']; loadDownloadFolder: DownloadsService['loadDownloadFolder']; + selectFolder: DownloadsService['selectFolder']; _isLoadingDownloads: WritableSignal; _hasLoadedDownloads: WritableSignal; loadDownloadsRequestId: number; - settingsStore: { - getDownloadFolder: () => string; - }; }; type DownloadsElectronStub = { downloadsGetDefaultFolder?: jest.Mock, []>; + downloadsSelectFolder?: jest.Mock, []>; downloadsGetList: jest.Mock, [string?]>; }; @@ -87,9 +85,6 @@ describe('DownloadsService', () => { _hasLoadedDownloads: hasLoadedDownloads, hasLoadedDownloads: hasLoadedDownloads.asReadonly(), loadDownloadsRequestId: 0, - settingsStore: { - getDownloadFolder: () => '/renderer-controlled', - }, }); return service; @@ -99,7 +94,6 @@ describe('DownloadsService', () => { const injector = createEnvironmentInjector( [ DownloadsService, - { provide: SettingsStore, useValue: {} }, { provide: RuntimeCapabilitiesService, useValue: { supportsDownloads: false }, @@ -156,6 +150,19 @@ describe('DownloadsService', () => { expect(electron.downloadsGetDefaultFolder).toHaveBeenCalledTimes(1); }); + it('stores a selected download folder returned by the main process', async () => { + const electron = { + downloadsSelectFolder: jest.fn(async () => '/selected'), + downloadsGetList: jest.fn(async () => []), + }; + testWindow.electron = electron; + const service = createService(); + + await expect(service.selectFolder()).resolves.toBe('/selected'); + expect(service.downloadFolder()).toBe('/selected'); + expect(electron.downloadsSelectFolder).toHaveBeenCalledTimes(1); + }); + it('marks downloads as loaded after a failed request while preserving existing data', async () => { const existing = createDownload(1); const error = new Error('download query failed'); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index fd550208a..4564e189c 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -1,6 +1,5 @@ import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; -import { SettingsStore } from './settings-store.service'; export type DownloadStatus = | 'queued' @@ -33,7 +32,6 @@ export interface DownloadItem { @Injectable({ providedIn: 'root' }) export class DownloadsService implements OnDestroy { private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly settingsStore = inject(SettingsStore); private unsubscribe?: () => void; private loadDownloadsRequestId = 0; @@ -330,8 +328,6 @@ export class DownloadsService implements OnDestroy { const folder = await window.electron.downloadsSelectFolder(); if (folder) { this.downloadFolder.set(folder); - // Save to settings - await this.settingsStore.updateSettings({ downloadFolder: folder }); } return folder; } catch (error) { From e8aa7c3a34eba84f56ebaf9c057aeb97796adff8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:46:24 +0200 Subject: [PATCH 6/8] [codex] Add scoped EPG security trust controls (#1054) * Add scoped EPG security trust controls * fix: address scoped trust review feedback --------- Co-authored-by: 4gray --- README.md | 13 +- .../src/app/api/main.preload.spec-data.ts | 17 ++- .../src/app/api/main.preload.ts | 21 ++- .../src/app/events/epg-worker.service.ts | 48 ++++-- .../src/app/events/epg.events.spec.ts | 1 + .../src/app/events/epg.events.ts | 49 ++++-- .../src/app/events/playlist-source.ts | 41 +++-- .../src/app/events/playlist.events.ts | 124 +++++++++------ .../src/app/util/secure-https.spec.ts | 41 ++++- .../src/app/util/secure-https.ts | 33 +++- .../src/app/util/security-errors.ts | 93 ++++++++++++ .../src/app/util/validated-axios.spec.ts | 10 +- .../src/app/util/validated-axios.ts | 6 +- .../src/app/workers/epg-parser.worker.ts | 86 ++++++++++- .../app/workers/playlist-refresh.worker.ts | 37 +++-- .../src/app/services/electron.service.spec.ts | 60 +++++++- apps/web/src/app/services/electron.service.ts | 118 ++++++++++++++- .../app/settings/settings.component.spec.ts | 55 ++++--- .../src/app/settings/settings.component.ts | 31 ++-- apps/web/src/assets/i18n/ar.json | 13 +- apps/web/src/assets/i18n/ary.json | 13 +- apps/web/src/assets/i18n/by.json | 13 +- apps/web/src/assets/i18n/de.json | 13 +- apps/web/src/assets/i18n/el.json | 13 +- apps/web/src/assets/i18n/en.json | 13 +- apps/web/src/assets/i18n/es.json | 13 +- apps/web/src/assets/i18n/fr.json | 13 +- apps/web/src/assets/i18n/it.json | 13 +- apps/web/src/assets/i18n/ja.json | 13 +- apps/web/src/assets/i18n/ko.json | 13 +- apps/web/src/assets/i18n/nl.json | 13 +- apps/web/src/assets/i18n/pl.json | 13 +- apps/web/src/assets/i18n/pt.json | 13 +- apps/web/src/assets/i18n/ru.json | 13 +- apps/web/src/assets/i18n/tr.json | 13 +- apps/web/src/assets/i18n/zh.json | 13 +- apps/web/src/assets/i18n/zhtw.json | 13 +- docs/architecture/electron-security.md | 18 ++- .../src/lib/epg-progress.service.spec.ts | 47 +++++- .../src/lib/epg-progress.service.ts | 73 ++++++++- .../lib/epg-runtime-bridge.service.spec.ts | 8 +- .../src/lib/epg-runtime-bridge.service.ts | 17 ++- .../data-access/src/lib/epg.service.spec.ts | 27 +++- libs/epg/data-access/src/lib/epg.service.ts | 13 +- .../playlist-refresh-action.service.spec.ts | 106 +++++++++++-- .../lib/playlist-refresh-action.service.ts | 112 +++++++++++++- .../src/lib/settings-store.service.ts | 18 +++ libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 36 ++++- .../src/lib/playlist-refresh.interface.ts | 1 + .../lib/security-policy-error.utils.spec.ts | 56 +++++++ .../src/lib/security-policy-error.utils.ts | 76 ++++++++++ .../interfaces/src/lib/settings.interface.ts | 12 ++ .../epg-progress-panel.component.html | 35 ++--- .../epg-progress-panel.component.scss | 11 ++ .../epg-progress-panel.component.ts | 141 +++++++++++++++++- 56 files changed, 1683 insertions(+), 243 deletions(-) create mode 100644 apps/electron-backend/src/app/util/security-errors.ts create mode 100644 libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts create mode 100644 libs/shared/interfaces/src/lib/security-policy-error.utils.ts diff --git a/README.md b/README.md index e5f638b03..500f194b2 100644 --- a/README.md +++ b/README.md @@ -306,12 +306,15 @@ Useful narrower flags: Security-sensitive network compatibility flags are opt-in: -- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits EPG URLs that resolve to - localhost, LAN, or other private addresses. Leave this unset for playlists - you do not fully trust. +- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits strict EPG fetches from + playlist metadata (`url-tvg`) to resolve to localhost, LAN, or other private + addresses. Directly configured Xtream/Stalker portals and private playlist + servers remain supported without this flag. Prefer the in-app source-scoped + “Allow source” action for a trusted EPG URL. - `IPTVNATOR_ALLOW_INSECURE_TLS=1` disables certificate validation for remote - playlist imports and refreshes. Use it only for a trusted provider with a - self-signed or otherwise invalid certificate. + playlist imports and refreshes for the whole Electron process. Prefer the + in-app host-scoped trust action for a trusted provider with a self-signed or + otherwise invalid certificate. If the local Nx daemon gets into a bad state before rerunning Electron, reset it: 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 d382aa3e6..bfe134b59 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 @@ -21,6 +21,10 @@ type PreloadInvokeCase = { const playlistId = 'playlist-1'; export const operationId = 'operation-1'; const epgUrls = ['https://example.com/guide.xml']; +const trustOptions = { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], +}; const channelIds = ['channel-1', 'channel-2']; const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' }; const playlists = [playlist]; @@ -338,9 +342,9 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ export const epgPreloadCases: PreloadInvokeCase[] = [ { method: 'fetchEpg', - args: [epgUrls], + args: [epgUrls, trustOptions], channel: 'FETCH_EPG', - forwardedArgs: [{ url: epgUrls }], + forwardedArgs: [{ url: epgUrls, options: trustOptions }], }, { method: 'getChannelPrograms', @@ -374,9 +378,14 @@ export const epgPreloadCases: PreloadInvokeCase[] = [ }, { method: 'forceFetchEpg', - args: ['https://example.com/guide.xml'], + args: ['https://example.com/guide.xml', trustOptions], channel: 'EPG_FORCE_FETCH', - forwardedArgs: ['https://example.com/guide.xml'], + forwardedArgs: [ + { + url: 'https://example.com/guide.xml', + options: trustOptions, + }, + ], }, { method: 'clearEpgData', diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 27b9ece70..38eda2a7b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -13,6 +13,7 @@ import type { ElectronBridgePlaylistUpsertInput, ElectronBridgeRemoteControlCommand, ElectronBridgeRemoteControlStatus, + ElectronBridgeTrustOptions, ElectronBridgeWindowState, ElectronBridgeXtreamContentStream, ExternalPlayerSession, @@ -302,8 +303,11 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.on(WINDOW_STATE_CHANGED, handler); return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler); }, - fetchPlaylistByUrl: (url: string, title?: string) => - ipcRenderer.invoke('fetch-playlist-by-url', url, title), + fetchPlaylistByUrl: ( + url: string, + title?: string, + options?: ElectronBridgeTrustOptions + ) => ipcRenderer.invoke('fetch-playlist-by-url', url, title, options), updatePlaylistFromFilePath: (filePath: string, title: string) => ipcRenderer.invoke('update-playlist-from-file-path', filePath, title), openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'), @@ -448,10 +452,12 @@ const electronApi: ElectronBridgeApi = { sessionId: string ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId), - autoUpdatePlaylists: (playlists) => - ipcRenderer.invoke('AUTO_UPDATE', playlists), - fetchEpg: (urls: string[]) => - ipcRenderer.invoke('FETCH_EPG', { url: urls }), + autoUpdatePlaylists: ( + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => ipcRenderer.invoke('AUTO_UPDATE', playlists, options), + fetchEpg: (urls: string[], options?: ElectronBridgeTrustOptions) => + ipcRenderer.invoke('FETCH_EPG', { url: urls, options }), getChannelPrograms: (channelId: string) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }), getCurrentProgramsBatch: (channelIds: string[]) => @@ -461,7 +467,8 @@ const electronApi: ElectronBridgeApi = { getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'), getEpgChannelsByRange: (skip: number, limit: number) => ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }), - forceFetchEpg: (url: string) => ipcRenderer.invoke('EPG_FORCE_FETCH', url), + forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) => + ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }), clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'), checkEpgFreshness: (urls: string[], maxAgeHours?: number) => ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }), 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 f725b3a61..1542335ef 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -2,6 +2,10 @@ import { app, BrowserWindow } from 'electron'; import * as path from 'path'; import { pathToFileURL } from 'url'; 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'; @@ -14,6 +18,8 @@ export interface EpgProgressStats { interface EpgWorkerMessage { type: string; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; url?: string; stats?: EpgProgressStats; } @@ -45,7 +51,9 @@ export class EpgWorkerService { status: EpgProgressStatus, stats?: EpgProgressStats, error?: string, - queuePosition?: number + queuePosition?: number, + errorCode?: ElectronBridgeSecurityErrorCode, + errorHost?: string ): void { const windows = BrowserWindow.getAllWindows(); windows.forEach((win) => { @@ -55,11 +63,16 @@ export class EpgWorkerService { stats, error, queuePosition, + errorCode, + errorHost, }); }); } - async fetchEpgFromUrl(url: string): Promise { + async fetchEpgFromUrl( + url: string, + options: ElectronBridgeTrustOptions = {} + ): Promise { // 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 @@ -84,14 +97,17 @@ export class EpgWorkerService { return; } - const fetchPromise = this.startFetch(url).finally(() => { + const fetchPromise = this.startFetch(url, options).finally(() => { this.inFlightFetches.delete(url); }); this.inFlightFetches.set(url, fetchPromise); return fetchPromise; } - private startFetch(url: string): Promise { + private startFetch( + url: string, + options: ElectronBridgeTrustOptions + ): Promise { return new Promise((resolve, reject) => { let worker: Worker; try { @@ -148,7 +164,11 @@ export class EpgWorkerService { totalChannels: 0, totalPrograms: 0, }); - worker.postMessage({ type: 'FETCH_EPG', url }); + worker.postMessage({ + type: 'FETCH_EPG', + url, + options, + }); break; case 'EPG_PROGRESS': @@ -192,7 +212,10 @@ export class EpgWorkerService { url, 'error', undefined, - message.error + message.error, + undefined, + message.errorCode, + message.errorHost ); this.workers.delete(url); settle(() => { @@ -313,13 +336,12 @@ export class EpgWorkerService { // 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( - runningWorker, - 'fetch during clear' - ) + const terminations = [...this.workers.values()].map( + (runningWorker) => + this.terminateWorker( + runningWorker, + 'fetch during clear' + ) ); this.workers.clear(); terminations.push( 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 349b51d46..4cbe17c7e 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -109,6 +109,7 @@ describe('EpgEvents', () => { expect(worker.postMessage).toHaveBeenCalledWith({ type: 'FETCH_EPG', url: 'https://example.com/guide.xml', + options: {}, }); worker.emit('message', { diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 65781279a..41edea95c 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,6 +1,10 @@ import { eq } from 'drizzle-orm'; import { ipcMain } from 'electron'; -import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + ElectronBridgeTrustOptions, + EpgChannelMetadata, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; import { epgQueryService } from './epg-query.service'; @@ -17,9 +21,15 @@ export default class EpgEvents { * Bootstrap EPG events */ static bootstrapEpgEvents(): Electron.IpcMain { - ipcMain.handle('FETCH_EPG', async (_event, args: { url: string[] }) => { - return await this.handleFetchEpg(args.url); - }); + ipcMain.handle( + 'FETCH_EPG', + async ( + _event, + args: { url: string[]; options?: ElectronBridgeTrustOptions } + ) => { + return await this.handleFetchEpg(args.url, args.options); + } + ); ipcMain.handle( 'GET_CHANNEL_PROGRAMS', @@ -53,10 +63,21 @@ export default class EpgEvents { } ); - ipcMain.handle('EPG_FORCE_FETCH', async (_event, url: string) => { - epgWorkerService.deleteFetchedUrl(url); - return await this.handleFetchEpg([url]); - }); + ipcMain.handle( + 'EPG_FORCE_FETCH', + async ( + _event, + args: + | string + | { url: string; options?: ElectronBridgeTrustOptions } + ) => { + const url = typeof args === 'string' ? args : args.url; + const options = + typeof args === 'string' ? undefined : args.options; + epgWorkerService.deleteFetchedUrl(url); + return await this.handleFetchEpg([url], options); + } + ); ipcMain.handle('EPG_CLEAR_ALL', async () => { await this.clearEpgData(); @@ -149,7 +170,8 @@ export default class EpgEvents { * Processes URLs sequentially to avoid SQLite database locking issues */ private static async handleFetchEpg( - urls: string[] + urls: string[], + options: ElectronBridgeTrustOptions = {} ): Promise<{ success: boolean; message?: string; skipped?: string[] }> { const validUrls = urls.filter((url) => url?.trim()); @@ -198,7 +220,7 @@ export default class EpgEvents { const errors: string[] = []; for (const url of urlsToFetch) { try { - await this.fetchEpgFromUrl(url); + await this.fetchEpgFromUrl(url, options); } catch (error) { console.error( this.loggerLabel, @@ -222,8 +244,11 @@ export default class EpgEvents { return { success: true, skipped: freshUrls }; } - private static async fetchEpgFromUrl(url: string): Promise { - return epgWorkerService.fetchEpgFromUrl(url); + private static async fetchEpgFromUrl( + url: string, + options: ElectronBridgeTrustOptions = {} + ): Promise { + return epgWorkerService.fetchEpgFromUrl(url, options); } private static async handleGetChannelPrograms( diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index 907867be1..a858927cd 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -7,20 +7,43 @@ import { parse } from 'iptv-playlist-parser'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { createPlaylistAgentFactory } from '../util/secure-https'; +import { + createInvalidTlsCertificateError, + getHostnameFromErrorUrl, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +export interface PlaylistFetchOptions { + trustedInsecureTlsHosts?: readonly string[]; +} + export async function fetchPlaylistFromUrl( url: string, - title?: string + title?: string, + options: PlaylistFetchOptions = {} ): Promise { - const result = await requestWithValidatedRedirects( - url, - { - agentFactory: createPlaylistAgentFactory(), - method: 'GET', - }, - { allowPrivateNetworks: true } - ); + let result; + try { + result = await requestWithValidatedRedirects( + url, + { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, + }), + method: 'GET', + }, + { allowPrivateNetworks: true } + ); + } catch (error) { + if (isInvalidTlsCertificateError(error)) { + throw createInvalidTlsCertificateError( + getHostnameFromErrorUrl(error, url) + ); + } + throw error; + } + const parsedPlaylist = parse(result.data); const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : ''; const playlistName = diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index fc2ad44c5..1fdbf34af 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -12,6 +12,7 @@ import { PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, PLAYLIST_REFRESH_EVENT, + ElectronBridgeTrustOptions, Playlist, PlaylistRefreshEvent, PlaylistRefreshPayload, @@ -85,14 +86,24 @@ function createPlaylistRefreshError(error: { return workerError; } -ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { - try { - return await fetchPlaylistFromUrl(url, title); - } catch (error) { - console.error('Error fetching playlist:', error); - throw error; +ipcMain.handle( + 'fetch-playlist-by-url', + async ( + event, + url, + title?: string, + options?: ElectronBridgeTrustOptions + ) => { + try { + return await fetchPlaylistFromUrl(url, title, { + trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts, + }); + } catch (error) { + console.error('Error fetching playlist:', error); + throw error; + } } -}); +); ipcMain.handle( 'update-playlist-from-file-path', @@ -132,57 +143,72 @@ ipcMain.handle('open-playlist-from-file', async () => { } }); -ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { - console.log(`Auto-updating ${playlists.length} playlist(s)...`); +ipcMain.handle( + AUTO_UPDATE_PLAYLISTS, + async ( + event, + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => { + console.log(`Auto-updating ${playlists.length} playlist(s)...`); - const updatedPlaylists: Playlist[] = []; + const updatedPlaylists: Playlist[] = []; - for (const playlist of playlists) { - try { - let playlistObject; + for (const playlist of playlists) { + try { + let playlistObject; + + if (playlist.importDate && playlist.url) { + // Update from URL + console.log( + `Updating playlist "${playlist.title}" from URL: ${playlist.url}` + ); + playlistObject = await fetchPlaylistFromUrl( + playlist.url, + playlist.title, + { + trustedInsecureTlsHosts: + options?.trustedInsecureTlsHosts, + } + ); + } else if (playlist.filePath) { + // Update from file path + console.log( + `Updating playlist "${playlist.title}" from file: ${playlist.filePath}` + ); + playlistObject = await fetchPlaylistFromFile( + playlist.filePath, + playlist.title + ); + } else { + console.warn( + `Skipping playlist "${playlist.title}": no URL or file path found` + ); + continue; + } + + updatedPlaylists.push( + preserveAutoUpdatedPlaylistFields(playlistObject, playlist) + ); - if (playlist.importDate && playlist.url) { - // Update from URL console.log( - `Updating playlist "${playlist.title}" from URL: ${playlist.url}` + `Successfully updated playlist "${playlist.title}"` ); - playlistObject = await fetchPlaylistFromUrl( - playlist.url, - playlist.title + } catch (error) { + console.error( + `Failed to update playlist "${playlist.title}":`, + error ); - } else if (playlist.filePath) { - // Update from file path - console.log( - `Updating playlist "${playlist.title}" from file: ${playlist.filePath}` - ); - playlistObject = await fetchPlaylistFromFile( - playlist.filePath, - playlist.title - ); - } else { - console.warn( - `Skipping playlist "${playlist.title}": no URL or file path found` - ); - continue; + // Continue with other playlists even if one fails } - - updatedPlaylists.push( - preserveAutoUpdatedPlaylistFields(playlistObject, playlist) - ); - - console.log(`Successfully updated playlist "${playlist.title}"`); - } catch (error) { - console.error( - `Failed to update playlist "${playlist.title}":`, - error - ); - // Continue with other playlists even if one fails } - } - console.log(`Auto-update completed: ${updatedPlaylists.length} updated`); - return updatedPlaylists; -}); + console.log( + `Auto-update completed: ${updatedPlaylists.length} updated` + ); + return updatedPlaylists; + } +); ipcMain.handle( PLAYLIST_REFRESH, diff --git a/apps/electron-backend/src/app/util/secure-https.spec.ts b/apps/electron-backend/src/app/util/secure-https.spec.ts index d0b8421a8..7e777c2c6 100644 --- a/apps/electron-backend/src/app/util/secure-https.spec.ts +++ b/apps/electron-backend/src/app/util/secure-https.spec.ts @@ -30,7 +30,10 @@ describe('secure-https', () => { delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; expect(isInsecureTlsAllowed()).toBe(false); - createPlaylistAgentFactory().createHttpsAgent(lookup); + createPlaylistAgentFactory().createHttpsAgent( + lookup, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ lookup, @@ -44,7 +47,10 @@ describe('secure-https', () => { process.env.IPTVNATOR_ALLOW_INSECURE_TLS = value; expect(isInsecureTlsAllowed()).toBe(true); - createPlaylistAgentFactory().createHttpsAgent(lookup); + createPlaylistAgentFactory().createHttpsAgent( + lookup, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ lookup, @@ -62,10 +68,39 @@ describe('secure-https', () => { it('preserves TLS policy when no pinned lookup is required', () => { delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; - createPlaylistAgentFactory().createHttpsAgent(); + createPlaylistAgentFactory().createHttpsAgent( + undefined, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ rejectUnauthorized: true, }); }); + + it('allows invalid certificates only for trusted hosts', () => { + delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; + + const factory = createPlaylistAgentFactory({ + trustedInsecureTlsHosts: ['playlist.local'], + }); + + factory.createHttpsAgent( + lookup, + new URL('https://playlist.local/list.m3u') + ); + factory.createHttpsAgent( + lookup, + new URL('https://other.local/list.m3u') + ); + + expect(agentConstructorMock).toHaveBeenNthCalledWith(1, { + lookup, + rejectUnauthorized: false, + }); + expect(agentConstructorMock).toHaveBeenNthCalledWith(2, { + lookup, + rejectUnauthorized: true, + }); + }); }); diff --git a/apps/electron-backend/src/app/util/secure-https.ts b/apps/electron-backend/src/app/util/secure-https.ts index 5ef49a432..b66aa7d8d 100644 --- a/apps/electron-backend/src/app/util/secure-https.ts +++ b/apps/electron-backend/src/app/util/secure-https.ts @@ -1,5 +1,6 @@ import { Agent } from 'node:https'; import type { LookupFunction } from 'node:net'; +import { normalizeHost } from '@iptvnator/shared/interfaces'; import type { ValidatedRequestAgentFactory } from './validated-axios'; const INSECURE_TLS_ENV = 'IPTVNATOR_ALLOW_INSECURE_TLS'; @@ -18,6 +19,20 @@ export function isInsecureTlsAllowed(): boolean { return value === '1' || value === 'true'; } +function shouldTrustInvalidCertificateForHost( + url: URL | undefined, + trustedHosts: readonly string[] +): boolean { + if (!url) { + return false; + } + + const host = normalizeHost(url.hostname); + return trustedHosts.some( + (trustedHost) => normalizeHost(trustedHost) === host + ); +} + /** * Builds the agent factory used for remote playlist fetches. * @@ -25,12 +40,22 @@ export function isInsecureTlsAllowed(): boolean { * (see {@link isInsecureTlsAllowed}). The validated request layer supplies a * DNS lookup pinned to the addresses approved for each redirect hop. */ -export function createPlaylistAgentFactory(): ValidatedRequestAgentFactory & { - createHttpsAgent(lookup?: LookupFunction): Agent; +export function createPlaylistAgentFactory( + options: { + trustedInsecureTlsHosts?: readonly string[]; + } = {} +): ValidatedRequestAgentFactory & { + createHttpsAgent(lookup?: LookupFunction, url?: URL): Agent; } { - const rejectUnauthorized = !isInsecureTlsAllowed(); + const trustedHosts = options.trustedInsecureTlsHosts ?? []; return { - createHttpsAgent: (lookup) => new Agent({ lookup, rejectUnauthorized }), + createHttpsAgent: (lookup, url) => + new Agent({ + lookup, + rejectUnauthorized: + !isInsecureTlsAllowed() && + !shouldTrustInvalidCertificateForHost(url, trustedHosts), + }), }; } diff --git a/apps/electron-backend/src/app/util/security-errors.ts b/apps/electron-backend/src/app/util/security-errors.ts new file mode 100644 index 000000000..421df1223 --- /dev/null +++ b/apps/electron-backend/src/app/util/security-errors.ts @@ -0,0 +1,93 @@ +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + SECURITY_ERROR_PREFIX, +} from '@iptvnator/shared/interfaces'; +export { + parseSecurityPolicyError, + SECURITY_ERROR_PREFIX, +} from '@iptvnator/shared/interfaces'; +export type { SerializedSecurityError } from '@iptvnator/shared/interfaces'; + +const TLS_CERTIFICATE_ERROR_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', +]); + +export class SecurityPolicyError extends Error { + constructor( + readonly code: string, + message: string, + readonly host?: string + ) { + super( + `${SECURITY_ERROR_PREFIX}${JSON.stringify({ code, host, message })}` + ); + this.name = 'SecurityPolicyError'; + } +} + +function readErrorProperty(error: unknown, property: string): unknown { + if (!error || typeof error !== 'object') { + return undefined; + } + return (error as Record)[property]; +} + +export function isInvalidTlsCertificateError(error: unknown): boolean { + const code = readErrorProperty(error, 'code'); + if (typeof code === 'string' && TLS_CERTIFICATE_ERROR_CODES.has(code)) { + return true; + } + + const cause = readErrorProperty(error, 'cause'); + const causeCode = readErrorProperty(cause, 'code'); + if ( + typeof causeCode === 'string' && + TLS_CERTIFICATE_ERROR_CODES.has(causeCode) + ) { + return true; + } + + const message = + error instanceof Error ? error.message : String(error ?? ''); + return /certificate|self[- ]signed|cert_altname|unable to verify/i.test( + message + ); +} + +export function createInvalidTlsCertificateError( + host: string | undefined, + message = 'Certificate for this playlist host is invalid.' +): SecurityPolicyError { + return new SecurityPolicyError( + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + message, + host + ); +} + +export function getHostnameFromUrl(url: string): string | undefined { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +export function getHostnameFromErrorUrl( + error: unknown, + fallbackUrl: string +): string | undefined { + const configUrl = + error && typeof error === 'object' + ? ((error as { config?: { url?: string } }).config?.url ?? + fallbackUrl) + : fallbackUrl; + + return getHostnameFromUrl(configUrl); +} diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts index 21bd3f8dd..ff4146eca 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -90,7 +90,10 @@ describe('requestWithValidatedRedirects', () => { ); }); - expect(factory.createHttpsAgent).toHaveBeenCalledWith(lookups[0]); + expect(factory.createHttpsAgent).toHaveBeenCalledWith( + lookups[0], + new URL('https://epg.example/guide.xml') + ); expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); expect(requestConfig).not.toHaveProperty('agentFactory'); expect(resolvedAddress).toEqual({ @@ -144,7 +147,10 @@ describe('requestWithValidatedRedirects', () => { ); const requestConfig = axiosMock.mock.calls[0][0]; - expect(factory.createHttpsAgent).toHaveBeenCalledWith(); + expect(factory.createHttpsAgent).toHaveBeenCalledWith( + undefined, + new URL('https://192.168.1.10/list.m3u') + ); expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); expect(requestConfig).not.toHaveProperty('agentFactory'); }); diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index ad6922198..ab554bd8a 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -22,7 +22,7 @@ const SENSITIVE_HEADERS = new Set([ export interface ValidatedRequestAgentFactory { createHttpAgent?(lookup?: LookupFunction): HttpAgent; - createHttpsAgent?(lookup?: LookupFunction): HttpsAgent; + createHttpsAgent?(lookup?: LookupFunction, url?: URL): HttpsAgent; } export type ValidatedAxiosRequestConfig = Omit< @@ -100,7 +100,7 @@ function pinRequestToValidatedAddresses( if (url.protocol === 'https:' && agentFactory?.createHttpsAgent) { return { ...axiosConfig, - httpsAgent: agentFactory.createHttpsAgent(), + httpsAgent: agentFactory.createHttpsAgent(undefined, url), }; } if (url.protocol === 'http:' && agentFactory?.createHttpAgent) { @@ -117,7 +117,7 @@ function pinRequestToValidatedAddresses( return { ...axiosConfig, httpsAgent: - agentFactory?.createHttpsAgent?.(lookup) ?? + agentFactory?.createHttpsAgent?.(lookup, url) ?? new HttpsAgent({ lookup }), proxy: false, }; 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 f65c95df8..31f647506 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,11 +1,25 @@ import type BetterSqlite3 from 'better-sqlite3'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ElectronBridgeSecurityErrorCode, + ElectronBridgeTrustOptions, +} from '@iptvnator/shared/interfaces'; import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; import { createGunzip } from 'zlib'; import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database'; import { StreamingEpgParser } from './epg-streaming-parser'; import { shouldGunzipEpgResponse } from './epg-response-utils'; -import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety'; +import { + isPrivateNetworkUrlAccessAllowed, + UnsafeUrlError, +} from '../events/url-safety'; +import { createPlaylistAgentFactory } from '../util/secure-https'; +import { + getHostnameFromErrorUrl, + getHostnameFromUrl, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { getNativeModuleSearchPaths, @@ -46,6 +60,7 @@ const Database = loadBetterSqlite3(); interface WorkerMessage { type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG'; url?: string; + options?: ElectronBridgeTrustOptions; } interface WorkerResponse { @@ -56,6 +71,8 @@ interface WorkerResponse { | 'CLEAR_COMPLETE' | 'READY'; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; url?: string; stats?: { totalChannels: number; @@ -73,7 +90,10 @@ const PROGRAM_BATCH_SIZE = 1000; * Fetches and parses EPG data from URL using streaming * Inserts directly into SQLite to avoid blocking main thread */ -async function fetchAndParseEpgStreaming(url: string): Promise { +async function fetchAndParseEpgStreaming( + url: string, + options: ElectronBridgeTrustOptions = {} +): Promise { console.log(loggerLabel, `Fetching EPG from ${url}`); // Create database connection in worker @@ -92,12 +112,17 @@ async function fetchAndParseEpgStreaming(url: string): Promise { const response = await requestWithValidatedRedirects( url.trim(), { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, + }), decompress: false, method: 'GET', responseType: 'stream', }, { - allowPrivateNetworks: isPrivateNetworkUrlAccessAllowed(), + allowPrivateNetworks: + isPrivateNetworkUrlAccessAllowed() || + isTrustedPrivateNetworkEpgSource(url, options), } ); const responseUrl = response.config.url; @@ -225,10 +250,55 @@ async function fetchAndParseEpgStreaming(url: string): Promise { }); } catch (error) { epgDb.close(); - throw error; + throw toEpgFetchError(error, url); } } +function isTrustedPrivateNetworkEpgSource( + url: string, + options: ElectronBridgeTrustOptions +): boolean { + const normalizedUrl = url.trim(); + return ( + options.trustedPrivateNetworkEpgUrls?.some( + (trustedUrl) => trustedUrl.trim() === normalizedUrl + ) ?? false + ); +} + +function toEpgFetchError( + error: unknown, + url: string +): Error & { + code?: ElectronBridgeSecurityErrorCode; + host?: string; +} { + if ( + error instanceof UnsafeUrlError && + /private|local network/i.test(error.message) + ) { + return Object.assign( + new Error('EPG source points to private network and was blocked.'), + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked, + host: getHostnameFromUrl(url), + } + ); + } + + if (isInvalidTlsCertificateError(error)) { + return Object.assign( + new Error('Certificate for this source host is invalid.'), + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: getHostnameFromErrorUrl(error, url), + } + ); + } + + return error instanceof Error ? error : new Error(String(error)); +} + /** * Clears all EPG data from the database * Runs in worker thread to avoid blocking main thread @@ -267,15 +337,21 @@ if (parentPort) { message.type === 'FETCH_EPG' || message.type === 'FORCE_FETCH' ) { - await fetchAndParseEpgStreaming(message.url!); + await fetchAndParseEpgStreaming(message.url!, message.options); } else if (message.type === 'CLEAR_EPG') { clearAllEpgData(); } } catch (error) { console.error(loggerLabel, 'Worker error:', error); + const typedError = error as { + code?: ElectronBridgeSecurityErrorCode; + host?: string; + }; const errorResponse: WorkerResponse = { type: 'EPG_ERROR', error: error instanceof Error ? error.message : String(error), + errorCode: typedError.code, + errorHost: typedError.host, url: message.url, }; parentPort?.postMessage(errorResponse); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index fd78af623..8dd781fab 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -15,6 +15,11 @@ import type { PlaylistRefreshWorkerIncomingMessage, PlaylistRefreshWorkerMessage, } from './playlist-refresh.worker.types'; +import { + createInvalidTlsCertificateError, + getHostnameFromErrorUrl, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; type ActiveRefreshState = { @@ -82,16 +87,28 @@ async function fetchPlaylistFromUrl( emitEvent(payload, { status: 'started', phase: 'fetching' }); checkpoint(payload); - const result = await requestWithValidatedRedirects( - payload.url!, - { - agentFactory: createPlaylistAgentFactory(), - method: 'GET', - signal: controller.signal, - timeout: 30000, - }, - { allowPrivateNetworks: true } - ); + let result; + try { + result = await requestWithValidatedRedirects( + payload.url!, + { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: payload.trustedInsecureTlsHosts, + }), + method: 'GET', + signal: controller.signal, + timeout: 30000, + }, + { allowPrivateNetworks: true } + ); + } catch (error) { + if (isInvalidTlsCertificateError(error)) { + throw createInvalidTlsCertificateError( + getHostnameFromErrorUrl(error, payload.url!) + ); + } + throw error; + } checkpoint(payload); emitEvent(payload, { status: 'progress', phase: 'parsing' }); diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 610e2cb57..ad0c5015b 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -2,7 +2,14 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { PLAYLIST_PARSE_BY_URL } from '@iptvnator/shared/interfaces'; +import { of } from 'rxjs'; +import { DialogService } from '@iptvnator/ui/components'; +import { SettingsStore } from '@iptvnator/services'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + PLAYLIST_PARSE_BY_URL, + SECURITY_ERROR_PREFIX, +} from '@iptvnator/shared/interfaces'; import { ElectronService } from './electron.service'; describe('ElectronService', () => { @@ -12,6 +19,7 @@ describe('ElectronService', () => { openInMpv: jest.Mock; openInVlc: jest.Mock; }; + let snackBar: { open: jest.Mock }; let service: ElectronService; beforeEach(() => { @@ -22,6 +30,11 @@ describe('ElectronService', () => { openInMpv: jest.fn().mockResolvedValue(session), openInVlc: jest.fn().mockResolvedValue(session), }; + snackBar = { + open: jest.fn(() => ({ + onAction: () => of(undefined), + })), + }; Object.defineProperty(window, 'electron', { configurable: true, @@ -33,8 +46,25 @@ describe('ElectronService', () => { ElectronService, { provide: MatSnackBar, + useValue: snackBar, + }, + { + provide: DialogService, useValue: { - open: jest.fn(), + openConfirmDialog: jest.fn(), + }, + }, + { + provide: SettingsStore, + useValue: { + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + })), + getSettings: jest.fn(() => ({ + trustedInsecureTlsHosts: [], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), }, }, { @@ -69,6 +99,32 @@ describe('ElectronService', () => { expect(electronBridge.fetchPlaylistByUrl).not.toHaveBeenCalled(); }); + it('shows the trust-host action for Electron-wrapped security errors', async () => { + const securityPayload = { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + }; + electronBridge.fetchPlaylistByUrl.mockRejectedValue( + new Error( + `Error invoking remote method 'FETCH_PLAYLIST_BY_URL': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify( + securityPayload + )}` + ) + ); + + await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { + url: 'https://playlist.local/list.m3u', + }); + await Promise.resolve(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Certificate for this playlist host is invalid.', + 'Trust host', + { duration: 10000 } + ); + }); + it('preserves an absent MPV user-agent so backend fallback headers can apply', async () => { await service.sendIpcEvent('OPEN_MPV_PLAYER', { url: 'https://example.test/live.m3u8', diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 9b2bf8069..e65717678 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -3,11 +3,15 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DataService } from '@iptvnator/services'; +import { DialogService } from '@iptvnator/ui/components'; +import { DataService, SettingsStore } from '@iptvnator/services'; import { AUTO_UPDATE_PLAYLISTS, createDevLogger, + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, ERROR, + normalizeHost, + parseSecurityPolicyError, PlayerContentInfo, Playlist, PLAYLIST_PARSE_BY_URL, @@ -46,7 +50,9 @@ export class ElectronService extends DataService { private eventListeners: { [key: string]: () => void } = {}; private messageListeners = new Map(); private readonly snackBar = inject(MatSnackBar); + private readonly dialogService = inject(DialogService); private readonly store = inject(Store); + private readonly settingsStore = inject(SettingsStore); private readonly translateService = inject(TranslateService); private readonly debugLog = createDevLogger('ElectronService'); private readonly silentXtreamActions = new Set([ @@ -212,7 +218,10 @@ export class ElectronService extends DataService { if (type === AUTO_UPDATE_PLAYLISTS) { const data = payload as Playlist[]; - const playlists = await window.electron.autoUpdatePlaylists(data); + const playlists = await window.electron.autoUpdatePlaylists( + data, + this.settingsStore.getTrustOptions() + ); this.store.dispatch( PlaylistActions.updateManyPlaylists({ playlists, @@ -276,7 +285,11 @@ export class ElectronService extends DataService { const title = payload.title?.trim() || undefined; window.electron - .fetchPlaylistByUrl(payload.url, title) + .fetchPlaylistByUrl( + payload.url, + title, + this.settingsStore.getTrustOptions() + ) .then((result) => { this.store.dispatch( PlaylistActions.handleAddingPlaylistByUrl({ @@ -286,6 +299,14 @@ export class ElectronService extends DataService { ); }) .catch((error: unknown) => { + if ( + this.handlePlaylistSecurityError(error, () => + this.fetchM3uPlaylistFromUrl(payload) + ) + ) { + return; + } + const statusCode = this.extractHttpStatusCode(error); let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED'; if (statusCode === 403) { @@ -331,7 +352,8 @@ export class ElectronService extends DataService { if (data.url && !data.filePath) { playlistObject = await window.electron.fetchPlaylistByUrl( data.url, - data.title + data.title, + this.settingsStore.getTrustOptions() ); } else if (data.filePath && !data.url) { playlistObject = @@ -365,6 +387,14 @@ export class ElectronService extends DataService { ); } catch (error: unknown) { console.error('Playlist refresh error:', error); + if ( + data.url && + this.handlePlaylistSecurityError(error, () => { + void this.updateM3uPlaylistFromFile(data); + }) + ) { + return; + } this.snackBar.open( this.getPlaylistRefreshErrorMessage(error, data), this.translateService.instant('CLOSE'), @@ -425,6 +455,86 @@ export class ElectronService extends DataService { return translated === key ? fallback : translated; } + private handlePlaylistSecurityError( + error: unknown, + retry: () => void + ): boolean { + const securityError = parseSecurityPolicyError(error); + if ( + securityError?.code !== + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ) { + return false; + } + + const ref = this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_INVALID_TLS', + 'Certificate for this playlist host is invalid.' + ), + this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + { duration: 10000 } + ); + + ref.onAction().subscribe(() => { + this.confirmTrustPlaylistHost(securityError.host, retry); + }); + return true; + } + + private confirmTrustPlaylistHost( + host: string | undefined, + retry: () => void + ): void { + if (!host) { + this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN', + 'Could not determine the playlist host. Please retry manually.' + ), + this.translateService.instant('CLOSE'), + { duration: 5000 } + ); + return; + } + + this.dialogService.openConfirmDialog({ + title: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + width: '420px', + onConfirm: () => { + void this.trustPlaylistHost(host).then(retry); + }, + }); + } + + private async trustPlaylistHost(host: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + normalizeHost(item) + ) + ); + trustedHosts.add(normalizeHost(host)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + } + /* private getErrorMessageByStatusCode(status: number) { let message = 'Something went wrong'; switch (status) { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 4bfc30de3..1f482dfb0 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -18,7 +18,10 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { Store } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -94,6 +97,21 @@ class MockSettingsStore { getSettings = () => this._settings(); + getTrustOptions = () => ({ + trustedPrivateNetworkEpgUrls: + ( + this._settings() as typeof DEFAULT_SETTINGS & { + trustedPrivateNetworkEpgUrls?: string[]; + } + ).trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: + ( + this._settings() as typeof DEFAULT_SETTINGS & { + trustedInsecureTlsHosts?: string[]; + } + ).trustedInsecureTlsHosts ?? [], + }); + loadSettings = jest.fn().mockResolvedValue(undefined); updateSettings = jest.fn().mockResolvedValue(undefined); @@ -488,9 +506,7 @@ describe('SettingsComponent', () => { expect( component .players() - .some( - (player) => player.id === VideoPlayer.EmbeddedMpv - ) + .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(true); } ); @@ -515,9 +531,9 @@ describe('SettingsComponent', () => { partialBridgeFixture.detectChanges(); expect(partialBridgeComponent.isDesktop).toBe(true); - expect( - partialBridgeComponent.supportsManagedExternalPlayers - ).toBe(false); + expect(partialBridgeComponent.supportsManagedExternalPlayers).toBe( + false + ); expect( partialBridgeComponent.supportsExternalPlayerPathSettings ).toBe(false); @@ -611,9 +627,7 @@ describe('SettingsComponent', () => { ).toBe(false); if (!resolveSupport) { - throw new Error( - 'Expected embedded MPV support probe to start' - ); + throw new Error('Expected embedded MPV support probe to start'); } resolveSupport({ @@ -882,7 +896,10 @@ describe('SettingsComponent', () => { it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => { const url = 'http://epg-url-here/data.xml'; component.refreshEpg(url); - expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url, { + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); }); it('clears EPG data with a busy state and refreshes all sources on success', async () => { @@ -1112,12 +1129,16 @@ describe('SettingsComponent', () => { component.onSubmit(); await fixture.whenStable(); - expect(mockStore.updateSettings).toHaveBeenCalledWith( - component.settingsForm.value - ); - expect(updateSettings).toHaveBeenCalledWith( - component.settingsForm.value - ); + expect(mockStore.updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + expect(updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); }); it('clears external player paths in Electron when saved as empty', async () => { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 3b8fef08a..a348c5b3a 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -25,7 +25,10 @@ import { import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -222,9 +225,7 @@ export class SettingsComponent implements OnInit, OnDestroy { ], recordingFolder: '', coverSize: 'medium' as CoverSize, - ...(this.supportsEpg - ? { preferUploadedEpgOverXtream: false } - : {}), + ...(this.supportsEpg ? { preferUploadedEpgOverXtream: false } : {}), }); /** Form array with epg sources */ @@ -241,12 +242,10 @@ export class SettingsComponent implements OnInit, OnDestroy { readonly removeAllProgress = signal(null); private settingsStore = inject(SettingsStore); - readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems( - { - supportsEpg: this.supportsEpg, - supportsRemoteControl: this.supportsRemoteControl, - } - ); + readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({ + supportsEpg: this.supportsEpg, + supportsRemoteControl: this.supportsRemoteControl, + }); readonly playlistDeleteSummary = computed( () => { @@ -560,6 +559,10 @@ export class SettingsComponent implements OnInit, OnDestroy { value.preferUploadedEpgOverXtream ?? currentSettings.preferUploadedEpgOverXtream ?? false, + trustedPrivateNetworkEpgUrls: + currentSettings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: + currentSettings.trustedInsecureTlsHosts ?? [], }; } @@ -616,7 +619,10 @@ export class SettingsComponent implements OnInit, OnDestroy { if (!this.epgBridge.supportsDataManagement || !url) { return; } - void this.epgBridge.forceFetchEpg(url); + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); } /** @@ -629,7 +635,8 @@ export class SettingsComponent implements OnInit, OnDestroy { const urls = (this.epgUrl.value as string[]) .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)); - urls.forEach((url) => void this.epgBridge.forceFetchEpg(url)); + const options = this.settingsStore.getTrustOptions(); + urls.forEach((url) => void this.epgBridge.forceFetchEpg(url, options)); } /** diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a4fa83135..1f80c9af6 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. يرجى التحقق من الرابط والمحاولة مرة أخرى.", "ERROR_403": "تم رفض الوصول (403): رفض الخادم الطلب. قد يتطلب الرابط مصادقة أو أن قائمة التشغيل مقيدة.", "ERROR_404": "قائمة التشغيل غير موجودة (404): الرابط لا يشير إلى قائمة تشغيل صالحة. يرجى التحقق من الرابط.", - "ERROR_401": "غير مصرح (401): المصادقة مطلوبة للوصول إلى قائمة التشغيل هذه." + "ERROR_401": "غير مصرح (401): المصادقة مطلوبة للوصول إلى قائمة التشغيل هذه.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "إدراج قائمة تشغيل M3U(8) كنص", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "إجمالي البرامج", "UP_TO_DATE": "EPG محدث، لا حاجة للمزامنة", "RETRY": "إعادة المحاولة", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c1381c693..1d37aafcc 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. عفاك تحقق من الرابط وحاول مرة أخرى.", "ERROR_403": "الوصول مرفوض (403): السيرفر رفض الطلب. يمكن الرابط محتاج مصادقة ولا قائمة التشغيل مقيدة.", "ERROR_404": "قائمة التشغيل ما تلقاتش (404): الرابط ما كيوجهش لقائمة تشغيل صحيحة. عفاك تحقق من الرابط.", - "ERROR_401": "غير مصرح (401): المصادقة مطلوبة باش تدخل لهاد قائمة التشغيل." + "ERROR_401": "غير مصرح (401): المصادقة مطلوبة باش تدخل لهاد قائمة التشغيل.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "دخل قائمة تشغيل m3u(8) على شكل نص", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "مجموع البرامج", "UP_TO_DATE": "EPG محدث، ما خاصش مزامنة", "RETRY": "حاول مرة أخرى", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 1a8f0889c..5f6dbe5ad 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Не ўдалося атрымаць плэйліст. Калі ласка, праверце URL і паспрабуйце яшчэ раз.", "ERROR_403": "Доступ забаронены (403): сервер адхіліў запыт. URL можа патрабаваць аўтэнтыфікацыі або плэйліст абмежаваны.", "ERROR_404": "Плэйліст не знойдзены (404): URL не паказвае на сапраўдны плэйліст. Калі ласка, праверце URL.", - "ERROR_401": "Несанкцыянавана (401): для доступу да гэтага плэйліста патрабуецца аўтэнтыфікацыя." + "ERROR_401": "Несанкцыянавана (401): для доступу да гэтага плэйліста патрабуецца аўтэнтыфікацыя.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Устаўце плэйліст фармату m3u(8) у гэта поле", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Усяго перадач", "UP_TO_DATE": "EPG актуальны, сінхранізацыя не патрэбна", "RETRY": "Паўтарыць", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 886cdd49e..98b7922a2 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Playlist konnte nicht abgerufen werden. Bitte überprüfen Sie die URL und versuchen Sie es erneut.", "ERROR_403": "Zugriff verweigert (403): Der Server hat die Anfrage abgelehnt. Die URL erfordert möglicherweise eine Authentifizierung oder die Playlist ist eingeschränkt.", "ERROR_404": "Playlist nicht gefunden (404): Die URL verweist nicht auf eine gültige Playlist. Bitte überprüfen Sie die URL.", - "ERROR_401": "Nicht autorisiert (401): Für den Zugriff auf diese Playlist ist eine Authentifizierung erforderlich." + "ERROR_401": "Nicht autorisiert (401): Für den Zugriff auf diese Playlist ist eine Authentifizierung erforderlich.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Füge m3u(8)-Wiedergabeliste hier als Text ein", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Programme insgesamt", "UP_TO_DATE": "EPG ist aktuell, keine Synchronisation nötig", "RETRY": "Erneut versuchen", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index befbf9e13..7b9e973f3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Αποτυχία λήψης της λίστας αναπαραγωγής. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.", "ERROR_403": "Άρνηση πρόσβασης (403): Ο διακομιστής απέρριψε το αίτημα. Η διεύθυνση URL μπορεί να απαιτεί έλεγχο ταυτότητας ή η λίστα αναπαραγωγής είναι περιορισμένη.", "ERROR_404": "Η λίστα αναπαραγωγής δεν βρέθηκε (404): Η διεύθυνση URL δεν δείχνει σε έγκυρη λίστα αναπαραγωγής. Ελέγξτε τη διεύθυνση URL.", - "ERROR_401": "Μη εξουσιοδοτημένος (401): Απαιτείται έλεγχος ταυτότητας για πρόσβαση σε αυτή τη λίστα αναπαραγωγής." + "ERROR_401": "Μη εξουσιοδοτημένος (401): Απαιτείται έλεγχος ταυτότητας για πρόσβαση σε αυτή τη λίστα αναπαραγωγής.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Προσθήκη m3u(8) λίστας αναπαραγωγής ως κείμενο", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Σύνολο προγραμμάτων", "UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός", "RETRY": "Επανάληψη", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 1f5c4a7d6..f0add855e 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Failed to fetch the playlist. Please check the URL and try again.", "ERROR_403": "Access denied (403): The server refused the request. The URL may require authentication or the playlist is restricted.", "ERROR_404": "Playlist not found (404): The URL does not point to a valid playlist. Please check the URL.", - "ERROR_401": "Unauthorized (401): Authentication is required to access this playlist." + "ERROR_401": "Unauthorized (401): Authentication is required to access this playlist.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total programs", "UP_TO_DATE": "EPG is up-to-date, no sync needed", "RETRY": "Retry", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 018f429b0..0ce3be118 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "No se pudo obtener la lista de reproducción. Verifica la URL e inténtalo de nuevo.", "ERROR_403": "Acceso denegado (403): el servidor rechazó la solicitud. La URL puede requerir autenticación o la lista de reproducción está restringida.", "ERROR_404": "Lista de reproducción no encontrada (404): la URL no apunta a una lista válida. Verifica la URL.", - "ERROR_401": "No autorizado (401): se requiere autenticación para acceder a esta lista de reproducción." + "ERROR_401": "No autorizado (401): se requiere autenticación para acceder a esta lista de reproducción.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Inserte la playlist m3u(8) como texto", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total de programas", "UP_TO_DATE": "EPG actualizada, no se necesita sincronización", "RETRY": "Reintentar", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d0e2129af..50130074a 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Échec de la récupération de la liste de lecture. Veuillez vérifier l'URL et réessayer.", "ERROR_403": "Accès refusé (403) : le serveur a refusé la requête. L'URL peut nécessiter une authentification ou la liste de lecture est restreinte.", "ERROR_404": "Liste de lecture introuvable (404) : l'URL ne pointe pas vers une liste de lecture valide. Veuillez vérifier l'URL.", - "ERROR_401": "Non autorisé (401) : une authentification est requise pour accéder à cette liste de lecture." + "ERROR_401": "Non autorisé (401) : une authentification est requise pour accéder à cette liste de lecture.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Insérer la liste m3u(8) sous forme de texte", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total des programmes", "UP_TO_DATE": "L'EPG est à jour, aucune synchronisation nécessaire", "RETRY": "Réessayer", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8da29c87b..f24d32eb1 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Impossibile recuperare la playlist. Controlla l'URL e riprova.", "ERROR_403": "Accesso negato (403): il server ha rifiutato la richiesta. L'URL potrebbe richiedere autenticazione o la playlist è soggetta a restrizioni.", "ERROR_404": "Playlist non trovata (404): l'URL non punta a una playlist valida. Controlla l'URL.", - "ERROR_401": "Non autorizzato (401): è richiesta l'autenticazione per accedere a questa playlist." + "ERROR_401": "Non autorizzato (401): è richiesta l'autenticazione per accedere a questa playlist.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Inserisci playlist m3u(8) come testo", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Totale programmi", "UP_TO_DATE": "L'EPG è aggiornato, nessuna sincronizzazione necessaria", "RETRY": "Riprova", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 27889de4d..3e45ce33d 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "プレイリストを取得できませんでした。URLを確認してもう一度お試しください。", "ERROR_403": "アクセスが拒否されました(403):サーバーがリクエストを拒否しました。URLに認証が必要か、プレイリストが制限されている可能性があります。", "ERROR_404": "プレイリストが見つかりません(404):URLは有効なプレイリストを指していません。URLを確認してください。", - "ERROR_401": "未認証(401):このプレイリストにアクセスするには認証が必要です。" + "ERROR_401": "未認証(401):このプレイリストにアクセスするには認証が必要です。", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "m3u(8)プレイリストをテキストとして挿入", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "総番組数", "UP_TO_DATE": "EPGは最新です。同期は不要です", "RETRY": "再試行", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0c6e45542..82a38ca01 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "재생목록을 가져오지 못했습니다. URL을 확인하고 다시 시도해 주세요.", "ERROR_403": "접근 거부됨 (403): 서버가 요청을 거부했습니다. URL에 인증이 필요하거나 재생목록이 제한되어 있을 수 있습니다.", "ERROR_404": "재생목록을 찾을 수 없습니다 (404): URL이 유효한 재생목록을 가리키지 않습니다. URL을 확인해 주세요.", - "ERROR_401": "권한 없음 (401): 이 재생목록에 접근하려면 인증이 필요합니다." + "ERROR_401": "권한 없음 (401): 이 재생목록에 접근하려면 인증이 필요합니다.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "전체 프로그램", "UP_TO_DATE": "EPG가 최신 상태이며 동기화가 필요하지 않습니다", "RETRY": "다시 시도", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2bc801996..1c56af772 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Ophalen van afspeellijst mislukt. Controleer de URL en probeer het opnieuw.", "ERROR_403": "Toegang geweigerd (403): de server heeft het verzoek geweigerd. De URL vereist mogelijk authenticatie of de afspeellijst is beperkt.", "ERROR_404": "Afspeellijst niet gevonden (404): de URL verwijst niet naar een geldige afspeellijst. Controleer de URL.", - "ERROR_401": "Niet geautoriseerd (401): authenticatie is vereist om deze afspeellijst te openen." + "ERROR_401": "Niet geautoriseerd (401): authenticatie is vereist om deze afspeellijst te openen.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Voer een m3u(8)-afspeellijst in als tekst", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Totaal aantal programma's", "UP_TO_DATE": "EPG is up-to-date, synchronisatie niet nodig", "RETRY": "Opnieuw proberen", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e62cea92b..cc288d25e 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Nie udało się pobrać listy odtwarzania. Sprawdź URL i spróbuj ponownie.", "ERROR_403": "Brak dostępu (403): Serwer odrzucił żądanie. URL może wymagać uwierzytelnienia lub lista jest ograniczona.", "ERROR_404": "Nie znaleziono listy odtwarzania (404): URL nie wskazuje prawidłowej listy. Sprawdź URL.", - "ERROR_401": "Brak autoryzacji (401): Dostęp do tej listy odtwarzania wymaga uwierzytelnienia." + "ERROR_401": "Brak autoryzacji (401): Dostęp do tej listy odtwarzania wymaga uwierzytelnienia.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Wklej listę odtwarzania m3u(8) jako tekst", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Wszystkie programy", "UP_TO_DATE": "EPG jest aktualne, synchronizacja nie jest potrzebna", "RETRY": "Spróbuj ponownie", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 90bfab72e..ad485a7b7 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Falha ao buscar a playlist. Verifique a URL e tente novamente.", "ERROR_403": "Acesso negado (403): O servidor recusou a solicitação. A URL pode exigir autenticação ou a playlist está restrita.", "ERROR_404": "Playlist não encontrada (404): A URL não aponta para uma playlist válida. Verifique a URL.", - "ERROR_401": "Não autorizado (401): É necessária autenticação para acessar esta playlist." + "ERROR_401": "Não autorizado (401): É necessária autenticação para acessar esta playlist.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Inserir playlist m3u(8) como texto", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total de programas", "UP_TO_DATE": "EPG está atualizado, sincronização não é necessária", "RETRY": "Tentar novamente", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f690bcea7..d99dcab30 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Не удалось получить плейлист. Проверьте URL и попробуйте снова.", "ERROR_403": "Доступ запрещён (403): сервер отклонил запрос. Возможно, URL требует аутентификации или плейлист ограничен.", "ERROR_404": "Плейлист не найден (404): URL не указывает на действительный плейлист. Проверьте URL.", - "ERROR_401": "Не авторизовано (401): для доступа к этому плейлисту требуется аутентификация." + "ERROR_401": "Не авторизовано (401): для доступа к этому плейлисту требуется аутентификация.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Вставьте плейлист формата m3u(8) в данное поле", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Всего программ", "UP_TO_DATE": "EPG актуален, синхронизация не требуется", "RETRY": "Повторить", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8a44cc88a..58ff1e31c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Oynatma listesi alınamadı. Lütfen URL'yi kontrol edip tekrar deneyin.", "ERROR_403": "Erişim reddedildi (403): Sunucu isteği reddetti. URL kimlik doğrulama gerektiriyor olabilir veya oynatma listesi kısıtlanmış olabilir.", "ERROR_404": "Oynatma listesi bulunamadı (404): URL geçerli bir oynatma listesine işaret etmiyor. Lütfen URL'yi kontrol edin.", - "ERROR_401": "Yetkisiz (401): Bu oynatma listesine erişmek için kimlik doğrulama gerekiyor." + "ERROR_401": "Yetkisiz (401): Bu oynatma listesine erişmek için kimlik doğrulama gerekiyor.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "m3u(8) oynatma listesini metin olarak girin", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Toplam program", "UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok", "RETRY": "Tekrar Dene", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index bbb4a10fc..6dedcf2dd 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "获取播放列表失败。请检查 URL 后重试。", "ERROR_403": "拒绝访问 (403):服务器拒绝了请求。该 URL 可能需要身份验证,或播放列表受到限制。", "ERROR_404": "未找到播放列表 (404):该 URL 不指向有效的播放列表。请检查 URL。", - "ERROR_401": "未授权 (401):访问此播放列表需要进行身份验证。" + "ERROR_401": "未授权 (401):访问此播放列表需要进行身份验证。", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "从文本导入播放列表(m3u, m3u8)", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "节目总数", "UP_TO_DATE": "EPG 已为最新,无需同步", "RETRY": "重试", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 524ecf1ea..310488386 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "無法擷取播放清單。請檢查網址後重試。", "ERROR_403": "存取被拒(403):伺服器拒絕了此請求。該網址可能需要驗證,或播放清單存取受限。", "ERROR_404": "找不到播放清單(404):該網址未指向有效的播放清單。請檢查網址。", - "ERROR_401": "未授權(401):存取此播放清單需要驗證。" + "ERROR_401": "未授權(401):存取此播放清單需要驗證。", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "將 m3u(8)播放清單以純文字的方式插入", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "節目總數", "UP_TO_DATE": "EPG 已是最新,無需同步", "RETRY": "重試", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 3163c1bdf..6f4d581fe 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -124,14 +124,20 @@ private Node `Agent.options` state. Cross-origin redirects must not forward `Aut `Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies. EPG URLs are strict by default because an M3U playlist can supply them through -`url-tvg`. Operators who intentionally use a LAN-hosted EPG source can opt in -for that run with `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1`. Directly configured -Xtream, Stalker, and playlist providers retain private-network support, but -still require HTTP(S), reject embedded credentials, and validate redirects. +`url-tvg`. Operators who intentionally use a LAN-hosted EPG source should prefer +the renderer's source-scoped “Allow source” action, which persists the exact EPG +URL in settings and retries that source only. The +`IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` environment flag remains an +emergency/development process-wide override for strict EPG fetches. Directly +configured Xtream, Stalker, and playlist providers retain private-network +support, but still require HTTP(S), reject embedded credentials, and validate +redirects. Remote playlist TLS certificates are validated by default. The -`IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch is only for explicitly trusted -providers with invalid or self-signed certificates. +renderer can persist a host-scoped invalid-certificate trust decision for a +playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch +is only for explicitly trusted providers with invalid or self-signed +certificates when the host-scoped UI path is not available. ## Filesystem Capabilities 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 19c2b0f5a..434194761 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,10 +3,16 @@ import { EpgImportProgress, EpgRuntimeBridgeService, } from './epg-runtime-bridge.service'; +import { SettingsStore } from '@iptvnator/services'; import { EpgProgressService } from './epg-progress.service'; describe('EpgProgressService', () => { let epgBridge: Partial; + let settingsStore: { + getSettings: jest.Mock; + getTrustOptions: jest.Mock; + updateSettings: jest.Mock; + }; beforeEach(() => { epgBridge = { @@ -15,6 +21,17 @@ describe('EpgProgressService', () => { supportsDataManagement: false, supportsProgress: false, }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; }); afterEach(() => { @@ -30,6 +47,10 @@ describe('EpgProgressService', () => { provide: EpgRuntimeBridgeService, useValue: epgBridge, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, ], }); @@ -57,7 +78,31 @@ describe('EpgProgressService', () => { service.retry('https://example.com/epg.xml'); expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( - 'https://example.com/epg.xml' + 'https://example.com/epg.xml', + { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + } + ); + }); + + it('trusts a private-network source and retries it', async () => { + epgBridge.supportsDataManagement = true; + const service = configureService(); + + await service.trustPrivateNetworkSourceAndRetry( + 'http://192.168.1.30/guide.xml' + ); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + trustedPrivateNetworkEpgUrls: [ + 'http://192.168.1.20/guide.xml', + 'http://192.168.1.30/guide.xml', + ], + }); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( + 'http://192.168.1.30/guide.xml', + expect.any(Object) ); }); 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 173344415..77292f314 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.ts @@ -1,4 +1,9 @@ import { Injectable, computed, inject, signal } from '@angular/core'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + normalizeHost, +} from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; import { EpgImportProgress, EpgRuntimeBridgeService, @@ -7,6 +12,7 @@ import { @Injectable({ providedIn: 'root' }) export class EpgProgressService { private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); private readonly importsMap = signal>( new Map() ); @@ -22,9 +28,7 @@ export class EpgProgressService { readonly queuedImports = computed(() => this.imports() .filter((item) => item.status === 'queued') - .sort( - (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) - ) + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)) ); readonly queuedCount = computed(() => this.queuedImports().length); readonly isVisible = computed(() => this.imports().length > 0); @@ -48,7 +52,46 @@ export class EpgProgressService { if (!this.epgBridge.supportsDataManagement) { return; } - void this.epgBridge.forceFetchEpg(url); + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); + } + + async trustPrivateNetworkSourceAndRetry(url: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedUrls = new Set( + settings.trustedPrivateNetworkEpgUrls ?? [] + ); + trustedUrls.add(url.trim()); + + await this.settingsStore.updateSettings({ + trustedPrivateNetworkEpgUrls: Array.from(trustedUrls), + }); + this.retry(url); + } + + async trustInsecureTlsHostAndRetry( + url: string, + host?: string + ): Promise { + const trustedHost = host ?? this.getHostname(url); + if (!trustedHost) { + return; + } + + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + normalizeHost(item) + ) + ); + trustedHosts.add(normalizeHost(trustedHost)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + this.retry(url); } private initializeListener(): void { @@ -71,11 +114,31 @@ export class EpgProgressService { return updated; }); - if (progress.status === 'complete' || progress.status === 'error') { + if ( + progress.status === 'complete' || + (progress.status === 'error' && !this.isActionableError(progress)) + ) { setTimeout(() => this.removeImport(progress.url), 5000); } } + private isActionableError(progress: EpgImportProgress): boolean { + return ( + progress.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked || + progress.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ); + } + + private getHostname(url: string): string | undefined { + try { + return new URL(url).hostname; + } catch { + return undefined; + } + } + private removeImport(url: string): void { this.importsMap.update((current) => { const updated = new Map(current); diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts index f73c40f68..c9f9f6f05 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts @@ -85,9 +85,13 @@ describe('EpgRuntimeBridgeService', () => { success: true, }); - expect(fetchEpg).toHaveBeenCalledWith(['https://example.com/epg.xml']); + expect(fetchEpg).toHaveBeenCalledWith( + ['https://example.com/epg.xml'], + undefined + ); expect(forceFetchEpg).toHaveBeenCalledWith( - 'https://example.com/epg.xml' + 'https://example.com/epg.xml', + undefined ); expect(clearEpgData).toHaveBeenCalledTimes(1); }); diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 339f209e2..4082b5d73 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -9,6 +9,7 @@ import { ElectronBridgeEpgFreshnessResult, ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeResult, + ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; @@ -76,20 +77,28 @@ export class EpgRuntimeBridgeService { return this.runtime.supportsEpgProgramSearch; } - fetchEpg(urls: string[]): Promise { + fetchEpg( + urls: string[], + options?: ElectronBridgeTrustOptions + ): Promise { if (!this.supportsImport) { return Promise.resolve(null); } - return this.bridge?.fetchEpg?.(urls) ?? Promise.resolve(null); + return this.bridge?.fetchEpg?.(urls, options) ?? Promise.resolve(null); } - forceFetchEpg(url: string): Promise { + forceFetchEpg( + url: string, + options?: ElectronBridgeTrustOptions + ): Promise { if (!this.supportsDataManagement) { return Promise.resolve(null); } - return this.bridge?.forceFetchEpg?.(url) ?? Promise.resolve(null); + return ( + this.bridge?.forceFetchEpg?.(url, options) ?? Promise.resolve(null) + ); } clearEpgData(): Promise { 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 44331afd0..136779e92 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; +import { SettingsStore } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { EpgService } from './epg.service'; @@ -9,6 +10,7 @@ describe('EpgService', () => { let service: EpgService; let epgBridge: Partial; let snackBar: { open: jest.Mock }; + let settingsStore: { getSettings: jest.Mock; getTrustOptions: jest.Mock }; beforeEach(() => { epgBridge = { @@ -22,6 +24,16 @@ describe('EpgService', () => { snackBar = { open: jest.fn(), }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + }; TestBed.configureTestingModule({ providers: [ @@ -40,6 +52,10 @@ describe('EpgService', () => { instant: (key: string) => key, }, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, ], }); @@ -61,10 +77,13 @@ describe('EpgService', () => { 'https://example.com/other.xml', ]); - expect(epgBridge.fetchEpg).toHaveBeenCalledWith([ - 'https://example.com/epg.xml', - 'https://example.com/other.xml', - ]); + expect(epgBridge.fetchEpg).toHaveBeenCalledWith( + ['https://example.com/epg.xml', 'https://example.com/other.xml'], + { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + } + ); }); it('does not show a fetch error when the bridge returns no result', async () => { diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 4a07c337c..9bc9b84f7 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -8,6 +8,7 @@ import { EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { normalizeEpgPrograms } from './epg-program-normalization.util'; @@ -25,6 +26,7 @@ export class EpgService { private snackBar = inject(MatSnackBar); private translate = inject(TranslateService); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); private epgAvailable = new BehaviorSubject(false); private currentEpgPrograms = new BehaviorSubject([]); @@ -46,7 +48,12 @@ export class EpgService { const validUrls = urls.filter((url) => url?.trim()); if (validUrls.length === 0) return; - from(this.epgBridge.fetchEpg(validUrls)) + from( + this.epgBridge.fetchEpg( + validUrls, + this.settingsStore.getTrustOptions() + ) + ) .pipe( tap((result) => { if (result === null) return; @@ -272,7 +279,9 @@ export class EpgService { return of(new Map()); } - return from(this.epgBridge.getChannelMetadata(normalizedChannelIds)).pipe( + return from( + this.epgBridge.getChannelMetadata(normalizedChannelIds) + ).pipe( map((metadataByChannelId) => { return new Map( normalizedChannelIds.map((channelId) => [ diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 02584545e..17912fcd0 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { DialogService } from '@iptvnator/ui/components'; import { DataService, @@ -12,12 +13,15 @@ import { PlaybackPositionService, PlaylistRefreshService, RuntimeCapabilitiesService, + SettingsStore, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, PLAYLIST_UPDATE, Playlist, PlaylistMeta, + SECURITY_ERROR_PREFIX, } from '@iptvnator/shared/interfaces'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistRefreshActionService } from './playlist-refresh-action.service'; @@ -84,6 +88,11 @@ describe('PlaylistRefreshActionService', () => { let playbackPositionService: { getAllPlaybackPositions: jest.Mock; }; + let settingsStore: { + getSettings: jest.Mock; + getTrustOptions: jest.Mock; + updateSettings: jest.Mock; + }; let runtime: { supportsPlaylistRefresh: boolean; supportsXtreamSqliteDataSource: boolean; @@ -131,7 +140,9 @@ describe('PlaylistRefreshActionService', () => { navigate: jest.fn().mockResolvedValue(true), }; snackBar = { - open: jest.fn(), + open: jest.fn(() => ({ + onAction: () => of(undefined), + })), }; store = { dispatch: jest.fn(), @@ -142,6 +153,16 @@ describe('PlaylistRefreshActionService', () => { playbackPositionService = { getAllPlaybackPositions: jest.fn().mockResolvedValue([]), }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedInsecureTlsHosts: ['playlist.local'], + })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: ['playlist.local'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; runtime = { supportsPlaylistRefresh: true, supportsXtreamSqliteDataSource: true, @@ -194,6 +215,10 @@ describe('PlaylistRefreshActionService', () => { provide: RuntimeCapabilitiesService, useValue: runtime, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, { provide: PlaylistContextFacade, useValue: { @@ -270,17 +295,80 @@ describe('PlaylistRefreshActionService', () => { service.refresh(playlist); - expect(dataService.sendIpcEvent).toHaveBeenCalledWith( - PLAYLIST_UPDATE, - { - id: 'playlist-url', - title: 'URL playlist', - url: 'https://example.com/playlist.m3u', - } - ); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, { + id: 'playlist-url', + title: 'URL playlist', + url: 'https://example.com/playlist.m3u', + }); expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); }); + it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => { + const playlist = createPlaylistMeta({ + _id: 'playlist-url', + title: 'URL playlist', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://playlist.local/list.m3u', + }); + playlistRefreshService.refreshPlaylist.mockResolvedValue({ + _id: playlist._id, + playlist: { items: [] }, + } as Playlist); + + service.refresh(playlist); + await Promise.resolve(); + + expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://playlist.local/list.m3u', + trustedInsecureTlsHosts: ['playlist.local'], + }) + ); + }); + + it('clears active M3U loading before showing a trust-host prompt', async () => { + routeProvider.set('playlists'); + resolvedPlaylistId.set('playlist-url'); + const playlist = createPlaylistMeta({ + _id: 'playlist-url', + title: 'URL playlist', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://playlist.local/list.m3u', + }); + playlistRefreshService.refreshPlaylist.mockRejectedValue( + new Error( + `Error invoking remote method 'PLAYLIST_REFRESH': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify( + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: 'playlist.local', + message: + 'Certificate for this playlist host is invalid.', + } + )}` + ) + ); + + service.refresh(playlist); + await Promise.resolve(); + await Promise.resolve(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Certificate for this playlist host is invalid.', + 'Trust host', + { duration: 10000 } + ); + expect(store.dispatch).toHaveBeenCalledWith( + ChannelActions.setChannelsLoading({ loading: true }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + ChannelActions.setChannelsLoading({ loading: false }) + ); + }); + it('treats Xtream playlists as refreshable only when the SQLite data source is available', () => { runtime.supportsXtreamSqliteDataSource = true; diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 03c800bd9..e74d7b26f 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -12,10 +12,17 @@ import { PlaybackPositionService, PlaylistRefreshService, RuntimeCapabilitiesService, + SettingsStore, XtreamPendingRestoreService, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; -import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + normalizeHost, + parseSecurityPolicyError, + PLAYLIST_UPDATE, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; export interface XtreamRefreshPreparationState { @@ -38,6 +45,7 @@ export class PlaylistRefreshActionService { private readonly playbackPositionService = inject(PlaybackPositionService); private readonly playlistRefreshService = inject(PlaylistRefreshService); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsStore = inject(SettingsStore); private readonly playlistContext = inject(PlaylistContextFacade); private readonly pendingRestoreService = inject( XtreamPendingRestoreService @@ -62,7 +70,9 @@ export class PlaylistRefreshActionService { return true; } - return this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath); + return ( + this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath) + ); } refresh(playlist: PlaylistMeta): void { @@ -205,6 +215,9 @@ export class PlaylistRefreshActionService { title: item.title, url: item.url, filePath: item.filePath, + trustedInsecureTlsHosts: + this.settingsStore.getTrustOptions() + .trustedInsecureTlsHosts, }); this.store.dispatch( @@ -227,19 +240,26 @@ export class PlaylistRefreshActionService { } catch (error) { if (!isDbAbortError(error)) { console.error('Error refreshing playlist:', error); + if ( + item.url && + this.handlePlaylistSecurityError(error, () => + this.refresh(item) + ) + ) { + return; + } this.snackBar.open( this.getRefreshErrorMessage(error, item), this.translate.instant('CLOSE'), { duration: 5000 } ); } - + } finally { if (isActiveM3uRoute) { this.store.dispatch( ChannelActions.setChannelsLoading({ loading: false }) ); } - } finally { this.isRefreshing.set(false); } } @@ -258,6 +278,90 @@ export class PlaylistRefreshActionService { return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); } + private handlePlaylistSecurityError( + error: unknown, + retry: () => void + ): boolean { + const securityError = parseSecurityPolicyError(error); + if ( + securityError?.code !== + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ) { + return false; + } + + const ref = this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_INVALID_TLS', + 'Certificate for this playlist host is invalid.' + ), + this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + { duration: 10000 } + ); + ref.onAction().subscribe(() => { + this.confirmTrustPlaylistHost(securityError.host, retry); + }); + return true; + } + + private confirmTrustPlaylistHost( + host: string | undefined, + retry: () => void + ): void { + if (!host) { + this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN', + 'Could not determine the playlist host. Please retry manually.' + ), + this.translate.instant('CLOSE'), + { duration: 5000 } + ); + return; + } + + this.dialogService.openConfirmDialog({ + title: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + width: '420px', + onConfirm: () => { + void this.trustPlaylistHost(host).then(retry); + }, + }); + } + + private async trustPlaylistHost(host: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + normalizeHost(item) + ) + ); + trustedHosts.add(normalizeHost(host)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + } + + private translateWithFallback(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return translated === key ? fallback : translated; + } + private updateRefreshPreparationFromEvent( playlistId: string, operationId: string, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index f6042734e..94e48daef 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -9,6 +9,7 @@ import { import { StorageMap } from '@ngx-pwa/local-storage'; import { firstValueFrom } from 'rxjs'; import { + ElectronBridgeTrustOptions, Language, Settings, StartupBehavior, @@ -41,6 +42,8 @@ const DEFAULT_SETTINGS: Settings = { recordingFolder: '', coverSize: 'medium', preferUploadedEpgOverXtream: false, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], }; let embeddedMpvPrepareScheduled = false; @@ -157,6 +160,12 @@ export const SettingsStore = signalStore( preferUploadedEpgOverXtream: store.preferUploadedEpgOverXtream?.() ?? DEFAULT_SETTINGS.preferUploadedEpgOverXtream, + trustedPrivateNetworkEpgUrls: + store.trustedPrivateNetworkEpgUrls?.() ?? + DEFAULT_SETTINGS.trustedPrivateNetworkEpgUrls, + trustedInsecureTlsHosts: + store.trustedInsecureTlsHosts?.() ?? + DEFAULT_SETTINGS.trustedInsecureTlsHosts, }; }, @@ -170,6 +179,15 @@ export const SettingsStore = signalStore( ); }, + getTrustOptions(): ElectronBridgeTrustOptions { + const settings = this.getSettings(); + return { + trustedPrivateNetworkEpgUrls: + settings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], + }; + }, + getPlayer() { return store.player(); }, diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 038682309..26b132d62 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -25,6 +25,7 @@ export * from './lib/playlist.interface'; export * from './lib/portal-activity-item.interface'; export * from './lib/portal-debug.interface'; export * from './lib/portal-playback.interface'; +export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; export * from './lib/stalker-portal-actions.enum'; export * from './lib/store-keys.enum'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index ac21d369a..538f84f1f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -67,6 +67,14 @@ export const ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES = { export type ElectronBridgeEpgProgressStatus = (typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES)[keyof typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES]; +export const ELECTRON_BRIDGE_SECURITY_ERROR_CODES = { + EpgPrivateNetworkBlocked: 'epg-private-network-blocked', + InvalidTlsCertificate: 'invalid-tls-certificate', +} as const; + +export type ElectronBridgeSecurityErrorCode = + (typeof ELECTRON_BRIDGE_SECURITY_ERROR_CODES)[keyof typeof ELECTRON_BRIDGE_SECURITY_ERROR_CODES]; + export const ELECTRON_BRIDGE_DB_OPERATION_STATUSES = { Cancelled: 'cancelled', Completed: 'completed', @@ -182,6 +190,11 @@ export interface ElectronBridgeEpgFetchResult extends ElectronBridgeResult { skipped?: string[]; } +export interface ElectronBridgeTrustOptions { + trustedPrivateNetworkEpgUrls?: string[]; + trustedInsecureTlsHosts?: string[]; +} + export interface ElectronBridgeEpgFreshnessResult { staleUrls: string[]; freshUrls: string[]; @@ -197,6 +210,8 @@ export interface ElectronBridgeEpgProgress { status: ElectronBridgeEpgProgressStatus; stats?: ElectronBridgeEpgProgressStats; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; queuePosition?: number; } @@ -437,7 +452,11 @@ export interface ElectronBridgeApi { onWindowStateChange: ( callback: (state: ElectronBridgeWindowState) => void ) => () => void; - fetchPlaylistByUrl: (url: string, title?: string) => Promise; + fetchPlaylistByUrl: ( + url: string, + title?: string, + options?: ElectronBridgeTrustOptions + ) => Promise; updatePlaylistFromFilePath: ( filePath: string, title: string @@ -479,8 +498,14 @@ export interface ElectronBridgeApi { startTime?: number, headers?: Record ) => Promise; - autoUpdatePlaylists: (playlists: Playlist[]) => Promise; - fetchEpg: (urls: string[]) => Promise; + autoUpdatePlaylists: ( + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => Promise; + fetchEpg: ( + urls: string[], + options?: ElectronBridgeTrustOptions + ) => Promise; getChannelPrograms: (channelId: string) => Promise; getCurrentProgramsBatch: ( channelIds: string[] @@ -493,7 +518,10 @@ export interface ElectronBridgeApi { skip: number, limit: number ) => Promise; - forceFetchEpg: (url: string) => Promise; + forceFetchEpg: ( + url: string, + options?: ElectronBridgeTrustOptions + ) => Promise; clearEpgData: () => Promise; checkEpgFreshness: ( urls: string[], diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts index 87ad10681..b02ee028f 100644 --- a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -25,4 +25,5 @@ export interface PlaylistRefreshPayload { title: string; filePath?: string; url?: string; + trustedInsecureTlsHosts?: string[]; } diff --git a/libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts b/libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts new file mode 100644 index 000000000..b6e331ea4 --- /dev/null +++ b/libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts @@ -0,0 +1,56 @@ +import { + normalizeHost, + parseSecurityPolicyError, + SECURITY_ERROR_PREFIX, +} from './security-policy-error.utils'; + +describe('security-policy-error utils', () => { + it('parses plain serialized security errors', () => { + expect( + parseSecurityPolicyError( + `${SECURITY_ERROR_PREFIX}${JSON.stringify({ + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + })}` + ) + ).toEqual({ + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + }); + }); + + it('parses security errors wrapped by Electron ipcRenderer.invoke', () => { + expect( + parseSecurityPolicyError( + new Error( + `Error invoking remote method 'FETCH_PLAYLIST_BY_URL': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify( + { + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: + 'Certificate for this playlist host is invalid.', + } + )}` + ) + ) + ).toEqual({ + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + }); + }); + + it('ignores malformed security payloads', () => { + expect( + parseSecurityPolicyError( + `${SECURITY_ERROR_PREFIX}{"code":"INVALID_TLS_CERTIFICATE"}` + ) + ).toBeNull(); + }); + + it('normalizes host values for trust comparisons', () => { + expect(normalizeHost(' [EXAMPLE.Local] ')).toBe('example.local'); + }); +}); diff --git a/libs/shared/interfaces/src/lib/security-policy-error.utils.ts b/libs/shared/interfaces/src/lib/security-policy-error.utils.ts new file mode 100644 index 000000000..275d6b820 --- /dev/null +++ b/libs/shared/interfaces/src/lib/security-policy-error.utils.ts @@ -0,0 +1,76 @@ +export const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; + +export interface SerializedSecurityError { + readonly code: string; + readonly host?: string; + readonly message: string; +} + +function readErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object') { + const message = (error as { readonly message?: unknown }).message; + if (typeof message === 'string') { + return message; + } + } + + return undefined; +} + +function parseSecurityPayload(payload: string): SerializedSecurityError | null { + try { + const parsed = JSON.parse(payload); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.code === 'string' && + typeof parsed.message === 'string' + ) { + return { + code: parsed.code, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + message: parsed.message, + }; + } + } catch { + return null; + } + + return null; +} + +export function parseSecurityPolicyError( + error: unknown +): SerializedSecurityError | null { + const message = readErrorMessage(error); + const prefixIndex = message?.indexOf(SECURITY_ERROR_PREFIX) ?? -1; + if (!message || prefixIndex < 0) { + return null; + } + + const payload = message.slice(prefixIndex + SECURITY_ERROR_PREFIX.length); + const parsed = parseSecurityPayload(payload); + if (parsed) { + return parsed; + } + + const payloadEnd = payload.lastIndexOf('}'); + return payloadEnd >= 0 + ? parseSecurityPayload(payload.slice(0, payloadEnd + 1)) + : null; +} + +export function normalizeHost(host: string): string { + return host + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, '$1'); +} diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 6f8e44a20..ebf0d0398 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -66,4 +66,16 @@ export interface Settings { * Only meaningful for Xtream playlists in Electron. */ preferUploadedEpgOverXtream?: boolean; + /** + * Exact EPG source URLs the user has allowed to resolve to private/LAN + * network addresses. Kept source-scoped instead of disabling SSRF + * protection globally. + */ + trustedPrivateNetworkEpgUrls?: string[]; + /** + * Lowercase hostnames whose invalid TLS certificates the user has chosen + * to trust. This is host-scoped and does not disable TLS validation for + * unrelated playlist or EPG hosts. + */ + trustedInsecureTlsHosts?: string[]; } diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html index cc22a7629..a767ffc1d 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html @@ -22,9 +22,7 @@ mat-icon-button class="minimize-btn" (click)="toggleMinimize()" - [attr.aria-label]=" - minimized() ? 'Expand' : 'Minimize' - " + [attr.aria-label]="minimized() ? 'Expand' : 'Minimize'" > {{ minimized() ? 'unfold_more' : 'unfold_less' }} @@ -50,9 +48,7 @@ >
{{ getStatusIcon(item.status) }} @@ -60,6 +56,16 @@ {{ getDisplayUrl(item.url) }} @if (item.status === 'error') { + @if (isActionableSecurityError(item)) { + + }
@@ -137,19 +139,14 @@ {{ i + 1 }} - + {{ getDisplayUrl(item.url) }} diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss index c032d61f8..f3d46d3c2 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss @@ -192,6 +192,17 @@ ); } } + + .trust-btn { + flex: 0 0 auto; + min-width: 0; + height: 26px; + padding: 0 8px; + border-radius: 6px; + font-size: 0.72rem; + line-height: 24px; + white-space: nowrap; + } } &:hover .dismiss-btn { 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 24b9141f1..ce8cb2a8d 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 @@ -1,19 +1,54 @@ import { Component, computed, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, +} from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EpgImportProgress, EpgProgressService, } from '@iptvnator/epg/data-access'; +import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces'; + +interface EpgTrustConfirmDialogData { + confirmLabel: string; + message: string; + title: string; +} + +@Component({ + selector: 'app-epg-trust-confirm-dialog', + imports: [MatButtonModule, MatDialogModule, TranslatePipe], + template: ` +

{{ data.title }}

+ + {{ data.message }} + + + + + + `, +}) +class EpgTrustConfirmDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); +} @Component({ selector: 'app-epg-progress-panel', standalone: true, imports: [ MatButtonModule, + MatDialogModule, MatIconModule, MatTooltip, MatProgressBar, @@ -24,6 +59,8 @@ import { }) export class EpgProgressPanelComponent { private readonly epgProgress = inject(EpgProgressService); + private readonly dialog = inject(MatDialog); + private readonly translate = inject(TranslateService); readonly imports = this.epgProgress.imports; readonly isVisible = this.epgProgress.isVisible; @@ -46,9 +83,7 @@ export class EpgProgressPanelComponent { get queuedImports() { return this.imports() .filter((item) => item.status === 'queued') - .sort( - (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) - ); + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); } getStatusIcon(status: EpgImportProgress['status']): string { @@ -88,4 +123,102 @@ export class EpgProgressPanelComponent { retry(url: string): void { this.epgProgress.retry(url); } + + isActionableSecurityError(item: EpgImportProgress): boolean { + return ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked || + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ); + } + + getActionLabel(item: EpgImportProgress): string { + if ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked + ) { + return this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE', + 'Allow source' + ); + } + + return this.translateWithFallback('EPG.TRUST_TLS_HOST', 'Trust host'); + } + + confirmTrust(item: EpgImportProgress): void { + if ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked + ) { + this.openTrustDialog( + { + title: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE_TITLE', + 'Allow private-network EPG source?' + ), + message: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE_WARNING', + 'Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.' + ), + confirmLabel: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE', + 'Allow source' + ), + }, + () => { + void this.epgProgress.trustPrivateNetworkSourceAndRetry( + item.url + ); + } + ); + return; + } + + this.openTrustDialog( + { + title: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST', + 'Trust host' + ), + }, + () => { + void this.epgProgress.trustInsecureTlsHostAndRetry( + item.url, + item.errorHost + ); + } + ); + } + + private openTrustDialog( + data: EpgTrustConfirmDialogData, + onConfirm: () => void + ): void { + this.dialog + .open( + EpgTrustConfirmDialogComponent, + { data, width: '420px' } + ) + .afterClosed() + .subscribe((confirmed) => { + if (confirmed) { + onConfirm(); + } + }); + } + + private translateWithFallback(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return translated === key ? fallback : translated; + } } From 83db27ef12f76595b7b4a136c478502163d1a384 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:19:51 +0200 Subject: [PATCH 7/8] fix(playback): detect embedded mpv keep-open eof (#1050) * fix(playback): detect embedded mpv keep-open eof * fix(playback): keep mpv redirects loading --- .../native/src/embedded_mpv.mm | 21 +++ .../native/src/embedded_mpv_wid_common.h | 37 +++++- .../embedded-mpv-native-source.spec.ts | 120 ++++++++++++++++-- docs/architecture/embedded-mpv-native.md | 3 + 4 files changed, 164 insertions(+), 17 deletions(-) diff --git a/apps/electron-backend/native/src/embedded_mpv.mm b/apps/electron-backend/native/src/embedded_mpv.mm index d44e3332f..23f46f114 100644 --- a/apps/electron-backend/native/src/embedded_mpv.mm +++ b/apps/electron-backend/native/src/embedded_mpv.mm @@ -1136,6 +1136,13 @@ void runEventLoop(const std::shared_ptr& session) endFile->reason == MPV_END_FILE_REASON_EOF && session->running.load()) { session->snapshot.status = SessionStatus::Ended; + } else if ( + endFile && + endFile->reason == MPV_END_FILE_REASON_REDIRECT && + session->running.load()) { + session->snapshot.status = SessionStatus::Loading; + session->snapshot.error.clear(); + session->loadedPath = false; } else if (session->running.load()) { session->snapshot.status = SessionStatus::Idle; } @@ -1181,6 +1188,19 @@ void runEventLoop(const std::shared_ptr& session) break; } + if (propertyName == "eof-reached" && + property->format == MPV_FORMAT_FLAG && + property->data) { + const bool eofReached = + *static_cast(property->data) != 0; + if (eofReached && + session->running.load() && + session->loadedPath) { + session->snapshot.status = SessionStatus::Ended; + } + break; + } + if (propertyName == "volume" && property->format == MPV_FORMAT_DOUBLE && property->data) { @@ -1694,6 +1714,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info) "video-aspect-override", MPV_FORMAT_STRING ); + mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG); session->running.store(true); session->eventThread = std::thread(runEventLoop, session); diff --git a/apps/electron-backend/native/src/embedded_mpv_wid_common.h b/apps/electron-backend/native/src/embedded_mpv_wid_common.h index a1d2a7489..4e76478b5 100644 --- a/apps/electron-backend/native/src/embedded_mpv_wid_common.h +++ b/apps/electron-backend/native/src/embedded_mpv_wid_common.h @@ -31,6 +31,7 @@ enum class SessionStatus { Loading, Playing, Paused, + Ended, Error, Closed, }; @@ -95,6 +96,8 @@ std::string toStatusString(SessionStatus status) return "playing"; case SessionStatus::Paused: return "paused"; + case SessionStatus::Ended: + return "ended"; case SessionStatus::Error: return "error"; case SessionStatus::Closed: @@ -518,6 +521,19 @@ void runEventLoop(std::shared_ptr session) session->snapshot.error = endFile->error < 0 ? mpv_error_string(endFile->error) : "Embedded MPV playback ended with an error."; + } else if ( + endFile && + endFile->reason == MPV_END_FILE_REASON_EOF && + session->running.load()) { + session->snapshot.status = SessionStatus::Ended; + } else if ( + endFile && + endFile->reason == MPV_END_FILE_REASON_REDIRECT && + session->running.load()) { + session->snapshot.status = SessionStatus::Loading; + session->snapshot.error.clear(); + } else if (session->running.load()) { + session->snapshot.status = SessionStatus::Idle; } break; } @@ -536,10 +552,22 @@ void runEventLoop(std::shared_ptr session) *static_cast(property->data); } else if (name == "pause" && property->format == MPV_FORMAT_FLAG) { const bool paused = *static_cast(property->data) != 0; - session->snapshot.status = paused - ? SessionStatus::Paused - : SessionStatus::Playing; - session->snapshot.error.clear(); + if ( + session->snapshot.status != SessionStatus::Loading && + session->snapshot.status != SessionStatus::Ended && + session->snapshot.status != SessionStatus::Error + ) { + session->snapshot.status = paused + ? SessionStatus::Paused + : SessionStatus::Playing; + session->snapshot.error.clear(); + } + } else if (name == "eof-reached" && property->format == MPV_FORMAT_FLAG) { + const bool eofReached = + *static_cast(property->data) != 0; + if (eofReached && session->running.load()) { + session->snapshot.status = SessionStatus::Ended; + } } else if (name == "volume" && property->format == MPV_FORMAT_DOUBLE) { session->snapshot.volumePercent = *static_cast(property->data); @@ -748,6 +776,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info) "video-aspect-override", MPV_FORMAT_STRING ); + mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG); session->running.store(true); session->eventThread = std::thread(runEventLoop, session); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 28c562b48..fd287922e 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -53,6 +53,27 @@ describe('Embedded MPV native source recording invariants', () => { throw new Error(`Unable to read ${name} body.`); } + function eventCase( + source: string, + eventName: string, + nextEventName: string + ): string { + const eventLoopBodyStart = source.indexOf('void runEventLoop('); + expect(eventLoopBodyStart).toBeGreaterThanOrEqual(0); + const eventCaseStart = source.indexOf( + `case ${eventName}`, + eventLoopBodyStart + ); + expect(eventCaseStart).toBeGreaterThanOrEqual(0); + const nextCaseStart = source.indexOf( + `case ${nextEventName}`, + eventCaseStart + ); + expect(nextCaseStart).toBeGreaterThan(eventCaseStart); + + return source.slice(eventCaseStart, nextCaseStart); + } + it('tracks LoadPlayback recording auto-stop replies through the reconciler', () => { const body = functionBody('LoadPlayback'); @@ -72,23 +93,96 @@ describe('Embedded MPV native source recording invariants', () => { it('maps successful MPV end-file events to an ended session status', () => { expect(nativeSource).toContain('Ended,'); expect(nativeSource).toContain('case SessionStatus::Ended:'); + expect(widCommonSource).toContain('Ended,'); + expect(widCommonSource).toContain('case SessionStatus::Ended:'); - const eventLoopBodyStart = nativeSource.indexOf('void runEventLoop('); - expect(eventLoopBodyStart).toBeGreaterThanOrEqual(0); - const endFileCaseStart = nativeSource.indexOf( - 'case MPV_EVENT_END_FILE', - eventLoopBodyStart + const endFileCase = eventCase( + nativeSource, + 'MPV_EVENT_END_FILE', + 'MPV_EVENT_PROPERTY_CHANGE' ); - expect(endFileCaseStart).toBeGreaterThanOrEqual(0); - const nextCaseStart = nativeSource.indexOf( - 'case MPV_EVENT_PROPERTY_CHANGE', - endFileCaseStart - ); - expect(nextCaseStart).toBeGreaterThan(endFileCaseStart); - - const endFileCase = nativeSource.slice(endFileCaseStart, nextCaseStart); expect(endFileCase).toContain('SessionStatus::Ended'); expect(endFileCase).toContain('MPV_END_FILE_REASON_EOF'); + + const widEndFileCase = eventCase( + widCommonSource, + 'MPV_EVENT_END_FILE', + 'MPV_EVENT_PROPERTY_CHANGE' + ); + expect(widEndFileCase).toContain('SessionStatus::Ended'); + expect(widEndFileCase).toContain('MPV_END_FILE_REASON_EOF'); + }); + + it('keeps MPV redirect end-file events in loading state', () => { + for (const endFileCase of [ + eventCase( + nativeSource, + 'MPV_EVENT_END_FILE', + 'MPV_EVENT_PROPERTY_CHANGE' + ), + eventCase( + widCommonSource, + 'MPV_EVENT_END_FILE', + 'MPV_EVENT_PROPERTY_CHANGE' + ), + ]) { + const redirectBranchStart = endFileCase.indexOf( + 'MPV_END_FILE_REASON_REDIRECT' + ); + expect(redirectBranchStart).toBeGreaterThanOrEqual(0); + const idleFallbackStart = endFileCase.indexOf( + 'SessionStatus::Idle', + redirectBranchStart + ); + expect(idleFallbackStart).toBeGreaterThan(redirectBranchStart); + + const redirectBranch = endFileCase.slice( + redirectBranchStart, + idleFallbackStart + ); + expect(redirectBranch).toContain('SessionStatus::Loading'); + expect(redirectBranch).toContain('session->snapshot.error.clear();'); + } + }); + + it('maps keep-open eof-reached property changes to an ended session status', () => { + expect(nativeSource).toContain( + 'mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);' + ); + expect(widCommonSource).toContain( + 'mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);' + ); + + const nativeEofBranchStart = nativeSource.indexOf( + 'propertyName == "eof-reached"' + ); + expect(nativeEofBranchStart).toBeGreaterThanOrEqual(0); + const nativeVolumeBranchStart = nativeSource.indexOf( + 'propertyName == "volume"', + nativeEofBranchStart + ); + expect(nativeVolumeBranchStart).toBeGreaterThan(nativeEofBranchStart); + const nativeEofBranch = nativeSource.slice( + nativeEofBranchStart, + nativeVolumeBranchStart + ); + expect(nativeEofBranch).toContain('SessionStatus::Ended'); + expect(nativeEofBranch).toContain('session->loadedPath'); + + const widEofBranchStart = widCommonSource.indexOf( + 'name == "eof-reached"' + ); + expect(widEofBranchStart).toBeGreaterThanOrEqual(0); + const widVolumeBranchStart = widCommonSource.indexOf( + 'name == "volume"', + widEofBranchStart + ); + expect(widVolumeBranchStart).toBeGreaterThan(widEofBranchStart); + const widEofBranch = widCommonSource.slice( + widEofBranchStart, + widVolumeBranchStart + ); + expect(widEofBranch).toContain('SessionStatus::Ended'); }); it('keeps Windows/Linux non-load async MPV command failures non-fatal', () => { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 0a1536426..bf6d38147 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -82,11 +82,14 @@ The renderer learns which features the loaded addon binary supports through the - `ended` when the end-file reason is `MPV_END_FILE_REASON_EOF` - `error` when MPV reports an end-file error +- `loading` when MPV reports `MPV_END_FILE_REASON_REDIRECT`, because playback continues with the redirected playlist contents - `idle` for other successful end-file reasons such as replacement/stop - `closed` only for dispose/manual teardown Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or `error` as a request to continue to the next episode. +The native addon also observes mpv's `eof-reached` property and maps a true value to `ended`. This is required because embedded sessions run with `keep-open=yes`; MPV can pause at EOF while keeping the file loaded, so relying only on `MPV_EVENT_END_FILE` can leave the renderer in a paused-at-end state and block series autoplay. + Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only as part of the embedded player control surface. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state. Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior. From 8d672f8b819f216a43f7e5a7118f3b307471522f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:19:57 +0200 Subject: [PATCH 8/8] feat(playback): add web player series navigation (#1049) --- docs/architecture/embedded-inline-playback.md | 27 +++ docs/architecture/stalker-portal.md | 4 +- .../art-player/art-player.component.spec.ts | 116 ++++++++++++- .../lib/art-player/art-player.component.ts | 36 +++- .../html-video-player.component.html | 15 +- .../html-video-player.component.scss | 8 + .../html-video-player.component.spec.ts | 124 ++++++++++++++ .../html-video-player.component.ts | 37 +++- ...layback-navigation-controls.component.scss | 59 +++++++ ...-playback-navigation-controls.component.ts | 81 +++++++++ .../lib/vjs-player/vjs-player.component.html | 26 ++- .../lib/vjs-player/vjs-player.component.scss | 8 + .../vjs-player/vjs-player.component.spec.ts | 158 +++++++++++++++++- .../lib/vjs-player/vjs-player.component.ts | 12 ++ .../web-player-view.component.html | 16 ++ .../web-player-view.component.spec.ts | 72 +++++++- 16 files changed, 769 insertions(+), 30 deletions(-) create mode 100644 libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss create mode 100644 libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.ts diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index e4866419d..514b4e70f 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -139,6 +139,33 @@ The click path must continue through each detail host's normal episode playback method so recent-item updates, inline/external player selection, resume offsets, and playback-position saving keep the same behavior as manual episode clicks. +## Series Inline Episode Continuation + +Xtream and Stalker series detail views own current-episode state and pass a +`SeriesPlaybackNavigation` payload through `PortalInlinePlayerComponent` and +`WebPlayerViewComponent` to the active embedded player. + +Current contract: + +- Video.js, HTML5, ArtPlayer, and embedded MPV emit `playbackEnded` only for + real media EOF/`ended` events. +- Teardown, replacement, manual close, player reload, idle state, and playback + errors must not emit `playbackEnded`. +- Series previous/next controls render only when the series navigation payload is + present. Movies, live streams, radio streams, and non-series VOD must not show + those buttons. +- The shared navigation payload contains `canPrevious`, `canNext`, and + `autoplayEnabled`. Player controls disable previous on the first episode and + next on the last episode of the current season. +- Autoplay is enabled by default for series playback. On `playbackEnded`, the + portal detail host starts the next episode only when `canNext` is true for the + current season. +- Autoplay and Next never cross season boundaries or lazy-load another season. + Quick start remains the flow that may choose an episode from another loaded or + newly loaded season before playback begins. +- Previous switches directly to the previous episode in the current season. It + does not implement a restart-threshold behavior. + ## Codec And Container Diagnostics The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback, including live streams opened from favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index cd26918b3..7c6f4f8cd 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -184,8 +184,8 @@ Series inline playback behavior is shared across all three modes: - `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`. - The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`. -- When experimental embedded MPV is active, the player receives previous/next episode state for the current season only. -- Embedded MPV autoplay is enabled by default. On MPV EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list. +- Embedded players receive previous/next episode state for the current season only. +- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list. - Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded `is_series=1` season. Quick start remains the only flow that may load another VOD-series season before playback. - Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode. diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 0d94f05ca..bdf573870 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { Channel } from '@iptvnator/shared/interfaces'; import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component'; @@ -31,9 +32,11 @@ class MockHls { static isSupported = jest.fn(() => true); readonly handlers = new Map void>(); - readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => { - this.handlers.set(event, handler); - }); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + this.handlers.set(event, handler); + } + ); readonly loadSource = jest.fn(); readonly attachMedia = jest.fn(); readonly destroy = jest.fn(); @@ -47,9 +50,11 @@ class MockHls { class MockMpegTsPlayer { readonly handlers = new Map void>(); readonly attachMediaElement = jest.fn(); - readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => { - this.handlers.set(event, handler); - }); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + this.handlers.set(event, handler); + } + ); readonly load = jest.fn(); readonly play = jest.fn(); readonly pause = jest.fn(); @@ -217,9 +222,11 @@ describe('ArtPlayerComponent', () => { artPlayerInstances[0].video, 'https://example.com/live/channel.ts' ); - mpegTsInstances[0].handlers - .get('error') - ?.('mediaError', 'unsupported codec', {}); + mpegTsInstances[0].handlers.get('error')?.( + 'mediaError', + 'unsupported codec', + {} + ); expect(issues).toEqual([ expect.objectContaining({ @@ -231,6 +238,97 @@ describe('ArtPlayerComponent', () => { ]); }); + it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { + const events: string[] = []; + createComponent({ + url: 'https://example.com/series/s01e02.mp4', + name: 'Episode 2', + }); + ( + component as unknown as { + playbackEnded: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).playbackEnded.subscribe(() => events.push('ended')); + + artPlayerInstances[0].video.dispatchEvent(new Event('ended')); + fixture.componentRef.setInput('channel', { + url: 'https://example.com/series/s01e03.mp4', + name: 'Episode 3', + }); + fixture.detectChanges(); + fixture.destroy(); + + expect(events).toEqual(['ended']); + }); + + it('hides series navigation controls when series navigation is absent', () => { + createComponent({ + url: 'https://example.com/movie.mp4', + name: 'Movie', + }); + + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ) + ).toBeNull(); + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ) + ).toBeNull(); + }); + + it('renders series navigation controls with boundary disabled state', () => { + const events: string[] = []; + createComponent({ + url: 'https://example.com/series/s01e10.mp4', + name: 'Episode 10', + }); + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: false, + autoplayEnabled: true, + }); + ( + component as unknown as { + previousEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).previousEpisodeRequested.subscribe(() => events.push('previous')); + ( + component as unknown as { + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ); + const nextButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ); + expect(previousButton).not.toBeNull(); + expect(previousButton.nativeElement.disabled).toBe(false); + expect(nextButton).not.toBeNull(); + expect(nextButton.nativeElement.disabled).toBe(true); + + previousButton.nativeElement.click(); + nextButton.nativeElement.click(); + + expect(events).toEqual(['previous']); + }); + function createComponent(channel: Pick): void { fixture = TestBed.createComponent(ArtPlayerComponent); component = fixture.componentInstance; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 403cf984e..3ad090bd6 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -24,6 +24,8 @@ import { createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; +import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; +import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; type AudioTrackSelector = { html: string | HTMLElement; @@ -34,8 +36,18 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; @Component({ selector: 'app-art-player', - imports: [], - template: `
`, + imports: [SeriesPlaybackNavigationControlsComponent], + template: ` +
+
+ + +
+ `, styles: [ ` :host { @@ -43,6 +55,13 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; width: 100%; height: 100%; } + .art-player-shell { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + } .artplayer-container { width: 100%; height: 100%; @@ -55,11 +74,15 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { @Input() volume = 1; @Input() showCaptions = false; @Input() startTime = 0; + @Input() seriesNavigation: SeriesPlaybackNavigation | null = null; @Output() timeUpdate = new EventEmitter<{ currentTime: number; duration: number; }>(); @Output() playbackIssue = new EventEmitter(); + @Output() playbackEnded = new EventEmitter(); + @Output() previousEpisodeRequested = new EventEmitter(); + @Output() nextEpisodeRequested = new EventEmitter(); private player!: Artplayer; private hls: Hls | null = null; @@ -82,6 +105,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { this.playbackIssue.emit(null); }; + private readonly handlePlaybackEnded = () => { + this.playbackEnded.emit(); + }; + ngOnInit(): void { this.initPlayer(); } @@ -122,6 +149,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { 'playing', this.clearPlaybackIssue ); + this.player.video?.removeEventListener( + 'ended', + this.handlePlaybackEnded + ); this.player.destroy(); } } @@ -232,6 +263,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { this.clearPlaybackIssue ); this.player.video.addEventListener('playing', this.clearPlaybackIssue); + this.player.video.addEventListener('ended', this.handlePlaybackEnded); if (this.startTime > 0) { this.player.on('ready', () => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index 5442850b1..ca9596274 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -1 +1,14 @@ - +
+ + + +
diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss index 08a451024..d30f9efd4 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.scss @@ -10,3 +10,11 @@ width: 100%; height: 100%; } + +.html-video-player-shell { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index b499917ae..e4e449a44 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -1,5 +1,6 @@ import { SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; @@ -106,6 +107,10 @@ describe('HtmlVideoPlayerComponent', () => { }); it('passes channel headers and stream URL to Electron header overrides', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'load' + ).mockImplementation(() => undefined); jest.spyOn( component.videoPlayer.nativeElement, 'play' @@ -130,6 +135,10 @@ describe('HtmlVideoPlayerComponent', () => { }); it('clears Electron header overrides for channels without custom headers', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'load' + ).mockImplementation(() => undefined); jest.spyOn( component.videoPlayer.nativeElement, 'play' @@ -153,6 +162,36 @@ describe('HtmlVideoPlayerComponent', () => { ); }); + it('replaces and reloads native video sources when switching episodes', () => { + const video = component.videoPlayer.nativeElement; + const loadSpy = jest + .spyOn(video, 'load') + .mockImplementation(() => undefined); + const playSpy = jest.spyOn(video, 'play').mockResolvedValue(undefined); + + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://stream.example/series/s01e01.mp4', + }); + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://stream.example/series/s01e02.mp4', + }); + + const sources = Array.from(video.querySelectorAll('source')); + const [source] = sources; + expect(sources).toHaveLength(1); + expect(source?.src).toBe( + 'https://stream.example/series/s01e02.mp4' + ); + expect(source?.type).toBe('video/mp4'); + expect(loadSpy).toHaveBeenCalledTimes(2); + expect(playSpy).toHaveBeenCalledTimes(2); + expect(loadSpy.mock.invocationCallOrder[1]).toBeLessThan( + playSpy.mock.invocationCallOrder[1] + ); + }); + it('emits a playback issue when the native video element reports an unsupported source', () => { const issues: unknown[] = []; component.channel = TEST_CHANNEL; @@ -237,4 +276,89 @@ describe('HtmlVideoPlayerComponent', () => { expect(issues[0].details).toContain('xhr setup failed'); expect(issues[0].details).toContain('"status":0'); }); + + it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { + const events: string[] = []; + ( + component as unknown as { + playbackEnded: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).playbackEnded.subscribe(() => events.push('ended')); + jest.spyOn( + component.videoPlayer.nativeElement, + 'load' + ).mockImplementation(() => undefined); + jest.spyOn( + component.videoPlayer.nativeElement, + 'play' + ).mockResolvedValue(undefined); + + component.videoPlayer.nativeElement.dispatchEvent(new Event('ended')); + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://stream.example/series/s01e03.mp4', + }); + fixture.destroy(); + + expect(events).toEqual(['ended']); + }); + + it('hides series navigation controls when series navigation is absent', () => { + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ) + ).toBeNull(); + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ) + ).toBeNull(); + }); + + it('renders series navigation controls with boundary disabled state', () => { + const events: string[] = []; + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: false, + autoplayEnabled: true, + }); + ( + component as unknown as { + previousEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).previousEpisodeRequested.subscribe(() => events.push('previous')); + ( + component as unknown as { + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ); + const nextButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ); + expect(previousButton).not.toBeNull(); + expect(previousButton.nativeElement.disabled).toBe(false); + expect(nextButton).not.toBeNull(); + expect(nextButton.nativeElement.disabled).toBe(true); + + previousButton.nativeElement.click(); + nextButton.nativeElement.click(); + + expect(events).toEqual(['previous']); + }); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index f1819bd5f..f24183368 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -25,6 +25,8 @@ import { createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; +import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; +import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -35,6 +37,7 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); selector: 'app-html-video-player', templateUrl: './html-video-player.component.html', styleUrls: ['./html-video-player.component.scss'], + imports: [SeriesPlaybackNavigationControlsComponent], standalone: true, }) export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { @@ -42,11 +45,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { @Input() channel!: Channel; @Input() volume = 1; @Input() startTime = 0; + @Input() seriesNavigation: SeriesPlaybackNavigation | null = null; @Output() timeUpdate = new EventEmitter<{ currentTime: number; duration: number; }>(); @Output() playbackIssue = new EventEmitter(); + @Output() playbackEnded = new EventEmitter(); + @Output() previousEpisodeRequested = new EventEmitter(); + @Output() nextEpisodeRequested = new EventEmitter(); private readonly dataService = inject(DataService); @@ -96,6 +103,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { }); }; + private readonly handlePlaybackEnded = (): void => { + this.playbackEnded.emit(); + }; + ngOnInit() { this.videoPlayer.nativeElement.addEventListener( 'volumechange', @@ -124,6 +135,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { 'playing', this.clearPlaybackIssue ); + this.videoPlayer.nativeElement.addEventListener( + 'ended', + this.handlePlaybackEnded + ); } /** @@ -157,6 +172,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.mpegtsPlayer = null; } if (this.hls) this.hls.destroy(); + this.clearNativeVideoSources(this.videoPlayer.nativeElement); if (channel.url) { this.playbackIssue.emit(null); const url = channel.url + (channel.epgParams ?? ''); @@ -225,21 +241,32 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.handlePlayOperation(); } else { debugHtmlPlayer('Using native video player'); - this.addSourceToVideo( + this.replaceNativeVideoSource( this.videoPlayer.nativeElement, url, 'video/mp4' ); - this.videoPlayer.nativeElement.play(); + this.handlePlayOperation(); } } } - addSourceToVideo(element: HTMLVideoElement, url: string, type: string) { + private clearNativeVideoSources(element: HTMLVideoElement): void { + element.removeAttribute('src'); + element.replaceChildren(); + } + + private replaceNativeVideoSource( + element: HTMLVideoElement, + url: string, + type: string + ): void { + this.clearNativeVideoSources(element); const source = document.createElement('source'); source.src = url; source.type = type; element.appendChild(source); + element.load(); } /** @@ -366,6 +393,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { 'playing', this.clearPlaybackIssue ); + this.videoPlayer.nativeElement.removeEventListener( + 'ended', + this.handlePlaybackEnded + ); if (this.mpegtsPlayer) { this.mpegtsPlayer.pause(); this.mpegtsPlayer.unload(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss new file mode 100644 index 000000000..c8fd3ae5a --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss @@ -0,0 +1,59 @@ +:host { + position: absolute; + right: 50%; + bottom: clamp(54px, 8%, 76px); + z-index: 4; + display: block; + transform: translateX(50%); + pointer-events: none; +} + +.series-playback-navigation-controls { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px; + border: 1px solid var(--mat-sys-outline-variant, rgba(255, 255, 255, 0.16)); + border-radius: 999px; + background: color-mix( + in srgb, + var(--mat-sys-surface, #101010) 76%, + transparent + ); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(16px); + pointer-events: auto; +} + +.series-playback-navigation-controls__button { + --mdc-icon-button-state-layer-size: 40px; + + width: 40px; + height: 40px; + color: var(--mat-sys-on-surface, #ffffff); +} + +.series-playback-navigation-controls__button:disabled { + color: color-mix( + in srgb, + var(--mat-sys-on-surface, #ffffff) 34%, + transparent + ); +} + +.series-playback-navigation-controls__button mat-icon { + width: 22px; + height: 22px; + font-size: 22px; +} + +@media (max-width: 599px) { + :host { + bottom: 64px; + } + + .series-playback-navigation-controls { + gap: 4px; + padding: 4px; + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.ts b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.ts new file mode 100644 index 000000000..56c325bea --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.ts @@ -0,0 +1,81 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import type { SeriesPlaybackNavigation } from './series-playback-navigation'; + +@Component({ + selector: 'app-series-playback-navigation-controls', + imports: [MatButtonModule, MatIconModule, MatTooltipModule], + template: ` + @if (navigation()) { + + } + `, + styleUrl: './series-playback-navigation-controls.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SeriesPlaybackNavigationControlsComponent { + readonly navigation = input(null); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); + + readonly canPrevious = computed( + () => this.navigation()?.canPrevious === true + ); + readonly canNext = computed(() => this.navigation()?.canNext === true); + + requestPreviousEpisode(): void { + if (!this.canPrevious()) { + return; + } + + this.previousEpisodeRequested.emit(); + } + + requestNextEpisode(): void { + if (!this.canNext()) { + return; + } + + this.nextEpisodeRequested.emit(); + } +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index abe0d6204..fd7707d0e 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -1,9 +1,17 @@ - +
+ + + +
diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss index 99c83a210..8fde05522 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.scss @@ -12,6 +12,14 @@ height: 100% !important; } +.vjs-player-shell { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + /* Hide the captions settings item from the captions menu. */ .hide-captions { .vjs-texttrack-settings, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index afbe6202a..3316b1db0 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -1,5 +1,6 @@ import { SimpleChange } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component'; const videoJsMock = jest.fn(); @@ -26,6 +27,7 @@ jest.unstable_mockModule('mpegts.js', () => ({ describe('VjsPlayerComponent', () => { let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent; + let fixture: ComponentFixture; let component: VjsPlayerComponentInstance; let player: VjsPlayerComponentInstance['player']; @@ -58,12 +60,13 @@ describe('VjsPlayerComponent', () => { load: jest.fn(), play: jest.fn(), }); + videoJsMock.mockReset(); await TestBed.configureTestingModule({ imports: [VjsPlayerComponent], }).compileComponents(); - component = - TestBed.createComponent(VjsPlayerComponent).componentInstance; + fixture = TestBed.createComponent(VjsPlayerComponent); + component = fixture.componentInstance; player = { error: jest.fn(), src: jest.fn(), @@ -74,6 +77,10 @@ describe('VjsPlayerComponent', () => { component.player = player; }); + afterEach(() => { + fixture.destroy(); + }); + it('uses signal-based inputs and outputs', () => { const signalComponent = component as unknown as SignalApiShape; @@ -328,8 +335,153 @@ describe('VjsPlayerComponent', () => { expect(testComponent.player.duration).toHaveBeenCalledWith(164.072); }); + + it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { + const events: string[] = []; + videoJsMock.mockReturnValue(createVideoJsPlayerMock()); + fixture.componentRef.setInput('options', { + sources: [ + { + src: 'https://example.com/series/s01e02.mp4', + type: 'video/mp4', + }, + ], + }); + ( + component as unknown as { + playbackEnded: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).playbackEnded.subscribe(() => events.push('ended')); + + fixture.detectChanges(); + fixture.nativeElement + .querySelector('video') + .dispatchEvent(new Event('ended')); + component.ngOnChanges({ + options: new SimpleChange( + { + sources: [ + { + src: 'https://example.com/series/s01e02.mp4', + type: 'video/mp4', + }, + ], + }, + { + sources: [ + { + src: 'https://example.com/series/s01e03.mp4', + type: 'video/mp4', + }, + ], + }, + false + ), + }); + fixture.destroy(); + + expect(events).toEqual(['ended']); + }); + + it('hides series navigation controls when series navigation is absent', () => { + videoJsMock.mockReturnValue(createVideoJsPlayerMock()); + fixture.componentRef.setInput('options', { + sources: [ + { + src: 'https://example.com/movie.mp4', + type: 'video/mp4', + }, + ], + }); + + fixture.detectChanges(); + + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ) + ).toBeNull(); + expect( + fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ) + ).toBeNull(); + }); + + it('renders series navigation controls with boundary disabled state', () => { + const events: string[] = []; + videoJsMock.mockReturnValue(createVideoJsPlayerMock()); + fixture.componentRef.setInput('options', { + sources: [ + { + src: 'https://example.com/series/s01e01.mp4', + type: 'video/mp4', + }, + ], + }); + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: false, + canNext: true, + autoplayEnabled: true, + }); + ( + component as unknown as { + previousEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).previousEpisodeRequested.subscribe(() => events.push('previous')); + ( + component as unknown as { + nextEpisodeRequested: { + subscribe: (fn: () => void) => { unsubscribe: () => void }; + }; + } + ).nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-previous-episode"]') + ); + const nextButton = fixture.debugElement.query( + By.css('[data-test-id="series-playback-next-episode"]') + ); + expect(previousButton).not.toBeNull(); + expect(previousButton.nativeElement.disabled).toBe(true); + expect(nextButton).not.toBeNull(); + expect(nextButton.nativeElement.disabled).toBe(false); + + previousButton.nativeElement.click(); + nextButton.nativeElement.click(); + + expect(events).toEqual(['next']); + }); }); +function createVideoJsPlayerMock(): VjsPlayerComponentInstance['player'] { + return { + audioTracks: jest.fn(() => null), + currentTime: jest.fn(() => 0), + duration: jest.fn(() => 0), + error: jest.fn(() => null), + getChild: jest.fn(() => null), + on: jest.fn(), + reset: jest.fn(), + src: jest.fn(), + tech: jest.fn(() => ({ el: () => null })), + volume: jest.fn(), + dispose: jest.fn(), + qualitySelectorHls: jest.fn(), + aspectRatioPanel: jest.fn(), + } as unknown as VjsPlayerComponentInstance['player']; +} + function createTimeRanges(ranges: Array<[number, number]>): TimeRanges { return { length: ranges.length, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index e5cacbb7b..c2c0ce799 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -24,6 +24,8 @@ import { createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; +import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; +import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; /** * This component contains the implementation of video player that is based on video.js library @@ -98,6 +100,7 @@ const debugVjsPlayer = createDevLogger('VjsPlayer'); templateUrl: './vjs-player.component.html', styleUrls: ['./vjs-player.component.scss'], encapsulation: ViewEncapsulation.None, + imports: [SeriesPlaybackNavigationControlsComponent], standalone: true, }) export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { @@ -119,15 +122,22 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { ] as const; readonly volume = input(1); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number; }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); private readonly clearPlaybackIssue = () => { this.playbackIssue.emit(null); }; + private readonly handlePlaybackEnded = () => { + this.playbackEnded.emit(); + }; private readonly scheduleMpegTsVodDurationSync = () => { this.syncMpegTsVodDuration(); this.queueDurationSync(() => this.syncMpegTsVodDuration()); @@ -142,6 +152,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { const targetVideo = this.target().nativeElement as HTMLVideoElement; targetVideo.addEventListener('loadeddata', this.clearPlaybackIssue); targetVideo.addEventListener('playing', this.clearPlaybackIssue); + targetVideo.addEventListener('ended', this.handlePlaybackEnded); // For raw MPEG-TS streams, init Video.js without a source (UI/controls only) const vjsOptions = isMpegTs @@ -303,6 +314,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.clearPlaybackIssue ); targetVideo.removeEventListener('playing', this.clearPlaybackIssue); + targetVideo.removeEventListener('ended', this.handlePlaybackEnded); } catch { // Required viewChild can be unavailable when a shallow unit test destroys an unrendered component. } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index cc4c21ce5..c0d97fd3a 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -4,8 +4,12 @@ [options]="vjsOptions" [volume]="volume()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { @@ -17,8 +21,12 @@ [volume]="volume()" [showCaptions]="showCaptions()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { @@ -30,8 +38,12 @@ [volume]="volume()" [showCaptions]="showCaptions()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { @@ -52,8 +64,12 @@ [options]="vjsOptions" [volume]="volume()" [startTime]="startTime()" + [seriesNavigation]="seriesNavigation()" (timeUpdate)="timeUpdate.emit($event)" (playbackIssue)="handlePlaybackIssue($event)" + (playbackEnded)="playbackEnded.emit()" + (previousEpisodeRequested)="previousEpisodeRequested.emit()" + (nextEpisodeRequested)="nextEpisodeRequested.emit()" /> } @placeholder { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 9d07c80f0..cbd8638c9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -37,8 +37,12 @@ class StubVjsPlayerComponent { readonly options = input(); readonly volume = input(1); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @Component({ @@ -50,8 +54,12 @@ class StubHtmlVideoPlayerComponent { readonly volume = input(1); readonly showCaptions = input(false); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @Component({ @@ -63,8 +71,12 @@ class StubArtPlayerComponent { readonly volume = input(1); readonly showCaptions = input(false); readonly startTime = input(0); + readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); } @Component({ @@ -347,7 +359,10 @@ describe('WebPlayerViewComponent', () => { it('suppresses browser diagnostics while embedded MPV is selected', () => { const requests: unknown[] = []; runtimeCapabilities.supportsManagedExternalPlayers = true; - fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv); + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.EmbeddedMpv + ); component.externalFallbackRequested.subscribe((request) => requests.push(request) ); @@ -429,6 +444,61 @@ describe('WebPlayerViewComponent', () => { expect(events).toEqual(['ended', 'previous', 'next']); }); + it.each([ + { + player: VideoPlayer.VideoJs, + directive: StubVjsPlayerComponent, + }, + { + player: VideoPlayer.Html5Player, + directive: StubHtmlVideoPlayerComponent, + }, + { + player: VideoPlayer.ArtPlayer, + directive: StubArtPlayerComponent, + }, + ])( + 'passes series navigation to $player and forwards episode navigation events', + async ({ player: selectedPlayer, directive }) => { + const events: string[] = []; + const seriesNavigation = { + canPrevious: true, + canNext: false, + autoplayEnabled: true, + }; + fixture.componentRef.setInput('playerOverride', selectedPlayer); + fixture.componentRef.setInput('seriesNavigation', seriesNavigation); + component.playbackEnded.subscribe(() => events.push('ended')); + component.previousEpisodeRequested.subscribe(() => + events.push('previous') + ); + component.nextEpisodeRequested.subscribe(() => events.push('next')); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const playerElement = fixture.debugElement.query( + By.directive(directive) + ); + expect(playerElement).not.toBeNull(); + + const player = playerElement.componentInstance as { + seriesNavigation: () => unknown; + playbackEnded: { emit: () => void }; + previousEpisodeRequested: { emit: () => void }; + nextEpisodeRequested: { emit: () => void }; + }; + expect(player.seriesNavigation()).toBe(seriesNavigation); + + player.playbackEnded.emit(); + player.previousEpisodeRequested.emit(); + player.nextEpisodeRequested.emit(); + + expect(events).toEqual(['ended', 'previous', 'next']); + } + ); + it('uses the PWA browser access diagnostic description key outside desktop', () => { const issue = createBrowserAccessDiagnostic();