From cc048ba6bd56edb63a603536958f4acedc838eed Mon Sep 17 00:00:00 2001 From: Mahmut YUM Date: Sun, 15 Nov 2026 01:48:27 +0300 Subject: [PATCH 1/2] fix(parental-lock): close external players on relock, count them as playback, release stalled video - A relock closes an MPV/VLC session whose media was opened while unlocked when its source has a locked category (any source, when it cannot be told) or the lock store is unreadable. Sessions opened while locked keep playing. Xtream Live claims its own sessions through ParentalLockSessionOwners and keeps closing only withheld categories. A session that could not be closed before an unlock is shown again. - The idle relock counts Embedded MPV while playing and an MPV/VLC film or episode while its reported position moves; players that report no position (live, M3U films, Flatpak MPV) cannot report a pause and do not hold the lock open. - Keep-awake releases a video that stalls without advancing for 60 s and keeps checking one that advanced until playing reports it resumed, so a dead stream no longer pins the display or defers the relock. --- .changes/parental-lock-external-players.md | 6 + .../src/parental-lock-external-player.e2e.ts | 182 ++++++++ .../parental-lock-enforcement.service.spec.ts | 49 ++- .../parental-lock-enforcement.service.ts | 14 +- .../parental-lock-external-sessions.spec.ts | 416 ++++++++++++++++++ .../parental-lock-external-sessions.ts | 250 +++++++++++ .../playback-keep-awake.service.spec.ts | 92 +++- .../services/playback-keep-awake.service.ts | 57 +++ docs/architecture/parental-lock.md | 43 +- docs/architecture/player-controls-contract.md | 6 + ...eam-external-live-sessions.service.spec.ts | 22 +- .../xtream-external-live-sessions.service.ts | 48 +- .../live-stream-layout/xtream-live-owner.ts | 30 ++ libs/services/src/lib/parental-lock/index.ts | 1 + .../parental-lock-lock-store.service.spec.ts | 24 + .../parental-lock-lock-store.service.ts | 14 + .../parental-lock-session-owners.ts | 28 ++ 17 files changed, 1243 insertions(+), 39 deletions(-) create mode 100644 .changes/parental-lock-external-players.md create mode 100644 apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts create mode 100644 apps/web/src/app/services/parental-lock-external-sessions.spec.ts create mode 100644 apps/web/src/app/services/parental-lock-external-sessions.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-owner.ts create mode 100644 libs/services/src/lib/parental-lock/parental-lock-session-owners.ts diff --git a/.changes/parental-lock-external-players.md b/.changes/parental-lock-external-players.md new file mode 100644 index 0000000000..f381c2cab9 --- /dev/null +++ b/.changes/parental-lock-external-players.md @@ -0,0 +1,6 @@ +--- +type: fix +area: parental-lock +--- + +Locking the app again now closes an MPV or VLC window opened while it was unlocked from a source with locked categories. Films in MPV, VLC and Embedded MPV no longer lock half way. A stream that stops playing no longer keeps the screen awake and the app unlocked. diff --git a/apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts b/apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts new file mode 100644 index 0000000000..bc77816b17 --- /dev/null +++ b/apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts @@ -0,0 +1,182 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openSettingsSection, + openWorkspaceSection, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +const PIN = '2468'; +const PID_KEY = '__parentalLockPlayerPids'; + +function stopPlayer(pid: number): void { + try { + process.kill(pid); + } catch { + // Already gone. + } +} + +function playerIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function launchPlayer( + app: Awaited>, + playerPath: string, + title: string +): Promise { + const before = await app.electronApp.evaluate( + (_, key) => (Reflect.get(globalThis, key) as number[]).length, + PID_KEY + ); + await app.mainWindow.evaluate( + async ({ playerPath, title }) => { + await window.electron.setMpvPlayerPath(playerPath); + await window.electron.openInMpv( + 'https://streams.example.test/synthetic.ts', + title, + '', + '' + ); + }, + { playerPath, title } + ); + await expect + .poll(() => + app.electronApp.evaluate( + (_, key) => (Reflect.get(globalThis, key) as number[]).length, + PID_KEY + ) + ) + .toBe(before + 1); + return app.electronApp.evaluate( + (_, key) => (Reflect.get(globalThis, key) as number[]).at(-1) ?? 0, + PID_KEY + ); +} + +async function enterPin(page: Page): Promise { + await page.getByTestId('parental-lock-pin').fill(PIN); + await page.getByTestId('parental-lock-pin-submit').click(); +} + +/** + * A relock closes an MPV session opened while unlocked from a source with + * a locked category, and leaves one opened while locked. The native process + * boundary is replaced by a quiet, long-lived child; CI installs no MPV. + */ +test('@electron @parental @playback relock closes an MPV session opened while unlocked', async ({ + dataDir, +}) => { + test.setTimeout(120_000); + const directory = mkdtempSync(join(tmpdir(), 'iptvnator-lock-player-')); + const playerPath = join(directory, 'synthetic-mpv'); + const app = await launchElectronApp(dataDir); + const pids: number[] = []; + try { + const page = app.mainWindow; + await app.electronApp.evaluate( + (_, { playerPath, nodePath, key }) => { + const childProcess = process.getBuiltinModule('child_process'); + const spawn = childProcess.spawn; + Reflect.set(globalThis, key, []); + childProcess.spawn = ((command, ...args) => { + if (command !== playerPath) + return Reflect.apply(spawn, childProcess, [ + command, + ...args, + ]); + const child = spawn( + nodePath, + ['-e', 'setInterval(() => {}, 1000)'], + { stdio: ['ignore', 'pipe', 'pipe'], detached: false } + ); + (Reflect.get(globalThis, key) as number[]).push( + child.pid as number + ); + return child; + }) as typeof spawn; + }, + { playerPath, nodePath: process.execPath, key: PID_KEY } + ); + await page.evaluate(() => + window.electron.updateSettings({ mpvReuseInstance: true }) + ); + + // Enabling the lock leaves this session unlocked. + await openSettings(page); + await openSettingsSection(page, 'parental'); + const toggle = page.locator( + '[data-test-id="parental-lock-enabled"] button[role="switch"]' + ); + await toggle.click(); + await page.getByTestId('parental-lock-pin').fill(PIN); + await page.getByTestId('parental-lock-pin-confirm').fill(PIN); + await page.getByTestId('parental-lock-pin-submit').click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + + await addXtreamPortal(page, { name: 'Synthetic Lock Portal' }); + await waitForXtreamWorkspaceReady(page); + await openWorkspaceSection(page, 'Live TV'); + await page.getByTestId('context-manage-categories').click(); + const dialog = page.locator('app-category-management-dialog'); + await dialog + .locator('[data-test-id^="category-lock-"]') + .first() + .click(); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/live/); + + // Opened while unlocked, from a source with a locked category. + const unlockedPid = await launchPlayer( + app, + playerPath, + 'Synthetic unlocked launch' + ); + pids.push(unlockedPid); + expect(playerIsAlive(unlockedPid)).toBe(true); + + const lockButton = page.getByTestId('header-parental-lock'); + await lockButton.click(); + await expect(lockButton).toHaveClass(/is-active/); + await expect.poll(() => playerIsAlive(unlockedPid)).toBe(false); + + // Opened while locked: it came from the filtered catalog and must + // survive an unlock and the next relock. + const lockedPid = await launchPlayer( + app, + playerPath, + 'Synthetic locked launch' + ); + pids.push(lockedPid); + await lockButton.click(); + await enterPin(page); + await expect(lockButton).not.toHaveClass(/is-active/); + await lockButton.click(); + await expect(lockButton).toHaveClass(/is-active/); + // Nothing to wait for when the session is rightly left alone: give + // a wrong close (sent at once on relock) time to land. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(3_000); + expect(playerIsAlive(lockedPid)).toBe(true); + } finally { + pids.forEach(stopPlayer); + await closeElectronApp(app); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index 8549df0fee..1eec1c784e 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -9,6 +9,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { ParentalLockService } from '@iptvnator/services'; import { ParentalLockEnforcementService } from './parental-lock-enforcement.service'; +import { ParentalLockExternalSessions } from './parental-lock-external-sessions'; import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; interface Applier { @@ -546,18 +547,40 @@ describe('ParentalLockEnforcementService apply serialization', () => { }); describe('ParentalLockEnforcementService busy probe', () => { - it('counts playing audio (the radio player) as activity', () => { - let probe: (() => boolean) | undefined; + let probe: (() => boolean) | undefined; + let version: ReturnType>; + let active: ReturnType>; + let externalSessions: { + start: jest.Mock; + isBusy: jest.Mock; + onLockChange: jest.Mock; + }; + + beforeEach(() => { + probe = undefined; + version = signal(0); + active = signal(false); + externalSessions = { + start: jest.fn(), + isBusy: jest.fn(() => false), + onLockChange: jest.fn(), + }; TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ { provide: ParentalLockService, useValue: { - version: signal(0), + version, + active, + isM3uGroupLocked: () => false, registerBusyProbe: (fn: () => boolean) => (probe = fn), }, }, + { + provide: ParentalLockExternalSessions, + useValue: externalSessions, + }, { provide: XtreamStore, useValue: {} }, { provide: XTREAM_DATA_SOURCE, useValue: {} }, { provide: StalkerStore, useValue: {} }, @@ -574,6 +597,26 @@ describe('ParentalLockEnforcementService busy probe', () => { }); const service = TestBed.inject(ParentalLockEnforcementService); TestBed.runInInjectionContext(() => service.start()); + TestBed.flushEffects(); + }); + + it('counts players outside the page (Embedded MPV, MPV/VLC films)', () => { + expect(externalSessions.start).toHaveBeenCalled(); + expect(probe?.()).toBe(false); + + externalSessions.isBusy.mockReturnValue(true); + expect(probe?.()).toBe(true); + }); + + it('hands a relock to the external session closer', () => { + active.set(true); + version.set(1); + TestBed.flushEffects(); + + expect(externalSessions.onLockChange).toHaveBeenCalledTimes(1); + }); + + it('counts playing audio (the radio player) as activity', () => { expect(probe?.()).toBe(false); const audio = document.createElement('audio'); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 16a0927559..1b78d8ebd6 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -14,6 +14,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { ParentalLockService } from '@iptvnator/services'; import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces'; +import { ParentalLockExternalSessions } from './parental-lock-external-sessions'; import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; const XTREAM_ROUTE = @@ -37,6 +38,7 @@ export class ParentalLockEnforcementService { private readonly router = inject(Router); private readonly store = inject(Store); private readonly keepAwake = inject(PlaybackKeepAwakeService); + private readonly externalSessions = inject(ParentalLockExternalSessions); private readonly activeChannel = this.store.selectSignal(selectActive); private started = false; private lastVersion = -1; @@ -50,10 +52,15 @@ export class ParentalLockEnforcementService { } this.started = true; // Playback counts as activity: video through the keep-awake - // tracker, and audio (the radio player) read directly, since the - // keep-awake service deliberately ignores