mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
* refactor(main): drop mining/anki main-deps builders * refactor(anilist): drop main-deps builders * refactor(jellyfin): drop main-deps builders * refactor(overlay): drop overlay and shortcut main-deps builders * refactor(main): drop ipc, tray, and osd main-deps builders * refactor(startup): drop startup main-deps builders * test(scripts): ignore stray local requests in AUR download test Also apply prettier to launcher/mpv.test.ts so format:check:src passes. * fix(shortcuts): forward overlay numeric selection hooks through the composer composeShortcutRuntimes only passed the digit callbacks, so the visible-overlay selection added in #78 never ran from the multi-copy/mine-sentence shortcuts. * fix(stats): skip mpv auto-connect when stats starts the immersion tracker The immersion-startup main-deps builder never forwarded shouldAutoConnectMpv, so background stats and stats CLI startup always connected to mpv. Drop the builder and pass the deps straight to the handler. * build(deps): bump follow-redirects override to 1.16.2 Resolves GHSA-mrwp-f3q8-3g9p (high), which fails the quality-gate bun audit --audit-level high step. * test(subtitles): cover the null-tokenization settle path in prefetch test The mock ignored the value the test resolved, so the null case never ran. * test(e2e): surface mpv crashes and install current mpv on macOS CI The harness IPC client never handled socket close, so a crashed mpv left commands pending until the 120s test timeout with an empty mpv.log. Reject pending commands on close, write mpv's own log via --log-file, and refresh Homebrew on macOS CI so the installed mpv build no longer depends on the runner image. * test(e2e): render mpv through cocoa-cb OpenGL on macOS mpv 0.41.0_12 with libplacebo 7.387.0 segfaults in gpu-next's MoltenVK path on GitHub's paravirtual macOS GPU on the first frame, so the E2E session never started. * test(e2e): name mpv-stdio.log in the mpv close diagnostic
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import type { E2eDisplay } from './display';
|
|
import { E2E_SCREEN } from './display';
|
|
import { applyEnvDelta } from './process';
|
|
import { waitUntil } from './wait';
|
|
|
|
export type MpvClient = {
|
|
/** Sends one mpv JSON IPC command and resolves with its `data` field. */
|
|
command: (...args: unknown[]) => Promise<unknown>;
|
|
close: () => void;
|
|
};
|
|
|
|
type MpvReply = { request_id?: number; error?: string; data?: unknown };
|
|
|
|
function videoOutputArgs(display: E2eDisplay): string[] {
|
|
// Xvfb has no GPU; the software X11 output always works there.
|
|
if (display.kind === 'xvfb') return ['--vo=x11'];
|
|
// gpu-next's Vulkan path (libplacebo over MoltenVK) segfaults on the first frame
|
|
// on GitHub's paravirtual macOS GPU with libplacebo 7.387. `--vo=libmpv` is mpv's
|
|
// cocoa-cb OpenGL output, which avoids it and still opens the window the overlay tracks.
|
|
if (process.platform === 'darwin') return ['--vo=libmpv'];
|
|
return [];
|
|
}
|
|
|
|
export async function startMpv(options: {
|
|
socketPath: string;
|
|
mediaPath: string;
|
|
subtitlePath: string;
|
|
/** mpv's own verbose log (`--log-file`); `--no-terminal` silences its console output. */
|
|
logPath: string;
|
|
/** Process stdout/stderr, which still catches crash output from mpv's libraries. */
|
|
stdioLogPath: string;
|
|
display: E2eDisplay;
|
|
}): Promise<ChildProcess> {
|
|
const log = fs.openSync(options.stdioLogPath, 'w');
|
|
const child = spawn(
|
|
'mpv',
|
|
[
|
|
'--no-config',
|
|
'--no-terminal',
|
|
`--log-file=${options.logPath}`,
|
|
'--pause',
|
|
'--keep-open=yes',
|
|
'--ao=null',
|
|
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
|
|
...videoOutputArgs(options.display),
|
|
`--input-ipc-server=${options.socketPath}`,
|
|
`--sub-file=${options.subtitlePath}`,
|
|
options.mediaPath,
|
|
],
|
|
{ env: applyEnvDelta(process.env, options.display.env), stdio: ['ignore', log, log] },
|
|
);
|
|
fs.closeSync(log);
|
|
await Promise.race([
|
|
once(child, 'spawn'),
|
|
once(child, 'error').then(([error]) => {
|
|
throw new Error(`Could not start mpv (${String(error)}). Is it on PATH?`);
|
|
}),
|
|
]);
|
|
return child;
|
|
}
|
|
|
|
function tryConnect(socketPath: string): Promise<net.Socket | null> {
|
|
return new Promise((resolve) => {
|
|
const socket = net.connect(socketPath);
|
|
socket.once('connect', () => resolve(socket));
|
|
socket.once('error', () => resolve(null));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Connects to mpv's JSON IPC socket. Once the socket closes (mpv exited or
|
|
* crashed), pending and later commands reject instead of hanging until the test
|
|
* timeout; `describeClose` adds context such as mpv's exit status.
|
|
*/
|
|
export async function connectMpv(
|
|
socketPath: string,
|
|
describeClose: () => string = () => 'mpv IPC connection closed',
|
|
): Promise<MpvClient> {
|
|
const socket = await waitUntil(() => tryConnect(socketPath), {
|
|
description: `mpv IPC socket ${socketPath}`,
|
|
});
|
|
const pending = new Map<number, (reply: MpvReply) => void>();
|
|
let nextRequestId = 1;
|
|
let buffer = '';
|
|
let closedError: Error | null = null;
|
|
|
|
const failPending = () => {
|
|
closedError ??= new Error(describeClose());
|
|
for (const settle of pending.values()) settle({ error: closedError.message });
|
|
pending.clear();
|
|
};
|
|
socket.on('close', failPending);
|
|
// 'close' always follows 'error'; listening here keeps the error from being thrown.
|
|
socket.on('error', () => {});
|
|
|
|
socket.setEncoding('utf8');
|
|
socket.on('data', (chunk: string) => {
|
|
buffer += chunk;
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() ?? '';
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
const reply: MpvReply = JSON.parse(line);
|
|
if (reply.request_id === undefined) continue;
|
|
pending.get(reply.request_id)?.(reply);
|
|
pending.delete(reply.request_id);
|
|
}
|
|
});
|
|
|
|
return {
|
|
command: (...args) =>
|
|
new Promise((resolve, reject) => {
|
|
if (closedError) {
|
|
reject(new Error(`mpv ${JSON.stringify(args)} failed: ${closedError.message}`));
|
|
return;
|
|
}
|
|
const requestId = nextRequestId++;
|
|
pending.set(requestId, (reply) => {
|
|
if (reply.error === 'success') resolve(reply.data);
|
|
else reject(new Error(`mpv ${JSON.stringify(args)} failed: ${reply.error}`));
|
|
});
|
|
socket.write(`${JSON.stringify({ command: args, request_id: requestId })}\n`);
|
|
}),
|
|
close: () => socket.destroy(),
|
|
};
|
|
}
|