Files
iptvnator/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts
T
4grayandClaude Opus 5 0334296f15 fix(electron-backend): make test suite and lint host-agnostic on Windows checkouts (#1176)
* fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts

Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures
and 5 Windows-only lint errors in electron-backend while Linux CI was
green:

- embedded-mpv-native-source.spec: normalize CRLF after readFileSync so
  multi-line source assertions match on autocrlf checkouts
- worker-runtime-paths.spec: build expected candidate paths with
  path.join instead of hardcoded POSIX strings
- external-player-launch-context: join darwin-only paths (.app bundle
  executables, Homebrew Caskroom) with path.posix.join so simulated
  darwin platforms resolve correctly on win32 hosts (no-op on macOS)
- app.spec: build packaged-navigation fixtures with path.resolve +
  pathToFileURL; file:///tmp/... is not a valid win32 file URL
- lint target: quote the eslint glob. The unquoted ** was expanded by
  the POSIX shell on Linux (shallow match), so CI linted only a subset
  of files while Windows passed the literal pattern to ESLint and
  linted the full tree - the hosts checked different file sets
- fix the 5 errors full-tree linting surfaces: prefer-const in
  epg-worker.service and database.worker-connection, no-unsafe-finally
  in external-player-session-registry and embedded-mpv-native.service
  (rewritten as catch-swallow with identical semantics, pinned by new
  regression tests), intentional no-control-regex in the recording
  filename sanitizer; drop stale unused disable directives
- document the quoted-glob convention in CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: mirror the quoted-lint-glob convention into AGENTS.md

AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from
CLAUDE.md, and it requires coding conventions to stay in sync between the two
files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping
from AGENTS.md could reintroduce a host-dependent lint target.

Also corrects the wording in both copies: the shallow expansion happens on
macOS as well as Linux — /bin/sh has no globstar on either — and the target
still exits 0 with a broken glob, which is why this went unnoticed. Adds the
file-count check that catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 23:05:22 +02:00

1198 lines
41 KiB
TypeScript

import { app, dialog, powerSaveBlocker, screen } from 'electron';
import { spawnSync } from 'child_process';
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
unlinkSync,
} from 'fs';
import { createRequire } from 'module';
import path from 'path';
import App from '../app';
import {
EmbeddedMpvAudioTrack,
EmbeddedMpvBounds,
EmbeddedMpvCapabilities,
EmbeddedMpvRecordingStartOptions,
EmbeddedMpvRecordingState,
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
EmbeddedMpvSubtitleTrack,
EmbeddedMpvEngine,
EmbeddedMpvFrameSource,
EmbeddedMpvSupport,
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getFrameCopyRuntimeAvailability,
getEmbeddedMpvAddonCandidatePaths,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
} from './embedded-mpv-frame-copy-platform.util';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
import {
EMBEDDED_MPV_EXPERIMENT_ENV,
isEmbeddedMpvFeatureEnabled,
} from './embedded-mpv-runtime-policy.util';
export interface NativeEmbeddedMpvSessionSnapshot {
status: EmbeddedMpvSessionStatus;
positionSeconds: number;
durationSeconds: number | null;
volume: number;
streamUrl: string;
audioTracks?: EmbeddedMpvAudioTrack[];
selectedAudioTrackId?: number | null;
subtitleTracks?: EmbeddedMpvSubtitleTrack[];
selectedSubtitleTrackId?: number | null;
playbackSpeed?: number;
aspectOverride?: string;
videoWidth?: number;
videoHeight?: number;
recording?: EmbeddedMpvRecordingState;
error?: string;
}
export interface NativeEmbeddedMpvAddon {
isSupported(): boolean;
createSession(
windowHandle: Buffer,
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
): string;
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void;
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
setPaused(sessionId: string, paused: boolean): void;
seek(sessionId: string, seconds: number): void;
setVolume(sessionId: string, volume: number): void;
setAudioTrack(sessionId: string, trackId: number): void;
setSubtitleTrack?(sessionId: string, trackId: number): void;
setSpeed?(sessionId: string, speed: number): void;
setAspect?(sessionId: string, aspect: string): void;
startRecording?(sessionId: string, targetPath: string): void;
stopRecording?(sessionId: string): void;
getSessionSnapshot(
sessionId: string
): NativeEmbeddedMpvSessionSnapshot | null;
disposeSession(sessionId: string): void;
}
interface EmbeddedMpvRuntimeSession {
id: string;
title: string;
streamUrl: string;
startedAt: string;
updatedAt: string;
lastPayloadKey: string;
lastStatus: EmbeddedMpvSessionStatus | null;
}
const EMBEDDED_MPV_FRAME_COPY_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
const SUPPORTED_EMBEDDED_MPV_PLATFORMS = new Set<NodeJS.Platform>([
'darwin',
'win32',
'linux',
]);
export class EmbeddedMpvNativeService {
private addon: NativeEmbeddedMpvAddon | null = null;
private addonLoadError: Error | null = null;
private readonly sessions = new Map<string, EmbeddedMpvRuntimeSession>();
private pollingTimer: NodeJS.Timeout | null = null;
private readonly pollFailuresLogged = new Set<string>();
private powerBlockerId: number | null = null;
private readonly loadAddonModule = createRequire(__filename);
private cachedLinuxMpvExecutableReason: string | null | undefined;
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
/**
* Frame-copy engine: helper process + shm ring + renderer canvas.
* Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux
* x64 and Windows, opted into with
* IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the regular
* embedded MPV experiment flag.
*/
private isFrameCopyEngineRequested(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(process.env[EMBEDDED_MPV_FRAME_COPY_ENV] ?? '')
.trim()
.toLowerCase()
);
}
private isFrameCopyEngineActive(): boolean {
// Requires the helper binary too: a stale opt-in (cleaned native
// build, bad install) must fall back to the native engine instead
// of leaving embedded MPV unsupported with no way to recover.
return this.isFrameCopyEngineRequested() && isFrameCopyRuntimeUsable();
}
getActiveEngine(): EmbeddedMpvEngine {
return this.isFrameCopyEngineActive() ? 'frame-copy' : 'native';
}
isFrameCopyAvailable(): boolean {
return isFrameCopyRuntimeUsable();
}
private getFrameCopySupportDetails(): Pick<
EmbeddedMpvSupport,
'frameCopyAvailable' | 'frameCopyUnavailableReason'
> {
const availability = getFrameCopyRuntimeAvailability();
if (!('reason' in availability)) {
return { frameCopyAvailable: true };
}
return {
frameCopyAvailable: false,
frameCopyUnavailableReason: availability.reason,
};
}
private resolveFrameCopyRuntimeMode(): EmbeddedMpvFrameCopyRuntimeMode | null {
if (process.platform !== 'linux') {
return null;
}
const availability = getFrameCopyRuntimeAvailability();
return availability.usable && 'runtimeMode' in availability
? availability.runtimeMode
: null;
}
getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null {
return this.frameCopyAdapter?.getFrameSource(sessionId) ?? null;
}
private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter {
if (!this.frameCopyAdapter) {
this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({
resolveHelperPath: resolveFrameCopyHelperPath,
resolveRuntimeMode: () => this.resolveFrameCopyRuntimeMode(),
getScaleFactor: () => this.getMainWindowScaleFactor(),
onFrameSourceChanged: (sessionId, source) => {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return;
}
App.mainWindow.webContents.send(
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
{ sessionId, source }
);
},
});
}
return this.frameCopyAdapter;
}
private getMainWindowScaleFactor(): number {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return 1;
}
return screen.getDisplayMatching(App.mainWindow.getBounds())
.scaleFactor;
} catch {
return 1;
}
}
private getMainWindowZoomFactor(): number {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return 1;
}
return App.mainWindow.webContents.getZoomFactor();
} catch {
return 1;
}
}
/**
* Renderer bounds arrive in CSS pixels; the native-view engines position
* OS windows in physical pixels (win32/linux) or points (macOS), so at
* page zoom or display scale ≠ 100% the raw values land the video toward
* the window's top-left corner at a fraction of its size (#1145). The
* frame-copy engine must bypass this: it paints into a DOM canvas laid
* out in CSS pixels, and its adapter already applies the display scale
* to the render size itself.
*/
private scaleBoundsForNativeView(
bounds: EmbeddedMpvBounds
): EmbeddedMpvBounds {
return toNativeViewBounds(bounds, {
platform: process.platform,
zoomFactor: this.getMainWindowZoomFactor(),
displayScaleFactor: this.getMainWindowScaleFactor(),
});
}
private detectCapabilities(): EmbeddedMpvCapabilities {
if (this.isFrameCopyEngineActive()) {
return {
subtitles: true,
playbackSpeed: true,
aspectOverride: true,
screenshot: false,
recording: true,
};
}
const addon = this.addon;
return {
subtitles: typeof addon?.setSubtitleTrack === 'function',
playbackSpeed: typeof addon?.setSpeed === 'function',
aspectOverride: typeof addon?.setAspect === 'function',
screenshot: false,
recording:
typeof addon?.startRecording === 'function' &&
typeof addon?.stopRecording === 'function',
};
}
getSupport(): EmbeddedMpvSupport {
if (!SUPPORTED_EMBEDDED_MPV_PLATFORMS.has(process.platform)) {
return {
supported: false,
platform: process.platform,
reason: 'Embedded MPV is currently available on macOS, Windows, and Linux only.',
};
}
// The frame-copy engine renders offscreen (headless EGL on Linux,
// WGL on Windows) into a renderer canvas: the Linux X11/Xwayland and
// system-mpv requirements below only bind the native --wid engine. Both
// native-engine failure returns still advertise frameCopyAvailable
// so the Settings toggle stays reachable — otherwise the states the
// frame-copy engine exists to fix would hide the way to enable it.
if (
this.isUnsupportedLinuxDisplayServer() &&
!this.isFrameCopyEngineActive()
) {
return {
supported: false,
platform: process.platform,
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
...this.getFrameCopySupportDetails(),
};
}
if (!isEmbeddedMpvFeatureEnabled()) {
return {
supported: false,
platform: process.platform,
reason: `Embedded MPV is an experimental desktop player. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable it for local development builds, or use a packaged build with the bundled runtime.`,
};
}
// A requested-but-unavailable frame-copy engine (wrong platform or
// missing helper) intentionally falls through to the native path so
// embedded MPV keeps working and Settings can clear the opt-in.
if (this.isFrameCopyEngineActive()) {
return {
supported: true,
platform: process.platform,
engine: 'frame-copy',
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
}
const missingLinuxMpvExecutableReason =
this.getMissingLinuxMpvExecutableReason();
if (missingLinuxMpvExecutableReason) {
return {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
...this.getFrameCopySupportDetails(),
};
}
if (this.addon) {
try {
if (!this.addon.isSupported()) {
return {
supported: false,
platform: process.platform,
reason: 'The native embedded MPV addon reported itself as unsupported.',
};
}
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
return {
supported: false,
platform: process.platform,
reason:
error instanceof Error ? error.message : String(error),
};
}
}
if (this.addonLoadError) {
return {
supported: false,
platform: process.platform,
reason: this.addonLoadError.message,
};
}
const candidatePaths = this.getAddonCandidatePaths();
const existingCandidatePath = candidatePaths.find((candidatePath) =>
existsSync(candidatePath)
);
if (!existingCandidatePath) {
return {
supported: false,
platform: process.platform,
reason:
this.readUnavailableReason(candidatePaths) ??
[
'Unable to locate the embedded MPV native addon.',
...candidatePaths.map(
(candidatePath) => `- ${candidatePath}`
),
].join('\n'),
};
}
const missingRuntimeReason = this.getMissingRuntimeReason(
existingCandidatePath
);
if (missingRuntimeReason) {
return {
supported: false,
platform: process.platform,
reason: missingRuntimeReason,
};
}
try {
const addon = this.getAddon();
if (!addon.isSupported()) {
return {
supported: false,
platform: process.platform,
reason: 'The native embedded MPV addon reported itself as unsupported.',
};
}
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
return {
supported: false,
platform: process.platform,
reason: error instanceof Error ? error.message : String(error),
};
}
}
prepareAddon(): EmbeddedMpvSupport {
const support = this.getSupport();
if (!support.supported) {
return support;
}
try {
const addon = this.getAddon();
if (!addon.isSupported()) {
return {
supported: false,
platform: process.platform,
reason: 'The native embedded MPV addon reported itself as unsupported.',
};
}
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
return {
supported: false,
platform: process.platform,
reason: error instanceof Error ? error.message : String(error),
};
}
}
createSession(
bounds: EmbeddedMpvBounds,
title = '',
initialVolume = 1
): EmbeddedMpvSession {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
// The frame-copy adapter ignores the native window handle (frames go
// through shm to a DOM canvas), so skip resolving it — under native
// Wayland the handle assertion would reject an engine that does not
// embed into the window at all. Derive the skip from the dispatched
// addon rather than re-evaluating the engine gate, so the two
// decisions cannot disagree.
const usesFrameCopyAddon =
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
const windowHandle = usesFrameCopyAddon
? Buffer.alloc(0)
: this.getMainWindowHandle();
const startedAt = new Date().toISOString();
const sessionId = addon.createSession(
windowHandle,
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds),
title,
initialVolume
);
this.sessions.set(sessionId, {
id: sessionId,
title,
streamUrl: '',
startedAt,
updatedAt: startedAt,
lastPayloadKey: '',
lastStatus: null,
});
this.ensurePolling();
this.ensureRendererLifecycleWatch();
return (
this.refreshSession(sessionId) ?? {
id: sessionId,
title,
streamUrl: '',
status: 'idle',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt,
updatedAt: startedAt,
}
);
}
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
const session = this.getRuntimeSession(sessionId);
session.title = playback.title ?? session.title;
session.streamUrl = playback.streamUrl ?? session.streamUrl;
session.updatedAt = new Date().toISOString();
addon.loadPlayback(sessionId, playback);
this.refreshSession(sessionId);
}
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
const usesFrameCopyAddon =
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
addon.setBounds(
sessionId,
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds)
);
}
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setPaused(sessionId, paused);
return this.refreshSession(sessionId);
}
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().seek(sessionId, seconds);
return this.refreshSession(sessionId);
}
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setVolume(sessionId, volume);
return this.refreshSession(sessionId);
}
setAudioTrack(
sessionId: string,
trackId: number
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setAudioTrack(sessionId, trackId);
return this.refreshSession(sessionId);
}
setSubtitleTrack(
sessionId: string,
trackId: number
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSubtitleTrack !== 'function') {
throw new Error(
'Embedded MPV addon does not support subtitle tracks. Rebuild the native addon to enable this feature.'
);
}
addon.setSubtitleTrack(sessionId, trackId);
return this.refreshSession(sessionId);
}
setSpeed(sessionId: string, speed: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSpeed !== 'function') {
throw new Error(
'Embedded MPV addon does not support playback speed. Rebuild the native addon to enable this feature.'
);
}
addon.setSpeed(sessionId, speed);
return this.refreshSession(sessionId);
}
setAspect(sessionId: string, aspect: string): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setAspect !== 'function') {
throw new Error(
'Embedded MPV addon does not support aspect override. Rebuild the native addon to enable this feature.'
);
}
addon.setAspect(sessionId, aspect);
return this.refreshSession(sessionId);
}
startRecording(
sessionId: string,
options: EmbeddedMpvRecordingStartOptions = {}
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (
typeof addon.startRecording !== 'function' ||
typeof addon.stopRecording !== 'function'
) {
throw new Error(
'Embedded MPV addon does not support stream recording. Rebuild the native addon to enable this feature.'
);
}
const session = this.getRuntimeSession(sessionId);
const directory =
options.directory?.trim() || this.getDefaultRecordingFolder();
mkdirSync(directory, { recursive: true });
const targetPath = this.reserveRecordingTargetPath(
directory,
options.title || session.title || 'IPTVnator recording'
);
try {
addon.startRecording(sessionId, targetPath);
} catch (error) {
this.releaseReservedRecordingTargetPath(targetPath);
throw error;
}
return this.refreshSession(sessionId);
}
stopRecording(sessionId: string): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.stopRecording !== 'function') {
throw new Error(
'Embedded MPV addon does not support stream recording. Rebuild the native addon to enable this feature.'
);
}
addon.stopRecording(sessionId);
return this.refreshSession(sessionId);
}
getDefaultRecordingFolder(): string {
return app.getPath('downloads');
}
async selectRecordingFolder(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: 'Select Recording Folder',
defaultPath: this.getDefaultRecordingFolder(),
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
}
disposeSession(sessionId: string): EmbeddedMpvSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
let lastRecording: EmbeddedMpvRecordingState | undefined;
try {
const addon = this.getAddon();
try {
lastRecording = addon.getSessionSnapshot(sessionId)?.recording;
} catch {
lastRecording = undefined;
}
addon.disposeSession(sessionId);
} catch {
// Native dispose failures must not block registry cleanup or the
// closed-session broadcast below.
}
this.sessions.delete(sessionId);
this.pollFailuresLogged.delete(sessionId);
const payload: EmbeddedMpvSession = {
id: session.id,
title: session.title,
streamUrl: session.streamUrl,
status: 'closed',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: this.createClosedRecordingState(lastRecording),
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
};
this.sendSessionUpdate(payload);
this.stopPollingIfIdle();
this.updatePowerBlocker();
return payload;
}
shutdown(): void {
const sessionIds = [...this.sessions.keys()];
sessionIds.forEach((sessionId) => this.disposeSession(sessionId));
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
this.pollingTimer = null;
}
this.updatePowerBlocker();
}
private ensurePolling(): void {
if (this.pollingTimer) {
return;
}
this.pollingTimer = setInterval(() => {
[...this.sessions.keys()].forEach((sessionId) => {
// An addon-side throw must not escape the interval callback:
// it would surface as an uncaughtException in the main
// process every 500 ms while the timer keeps running. Log
// each session's first failure only — tracked per session so
// a healthy session in the same tick cannot reset the
// suppression for a failing one.
try {
this.refreshSession(sessionId);
this.pollFailuresLogged.delete(sessionId);
} catch (error) {
if (!this.pollFailuresLogged.has(sessionId)) {
this.pollFailuresLogged.add(sessionId);
console.error(
`[Embedded MPV] Polling session "${sessionId}" failed:`,
error
);
}
}
});
}, 500);
}
/**
* Sessions are torn down by the renderer's Angular lifecycle, which
* never runs when the renderer crashes or hard-reloads (dev-server HMR,
* Cmd+R). Without this watch, native mpv handles or frame-copy helper
* processes leak until app shutdown.
*/
private rendererWatchInstalled = false;
private ensureRendererLifecycleWatch(): void {
if (
this.rendererWatchInstalled ||
!App.mainWindow ||
App.mainWindow.isDestroyed()
) {
return;
}
this.rendererWatchInstalled = true;
const disposeAll = (reason: string) => {
if (this.sessions.size === 0) {
return;
}
console.warn(
`[Embedded MPV] Disposing ${this.sessions.size} session(s): renderer ${reason}`
);
[...this.sessions.keys()].forEach((sessionId) => {
try {
this.disposeSession(sessionId);
} catch {
// best-effort reaping; polling cleanup handles the rest
}
});
};
App.mainWindow.webContents.on(
'render-process-gone',
(_event, details) => disposeAll(`process gone (${details.reason})`)
);
// Full navigations/reloads only — in-app Angular routing emits
// did-navigate-in-page and must not kill the active session.
App.mainWindow.webContents.on('did-navigate', () =>
disposeAll('reloaded')
);
}
private stopPollingIfIdle(): void {
if (this.sessions.size > 0 || !this.pollingTimer) {
return;
}
clearInterval(this.pollingTimer);
this.pollingTimer = null;
}
private refreshSession(sessionId: string): EmbeddedMpvSession | null {
const addon = this.getAddon();
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
const snapshot = addon.getSessionSnapshot(sessionId);
if (!snapshot) {
return null;
}
const payload: EmbeddedMpvSession = {
id: session.id,
title: session.title,
streamUrl: snapshot.streamUrl || session.streamUrl,
status: snapshot.status,
positionSeconds: Math.max(0, Math.floor(snapshot.positionSeconds)),
durationSeconds:
typeof snapshot.durationSeconds === 'number'
? Math.max(0, Math.floor(snapshot.durationSeconds))
: null,
volume: typeof snapshot.volume === 'number' ? snapshot.volume : 1,
audioTracks: Array.isArray(snapshot.audioTracks)
? snapshot.audioTracks
: [],
selectedAudioTrackId:
typeof snapshot.selectedAudioTrackId === 'number'
? snapshot.selectedAudioTrackId
: null,
subtitleTracks: Array.isArray(snapshot.subtitleTracks)
? snapshot.subtitleTracks
: [],
selectedSubtitleTrackId:
typeof snapshot.selectedSubtitleTrackId === 'number'
? snapshot.selectedSubtitleTrackId
: null,
playbackSpeed:
typeof snapshot.playbackSpeed === 'number'
? snapshot.playbackSpeed
: 1,
aspectOverride:
typeof snapshot.aspectOverride === 'string'
? snapshot.aspectOverride
: 'no',
...(typeof snapshot.videoWidth === 'number' &&
typeof snapshot.videoHeight === 'number'
? {
videoWidth: snapshot.videoWidth,
videoHeight: snapshot.videoHeight,
}
: {}),
recording: snapshot.recording ?? { active: false },
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
...(snapshot.error ? { error: snapshot.error } : {}),
};
session.streamUrl = payload.streamUrl;
session.updatedAt = payload.updatedAt;
session.lastStatus = payload.status;
// The refresh timestamp must not participate in the diff key,
// otherwise every poll tick looks like a change and the renderer
// receives an IPC update every 500 ms even while paused.
const nextPayloadKey = JSON.stringify({ ...payload, updatedAt: '' });
if (session.lastPayloadKey !== nextPayloadKey) {
session.lastPayloadKey = nextPayloadKey;
this.sendSessionUpdate(payload);
}
this.updatePowerBlocker();
return payload;
}
private hasPlayingSession(): boolean {
for (const session of this.sessions.values()) {
if (session.lastStatus === 'playing') {
return true;
}
}
return false;
}
private updatePowerBlocker(): void {
const shouldBlock = this.hasPlayingSession();
if (shouldBlock && this.powerBlockerId === null) {
try {
this.powerBlockerId = powerSaveBlocker.start(
'prevent-display-sleep'
);
} catch {
this.powerBlockerId = null;
}
return;
}
if (!shouldBlock && this.powerBlockerId !== null) {
const blockerId = this.powerBlockerId;
this.powerBlockerId = null;
try {
if (powerSaveBlocker.isStarted(blockerId)) {
powerSaveBlocker.stop(blockerId);
}
} catch {
// ignore — assertion will be cleaned up when the process exits
}
}
}
private sendSessionUpdate(session: EmbeddedMpvSession): void {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return;
}
App.mainWindow.webContents.send(EMBEDDED_MPV_SESSION_UPDATE, session);
}
private getRuntimeSession(sessionId: string): EmbeddedMpvRuntimeSession {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(
`Embedded MPV session "${sessionId}" was not found.`
);
}
return session;
}
private getMainWindowHandle(): Buffer {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
throw new Error('The Electron main window is not available.');
}
const windowHandle = App.mainWindow.getNativeWindowHandle();
if (this.isInvalidLinuxWaylandWindowHandle(windowHandle)) {
throw new Error(
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland. Native Wayland embedding is not supported yet. Start IPTVnator with --ozone-platform=x11.'
);
}
return windowHandle;
}
private reserveRecordingTargetPath(
directory: string,
title: string
): string {
const baseName = this.sanitizeRecordingFileName(title);
const timestamp = this.formatRecordingTimestamp(new Date());
let candidate = path.join(directory, `${baseName}-${timestamp}.ts`);
let suffix = 2;
while (true) {
try {
const fd = openSync(candidate, 'wx');
closeSync(fd);
return candidate;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
candidate = path.join(
directory,
`${baseName}-${timestamp}-${suffix}.ts`
);
suffix += 1;
continue;
}
throw error;
}
}
}
private releaseReservedRecordingTargetPath(targetPath: string): void {
try {
unlinkSync(targetPath);
} catch {
// Ignore cleanup failures; the start error is more useful.
}
}
private createClosedRecordingState(
recording?: EmbeddedMpvRecordingState
): EmbeddedMpvRecordingState {
return {
active: false,
...(recording?.targetPath
? { targetPath: recording.targetPath }
: {}),
...(recording?.error ? { error: recording.error } : {}),
};
}
private sanitizeRecordingFileName(title: string): string {
const normalized = title
// Control characters are invalid in file names on Windows; the
// range is matched intentionally.
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
.replace(/\s+/g, ' ')
.trim();
return (normalized || 'IPTVnator recording').slice(0, 120);
}
private formatRecordingTimestamp(date: Date): string {
const parts = [
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
].map((part) => String(part).padStart(2, '0'));
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
}
private assertEmbeddedMpvEnabled(): void {
if (!isEmbeddedMpvFeatureEnabled()) {
throw new Error(
`Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local desktop harness, or use a packaged build with the bundled runtime.`
);
}
}
private isUnsupportedLinuxDisplayServer(): boolean {
if (process.platform !== 'linux' || !process.env.WAYLAND_DISPLAY) {
return false;
}
return !process.env.DISPLAY || !this.isLinuxX11OzoneRequested();
}
private isLinuxX11OzoneRequested(): boolean {
return (
app.commandLine.getSwitchValue('ozone-platform').toLowerCase() ===
'x11'
);
}
private getMissingLinuxMpvExecutableReason(): string | null {
if (process.platform !== 'linux') {
return null;
}
if (this.cachedLinuxMpvExecutableReason !== undefined) {
return this.cachedLinuxMpvExecutableReason;
}
const result = spawnSync('mpv', ['--version'], {
stdio: 'ignore',
timeout: 3000,
});
this.cachedLinuxMpvExecutableReason =
result.status === 0 ? null : this.getLinuxMpvMissingMessage();
return this.cachedLinuxMpvExecutableReason;
}
private getLinuxMpvMissingMessage(): string {
// Flatpak/Snap sandboxes cannot see a host-installed mpv, so telling
// the user to install it would send them down a dead end.
if (process.env.FLATPAK_ID || process.env.SNAP) {
return 'Embedded MPV is not available in sandboxed Flatpak/Snap packages because they cannot access a system mpv executable. Use the built-in player, or install the .deb/.rpm/AppImage package to enable Embedded MPV.';
}
return 'Embedded MPV on Linux requires the mpv executable on PATH. Install the mpv package for your distribution and restart IPTVnator.';
}
private isInvalidLinuxWaylandWindowHandle(windowHandle: Buffer): boolean {
if (
process.platform !== 'linux' ||
!process.env.WAYLAND_DISPLAY ||
!process.env.DISPLAY
) {
return false;
}
if (windowHandle.length === 0) {
return false;
}
const windowHandleValue =
windowHandle.length >= 4
? windowHandle.readUInt32LE(0)
: windowHandle.readUIntLE(0, windowHandle.length);
return windowHandleValue <= 1;
}
private getAddon(): NativeEmbeddedMpvAddon {
if (this.isFrameCopyEngineActive()) {
return this.getFrameCopyAdapter();
}
if (this.addon) {
return this.addon;
}
if (this.addonLoadError) {
throw this.addonLoadError;
}
const candidatePaths = this.getAddonCandidatePaths();
const existingCandidatePaths = candidatePaths.filter((candidatePath) =>
existsSync(candidatePath)
);
if (existingCandidatePaths.length === 0) {
this.addonLoadError = new Error(
[
'Unable to locate the embedded MPV native addon.',
...candidatePaths.map(
(candidatePath) => `- ${candidatePath}`
),
].join('\n')
);
throw this.addonLoadError;
}
const loadErrors: string[] = [];
for (const candidatePath of existingCandidatePaths) {
try {
this.addon = this.loadAddonModule(
candidatePath
) as NativeEmbeddedMpvAddon;
return this.addon;
} catch (error) {
loadErrors.push(
`${candidatePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
this.addonLoadError = new Error(
[
'Unable to load the embedded MPV native addon.',
...loadErrors.map((error) => `- ${error}`),
].join('\n')
);
throw this.addonLoadError;
}
private getAddonCandidatePaths(): string[] {
return getEmbeddedMpvAddonCandidatePaths();
}
private readUnavailableReason(candidatePaths: string[]): string | null {
for (const candidatePath of candidatePaths) {
const unavailablePath = path.join(
path.dirname(candidatePath),
'embedded-mpv-unavailable.txt'
);
if (existsSync(unavailablePath)) {
return readFileSync(unavailablePath, 'utf8').trim();
}
}
return null;
}
private getMissingRuntimeReason(addonPath: string): string | null {
const nativeDir = path.dirname(addonPath);
const runtimeCandidates = this.getRuntimeLibraryCandidates(nativeDir);
if (runtimeCandidates.length === 0) {
return null;
}
if (!runtimeCandidates.some((candidate) => existsSync(candidate))) {
return [
'Embedded MPV runtime is incomplete. Missing one of:',
...runtimeCandidates.map((candidate) => `- ${candidate}`),
].join('\n');
}
return null;
}
private getRuntimeLibraryCandidates(nativeDir: string): string[] {
switch (process.platform) {
case 'darwin':
return [
path.join(nativeDir, 'lib', 'libmpv.2.dylib'),
path.join(nativeDir, 'lib', 'libmpv.dylib'),
];
case 'win32':
return [
path.join(nativeDir, 'lib', 'mpv-2.dll'),
path.join(nativeDir, 'lib', 'libmpv-2.dll'),
path.join(nativeDir, 'mpv-2.dll'),
path.join(nativeDir, 'libmpv-2.dll'),
path.join(nativeDir, 'lib', 'mpv.dll'),
path.join(nativeDir, 'lib', 'libmpv.dll'),
path.join(nativeDir, 'mpv.dll'),
path.join(nativeDir, 'libmpv.dll'),
];
case 'linux':
return [];
default:
return [];
}
}
}
export const embeddedMpvNativeService = new EmbeddedMpvNativeService();