mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
MPV and VLC wait 2 s and 1.5 s before their first position poll, but the delay timer was never stored, so stopping the poll during that wait could not cancel it. A player that exited, errored or was replaced early left a 5 s (MPV) or 2 s (VLC) interval polling a dead socket or RC port until the next launch or app quit. Keep the delay handle and clear it with the interval. Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
import { spawn } from 'child_process';
|
|
import { createConnection } from 'net';
|
|
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
|
|
import {
|
|
MPV_PLAYER_ARGUMENTS,
|
|
MPV_PLAYER_PATH,
|
|
MPV_REUSE_INSTANCE,
|
|
store,
|
|
} from '../services/store.service';
|
|
import {
|
|
buildExternalPlayerSpawnSpec,
|
|
buildPlayerArgsWithCustomArguments,
|
|
getDefaultMpvPath,
|
|
isRunningInFlatpak,
|
|
normalizeCustomPlayerPath,
|
|
parseExternalPlayerArguments,
|
|
PlayerPathOptions,
|
|
resolveExternalPlayerLaunchContext,
|
|
shouldReuseMpvInstance,
|
|
shouldUseMpvSocketBridge,
|
|
} from './external-player-launch-context';
|
|
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
|
|
import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request';
|
|
import {
|
|
buildPlayerStartError,
|
|
externalPlayerSessions,
|
|
maskUrlForLogs,
|
|
sendPlaybackPositionUpdate,
|
|
sendPlayerErrorNotification,
|
|
traceExternalPlayer,
|
|
} from './external-player-runtime';
|
|
import { externalPlayerProcessTeardownGate } from './external-player-process';
|
|
import {
|
|
MpvReusableProcess,
|
|
MpvReuseAttemptState,
|
|
} from './mpv-reusable-process';
|
|
|
|
export interface OpenExternalPlayerRequest {
|
|
url: string;
|
|
title: string;
|
|
thumbnail?: string;
|
|
userAgent?: string;
|
|
referer?: string;
|
|
origin?: string;
|
|
contentInfo?: PlayerContentInfo;
|
|
startTime?: number;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
const reusableMpvProcess = new MpvReusableProcess();
|
|
let positionPollingInterval: NodeJS.Timeout | null = null;
|
|
// The first poll waits for MPV to open its IPC socket. The handle is kept so
|
|
// a player that exits during that wait does not start an orphaned interval.
|
|
let positionPollingDelay: NodeJS.Timeout | null = null;
|
|
|
|
function getMpvPath(options: PlayerPathOptions = {}): string {
|
|
return (
|
|
normalizeCustomPlayerPath(store.get(MPV_PLAYER_PATH)) ??
|
|
getDefaultMpvPath(options)
|
|
);
|
|
}
|
|
|
|
async function getMpvProperty(
|
|
socketPath: string,
|
|
property: string
|
|
): Promise<number | null> {
|
|
return new Promise((resolve) => {
|
|
const client = createConnection(socketPath);
|
|
const request =
|
|
JSON.stringify({
|
|
command: ['get_property', property],
|
|
}) + '\n';
|
|
|
|
let data = '';
|
|
|
|
client.on('connect', () => {
|
|
client.write(request);
|
|
});
|
|
|
|
const timeoutHandle = setTimeout(() => {
|
|
client.destroy();
|
|
resolve(null);
|
|
}, 2000);
|
|
|
|
client.on('data', (chunk) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\n')) {
|
|
clearTimeout(timeoutHandle);
|
|
try {
|
|
const lines = data.split('\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
const response = JSON.parse(line);
|
|
if (response.data !== undefined) {
|
|
client.destroy();
|
|
resolve(response.data);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore partial JSON while MPV is still writing.
|
|
}
|
|
}
|
|
});
|
|
|
|
client.on('end', () => {
|
|
clearTimeout(timeoutHandle);
|
|
if (data && !data.includes('\n')) {
|
|
try {
|
|
const response = JSON.parse(data);
|
|
resolve(response.data ?? null);
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
}
|
|
});
|
|
|
|
client.on('error', () => {
|
|
clearTimeout(timeoutHandle);
|
|
resolve(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopPositionPolling(): void {
|
|
if (positionPollingDelay) {
|
|
clearTimeout(positionPollingDelay);
|
|
positionPollingDelay = null;
|
|
}
|
|
if (positionPollingInterval) {
|
|
clearInterval(positionPollingInterval);
|
|
positionPollingInterval = null;
|
|
}
|
|
}
|
|
|
|
function shouldIgnoreMpvStdoutLine(line: string): boolean {
|
|
return /^(\(Paused\)\s*)?AV:\s/.test(line);
|
|
}
|
|
|
|
function startPositionPolling(
|
|
socketPath: string,
|
|
contentInfo: PlayerContentInfo,
|
|
sessionId: string
|
|
): void {
|
|
stopPositionPolling();
|
|
|
|
positionPollingDelay = setTimeout(() => {
|
|
positionPollingDelay = null;
|
|
positionPollingInterval = setInterval(async () => {
|
|
try {
|
|
const position = await getMpvProperty(socketPath, 'time-pos');
|
|
const duration = await getMpvProperty(socketPath, 'duration');
|
|
|
|
if (position !== null) {
|
|
sendPlaybackPositionUpdate(sessionId, contentInfo, {
|
|
positionSeconds: Math.floor(position),
|
|
durationSeconds: duration ? Math.floor(duration) : null,
|
|
});
|
|
}
|
|
} catch {
|
|
stopPositionPolling();
|
|
}
|
|
}, 5000);
|
|
}, 2000);
|
|
}
|
|
|
|
export function setMpvReuseInstance(reuseInstance: boolean): void {
|
|
traceExternalPlayer('set mpv reuse instance', { reuseInstance });
|
|
store.set(MPV_REUSE_INSTANCE, reuseInstance);
|
|
|
|
if (!reuseInstance) {
|
|
reusableMpvProcess.stopStored(
|
|
'clean up mpv process after disabling reuse',
|
|
stopPositionPolling,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kill the MPV instance kept alive for reuse. The reused process is spawned
|
|
* non-detached with piped stdio, so without an explicit kill it outlives the
|
|
* app and keeps playing after quit.
|
|
*/
|
|
export function shutdownMpvSession(): void {
|
|
reusableMpvProcess.stopStored(
|
|
'kill reused mpv process on app shutdown',
|
|
stopPositionPolling
|
|
);
|
|
}
|
|
|
|
export async function openMpvPlayer({
|
|
url,
|
|
title,
|
|
thumbnail,
|
|
userAgent,
|
|
referer,
|
|
origin,
|
|
contentInfo,
|
|
startTime,
|
|
headers,
|
|
}: OpenExternalPlayerRequest) {
|
|
externalPlayerProcessTeardownGate.assertLaunchAllowed();
|
|
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
|
|
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
|
|
const session = externalPlayerSessions.beginSession({
|
|
player: 'mpv',
|
|
title,
|
|
thumbnail,
|
|
streamUrl: url,
|
|
contentInfo,
|
|
});
|
|
const reuseState: MpvReuseAttemptState = {
|
|
teardownUnconfirmed: false,
|
|
contentMutated: false,
|
|
};
|
|
let freshTeardownUnconfirmed = false;
|
|
|
|
try {
|
|
const isFlatpak = isRunningInFlatpak();
|
|
const mpvLaunchContext = resolveExternalPlayerLaunchContext(
|
|
'mpv',
|
|
getMpvPath({ isFlatpak }),
|
|
{ isFlatpak }
|
|
);
|
|
const customMpvArguments = store.get(MPV_PLAYER_ARGUMENTS, '');
|
|
const requestedReuseInstance = store.get(MPV_REUSE_INSTANCE, false);
|
|
const reuseInstance = shouldReuseMpvInstance(
|
|
requestedReuseInstance,
|
|
isFlatpak
|
|
);
|
|
const useMpvSocketBridge = shouldUseMpvSocketBridge(isFlatpak);
|
|
const {
|
|
effectiveOrigin,
|
|
effectiveReferer,
|
|
effectiveUserAgent,
|
|
headerFields,
|
|
} = resolveEffectiveExternalPlaybackRequest({
|
|
url,
|
|
userAgent,
|
|
referer,
|
|
origin,
|
|
headers,
|
|
});
|
|
|
|
traceExternalPlayer('open mpv player', {
|
|
path: mpvLaunchContext.playerPath,
|
|
launchMode: mpvLaunchContext.mode,
|
|
requestedReuseInstance,
|
|
reuseInstance,
|
|
stream: maskUrlForLogs(url),
|
|
hasUserAgent: Boolean(effectiveUserAgent),
|
|
hasReferer: Boolean(effectiveReferer),
|
|
hasOrigin: Boolean(effectiveOrigin),
|
|
headerCount: headerFields.length,
|
|
hasContentInfo: Boolean(contentInfo),
|
|
startTime: startTime ?? null,
|
|
customArgumentCount:
|
|
parseExternalPlayerArguments(customMpvArguments).length,
|
|
});
|
|
|
|
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');
|
|
|
|
let socketPath: string | null = null;
|
|
const args: string[] = [];
|
|
|
|
if (useMpvSocketBridge) {
|
|
socketPath =
|
|
process.platform === 'win32'
|
|
? `\\\\.\\pipe\\mpv-${Date.now()}`
|
|
: `/tmp/mpvsocket-${Date.now()}`;
|
|
args.push(`--input-ipc-server=${socketPath}`, '--idle=yes');
|
|
}
|
|
|
|
args.push('--ytdl=no');
|
|
|
|
if (effectiveUserAgent) {
|
|
args.push(`--user-agent=${effectiveUserAgent}`);
|
|
}
|
|
|
|
if (effectiveReferer) {
|
|
args.push(`--referrer=${effectiveReferer}`);
|
|
}
|
|
|
|
if (headerFields.length > 0) {
|
|
args.push(
|
|
`--http-header-fields=${joinMpvHeaderFields(headerFields)}`
|
|
);
|
|
}
|
|
|
|
if (title) {
|
|
args.push(`--force-media-title=${title}`);
|
|
}
|
|
|
|
if (startTime) {
|
|
args.push(`--start=${startTime}`);
|
|
}
|
|
|
|
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,
|
|
stdio: reuseInstance ? ['ignore', 'pipe', 'pipe'] : 'ignore',
|
|
});
|
|
|
|
if (proc.stdout) {
|
|
proc.stdout.on('data', (data) => {
|
|
const lines = data
|
|
.toString()
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
for (const output of lines) {
|
|
if (shouldIgnoreMpvStdoutLine(output)) {
|
|
continue;
|
|
}
|
|
|
|
traceExternalPlayer('mpv stdout', { output });
|
|
|
|
if (
|
|
output.includes('Failed to open') ||
|
|
output.includes('Error opening') ||
|
|
output.includes('Protocol not found') ||
|
|
output.includes('Connection refused') ||
|
|
output.includes('error') ||
|
|
output.includes('403') ||
|
|
output.includes('404')
|
|
) {
|
|
console.error('[MPV ERROR from stdout]:', output);
|
|
sendPlayerErrorNotification('MPV', output);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (proc.stderr) {
|
|
proc.stderr.on('data', (data) => {
|
|
const output = data.toString().trim();
|
|
if (output) {
|
|
console.error('[MPV stderr]:', output);
|
|
|
|
if (
|
|
output.includes('Failed to open') ||
|
|
output.includes(
|
|
'Exiting... (Errors when loading file)'
|
|
) ||
|
|
output.includes('Error opening') ||
|
|
output.includes('Protocol not found') ||
|
|
output.includes('Connection refused') ||
|
|
output.includes('error') ||
|
|
output.includes('403') ||
|
|
output.includes('404')
|
|
) {
|
|
console.error('[MPV ERROR]:', output);
|
|
sendPlayerErrorNotification('MPV', output);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
proc.on('error', (err) => {
|
|
console.error('Failed to start MPV player:', err);
|
|
const processSessionId = reusableMpvProcess.sessionIdFor(
|
|
proc,
|
|
session.id
|
|
);
|
|
reusableMpvProcess.clear(proc);
|
|
stopPositionPolling();
|
|
externalPlayerSessions.markError(
|
|
processSessionId,
|
|
`Failed to start MPV player: ${err.message}`
|
|
);
|
|
rejectLaunch(
|
|
buildPlayerStartError('MPV', err, mpvLaunchContext)
|
|
);
|
|
});
|
|
|
|
proc.on('exit', (code) => {
|
|
traceExternalPlayer('mpv exited', { code });
|
|
const processSessionId = reusableMpvProcess.sessionIdFor(
|
|
proc,
|
|
session.id
|
|
);
|
|
reusableMpvProcess.clear(proc);
|
|
stopPositionPolling();
|
|
|
|
if (code !== 0 && code !== null) {
|
|
console.error(
|
|
`[MPV ERROR] MPV exited with error code ${code}`
|
|
);
|
|
sendPlayerErrorNotification(
|
|
'MPV',
|
|
`MPV player closed unexpectedly (exit code: ${code})`
|
|
);
|
|
externalPlayerSessions.markError(
|
|
processSessionId,
|
|
`MPV player closed unexpectedly (exit code: ${code})`
|
|
);
|
|
resolveLaunch();
|
|
return;
|
|
}
|
|
|
|
externalPlayerSessions.markClosed(processSessionId);
|
|
resolveLaunch();
|
|
});
|
|
|
|
if (reuseInstance && socketPath) {
|
|
reusableMpvProcess.track(proc, socketPath, session.id);
|
|
traceExternalPlayer('stored mpv process for reuse', {
|
|
socketPath,
|
|
});
|
|
} else {
|
|
proc.unref();
|
|
}
|
|
|
|
externalPlayerSessions.attachCloser(session.id, async () => {
|
|
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;
|
|
}
|
|
});
|
|
|
|
if (useMpvSocketBridge && contentInfo && socketPath) {
|
|
startPositionPolling(socketPath, contentInfo, session.id);
|
|
}
|
|
|
|
startConfirmationTimer = setTimeout(() => {
|
|
if (!proc.killed) {
|
|
resolveLaunch();
|
|
}
|
|
}, 100);
|
|
startConfirmationTimer.unref();
|
|
});
|
|
|
|
return externalPlayerSessions.markOpened(session.id) ?? session;
|
|
} catch (error) {
|
|
console.error('Error opening MPV player:', error);
|
|
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),
|
|
{
|
|
canClose:
|
|
freshTeardownUnconfirmed ||
|
|
(reuseState.teardownUnconfirmed &&
|
|
(reuseState.contentMutated || !restoredSession)),
|
|
}
|
|
);
|
|
throw error;
|
|
}
|
|
}
|