Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/parental-lock-external-players.md
Original file line number Diff line number Diff line change
@@ -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.
182 changes: 182 additions & 0 deletions apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof launchElectronApp>>,
playerPath: string,
title: string
): Promise<number> {
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<void> {
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 });
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<typeof signal<number>>;
let active: ReturnType<typeof signal<boolean>>;
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: {} },
Expand All @@ -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');
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/app/services/parental-lock-enforcement.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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;
Expand All @@ -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 <audio>.
// tracker, audio (the radio player) read directly, since the
// keep-awake service deliberately ignores <audio>, and the players
// that render outside the page (Embedded MPV, MPV/VLC films).
this.externalSessions.start();
this.parentalLock.registerBusyProbe(
() => this.keepAwake.hasPlayingVideo() || hasPlayingAudio()
() =>
this.keepAwake.hasPlayingVideo() ||
hasPlayingAudio() ||
this.externalSessions.isBusy()
);
// The Stalker step is loaded as soon as a Stalker route opens, so a
// relock there can run it synchronously instead of awaiting a chunk.
Expand Down Expand Up @@ -94,6 +101,7 @@ export class ParentalLockEnforcementService {
if (this.parentalLock.active()) {
this.failClosedNow();
}
this.externalSessions.onLockChange();
this.scheduleApply();
});
});
Expand Down
Loading
Loading