mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): clarify external player launch feedback (#1388)
This commit is contained in:
1 parent
8442747c37
commit
d73acd6bfc
101 files changed
+8819
-850
No files matched your search
@@ -0,0 +1,150 @@
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
ExternalPlayerProcessTeardownGate,
|
||||
terminateExternalPlayerProcess,
|
||||
waitForExternalPlayerProcessExit,
|
||||
} from './external-player-process';
|
||||
|
||||
function createMockChildProcess(): ChildProcess {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
killed: false,
|
||||
kill: jest.fn(() => true),
|
||||
signalCode: null,
|
||||
stderr: null,
|
||||
stdout: null,
|
||||
unref: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
describe('external player process teardown', () => {
|
||||
it('accepts close as confirmed termination after a spawn error', async () => {
|
||||
const child = createMockChildProcess();
|
||||
child.on('error', () => undefined);
|
||||
let settled = false;
|
||||
const exit = waitForExternalPlayerProcessExit(child).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
child.emit(
|
||||
'error',
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT' })
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
child.emit('close', -2, null);
|
||||
|
||||
await expect(exit).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat a process error as confirmed exit', async () => {
|
||||
const child = createMockChildProcess();
|
||||
child.on('error', () => undefined);
|
||||
let settled = false;
|
||||
const exit = waitForExternalPlayerProcessExit(child).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
child.emit('error', new Error('kill failed'));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(settled).toBe(false);
|
||||
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
child.emit('exit', 0);
|
||||
|
||||
await expect(exit).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('escalates teardown and resolves only after confirmed exit', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const child = createMockChildProcess();
|
||||
const teardown = terminateExternalPlayerProcess(child);
|
||||
|
||||
expect(child.kill).toHaveBeenNthCalledWith(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
|
||||
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
|
||||
|
||||
child.emit('close', null, 'SIGKILL');
|
||||
|
||||
await expect(teardown).resolves.toBeUndefined();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows a protocol quit grace period before forced termination', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const child = createMockChildProcess();
|
||||
const teardown = terminateExternalPlayerProcess(child, {
|
||||
sendTerminationSignal: false,
|
||||
});
|
||||
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
|
||||
child.emit('exit', null, 'SIGKILL');
|
||||
|
||||
await expect(teardown).resolves.toBeUndefined();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects after a bounded forced-teardown wait', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const child = createMockChildProcess();
|
||||
const teardown = terminateExternalPlayerProcess(child);
|
||||
const result = expect(teardown).rejects.toThrow(
|
||||
'External player process did not exit'
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await result;
|
||||
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks replacement launches until an unconfirmed child actually exits', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const child = createMockChildProcess();
|
||||
const gate = new ExternalPlayerProcessTeardownGate();
|
||||
const teardown = gate.terminate(child);
|
||||
const rejection = expect(teardown).rejects.toThrow(
|
||||
'External player process did not exit'
|
||||
);
|
||||
|
||||
expect(() => gate.assertLaunchAllowed()).toThrow(
|
||||
'previous external player is still shutting down'
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await rejection;
|
||||
|
||||
expect(() => gate.assertLaunchAllowed()).toThrow(
|
||||
'previous external player is still shutting down'
|
||||
);
|
||||
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
child.emit('exit', 0);
|
||||
|
||||
expect(() => gate.assertLaunchAllowed()).not.toThrow();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
const TERMINATION_GRACE_PERIOD_MS = 3_000;
|
||||
const FORCED_TERMINATION_WAIT_MS = 2_000;
|
||||
|
||||
interface TerminateExternalPlayerProcessOptions {
|
||||
sendTerminationSignal?: boolean;
|
||||
}
|
||||
|
||||
const EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR =
|
||||
'Cannot launch player because the previous external player is still shutting down';
|
||||
|
||||
/**
|
||||
* Serializes replacement launches against exact children whose exit has not
|
||||
* been confirmed. `ChildProcess.killed` only means a signal was sent, so it is
|
||||
* deliberately not used as a release condition.
|
||||
*/
|
||||
export class ExternalPlayerProcessTeardownGate {
|
||||
private readonly pending = new Map<ChildProcess, () => void>();
|
||||
|
||||
assertLaunchAllowed(): void {
|
||||
for (const child of this.pending.keys()) {
|
||||
if (hasExited(child)) {
|
||||
this.release(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pending.size > 0) {
|
||||
throw new Error(EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard replacement launches before a potentially slow protocol-level
|
||||
* quit command is dispatched. `terminate()` keeps using the same exact
|
||||
* child registration once that command completes or fails.
|
||||
*/
|
||||
beginTeardown(child: ChildProcess): void {
|
||||
this.track(child);
|
||||
}
|
||||
|
||||
async terminate(
|
||||
child: ChildProcess,
|
||||
options: TerminateExternalPlayerProcessOptions = {}
|
||||
): Promise<void> {
|
||||
this.track(child);
|
||||
try {
|
||||
await terminateExternalPlayerProcess(child, options);
|
||||
} finally {
|
||||
if (hasExited(child)) {
|
||||
this.release(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
terminateInBackground(child: ChildProcess): void {
|
||||
void this.terminate(child).catch(() => {
|
||||
// The child remains registered until an exact exit/close event.
|
||||
// A later launch therefore still fails closed.
|
||||
});
|
||||
}
|
||||
|
||||
private track(child: ChildProcess): void {
|
||||
if (hasExited(child) || this.pending.has(child)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const release = () => this.release(child);
|
||||
this.pending.set(child, release);
|
||||
child.once('exit', release);
|
||||
child.once('close', release);
|
||||
}
|
||||
|
||||
private release(child: ChildProcess): void {
|
||||
const release = this.pending.get(child);
|
||||
if (!release) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.off('exit', release);
|
||||
child.off('close', release);
|
||||
this.pending.delete(child);
|
||||
}
|
||||
}
|
||||
|
||||
export const externalPlayerProcessTeardownGate =
|
||||
new ExternalPlayerProcessTeardownGate();
|
||||
|
||||
/**
|
||||
* Resolves only after the exact child has stopped. A sent termination signal
|
||||
* is not itself proof that a replacement can be started safely.
|
||||
*/
|
||||
export function waitForExternalPlayerProcessExit(
|
||||
child: ChildProcess
|
||||
): Promise<void> {
|
||||
if (hasExited(child)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const complete = () => {
|
||||
child.off('exit', complete);
|
||||
child.off('close', complete);
|
||||
resolve();
|
||||
};
|
||||
child.once('exit', complete);
|
||||
child.once('close', complete);
|
||||
});
|
||||
}
|
||||
|
||||
export async function terminateExternalPlayerProcess(
|
||||
child: ChildProcess,
|
||||
options: TerminateExternalPlayerProcessOptions = {}
|
||||
): Promise<void> {
|
||||
if (hasExited(child)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
options.sendTerminationSignal !== false &&
|
||||
!hasExited(child) &&
|
||||
!child.killed
|
||||
) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// The forced termination attempt below still gets a chance to
|
||||
// confirm that the exact child stopped.
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
await waitForExternalPlayerProcessExitWithin(
|
||||
child,
|
||||
TERMINATION_GRACE_PERIOD_MS
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// Keep waiting for the bounded confirmation window. If the child does
|
||||
// not report exit, reject so callers cannot launch a replacement.
|
||||
}
|
||||
|
||||
if (
|
||||
await waitForExternalPlayerProcessExitWithin(
|
||||
child,
|
||||
FORCED_TERMINATION_WAIT_MS
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('External player process did not exit');
|
||||
}
|
||||
|
||||
function waitForExternalPlayerProcessExitWithin(
|
||||
child: ChildProcess,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
if (hasExited(child)) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const complete = (exited: boolean) => {
|
||||
child.off('exit', onExit);
|
||||
child.off('close', onExit);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
resolve(exited);
|
||||
};
|
||||
const onExit = () => complete(true);
|
||||
|
||||
child.once('exit', onExit);
|
||||
child.once('close', onExit);
|
||||
timer = setTimeout(() => complete(hasExited(child)), timeoutMs);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
|
||||
function hasExited(child: ChildProcess): boolean {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
@@ -47,7 +47,27 @@ describe('ExternalPlayerSessionRegistry', () => {
|
||||
expect(registry.getActiveSessionId()).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the session closed even when the runtime close fails', async () => {
|
||||
it('does not invoke a stale closer for an already closed session', async () => {
|
||||
const close = jest.fn();
|
||||
const session = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Closed',
|
||||
streamUrl: 'https://example.com/closed.m3u8',
|
||||
});
|
||||
registry.attachCloser(session.id, close);
|
||||
registry.markClosed(session.id);
|
||||
|
||||
const closed = await registry.closeSession(session.id);
|
||||
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(closed).toMatchObject({
|
||||
id: session.id,
|
||||
status: 'closed',
|
||||
canClose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the session live when runtime close cannot be confirmed', async () => {
|
||||
const close = jest.fn().mockRejectedValue(new Error('close failed'));
|
||||
const session = registry.beginSession({
|
||||
player: 'vlc',
|
||||
@@ -57,12 +77,14 @@ describe('ExternalPlayerSessionRegistry', () => {
|
||||
|
||||
registry.attachCloser(session.id, close);
|
||||
|
||||
const closed = await registry.closeSession(session.id);
|
||||
await expect(registry.closeSession(session.id)).rejects.toThrow(
|
||||
'close failed'
|
||||
);
|
||||
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(closed?.status).toBe('closed');
|
||||
expect(closed?.canClose).toBe(false);
|
||||
expect(registry.getActiveSessionId()).toBeNull();
|
||||
expect(registry.getSession(session.id)?.status).toBe('launching');
|
||||
expect(registry.getSession(session.id)?.canClose).toBe(true);
|
||||
expect(registry.getActiveSessionId()).toBe(session.id);
|
||||
});
|
||||
|
||||
it('marks runtime failures as errors without clearing the active id', () => {
|
||||
@@ -78,4 +100,121 @@ describe('ExternalPlayerSessionRegistry', () => {
|
||||
expect(errored?.error).toBe('Failed to launch');
|
||||
expect(registry.getActiveSessionId()).toBe(session.id);
|
||||
});
|
||||
|
||||
it('does not overwrite terminal status with late lifecycle updates', () => {
|
||||
const failed = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Failed',
|
||||
streamUrl: 'https://example.com/failed.m3u8',
|
||||
});
|
||||
registry.markError(failed.id, 'Failed to launch');
|
||||
|
||||
expect(registry.markOpened(failed.id)?.status).toBe('error');
|
||||
|
||||
const closed = registry.beginSession({
|
||||
player: 'vlc',
|
||||
title: 'Closed',
|
||||
streamUrl: 'https://example.com/closed.m3u8',
|
||||
});
|
||||
registry.markClosed(closed.id);
|
||||
|
||||
expect(registry.markPlaying(closed.id)?.status).toBe('closed');
|
||||
expect(
|
||||
registry.markError(closed.id, 'Late process exit')?.status
|
||||
).toBe('closed');
|
||||
expect(updates.at(-1)).toMatchObject({
|
||||
id: closed.id,
|
||||
status: 'closed',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps confirmed playback ahead of a late opened acknowledgement', () => {
|
||||
const session = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Fast playback',
|
||||
streamUrl: 'https://example.com/fast.m3u8',
|
||||
});
|
||||
|
||||
expect(registry.markPlaying(session.id)?.status).toBe('playing');
|
||||
expect(registry.markOpened(session.id)?.status).toBe('playing');
|
||||
});
|
||||
|
||||
it('restores a previous live session after a replacement fails', () => {
|
||||
const previous = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Previous',
|
||||
streamUrl: 'https://example.com/previous.m3u8',
|
||||
});
|
||||
registry.attachCloser(previous.id, jest.fn());
|
||||
registry.markOpened(previous.id);
|
||||
const replacement = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Replacement',
|
||||
streamUrl: 'https://example.com/replacement.m3u8',
|
||||
});
|
||||
registry.markError(replacement.id, 'teardown unconfirmed');
|
||||
|
||||
const restored = registry.restoreActiveSession(
|
||||
previous.id,
|
||||
replacement.id
|
||||
);
|
||||
|
||||
expect(restored).toMatchObject({
|
||||
id: previous.id,
|
||||
status: 'opened',
|
||||
canClose: true,
|
||||
});
|
||||
expect(registry.getActiveSessionId()).toBe(previous.id);
|
||||
expect(updates.at(-1)).toMatchObject({
|
||||
id: previous.id,
|
||||
restoredFromSessionId: replacement.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not restore over a newer active session', () => {
|
||||
const previous = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Previous',
|
||||
streamUrl: 'https://example.com/previous.m3u8',
|
||||
});
|
||||
registry.markOpened(previous.id);
|
||||
const failedReplacement = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Failed replacement',
|
||||
streamUrl: 'https://example.com/failed.m3u8',
|
||||
});
|
||||
const newer = registry.beginSession({
|
||||
player: 'vlc',
|
||||
title: 'Newer',
|
||||
streamUrl: 'https://example.com/newer.m3u8',
|
||||
});
|
||||
const updateCount = updates.length;
|
||||
|
||||
expect(
|
||||
registry.restoreActiveSession(previous.id, failedReplacement.id)
|
||||
).toBeNull();
|
||||
expect(registry.getActiveSessionId()).toBe(newer.id);
|
||||
expect(updates).toHaveLength(updateCount);
|
||||
});
|
||||
|
||||
it('does not restore an unclosable terminal error session', () => {
|
||||
const terminal = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Terminal failure',
|
||||
streamUrl: 'https://example.com/terminal.m3u8',
|
||||
});
|
||||
registry.markError(terminal.id, 'No player process remains');
|
||||
const replacement = registry.beginSession({
|
||||
player: 'vlc',
|
||||
title: 'Replacement',
|
||||
streamUrl: 'https://example.com/replacement.m3u8',
|
||||
});
|
||||
const updateCount = updates.length;
|
||||
|
||||
expect(
|
||||
registry.restoreActiveSession(terminal.id, replacement.id)
|
||||
).toBeNull();
|
||||
expect(registry.getActiveSessionId()).toBe(replacement.id);
|
||||
expect(updates).toHaveLength(updateCount);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,19 @@ interface ExternalPlayerSessionRuntime {
|
||||
close?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface MarkExternalPlayerSessionErrorOptions {
|
||||
canClose?: boolean;
|
||||
}
|
||||
|
||||
function isRestorableSession(session: ExternalPlayerSession): boolean {
|
||||
return (
|
||||
session.status === 'launching' ||
|
||||
session.status === 'opened' ||
|
||||
session.status === 'playing' ||
|
||||
(session.status === 'error' && session.canClose)
|
||||
);
|
||||
}
|
||||
|
||||
export class ExternalPlayerSessionRegistry {
|
||||
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
|
||||
private activeSessionId: string | null = null;
|
||||
@@ -62,6 +75,32 @@ export class ExternalPlayerSessionRegistry {
|
||||
return this.sessions.get(id)?.snapshot ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-publish the exact still-live session that a failed replacement had
|
||||
* temporarily displaced. Its existing closer remains attached.
|
||||
*/
|
||||
restoreActiveSession(
|
||||
id: string,
|
||||
displacedSessionId: string
|
||||
): ExternalPlayerSession | null {
|
||||
const runtime = this.sessions.get(id);
|
||||
if (
|
||||
!runtime ||
|
||||
!isRestorableSession(runtime.snapshot) ||
|
||||
this.activeSessionId !== displacedSessionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.activeSessionId = id;
|
||||
this.onUpdate({
|
||||
...runtime.snapshot,
|
||||
updatedAt: new Date().toISOString(),
|
||||
restoredFromSessionId: displacedSessionId,
|
||||
});
|
||||
return runtime.snapshot;
|
||||
}
|
||||
|
||||
attachCloser(
|
||||
id: string,
|
||||
close: () => Promise<void> | void
|
||||
@@ -95,12 +134,21 @@ export class ExternalPlayerSessionRegistry {
|
||||
}
|
||||
|
||||
markOpened(id: string): ExternalPlayerSession | null {
|
||||
const current = this.getSession(id);
|
||||
if (!current || current.status !== 'launching') {
|
||||
return current;
|
||||
}
|
||||
return this.updateSession(id, { status: 'opened' });
|
||||
}
|
||||
|
||||
markPlaying(id: string): ExternalPlayerSession | null {
|
||||
const current = this.getSession(id);
|
||||
if (!current || current.status === 'playing') {
|
||||
if (
|
||||
!current ||
|
||||
current.status === 'playing' ||
|
||||
current.status === 'error' ||
|
||||
current.status === 'closed'
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -108,17 +156,30 @@ export class ExternalPlayerSessionRegistry {
|
||||
}
|
||||
|
||||
markClosed(id: string): ExternalPlayerSession | null {
|
||||
const current = this.getSession(id);
|
||||
if (!current || current.status === 'closed') {
|
||||
return current;
|
||||
}
|
||||
if (this.activeSessionId === id) {
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
return this.updateSession(id, { status: 'closed', canClose: false });
|
||||
}
|
||||
|
||||
markError(id: string, error: string): ExternalPlayerSession | null {
|
||||
markError(
|
||||
id: string,
|
||||
error: string,
|
||||
options: MarkExternalPlayerSessionErrorOptions = {}
|
||||
): ExternalPlayerSession | null {
|
||||
const current = this.getSession(id);
|
||||
if (!current || current.status === 'closed') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return this.updateSession(id, {
|
||||
status: 'error',
|
||||
error,
|
||||
canClose: false,
|
||||
canClose: options.canClose ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,13 +189,19 @@ export class ExternalPlayerSessionRegistry {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await runtime.close?.();
|
||||
} catch {
|
||||
// Close failures must not keep the session in a live state; the
|
||||
// registry still reports it as closed below.
|
||||
// A renderer can deliver a delayed duplicate Stop after the exact
|
||||
// child has already exited and a newer external player owns the
|
||||
// process slot. Never re-enter the terminal session's saved closer:
|
||||
// its protocol endpoint may since have been reused by another child.
|
||||
if (runtime.snapshot.status === 'closed') {
|
||||
return runtime.snapshot;
|
||||
}
|
||||
|
||||
// A failed closer cannot prove that the underlying process stopped.
|
||||
// Preserve the live session and propagate the failure so callers do
|
||||
// not start a replacement process alongside it.
|
||||
await runtime.close?.();
|
||||
|
||||
return this.markClosed(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { createConnection } from 'net';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
|
||||
import { externalPlayerProcessTeardownGate } from './external-player-process';
|
||||
import {
|
||||
externalPlayerSessions,
|
||||
traceExternalPlayer,
|
||||
} from './external-player-runtime';
|
||||
|
||||
const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000;
|
||||
|
||||
export interface MpvReuseAttemptState {
|
||||
contentMutated: boolean;
|
||||
teardownUnconfirmed: boolean;
|
||||
}
|
||||
|
||||
interface MpvReuseOptions {
|
||||
session: ExternalPlayerSession;
|
||||
previousProcessSessionId: string | null;
|
||||
url: string;
|
||||
title: string;
|
||||
effectiveUserAgent?: string;
|
||||
effectiveReferer?: string;
|
||||
headerFields: string[];
|
||||
contentInfo?: PlayerContentInfo;
|
||||
startTime?: number;
|
||||
state: MpvReuseAttemptState;
|
||||
startPositionPolling: (
|
||||
socketPath: string,
|
||||
contentInfo: PlayerContentInfo,
|
||||
sessionId: string
|
||||
) => void;
|
||||
stopPositionPolling: () => void;
|
||||
}
|
||||
|
||||
function sendMpvCommand(
|
||||
socketPath: string,
|
||||
command: string,
|
||||
args: Array<string | number>,
|
||||
shouldDispatch?: () => boolean
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = createConnection(socketPath);
|
||||
const request = JSON.stringify({ command: [command, ...args] }) + '\n';
|
||||
let settled = false;
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
const complete = (error?: Error, dispatched = true) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (!dispatched && !client.destroyed) client.destroy();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(dispatched);
|
||||
}
|
||||
};
|
||||
|
||||
client.on('connect', () => {
|
||||
if (shouldDispatch && !shouldDispatch()) {
|
||||
complete(undefined, false);
|
||||
return;
|
||||
}
|
||||
traceExternalPlayer('mpv ipc command', {
|
||||
command,
|
||||
argsCount: args.length,
|
||||
});
|
||||
try {
|
||||
client.write(request);
|
||||
client.end();
|
||||
complete();
|
||||
} catch (error) {
|
||||
complete(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
});
|
||||
client.on('error', (error) => complete(error));
|
||||
timeoutHandle = setTimeout(() => {
|
||||
complete(new Error('MPV IPC command timed out'));
|
||||
client.destroy();
|
||||
}, MPV_IPC_COMMAND_TIMEOUT_MS);
|
||||
timeoutHandle.unref();
|
||||
});
|
||||
}
|
||||
|
||||
/** Owns the one MPV child/socket retained when instance reuse is enabled. */
|
||||
export class MpvReusableProcess {
|
||||
private process: ChildProcess | null = null;
|
||||
private socketPath: string | null = null;
|
||||
private processSessionId: string | null = null;
|
||||
private readonly processSessionIds = new WeakMap<ChildProcess, string>();
|
||||
|
||||
currentSessionId(): string | null {
|
||||
return this.processSessionId;
|
||||
}
|
||||
|
||||
sessionIdFor(process: ChildProcess, fallback: string): string {
|
||||
return this.processSessionIds.get(process) ?? fallback;
|
||||
}
|
||||
|
||||
owns(process: ChildProcess, sessionId: string): boolean {
|
||||
return this.processSessionIds.get(process) === sessionId;
|
||||
}
|
||||
|
||||
track(process: ChildProcess, socketPath: string, sessionId: string): void {
|
||||
this.process = process;
|
||||
this.socketPath = socketPath;
|
||||
this.processSessionId = sessionId;
|
||||
this.processSessionIds.set(process, sessionId);
|
||||
}
|
||||
|
||||
clear(process: ChildProcess): boolean {
|
||||
if (this.process !== process) return false;
|
||||
this.process = null;
|
||||
this.socketPath = null;
|
||||
this.processSessionId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
stopStored(
|
||||
reason: string,
|
||||
stopPositionPolling: () => void,
|
||||
guardFutureLaunches = false
|
||||
): void {
|
||||
const process = this.process;
|
||||
if (!process || process.killed) return;
|
||||
traceExternalPlayer(reason);
|
||||
if (guardFutureLaunches) {
|
||||
externalPlayerProcessTeardownGate.terminateInBackground(process);
|
||||
} else {
|
||||
process.kill();
|
||||
}
|
||||
this.clear(process);
|
||||
stopPositionPolling();
|
||||
}
|
||||
|
||||
async tryReuse(
|
||||
options: MpvReuseOptions
|
||||
): Promise<ExternalPlayerSession | null> {
|
||||
const reusedProcess = this.process;
|
||||
const reusedSocketPath = this.socketPath;
|
||||
if (!reusedProcess || reusedProcess.killed || !reusedSocketPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
traceExternalPlayer('reuse existing mpv instance');
|
||||
const { session, state } = options;
|
||||
const reusedProcessSessionId =
|
||||
this.processSessionIds.get(reusedProcess) ??
|
||||
options.previousProcessSessionId;
|
||||
let closeRequested = false;
|
||||
let retryableClose: Promise<void> | null = null;
|
||||
let launchClose: Promise<void> | null = null;
|
||||
|
||||
const ownsReusedProcess = () =>
|
||||
this.processSessionIds.get(reusedProcess) === session.id ||
|
||||
externalPlayerSessions.getActiveSessionId() === session.id;
|
||||
const closeReusedProcess = async () => {
|
||||
externalPlayerProcessTeardownGate.beginTeardown(reusedProcess);
|
||||
try {
|
||||
await sendMpvCommand(reusedSocketPath, 'quit', []);
|
||||
} catch {
|
||||
await externalPlayerProcessTeardownGate.terminate(
|
||||
reusedProcess
|
||||
);
|
||||
return;
|
||||
}
|
||||
await externalPlayerProcessTeardownGate.terminate(reusedProcess, {
|
||||
sendTerminationSignal: false,
|
||||
});
|
||||
};
|
||||
const finishRequestedClose = async () => {
|
||||
if (launchClose) {
|
||||
try {
|
||||
await launchClose;
|
||||
} catch (error) {
|
||||
state.teardownUnconfirmed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return externalPlayerSessions.markClosed(session.id) ?? session;
|
||||
};
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, () => {
|
||||
closeRequested = true;
|
||||
if (!ownsReusedProcess()) return;
|
||||
if (!retryableClose) {
|
||||
const closeAttempt = closeReusedProcess();
|
||||
retryableClose = closeAttempt;
|
||||
launchClose ??= closeAttempt;
|
||||
void closeAttempt.catch((error) => {
|
||||
if (retryableClose === closeAttempt) retryableClose = null;
|
||||
state.teardownUnconfirmed = true;
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{ canClose: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
return retryableClose;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.applyReuseCommands(
|
||||
options,
|
||||
reusedSocketPath,
|
||||
() => !closeRequested
|
||||
);
|
||||
if (closeRequested) return await finishRequestedClose();
|
||||
|
||||
state.contentMutated = true;
|
||||
this.processSessionId = session.id;
|
||||
this.processSessionIds.set(reusedProcess, session.id);
|
||||
options.stopPositionPolling();
|
||||
|
||||
if (options.startTime) {
|
||||
await sendMpvCommand(
|
||||
reusedSocketPath,
|
||||
'seek',
|
||||
[String(options.startTime), 'absolute'],
|
||||
() => !closeRequested
|
||||
);
|
||||
}
|
||||
if (closeRequested) return await finishRequestedClose();
|
||||
|
||||
if (options.contentInfo) {
|
||||
options.startPositionPolling(
|
||||
reusedSocketPath,
|
||||
options.contentInfo,
|
||||
session.id
|
||||
);
|
||||
} else {
|
||||
options.stopPositionPolling();
|
||||
}
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (error) {
|
||||
const current = externalPlayerSessions.getSession(session.id);
|
||||
if (current?.status === 'closed') return current;
|
||||
if (closeRequested) return await finishRequestedClose();
|
||||
console.error('Failed to send command to existing MPV:', error);
|
||||
|
||||
if (state.contentMutated) {
|
||||
if (reusedProcessSessionId) {
|
||||
this.processSessionIds.set(
|
||||
reusedProcess,
|
||||
reusedProcessSessionId
|
||||
);
|
||||
} else {
|
||||
this.processSessionIds.delete(reusedProcess);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await externalPlayerProcessTeardownGate.terminate(
|
||||
reusedProcess
|
||||
);
|
||||
} catch (teardownError) {
|
||||
if (state.contentMutated) {
|
||||
this.processSessionIds.set(reusedProcess, session.id);
|
||||
}
|
||||
state.teardownUnconfirmed = true;
|
||||
throw teardownError;
|
||||
}
|
||||
this.clear(reusedProcess);
|
||||
options.stopPositionPolling();
|
||||
if (closeRequested) return await finishRequestedClose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async applyReuseCommands(
|
||||
options: MpvReuseOptions,
|
||||
socketPath: string,
|
||||
shouldDispatch: () => boolean
|
||||
): Promise<void> {
|
||||
if (options.effectiveUserAgent) {
|
||||
const dispatched = await sendMpvCommand(
|
||||
socketPath,
|
||||
'set_property',
|
||||
['user-agent', options.effectiveUserAgent],
|
||||
shouldDispatch
|
||||
);
|
||||
if (!dispatched) return;
|
||||
}
|
||||
if (options.effectiveReferer) {
|
||||
const dispatched = await sendMpvCommand(
|
||||
socketPath,
|
||||
'set_property',
|
||||
['referrer', options.effectiveReferer],
|
||||
shouldDispatch
|
||||
);
|
||||
if (!dispatched) return;
|
||||
}
|
||||
if (options.headerFields.length > 0) {
|
||||
const dispatched = await sendMpvCommand(
|
||||
socketPath,
|
||||
'set_property',
|
||||
[
|
||||
'http-header-fields',
|
||||
joinMpvHeaderFields(options.headerFields),
|
||||
],
|
||||
shouldDispatch
|
||||
);
|
||||
if (!dispatched) return;
|
||||
}
|
||||
if (!shouldDispatch()) return;
|
||||
const loadFileArgs: Array<string | number> = [options.url, 'replace'];
|
||||
if (options.title) {
|
||||
loadFileArgs.push(-1, `force-media-title=${options.title}`);
|
||||
}
|
||||
const dispatched = await sendMpvCommand(
|
||||
socketPath,
|
||||
'loadfile',
|
||||
loadFileArgs,
|
||||
shouldDispatch
|
||||
);
|
||||
if (!dispatched) return;
|
||||
traceExternalPlayer('loaded new url in existing mpv instance');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { createConnection } from 'net';
|
||||
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
sendPlayerErrorNotification,
|
||||
traceExternalPlayer,
|
||||
} from './external-player-runtime';
|
||||
import { externalPlayerProcessTeardownGate } from './external-player-process';
|
||||
import {
|
||||
MpvReusableProcess,
|
||||
MpvReuseAttemptState,
|
||||
} from './mpv-reusable-process';
|
||||
|
||||
export interface OpenExternalPlayerRequest {
|
||||
url: string;
|
||||
@@ -42,8 +47,7 @@ export interface OpenExternalPlayerRequest {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
let mpvProcess: ChildProcess | null = null;
|
||||
let mpvSocketPath: string | null = null;
|
||||
const reusableMpvProcess = new MpvReusableProcess();
|
||||
let positionPollingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
function getMpvPath(options: PlayerPathOptions = {}): string {
|
||||
@@ -152,53 +156,16 @@ function startPositionPolling(
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function sendMpvCommand(
|
||||
command: string,
|
||||
args: Array<string | number>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!mpvSocketPath) {
|
||||
reject(new Error('No MPV socket path available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const client = createConnection(mpvSocketPath);
|
||||
const request = JSON.stringify({ command: [command, ...args] }) + '\n';
|
||||
|
||||
client.on('connect', () => {
|
||||
traceExternalPlayer('mpv ipc command', {
|
||||
command,
|
||||
argsCount: args.length,
|
||||
});
|
||||
client.write(request);
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error('MPV socket error:', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function killStoredMpvProcess(reason: string): void {
|
||||
if (!mpvProcess || mpvProcess.killed) {
|
||||
return;
|
||||
}
|
||||
traceExternalPlayer(reason);
|
||||
mpvProcess.kill();
|
||||
mpvProcess = null;
|
||||
mpvSocketPath = null;
|
||||
stopPositionPolling();
|
||||
}
|
||||
|
||||
export function setMpvReuseInstance(reuseInstance: boolean): void {
|
||||
traceExternalPlayer('set mpv reuse instance', { reuseInstance });
|
||||
store.set(MPV_REUSE_INSTANCE, reuseInstance);
|
||||
|
||||
if (!reuseInstance) {
|
||||
killStoredMpvProcess('clean up mpv process after disabling reuse');
|
||||
reusableMpvProcess.stopStored(
|
||||
'clean up mpv process after disabling reuse',
|
||||
stopPositionPolling,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +175,10 @@ export function setMpvReuseInstance(reuseInstance: boolean): void {
|
||||
* app and keeps playing after quit.
|
||||
*/
|
||||
export function shutdownMpvSession(): void {
|
||||
killStoredMpvProcess('kill reused mpv process on app shutdown');
|
||||
reusableMpvProcess.stopStored(
|
||||
'kill reused mpv process on app shutdown',
|
||||
stopPositionPolling
|
||||
);
|
||||
}
|
||||
|
||||
export async function openMpvPlayer({
|
||||
@@ -222,6 +192,9 @@ export async function openMpvPlayer({
|
||||
startTime,
|
||||
headers,
|
||||
}: OpenExternalPlayerRequest) {
|
||||
externalPlayerProcessTeardownGate.assertLaunchAllowed();
|
||||
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
|
||||
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
|
||||
const session = externalPlayerSessions.beginSession({
|
||||
player: 'mpv',
|
||||
title,
|
||||
@@ -229,6 +202,11 @@ export async function openMpvPlayer({
|
||||
streamUrl: url,
|
||||
contentInfo,
|
||||
});
|
||||
const reuseState: MpvReuseAttemptState = {
|
||||
teardownUnconfirmed: false,
|
||||
contentMutated: false,
|
||||
};
|
||||
let freshTeardownUnconfirmed = false;
|
||||
|
||||
try {
|
||||
const isFlatpak = isRunningInFlatpak();
|
||||
@@ -273,80 +251,22 @@ export async function openMpvPlayer({
|
||||
parseExternalPlayerArguments(customMpvArguments).length,
|
||||
});
|
||||
|
||||
if (
|
||||
reuseInstance &&
|
||||
mpvProcess &&
|
||||
!mpvProcess.killed &&
|
||||
mpvSocketPath
|
||||
) {
|
||||
traceExternalPlayer('reuse existing mpv instance');
|
||||
try {
|
||||
if (effectiveUserAgent) {
|
||||
await sendMpvCommand('set_property', [
|
||||
'user-agent',
|
||||
effectiveUserAgent,
|
||||
]);
|
||||
}
|
||||
if (effectiveReferer) {
|
||||
await sendMpvCommand('set_property', [
|
||||
'referrer',
|
||||
effectiveReferer,
|
||||
]);
|
||||
}
|
||||
if (headerFields.length > 0) {
|
||||
await sendMpvCommand('set_property', [
|
||||
'http-header-fields',
|
||||
joinMpvHeaderFields(headerFields),
|
||||
]);
|
||||
}
|
||||
|
||||
const loadFileArgs: Array<string | number> = [url, 'replace'];
|
||||
const loadFileOptions: string[] = [];
|
||||
|
||||
if (title) {
|
||||
loadFileOptions.push(`force-media-title=${title}`);
|
||||
}
|
||||
if (loadFileOptions.length > 0) {
|
||||
loadFileArgs.push(-1, loadFileOptions.join(','));
|
||||
}
|
||||
|
||||
await sendMpvCommand('loadfile', loadFileArgs);
|
||||
traceExternalPlayer('loaded new url in existing mpv instance');
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, async () => {
|
||||
try {
|
||||
await sendMpvCommand('quit', []);
|
||||
} catch {
|
||||
if (mpvProcess && !mpvProcess.killed) {
|
||||
mpvProcess.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (startTime) {
|
||||
await sendMpvCommand('seek', [
|
||||
String(startTime),
|
||||
'absolute',
|
||||
]);
|
||||
}
|
||||
|
||||
if (contentInfo) {
|
||||
startPositionPolling(
|
||||
mpvSocketPath,
|
||||
contentInfo,
|
||||
session.id
|
||||
);
|
||||
} else {
|
||||
stopPositionPolling();
|
||||
}
|
||||
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (err) {
|
||||
console.error('Failed to send command to existing MPV:', err);
|
||||
mpvProcess = null;
|
||||
mpvSocketPath = null;
|
||||
stopPositionPolling();
|
||||
}
|
||||
if (reuseInstance) {
|
||||
const reused = await reusableMpvProcess.tryReuse({
|
||||
session,
|
||||
previousProcessSessionId,
|
||||
url,
|
||||
title,
|
||||
effectiveUserAgent,
|
||||
effectiveReferer,
|
||||
headerFields,
|
||||
contentInfo,
|
||||
startTime,
|
||||
state: reuseState,
|
||||
startPositionPolling,
|
||||
stopPositionPolling,
|
||||
});
|
||||
if (reused) return reused;
|
||||
}
|
||||
|
||||
traceExternalPlayer('create new mpv instance');
|
||||
@@ -373,7 +293,9 @@ export async function openMpvPlayer({
|
||||
}
|
||||
|
||||
if (headerFields.length > 0) {
|
||||
args.push(`--http-header-fields=${joinMpvHeaderFields(headerFields)}`);
|
||||
args.push(
|
||||
`--http-header-fields=${joinMpvHeaderFields(headerFields)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (title) {
|
||||
@@ -387,10 +309,36 @@ export async function openMpvPlayer({
|
||||
args.push(url);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let startConfirmationTimer: NodeJS.Timeout | null = null;
|
||||
const resolveLaunch = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (startConfirmationTimer) {
|
||||
clearTimeout(startConfirmationTimer);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
const rejectLaunch = (error: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (startConfirmationTimer) {
|
||||
clearTimeout(startConfirmationTimer);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
const spawnSpec = buildExternalPlayerSpawnSpec(
|
||||
mpvLaunchContext,
|
||||
buildPlayerArgsWithCustomArguments(customMpvArguments, args)
|
||||
);
|
||||
// Reuse teardown yields while waiting for the old child. Another
|
||||
// external process may enter teardown during that window, so the
|
||||
// process-wide invariant must be checked at the actual spawn too.
|
||||
externalPlayerProcessTeardownGate.assertLaunchAllowed();
|
||||
const proc = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
shell: false,
|
||||
detached: !reuseInstance,
|
||||
@@ -455,20 +403,28 @@ export async function openMpvPlayer({
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error('Failed to start MPV player:', err);
|
||||
mpvProcess = null;
|
||||
mpvSocketPath = null;
|
||||
const processSessionId = reusableMpvProcess.sessionIdFor(
|
||||
proc,
|
||||
session.id
|
||||
);
|
||||
reusableMpvProcess.clear(proc);
|
||||
stopPositionPolling();
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
processSessionId,
|
||||
`Failed to start MPV player: ${err.message}`
|
||||
);
|
||||
reject(buildPlayerStartError('MPV', err, mpvLaunchContext));
|
||||
rejectLaunch(
|
||||
buildPlayerStartError('MPV', err, mpvLaunchContext)
|
||||
);
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
traceExternalPlayer('mpv exited', { code });
|
||||
mpvProcess = null;
|
||||
mpvSocketPath = null;
|
||||
const processSessionId = reusableMpvProcess.sessionIdFor(
|
||||
proc,
|
||||
session.id
|
||||
);
|
||||
reusableMpvProcess.clear(proc);
|
||||
stopPositionPolling();
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
@@ -480,18 +436,19 @@ export async function openMpvPlayer({
|
||||
`MPV player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
processSessionId,
|
||||
`MPV player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
resolveLaunch();
|
||||
return;
|
||||
}
|
||||
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
externalPlayerSessions.markClosed(processSessionId);
|
||||
resolveLaunch();
|
||||
});
|
||||
|
||||
if (reuseInstance && socketPath) {
|
||||
mpvProcess = proc;
|
||||
mpvSocketPath = socketPath;
|
||||
reusableMpvProcess.track(proc, socketPath, session.id);
|
||||
traceExternalPlayer('stored mpv process for reuse', {
|
||||
socketPath,
|
||||
});
|
||||
@@ -500,8 +457,27 @@ export async function openMpvPlayer({
|
||||
}
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, async () => {
|
||||
if (!proc.killed) {
|
||||
proc.kill();
|
||||
if (
|
||||
reuseInstance &&
|
||||
!reusableMpvProcess.owns(proc, session.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await externalPlayerProcessTeardownGate.terminate(proc);
|
||||
} catch (error) {
|
||||
const teardownError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
freshTeardownUnconfirmed = true;
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
teardownError.message,
|
||||
{ canClose: true }
|
||||
);
|
||||
rejectLaunch(teardownError);
|
||||
throw teardownError;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -509,22 +485,38 @@ export async function openMpvPlayer({
|
||||
startPositionPolling(socketPath, contentInfo, session.id);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
startConfirmationTimer = setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
resolve();
|
||||
resolveLaunch();
|
||||
}
|
||||
}, 100);
|
||||
startConfirmationTimer.unref();
|
||||
});
|
||||
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (error) {
|
||||
console.error('Error opening MPV player:', error);
|
||||
mpvProcess = null;
|
||||
mpvSocketPath = null;
|
||||
stopPositionPolling();
|
||||
if (!reuseState.teardownUnconfirmed && !freshTeardownUnconfirmed) {
|
||||
stopPositionPolling();
|
||||
}
|
||||
const restoredSession =
|
||||
reuseState.teardownUnconfirmed &&
|
||||
!reuseState.contentMutated &&
|
||||
displacedSessionId
|
||||
? externalPlayerSessions.restoreActiveSession(
|
||||
displacedSessionId,
|
||||
session.id
|
||||
)
|
||||
: null;
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{
|
||||
canClose:
|
||||
freshTeardownUnconfirmed ||
|
||||
(reuseState.teardownUnconfirmed &&
|
||||
(reuseState.contentMutated || !restoredSession)),
|
||||
}
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { createConnection } from 'net';
|
||||
import { ExternalPlaybackSnapshot } from './external-player-runtime';
|
||||
|
||||
export function buildVlcEnqueueCommands(options: {
|
||||
url: string;
|
||||
title?: string;
|
||||
userAgent?: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
headers?: Record<string, string>;
|
||||
startTime?: number;
|
||||
}): string[] {
|
||||
const inputOptions: string[] = [];
|
||||
|
||||
if (options.userAgent) {
|
||||
inputOptions.push(`:http-user-agent=${options.userAgent}`);
|
||||
}
|
||||
if (options.referer) {
|
||||
inputOptions.push(`:http-referrer=${options.referer}`);
|
||||
} else if (options.origin) {
|
||||
inputOptions.push(`:http-referrer=${options.origin}`);
|
||||
}
|
||||
Object.entries(options.headers ?? {}).forEach(([name, value]) => {
|
||||
if (!name || value === undefined || value === null) return;
|
||||
const trimmedValue = String(value).trim();
|
||||
if (!trimmedValue) return;
|
||||
inputOptions.push(`:http-header=${name}: ${trimmedValue}`);
|
||||
});
|
||||
if (options.title) {
|
||||
inputOptions.push(`:meta-title=${options.title}`);
|
||||
}
|
||||
|
||||
const inputLine =
|
||||
inputOptions.length > 0
|
||||
? `${options.url} ${inputOptions.join(' ')}`
|
||||
: options.url;
|
||||
const commands = ['clear', `add ${inputLine}`];
|
||||
if (options.startTime && Number.isFinite(options.startTime)) {
|
||||
commands.push(`seek ${Math.floor(options.startTime)}`);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function sendVlcRcCommand(
|
||||
port: number,
|
||||
command: string,
|
||||
onDispatched?: () => void,
|
||||
shouldDispatch?: () => boolean
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = createConnection({ port, host: '127.0.0.1' });
|
||||
let settled = false;
|
||||
const finish = (error?: Error, dispatched = true) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
if (!client.destroyed) client.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve(dispatched);
|
||||
};
|
||||
const timeoutHandle = setTimeout(
|
||||
() => finish(new Error('VLC RC command timed out')),
|
||||
2_000
|
||||
);
|
||||
|
||||
client.on('connect', () => {
|
||||
if (shouldDispatch && !shouldDispatch()) {
|
||||
finish(undefined, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
client.write(`${command}\n`);
|
||||
onDispatched?.();
|
||||
} catch (error) {
|
||||
finish(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
});
|
||||
client.on('data', (chunk) => {
|
||||
if (chunk.toString().includes('>')) finish();
|
||||
});
|
||||
client.on('error', (error) => finish(error));
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendVlcRcCommands(
|
||||
port: number,
|
||||
commands: string[],
|
||||
onCommandSent?: (command: string, index: number) => void,
|
||||
shouldDispatch?: () => boolean
|
||||
): Promise<void> {
|
||||
for (const [index, command] of commands.entries()) {
|
||||
if (shouldDispatch && !shouldDispatch()) return;
|
||||
const dispatched = await sendVlcRcCommand(
|
||||
port,
|
||||
command,
|
||||
() => onCommandSent?.(command, index),
|
||||
shouldDispatch
|
||||
);
|
||||
if (!dispatched) return;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVlcRcNumericResponse(data: string): string {
|
||||
return data.match(/>\s*(-?\d+(?:\.\d+)?)/)?.[1] ?? '';
|
||||
}
|
||||
|
||||
export function parseVlcRcPlaybackState(data: string): string | null {
|
||||
return (
|
||||
data
|
||||
.match(/\(\s*state\s+([^)]+)\s*\)/i)?.[1]
|
||||
?.trim()
|
||||
.toLowerCase() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getVlcCommandResponse(port: number, command: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const client = createConnection({ port, host: '127.0.0.1' });
|
||||
let data = '';
|
||||
let settled = false;
|
||||
const finish = (result: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
if (!client.destroyed) client.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
const timeoutHandle = setTimeout(() => finish(''), 2_000);
|
||||
|
||||
client.on('connect', () => client.write(`${command}\n`));
|
||||
client.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
if (data.includes('>')) finish(data);
|
||||
});
|
||||
client.on('error', () => finish(''));
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVlcPlaybackState(
|
||||
port: number
|
||||
): Promise<string | null> {
|
||||
return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status'));
|
||||
}
|
||||
|
||||
export async function getVlcPlaybackSnapshot(
|
||||
port: number
|
||||
): Promise<ExternalPlaybackSnapshot | null> {
|
||||
const time = parseInt(
|
||||
parseVlcRcNumericResponse(
|
||||
await getVlcCommandResponse(port, 'get_time')
|
||||
),
|
||||
10
|
||||
);
|
||||
const duration = parseInt(
|
||||
parseVlcRcNumericResponse(
|
||||
await getVlcCommandResponse(port, 'get_length')
|
||||
),
|
||||
10
|
||||
);
|
||||
if (Number.isNaN(time)) return null;
|
||||
return {
|
||||
positionSeconds: time,
|
||||
durationSeconds: Number.isNaN(duration) ? null : duration,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { externalPlayerProcessTeardownGate } from './external-player-process';
|
||||
import {
|
||||
ExternalPlaybackSnapshot,
|
||||
externalPlayerSessions,
|
||||
sendPlaybackPositionUpdate,
|
||||
traceExternalPlayer,
|
||||
} from './external-player-runtime';
|
||||
import {
|
||||
buildVlcEnqueueCommands,
|
||||
sendVlcRcCommand,
|
||||
sendVlcRcCommands,
|
||||
} from './vlc-rc';
|
||||
|
||||
export interface VlcReuseAttemptState {
|
||||
contentMutated: boolean;
|
||||
teardownUnconfirmed: boolean;
|
||||
closeRequested: boolean;
|
||||
requestedClose: Promise<void> | null;
|
||||
}
|
||||
|
||||
interface VlcReuseOptions {
|
||||
session: ExternalPlayerSession;
|
||||
previousProcessSessionId: string | null;
|
||||
url: string;
|
||||
title: string;
|
||||
effectiveUserAgent?: string;
|
||||
effectiveReferer?: string;
|
||||
effectiveOrigin?: string;
|
||||
mergedHeaders: Record<string, string>;
|
||||
contentInfo?: PlayerContentInfo;
|
||||
startTime?: number;
|
||||
state: VlcReuseAttemptState;
|
||||
startPositionPolling: (
|
||||
port: number,
|
||||
contentInfo: PlayerContentInfo,
|
||||
sessionId: string,
|
||||
onSnapshot?: (snapshot: ExternalPlaybackSnapshot) => void,
|
||||
onStopped?: () => void
|
||||
) => void;
|
||||
stopPositionPolling: () => void;
|
||||
}
|
||||
|
||||
/** Owns the one VLC child/RC port retained when instance reuse is enabled. */
|
||||
export class VlcReusableProcess {
|
||||
private process: ChildProcess | null = null;
|
||||
private rcPort: number | null = null;
|
||||
private processSessionId: string | null = null;
|
||||
private readonly processSessionIds = new WeakMap<ChildProcess, string>();
|
||||
|
||||
currentSessionId(): string | null {
|
||||
return this.processSessionId;
|
||||
}
|
||||
|
||||
sessionIdFor(process: ChildProcess, fallback: string): string {
|
||||
return this.processSessionIds.get(process) ?? fallback;
|
||||
}
|
||||
|
||||
owns(process: ChildProcess, sessionId: string): boolean {
|
||||
return this.processSessionIds.get(process) === sessionId;
|
||||
}
|
||||
|
||||
track(process: ChildProcess, rcPort: number, sessionId: string): void {
|
||||
this.process = process;
|
||||
this.rcPort = rcPort;
|
||||
this.processSessionId = sessionId;
|
||||
this.processSessionIds.set(process, sessionId);
|
||||
}
|
||||
|
||||
clear(process: ChildProcess): boolean {
|
||||
if (this.process !== process) return false;
|
||||
this.process = null;
|
||||
this.rcPort = null;
|
||||
this.processSessionId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
stopStored(
|
||||
reason: string,
|
||||
stopPositionPolling: () => void,
|
||||
guardFutureLaunches = false
|
||||
): void {
|
||||
const process = this.process;
|
||||
if (!process || process.killed) return;
|
||||
traceExternalPlayer(reason);
|
||||
if (guardFutureLaunches) {
|
||||
externalPlayerProcessTeardownGate.terminateInBackground(process);
|
||||
} else {
|
||||
process.kill();
|
||||
}
|
||||
this.clear(process);
|
||||
stopPositionPolling();
|
||||
}
|
||||
|
||||
async tryReuse(
|
||||
options: VlcReuseOptions
|
||||
): Promise<ExternalPlayerSession | null> {
|
||||
const reusedProcess = this.process;
|
||||
const reusedRcPort = this.rcPort;
|
||||
if (!reusedProcess || reusedProcess.killed || !reusedRcPort)
|
||||
return null;
|
||||
|
||||
traceExternalPlayer('reuse existing vlc instance', {
|
||||
rcPort: reusedRcPort,
|
||||
});
|
||||
const { session, state } = options;
|
||||
const reusedProcessSessionId =
|
||||
this.processSessionIds.get(reusedProcess) ??
|
||||
options.previousProcessSessionId;
|
||||
let closeRequested = false;
|
||||
let retryableClose: Promise<void> | null = null;
|
||||
let launchClose: Promise<void> | null = null;
|
||||
|
||||
const ownsReusedProcess = () =>
|
||||
this.processSessionIds.get(reusedProcess) === session.id ||
|
||||
externalPlayerSessions.getActiveSessionId() === session.id;
|
||||
const closeReusedProcess = async () => {
|
||||
externalPlayerProcessTeardownGate.beginTeardown(reusedProcess);
|
||||
try {
|
||||
await sendVlcRcCommand(reusedRcPort, 'quit');
|
||||
} catch {
|
||||
await externalPlayerProcessTeardownGate.terminate(
|
||||
reusedProcess
|
||||
);
|
||||
return;
|
||||
}
|
||||
await externalPlayerProcessTeardownGate.terminate(reusedProcess, {
|
||||
sendTerminationSignal: false,
|
||||
});
|
||||
};
|
||||
const finishRequestedClose = async () => {
|
||||
if (launchClose) {
|
||||
try {
|
||||
await launchClose;
|
||||
} catch (error) {
|
||||
state.teardownUnconfirmed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return externalPlayerSessions.markClosed(session.id) ?? session;
|
||||
};
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, () => {
|
||||
closeRequested = true;
|
||||
state.closeRequested = true;
|
||||
if (!ownsReusedProcess()) return;
|
||||
if (!retryableClose) {
|
||||
const closeAttempt = closeReusedProcess();
|
||||
retryableClose = closeAttempt;
|
||||
launchClose ??= closeAttempt;
|
||||
void closeAttempt.catch((error) => {
|
||||
if (retryableClose === closeAttempt) retryableClose = null;
|
||||
state.teardownUnconfirmed = true;
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{ canClose: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
state.requestedClose = retryableClose;
|
||||
return retryableClose;
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVlcRcCommands(
|
||||
reusedRcPort,
|
||||
buildVlcEnqueueCommands({
|
||||
url: options.url,
|
||||
title: options.title,
|
||||
userAgent: options.effectiveUserAgent,
|
||||
referer: options.effectiveReferer,
|
||||
origin: options.effectiveOrigin,
|
||||
headers: options.mergedHeaders,
|
||||
startTime: options.startTime,
|
||||
}),
|
||||
(_command, index) => {
|
||||
if (index !== 0) return;
|
||||
state.contentMutated = true;
|
||||
this.processSessionId = session.id;
|
||||
this.processSessionIds.set(reusedProcess, session.id);
|
||||
options.stopPositionPolling();
|
||||
},
|
||||
() => !closeRequested
|
||||
);
|
||||
if (closeRequested) return await finishRequestedClose();
|
||||
traceExternalPlayer('loaded new url in existing vlc instance');
|
||||
|
||||
let lastSnapshot: ExternalPlaybackSnapshot | null = null;
|
||||
if (options.contentInfo) {
|
||||
options.startPositionPolling(
|
||||
reusedRcPort,
|
||||
options.contentInfo,
|
||||
session.id,
|
||||
(snapshot) => {
|
||||
lastSnapshot = snapshot;
|
||||
},
|
||||
() => {
|
||||
if (
|
||||
lastSnapshot &&
|
||||
externalPlayerSessions.getSession(session.id)
|
||||
?.status !== 'closed'
|
||||
) {
|
||||
sendPlaybackPositionUpdate(
|
||||
session.id,
|
||||
options.contentInfo as PlayerContentInfo,
|
||||
lastSnapshot
|
||||
);
|
||||
}
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
options.stopPositionPolling();
|
||||
}
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (error) {
|
||||
const current = externalPlayerSessions.getSession(session.id);
|
||||
if (current?.status === 'closed') return current;
|
||||
if (closeRequested) return await finishRequestedClose();
|
||||
console.error(
|
||||
'Failed to reuse existing VLC, spawning fresh:',
|
||||
error
|
||||
);
|
||||
|
||||
if (state.contentMutated) {
|
||||
if (reusedProcessSessionId) {
|
||||
this.processSessionIds.set(
|
||||
reusedProcess,
|
||||
reusedProcessSessionId
|
||||
);
|
||||
} else {
|
||||
this.processSessionIds.delete(reusedProcess);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await externalPlayerProcessTeardownGate.terminate(
|
||||
reusedProcess
|
||||
);
|
||||
} catch (teardownError) {
|
||||
if (state.contentMutated) {
|
||||
this.processSessionIds.set(reusedProcess, session.id);
|
||||
}
|
||||
state.teardownUnconfirmed = true;
|
||||
throw teardownError;
|
||||
}
|
||||
this.clear(reusedProcess);
|
||||
options.stopPositionPolling();
|
||||
if (closeRequested) return await finishRequestedClose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import { AddressInfo, createConnection, createServer } from 'net';
|
||||
import { spawn } from 'child_process';
|
||||
import { AddressInfo, createServer } from 'net';
|
||||
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
VLC_PLAYER_ARGUMENTS,
|
||||
@@ -28,6 +28,17 @@ import {
|
||||
sendPlayerErrorNotification,
|
||||
traceExternalPlayer,
|
||||
} from './external-player-runtime';
|
||||
import { externalPlayerProcessTeardownGate } from './external-player-process';
|
||||
import { getVlcPlaybackSnapshot, getVlcPlaybackState } from './vlc-rc';
|
||||
export {
|
||||
buildVlcEnqueueCommands,
|
||||
parseVlcRcNumericResponse,
|
||||
parseVlcRcPlaybackState,
|
||||
} from './vlc-rc';
|
||||
import {
|
||||
VlcReusableProcess,
|
||||
VlcReuseAttemptState,
|
||||
} from './vlc-reusable-process';
|
||||
|
||||
export interface OpenVlcPlayerRequest {
|
||||
url: string;
|
||||
@@ -41,8 +52,7 @@ export interface OpenVlcPlayerRequest {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
let vlcProcess: ChildProcess | null = null;
|
||||
let vlcRcPort: number | null = null;
|
||||
const reusableVlcProcess = new VlcReusableProcess();
|
||||
let vlcPollingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
function getVlcPath(options: PlayerPathOptions = {}): string {
|
||||
@@ -52,103 +62,6 @@ function getVlcPath(options: PlayerPathOptions = {}): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function buildVlcEnqueueCommands(options: {
|
||||
url: string;
|
||||
title?: string;
|
||||
userAgent?: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
headers?: Record<string, string>;
|
||||
startTime?: number;
|
||||
}): string[] {
|
||||
const inputOptions: string[] = [];
|
||||
|
||||
if (options.userAgent) {
|
||||
inputOptions.push(`:http-user-agent=${options.userAgent}`);
|
||||
}
|
||||
if (options.referer) {
|
||||
inputOptions.push(`:http-referrer=${options.referer}`);
|
||||
} else if (options.origin) {
|
||||
inputOptions.push(`:http-referrer=${options.origin}`);
|
||||
}
|
||||
Object.entries(options.headers ?? {}).forEach(([name, value]) => {
|
||||
if (!name || value === undefined || value === null) return;
|
||||
const trimmedValue = String(value).trim();
|
||||
if (!trimmedValue) return;
|
||||
inputOptions.push(`:http-header=${name}: ${trimmedValue}`);
|
||||
});
|
||||
if (options.title) {
|
||||
inputOptions.push(`:meta-title=${options.title}`);
|
||||
}
|
||||
|
||||
const inputLine =
|
||||
inputOptions.length > 0
|
||||
? `${options.url} ${inputOptions.join(' ')}`
|
||||
: options.url;
|
||||
|
||||
const commands = ['clear', `add ${inputLine}`];
|
||||
|
||||
if (options.startTime && Number.isFinite(options.startTime)) {
|
||||
commands.push(`seek ${Math.floor(options.startTime)}`);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function sendVlcRcCommand(port: number, command: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = createConnection({ port, host: '127.0.0.1' });
|
||||
let settled = false;
|
||||
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
if (!client.destroyed) client.destroy();
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timeoutHandle = setTimeout(
|
||||
() => finish(new Error('VLC RC command timed out')),
|
||||
2000
|
||||
);
|
||||
|
||||
client.on('connect', () => {
|
||||
client.write(`${command}\n`);
|
||||
});
|
||||
client.on('data', (chunk) => {
|
||||
if (chunk.toString().includes('>')) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
client.on('error', (err) => finish(err));
|
||||
});
|
||||
}
|
||||
|
||||
async function sendVlcRcCommands(
|
||||
port: number,
|
||||
commands: string[]
|
||||
): Promise<void> {
|
||||
for (const command of commands) {
|
||||
await sendVlcRcCommand(port, command);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVlcRcNumericResponse(data: string): string {
|
||||
const match = data.match(/>\s*(-?\d+(?:\.\d+)?)/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
export function parseVlcRcPlaybackState(data: string): string | null {
|
||||
const match = data.match(/\(\s*state\s+([^)]+)\s*\)/i);
|
||||
return match ? match[1].trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
function stopVlcPositionPolling(): void {
|
||||
if (vlcPollingInterval) {
|
||||
clearInterval(vlcPollingInterval);
|
||||
@@ -156,69 +69,6 @@ function stopVlcPositionPolling(): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function getVlcCommandResponse(
|
||||
port: number,
|
||||
command: string
|
||||
): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const client = createConnection({ port, host: '127.0.0.1' });
|
||||
let data = '';
|
||||
let resolved = false;
|
||||
|
||||
const done = (result: string) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
if (!client.destroyed) client.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeoutHandle = setTimeout(() => done(''), 2000);
|
||||
|
||||
client.on('connect', () => {
|
||||
client.write(command + '\n');
|
||||
});
|
||||
|
||||
client.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
if (data.includes('>')) {
|
||||
done(data);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', () => done(''));
|
||||
});
|
||||
}
|
||||
|
||||
async function getVlcProperty(port: number, command: string): Promise<string> {
|
||||
return parseVlcRcNumericResponse(
|
||||
await getVlcCommandResponse(port, command)
|
||||
);
|
||||
}
|
||||
|
||||
async function getVlcPlaybackState(port: number): Promise<string | null> {
|
||||
return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status'));
|
||||
}
|
||||
|
||||
async function getVlcPlaybackSnapshot(
|
||||
port: number
|
||||
): Promise<ExternalPlaybackSnapshot | null> {
|
||||
const timeStr = await getVlcProperty(port, 'get_time');
|
||||
const lenStr = await getVlcProperty(port, 'get_length');
|
||||
|
||||
const position = parseInt(timeStr, 10);
|
||||
const duration = parseInt(lenStr, 10);
|
||||
|
||||
if (isNaN(position)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
positionSeconds: position,
|
||||
durationSeconds: !isNaN(duration) ? duration : null,
|
||||
};
|
||||
}
|
||||
|
||||
function startVlcPositionPolling(
|
||||
port: number,
|
||||
contentInfo: PlayerContentInfo,
|
||||
@@ -270,23 +120,16 @@ function getFreePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
function killStoredVlcProcess(reason: string): void {
|
||||
if (!vlcProcess || vlcProcess.killed) {
|
||||
return;
|
||||
}
|
||||
traceExternalPlayer(reason);
|
||||
vlcProcess.kill();
|
||||
vlcProcess = null;
|
||||
vlcRcPort = null;
|
||||
stopVlcPositionPolling();
|
||||
}
|
||||
|
||||
export function setVlcReuseInstance(reuseInstance: boolean): void {
|
||||
traceExternalPlayer('set vlc reuse instance', { reuseInstance });
|
||||
store.set(VLC_REUSE_INSTANCE, reuseInstance);
|
||||
|
||||
if (!reuseInstance) {
|
||||
killStoredVlcProcess('clean up vlc process after disabling reuse');
|
||||
reusableVlcProcess.stopStored(
|
||||
'clean up vlc process after disabling reuse',
|
||||
stopVlcPositionPolling,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +139,10 @@ export function setVlcReuseInstance(reuseInstance: boolean): void {
|
||||
* playing after quit.
|
||||
*/
|
||||
export function shutdownVlcSession(): void {
|
||||
killStoredVlcProcess('kill reused vlc process on app shutdown');
|
||||
reusableVlcProcess.stopStored(
|
||||
'kill reused vlc process on app shutdown',
|
||||
stopVlcPositionPolling
|
||||
);
|
||||
}
|
||||
|
||||
export async function openVlcPlayer({
|
||||
@@ -310,6 +156,9 @@ export async function openVlcPlayer({
|
||||
startTime,
|
||||
headers,
|
||||
}: OpenVlcPlayerRequest) {
|
||||
externalPlayerProcessTeardownGate.assertLaunchAllowed();
|
||||
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
|
||||
const previousProcessSessionId = reusableVlcProcess.currentSessionId();
|
||||
const session = externalPlayerSessions.beginSession({
|
||||
player: 'vlc',
|
||||
title,
|
||||
@@ -317,6 +166,13 @@ export async function openVlcPlayer({
|
||||
streamUrl: url,
|
||||
contentInfo,
|
||||
});
|
||||
const reuseState: VlcReuseAttemptState = {
|
||||
teardownUnconfirmed: false,
|
||||
contentMutated: false,
|
||||
closeRequested: false,
|
||||
requestedClose: null,
|
||||
};
|
||||
let freshTeardownUnconfirmed = false;
|
||||
|
||||
try {
|
||||
const isFlatpak = isRunningInFlatpak();
|
||||
@@ -358,79 +214,23 @@ export async function openVlcPlayer({
|
||||
parseExternalPlayerArguments(customVlcArguments).length,
|
||||
});
|
||||
|
||||
if (reuseInstance && vlcProcess && !vlcProcess.killed && vlcRcPort) {
|
||||
traceExternalPlayer('reuse existing vlc instance', {
|
||||
rcPort: vlcRcPort,
|
||||
if (reuseInstance) {
|
||||
const reused = await reusableVlcProcess.tryReuse({
|
||||
session,
|
||||
previousProcessSessionId,
|
||||
url,
|
||||
title,
|
||||
effectiveUserAgent,
|
||||
effectiveReferer,
|
||||
effectiveOrigin,
|
||||
mergedHeaders,
|
||||
contentInfo,
|
||||
startTime,
|
||||
state: reuseState,
|
||||
startPositionPolling: startVlcPositionPolling,
|
||||
stopPositionPolling: stopVlcPositionPolling,
|
||||
});
|
||||
try {
|
||||
const enqueueCommands = buildVlcEnqueueCommands({
|
||||
url,
|
||||
title,
|
||||
userAgent: effectiveUserAgent,
|
||||
referer: effectiveReferer,
|
||||
origin: effectiveOrigin,
|
||||
headers: mergedHeaders,
|
||||
startTime,
|
||||
});
|
||||
await sendVlcRcCommands(vlcRcPort, enqueueCommands);
|
||||
traceExternalPlayer('loaded new url in existing vlc instance');
|
||||
|
||||
const reusedRcPort = vlcRcPort;
|
||||
let lastReusedSnapshot: ExternalPlaybackSnapshot | null = null;
|
||||
externalPlayerSessions.attachCloser(session.id, async () => {
|
||||
try {
|
||||
await sendVlcRcCommand(reusedRcPort, 'stop');
|
||||
} catch {
|
||||
if (vlcProcess && !vlcProcess.killed) {
|
||||
vlcProcess.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (contentInfo) {
|
||||
startVlcPositionPolling(
|
||||
reusedRcPort,
|
||||
contentInfo,
|
||||
session.id,
|
||||
(snapshot) => {
|
||||
lastReusedSnapshot = snapshot;
|
||||
},
|
||||
() => {
|
||||
if (
|
||||
lastReusedSnapshot &&
|
||||
externalPlayerSessions.getSession(session.id)
|
||||
?.status !== 'closed'
|
||||
) {
|
||||
sendPlaybackPositionUpdate(
|
||||
session.id,
|
||||
contentInfo,
|
||||
lastReusedSnapshot
|
||||
);
|
||||
}
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
stopVlcPositionPolling();
|
||||
}
|
||||
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Failed to reuse existing VLC, spawning fresh:',
|
||||
err
|
||||
);
|
||||
if (vlcProcess && !vlcProcess.killed) {
|
||||
try {
|
||||
vlcProcess.kill();
|
||||
} catch {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
}
|
||||
vlcProcess = null;
|
||||
vlcRcPort = null;
|
||||
stopVlcPositionPolling();
|
||||
}
|
||||
if (reused) return reused;
|
||||
}
|
||||
|
||||
let rcPort = 0;
|
||||
@@ -488,6 +288,8 @@ export async function openVlcPlayer({
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let closeRequested = false;
|
||||
let requestedClose: Promise<void> | null = null;
|
||||
|
||||
const resolveSpawn = () => {
|
||||
if (settled) {
|
||||
@@ -508,6 +310,50 @@ export async function openVlcPlayer({
|
||||
};
|
||||
|
||||
const spawnVlc = (playerArgs: string[], isRetry = false) => {
|
||||
if (reuseState.closeRequested) {
|
||||
closeRequested = true;
|
||||
const pendingReuseClose =
|
||||
reuseState.requestedClose ?? Promise.resolve();
|
||||
void pendingReuseClose.then(() => {
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
resolveSpawn();
|
||||
}, rejectSpawn);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
externalPlayerSessions.getSession(session.id)?.status ===
|
||||
'closed'
|
||||
) {
|
||||
closeRequested = true;
|
||||
resolveSpawn();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Port allocation and reuse fallback both yield. Another
|
||||
// exact child can enter teardown during either wait, so
|
||||
// the process-wide invariant must be checked at the
|
||||
// actual spawn boundary as well as at request entry.
|
||||
externalPlayerProcessTeardownGate.assertLaunchAllowed();
|
||||
} catch (error) {
|
||||
const launchError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
if (settled) {
|
||||
const current = externalPlayerSessions.getSession(
|
||||
session.id
|
||||
);
|
||||
if (current?.status !== 'closed') {
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
launchError.message
|
||||
);
|
||||
}
|
||||
} else {
|
||||
rejectSpawn(launchError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const spawnSpec = buildExternalPlayerSpawnSpec(
|
||||
vlcLaunchContext,
|
||||
buildPlayerArgsWithCustomArguments(
|
||||
@@ -515,18 +361,24 @@ export async function openVlcPlayer({
|
||||
playerArgs
|
||||
)
|
||||
);
|
||||
const trackProcess = reuseInstance && !isRetry;
|
||||
// Reuse ownership exists only when an RC port was allocated.
|
||||
// Without it this child is a normal one-shot process whose
|
||||
// exact session closer must still terminate it.
|
||||
const trackProcess = reuseInstance && !isRetry && rcPort > 0;
|
||||
const proc = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
shell: false,
|
||||
detached: !trackProcess,
|
||||
stdio: trackProcess ? ['ignore', 'pipe', 'pipe'] : 'ignore',
|
||||
});
|
||||
|
||||
proc.once('spawn', resolveSpawn);
|
||||
proc.once('spawn', () => {
|
||||
if (!closeRequested) {
|
||||
resolveSpawn();
|
||||
}
|
||||
});
|
||||
|
||||
if (trackProcess && rcPort > 0) {
|
||||
vlcProcess = proc;
|
||||
vlcRcPort = rcPort;
|
||||
reusableVlcProcess.track(proc, rcPort, session.id);
|
||||
traceExternalPlayer('tracking vlc process for reuse', {
|
||||
rcPort,
|
||||
});
|
||||
@@ -571,11 +423,45 @@ export async function openVlcPlayer({
|
||||
);
|
||||
};
|
||||
|
||||
externalPlayerSessions.attachCloser(session.id, async () => {
|
||||
await flushVlcPlaybackPosition();
|
||||
if (!proc.killed) {
|
||||
proc.kill();
|
||||
externalPlayerSessions.attachCloser(session.id, () => {
|
||||
closeRequested = true;
|
||||
if (
|
||||
trackProcess &&
|
||||
!reusableVlcProcess.owns(proc, session.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!requestedClose) {
|
||||
// Position flush uses two bounded RC requests. Guard
|
||||
// the exact child before either request yields so no
|
||||
// replacement can reuse or overlap it while Stop is
|
||||
// still preparing the teardown.
|
||||
externalPlayerProcessTeardownGate.beginTeardown(proc);
|
||||
const closeAttempt = (async () => {
|
||||
await flushVlcPlaybackPosition();
|
||||
await externalPlayerProcessTeardownGate.terminate(
|
||||
proc
|
||||
);
|
||||
})();
|
||||
requestedClose = closeAttempt;
|
||||
void closeAttempt.catch((error) => {
|
||||
if (requestedClose === closeAttempt) {
|
||||
requestedClose = null;
|
||||
}
|
||||
const teardownError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
freshTeardownUnconfirmed = true;
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
teardownError.message,
|
||||
{ canClose: true }
|
||||
);
|
||||
rejectSpawn(teardownError);
|
||||
});
|
||||
}
|
||||
return requestedClose;
|
||||
});
|
||||
|
||||
if (!isRetry && rcPort > 0 && contentInfo) {
|
||||
@@ -612,9 +498,21 @@ export async function openVlcPlayer({
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error('Failed to start VLC player:', err);
|
||||
if (vlcProcess === proc) {
|
||||
vlcProcess = null;
|
||||
vlcRcPort = null;
|
||||
const processSessionId = reusableVlcProcess.sessionIdFor(
|
||||
proc,
|
||||
session.id
|
||||
);
|
||||
reusableVlcProcess.clear(proc);
|
||||
if (closeRequested) {
|
||||
void requestedClose?.then(() => {
|
||||
// A spawn failure reports `close` without an
|
||||
// `exit` event. Close the exact session before
|
||||
// settling OPEN_VLC_PLAYER so the renderer
|
||||
// cannot receive a stale `opened` result.
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
resolveSpawn();
|
||||
}, rejectSpawn);
|
||||
return;
|
||||
}
|
||||
if (!isRetry && rcPort > 0) {
|
||||
traceExternalPlayer(
|
||||
@@ -629,7 +527,7 @@ export async function openVlcPlayer({
|
||||
spawnVlc(retryArgs, true);
|
||||
} else {
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
processSessionId,
|
||||
`Failed to start VLC player: ${err.message}`
|
||||
);
|
||||
rejectSpawn(
|
||||
@@ -640,13 +538,15 @@ export async function openVlcPlayer({
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
traceExternalPlayer('vlc exited', { code });
|
||||
if (vlcProcess === proc) {
|
||||
vlcProcess = null;
|
||||
vlcRcPort = null;
|
||||
}
|
||||
const processSessionId = reusableVlcProcess.sessionIdFor(
|
||||
proc,
|
||||
session.id
|
||||
);
|
||||
reusableVlcProcess.clear(proc);
|
||||
stopVlcPositionPolling();
|
||||
|
||||
if (
|
||||
!closeRequested &&
|
||||
lastVlcSnapshot &&
|
||||
contentInfo &&
|
||||
externalPlayerSessions.getSession(session.id)
|
||||
@@ -659,7 +559,12 @@ export async function openVlcPlayer({
|
||||
);
|
||||
}
|
||||
|
||||
if (code === 1 && !isRetry && rcPort > 0) {
|
||||
if (
|
||||
code === 1 &&
|
||||
!closeRequested &&
|
||||
!isRetry &&
|
||||
rcPort > 0
|
||||
) {
|
||||
traceExternalPlayer(
|
||||
'retry vlc without rc interface after exit'
|
||||
);
|
||||
@@ -683,13 +588,15 @@ export async function openVlcPlayer({
|
||||
`VLC player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
processSessionId,
|
||||
`VLC player closed unexpectedly (exit code: ${code})`
|
||||
);
|
||||
resolveSpawn();
|
||||
return;
|
||||
}
|
||||
|
||||
externalPlayerSessions.markClosed(session.id);
|
||||
externalPlayerSessions.markClosed(processSessionId);
|
||||
resolveSpawn();
|
||||
});
|
||||
|
||||
if (!trackProcess) {
|
||||
@@ -703,9 +610,24 @@ export async function openVlcPlayer({
|
||||
return externalPlayerSessions.markOpened(session.id) ?? session;
|
||||
} catch (error) {
|
||||
console.error('Error opening VLC player:', error);
|
||||
const restoredSession =
|
||||
reuseState.teardownUnconfirmed &&
|
||||
!reuseState.contentMutated &&
|
||||
displacedSessionId
|
||||
? externalPlayerSessions.restoreActiveSession(
|
||||
displacedSessionId,
|
||||
session.id
|
||||
)
|
||||
: null;
|
||||
externalPlayerSessions.markError(
|
||||
session.id,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{
|
||||
canClose:
|
||||
freshTeardownUnconfirmed ||
|
||||
(reuseState.teardownUnconfirmed &&
|
||||
(reuseState.contentMutated || !restoredSession)),
|
||||
}
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user